diff --git a/.changeset/update-effect-rc-112.md b/.changeset/update-effect-rc-112.md new file mode 100644 index 0000000000..60c243f635 --- /dev/null +++ b/.changeset/update-effect-rc-112.md @@ -0,0 +1,17 @@ +--- +"@effect-app/vue-components": patch +"effect-app": patch +"@effect-app/infra": patch +"@effect-app/cli": patch +"@effect-app/vue": patch +--- + +Update effect packages to `4.0.0-rc.112` (from `beta.107`): `effect`, `@effect/platform-node`, `@effect/platform-browser`, `@effect/atom-vue`, `@effect/sql-sqlite-node`, `@effect/vitest`. Sync `repos/effect` subtree from `Effect-TS/effect` at `effect@4.0.0-rc.112`. + +API adaptations for rc.112: + +- cluster encoded driver `resetAddress` → batched `resetAddresses` +- Cosmos `unprocessedMessages` honors optional `limit` / `addresses` (only claimed rows are returned) +- Service Bus `Runners.make` supplies `codecFor` for schema-aware RPC serialization +- `pnpm subtree:effect` passes `--url https://github.com/Effect-TS/effect.git` (published CLI still defaults to effect-smol) +- JSON Schema check constraints are compacted onto the parent (`minLength`/`maxLength` instead of `allOf`) diff --git a/.specs/Update-to-latest-Effect.md b/.specs/Update-to-latest-Effect.md index e7cd1fa370..88edfa6d0c 100644 --- a/.specs/Update-to-latest-Effect.md +++ b/.specs/Update-to-latest-Effect.md @@ -20,4 +20,4 @@ If new errors occur, first describe the problem, propose solutions and wait for ## Notes - subtree url: `https://github.com/Effect-TS/effect.git`, prefix `repos/effect`, tag `effect@`. -- v4 betas live under the npm `beta` dist-tag (npm `latest` is still v3). Find target with `npm view effect dist-tags`. +- v4 currently lives under the npm `rc` dist-tag (`beta` is frozen at `4.0.0-beta.107`; npm `latest` is still v3). Find target with `npm view effect dist-tags` — prefer `rc` if present, else `beta`. diff --git a/package.json b/package.json index 0346baaec2..443358225d 100644 --- a/package.json +++ b/package.json @@ -50,15 +50,15 @@ "@effect-app/infra": "workspace:*", "@effect/language-service": "0.86.2", "@effect/tsgo": "^0.31.0", - "@effect/platform-node": "4.0.0-beta.107", - "@effect/vitest": "4.0.0-beta.107", + "@effect/platform-node": "4.0.0-rc.112", + "@effect/vitest": "4.0.0-rc.112", "@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-beta.107", + "effect": "4.0.0-rc.112", "effect-app": "workspace:*", "eslint": "^10.4.1", "json5": "^2.2.3", diff --git a/packages/cli/package.json b/packages/cli/package.json index 07801925c1..ddf8b6fd02 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-beta.107", - "effect": "4.0.0-beta.107", + "@effect/platform-node": "4.0.0-rc.112", + "effect": "4.0.0-rc.112", "js-yaml": "4.2.0" }, "devDependencies": { diff --git a/packages/e2e/package.json b/packages/e2e/package.json index ce25bd8e62..12d4386651 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-beta.107", - "@effect/platform-node": "4.0.0-beta.107", - "@effect/vitest": "4.0.0-beta.107", + "@effect/atom-vue": "^4.0.0-rc.112", + "@effect/platform-node": "4.0.0-rc.112", + "@effect/vitest": "4.0.0-rc.112", "@tanstack/vue-query": "5.96.2", "@types/node": "25.9.1", "@vitejs/plugin-vue": "^6.0.7", - "effect": "^4.0.0-beta.107", + "effect": "^4.0.0-rc.112", "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 51fa18e046..9301cfc582 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-beta.107" + "effect": "^4.0.0-rc.112" }, "typesVersions": { "*": { diff --git a/packages/effect-app/test/schema.test.ts b/packages/effect-app/test/schema.test.ts index 4d5083f77b..da644edb4a 100644 --- a/packages/effect-app/test/schema.test.ts +++ b/packages/effect-app/test/schema.test.ts @@ -491,10 +491,8 @@ describe("JSON Schema", () => { title: "Email", description: "an email according to RFC 5322", format: "email", - allOf: [ - { minLength: 3 }, - { maxLength: 998 } - ] + minLength: 3, + maxLength: 998 } } }) diff --git a/packages/infra/package.json b/packages/infra/package.json index a8c1ad0fca..af90bd247b 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-beta.107", + "@effect/sql-sqlite-node": "4.0.0-rc.112", "@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-beta.107", + "@effect/vitest": "^4.0.0-rc.112", "@sendgrid/helpers": "^8.0.0", "@sendgrid/mail": "^8.1.6", "@sentry/node": "10.55.0", "@sentry/opentelemetry": "10.55.0", - "effect": "^4.0.0-beta.107", + "effect": "^4.0.0-rc.112", "jwt-decode": "^4.0.0", "redis": "^3.1.2", "redlock": "^4.2.0" diff --git a/packages/infra/src/ClusterCosmos.ts b/packages/infra/src/ClusterCosmos.ts index 8318f89233..79d0eb9d82 100644 --- a/packages/infra/src/ClusterCosmos.ts +++ b/packages/infra/src/ClusterCosmos.ts @@ -624,21 +624,34 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: { withTracerDisabled ), - unprocessedMessages: (shardIds, now) => - queryMessages( - "SELECT * FROM c WHERE c.type = 'message' AND ARRAY_CONTAINS(@shardIds, c.shardId) AND c.processed = false AND (NOT IS_DEFINED(c.lastRead) OR IS_NULL(c.lastRead) OR c.lastRead < @lastReadBefore) AND (NOT IS_DEFINED(c.deliverAt) OR IS_NULL(c.deliverAt) OR c.deliverAt <= @now) ORDER BY c.rowid", - [ - { name: "@shardIds", value: Array.from(shardIds) }, - { name: "@lastReadBefore", value: now - tenMinutes }, - { name: "@now", value: now } - ] - ) + unprocessedMessages: (shardIds, now, options) => { + if (options?.addresses !== undefined && options.addresses.length === 0) { + return Effect.succeed([]) + } + const parameters: Array = [ + { name: "@shardIds", value: Array.from(shardIds) }, + { name: "@lastReadBefore", value: now - tenMinutes }, + { name: "@now", value: now } + ] + let query = + "SELECT * FROM c WHERE c.type = 'message' AND ARRAY_CONTAINS(@shardIds, c.shardId) AND c.processed = false AND (NOT IS_DEFINED(c.lastRead) OR IS_NULL(c.lastRead) OR c.lastRead < @lastReadBefore) AND (NOT IS_DEFINED(c.deliverAt) OR IS_NULL(c.deliverAt) OR c.deliverAt <= @now)" + if (options?.addresses !== undefined) { + const match = addressClauses(options.addresses, "a") + query += ` AND (${match.sql})` + parameters.push(...match.parameters) + } + query += " ORDER BY c.rowid" + if (options?.limit !== undefined) { + query += ` OFFSET 0 LIMIT ${Math.floor(options.limit)}` + } + return queryMessages(query, parameters) .pipe( Effect.flatMap((docs) => collectUnprocessed(docs, now, claimMessageRead, queryReplies)), annotate("unprocessedMessages"), refailPersistence, withTracerDisabled - ), + ) + }, unprocessedMessagesById: (messageIds, now) => queryMessages( @@ -655,21 +668,20 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: { withTracerDisabled ), - resetAddress: (address) => - queryMessages( - "SELECT * FROM c WHERE c.type = 'message' AND c.processed = false AND c.shardId = @shardId AND c.entityType = @entityType AND c.entityId = @entityId", - [ - { name: "@shardId", value: ShardId.toString(address.shardId) }, - { name: "@entityType", value: address.entityType }, - { name: "@entityId", value: address.entityId } - ] + resetAddresses: (addresses) => { + if (addresses.length === 0) return Effect.void + const match = addressClauses(addresses, "r") + return queryMessages( + `SELECT * FROM c WHERE c.type = 'message' AND c.processed = false AND (${match.sql})`, + match.parameters ) .pipe( Effect.flatMap((docs) => patchDocs(docs, () => [{ op: "set", path: "/lastRead", value: null }])), - annotate("resetAddress"), + annotate("resetAddresses"), refailPersistence, withTracerDisabled - ), + ) + }, clearAddress: (address) => queryMessages( @@ -713,6 +725,30 @@ export const makeMessageStorage = Effect.fnUntraced(function*(options?: { }) }) +const addressClauses = ( + addresses: ReadonlyArray<{ + readonly shardId: Parameters[0] + readonly entityType: string + readonly entityId: string + }>, + prefix: string +): { readonly sql: string; readonly parameters: Array } => { + const parameters: Array = [] + const clauses: Array = [] + for (const [i, address] of addresses.entries()) { + const shard = `@${prefix}s${i}` + const entityType = `@${prefix}t${i}` + const entityId = `@${prefix}e${i}` + clauses.push(`(c.shardId = ${shard} AND c.entityType = ${entityType} AND c.entityId = ${entityId})`) + parameters.push( + { name: shard, value: ShardId.toString(address.shardId) }, + { name: entityType, value: address.entityType }, + { name: entityId, value: address.entityId } + ) + } + return { sql: clauses.join(" OR "), parameters } +} + const collectUnprocessed = ( docs: ReadonlyArray, now: number, diff --git a/packages/infra/src/ClusterServiceBus.ts b/packages/infra/src/ClusterServiceBus.ts index 13fc12b18f..13d60402d6 100644 --- a/packages/infra/src/ClusterServiceBus.ts +++ b/packages/infra/src/ClusterServiceBus.ts @@ -3,6 +3,7 @@ import * as Context from "effect-app/Context" import * as Effect from "effect-app/Effect" import * as Layer from "effect-app/Layer" import * as Data from "effect/Data" +import * as Schema from "effect/Schema" import * as Scope from "effect/Scope" import * as ClusterError from "effect/unstable/cluster/ClusterError" import { Persisted } from "effect/unstable/cluster/ClusterSchema" @@ -121,6 +122,7 @@ export const makeRunners: ( ) return yield* Runners.make({ + codecFor: Schema.toCodecJson, ping: (address) => Effect.fail(new ClusterError.RunnerUnavailable({ address })), send: ({ address, message }) => { const persisted = Context.get(message.rpc.annotations, Persisted) diff --git a/packages/vue-components/package.json b/packages/vue-components/package.json index e9205ca100..02f55466f0 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-beta.107", + "effect": "^4.0.0-rc.112", "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 7e9ca9532f..0cf4ff3d45 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-beta.107", - "@effect/platform-browser": "^4.0.0-beta.107", + "@effect/atom-vue": "^4.0.0-rc.112", + "@effect/platform-browser": "^4.0.0-rc.112", "@sentry/browser": "^10.55.0", - "effect": "^4.0.0-beta.107", + "effect": "^4.0.0-rc.112", "vue": "^3.5.35" }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.107", + "@effect/vitest": "4.0.0-rc.112", "@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 428be274a1..ff9359f43b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,14 +45,14 @@ importers: specifier: 0.86.2 version: 0.86.2 "@effect/platform-node": - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.9.3(supports-color@8.1.1)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1(@opentelemetry/api@1.9.1)) "@effect/tsgo": specifier: ^0.31.0 version: 0.31.0 "@effect/vitest": - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(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.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(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-beta.107 - version: 4.0.0-beta.107 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 effect-app: specifier: workspace:* version: link:packages/effect-app @@ -117,11 +117,11 @@ importers: packages/cli: dependencies: "@effect/platform-node": - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.9.3(supports-color@8.1.1)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1(@opentelemetry/api@1.9.1)) effect: - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 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-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) + specifier: ^4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) "@effect/platform-node": - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.9.3(supports-color@8.1.1)) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1(@opentelemetry/api@1.9.1)) "@effect/vitest": - specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(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.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(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-beta.107 - version: 4.0.0-beta.107 + specifier: ^4.0.0-rc.112 + version: 4.0.0-rc.112 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-beta.107 - version: 4.0.0-beta.107 + specifier: ^4.0.0-rc.112 + version: 4.0.0-rc.112 nanoid: specifier: ^5.1.11 version: 5.1.11 @@ -331,8 +331,8 @@ importers: packages/infra: dependencies: "@effect/vitest": - specifier: ^4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(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.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(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-beta.107 - version: 4.0.0-beta.107 + specifier: ^4.0.0-rc.112 + version: 4.0.0-rc.112 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-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107) + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112) "@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-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) + specifier: ^4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) "@effect/platform-browser": - specifier: ^4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107) + specifier: ^4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112) "@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-beta.107 - version: 4.0.0-beta.107 + specifier: ^4.0.0-rc.112 + version: 4.0.0-rc.112 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-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107)(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.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(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-beta.107 - version: 4.0.0-beta.107 + specifier: ^4.0.0-rc.112 + version: 4.0.0-rc.112 effect-app: specifier: workspace:* version: link:../effect-app @@ -1188,12 +1188,12 @@ packages: } hasBin: true - "@effect/atom-vue@4.0.0-beta.107": + "@effect/atom-vue@4.0.0-rc.112": resolution: { - integrity: sha512-RQh5iZapR3rTgO6KHBLUTj4bJh8LkYPWomwIXHzMu8JkRh7JD7+IctVfyKAz/YD1hNur6XvCoQmcS/CI0EQAqw==, + integrity: sha512-2sOESDblJGtJMxObqAw9UHHVrqhLHr2ha7JqvXdt2yhf4h57rK+rY3eEjclSgNsshABdcClP/Z0jNvgUD+A42g==, } peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.112 vue: ^3.5.35 "@effect/language-service@0.86.2": @@ -1202,20 +1202,12 @@ packages: } hasBin: true - "@effect/platform-browser@4.0.0-beta.107": + "@effect/platform-browser@4.0.0-rc.112": resolution: { - integrity: sha512-PGNHndbY0tRyqWfZZJWQMdQdh8Q1D9qfOnlI2EV2claAmNG7iGs+qWSUTBoCQo7MBgfJOYIC8M/AKq1ZfFWJuw==, + integrity: sha512-GSlqNDnjILz2EqOFPhVdMEHxlPq6SGb8+KOpNnLfRvVJjXRTMawEO+v1PCuwt2CHAqESbJAa2Nk+qcQvTrv4MQ==, } peerDependencies: - effect: ^4.0.0-beta.107 - - "@effect/platform-node-shared@4.0.0-beta.107": - resolution: { - integrity: sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA==, - } - engines: { node: ">=18.0.0" } - peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.112 "@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-beta.107": + "@effect/platform-node-shared@4.0.0-rc.112": resolution: { - integrity: sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA==, + integrity: sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ==, } engines: { node: ">=18.0.0" } peerDependencies: - effect: ^4.0.0-beta.107 - ioredis: ">=5.7.0 <6.0.0" + effect: ^4.0.0-rc.112 "@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-beta.107": + "@effect/platform-node@4.0.0-rc.112": + resolution: { + integrity: sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ==, + } + engines: { node: ">=18.0.0" } + peerDependencies: + effect: ^4.0.0-rc.112 + redis: ">=5.0.0 <7.0.0" + + "@effect/sql-sqlite-node@4.0.0-rc.112": resolution: { - integrity: sha512-hfYptkItEs/AdEIPEDkziC1TbnC7FyGISowKIvaZtscoWyxPaUtWsxM8EYgfOJ61aZwGgKQxZlXIgJ0fw25Lng==, + integrity: sha512-jGRtbJsn5z7DqsMEPSbXq2Q9mYEvMy1v9DTeziTtDEnDzlxhxfrXkSeupZbYqAP5pukIM1JBa10zfAn6KnHG2Q==, } peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.112 "@effect/tsgo-darwin-arm64@0.31.0": resolution: { @@ -1305,12 +1305,12 @@ packages: } hasBin: true - "@effect/vitest@4.0.0-beta.107": + "@effect/vitest@4.0.0-rc.112": resolution: { - integrity: sha512-n4/qsx4DnT4dEI/wNgMivxyUeJoeiU1TCSz0WnoHWk/dny40Oxjip2P9IXGQDgPb9fsYVnerF0QRA6nPUuExQA==, + integrity: sha512-mEKh/FI64mt8JK1/v9mpOrJYdnp+UFZdRUBMEdZMiKz7klg6NPqVgg/oeAGH6wOOQc2iAPcfc2H9BbAv1KyzMQ==, } peerDependencies: - effect: ^4.0.0-beta.107 + effect: ^4.0.0-rc.112 vitest: ">=4.1.0 <5.0.0" "@emnapi/core@1.10.0": @@ -2160,13 +2160,6 @@ packages: integrity: sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g==, } - "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": - resolution: { - integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==, - } - cpu: [arm64] - os: [darwin] - "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": resolution: { integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==, @@ -2174,13 +2167,6 @@ packages: cpu: [arm64] os: [darwin] - "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3": - resolution: { - integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==, - } - cpu: [x64] - os: [darwin] - "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": resolution: { integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==, @@ -2188,13 +2174,6 @@ packages: cpu: [x64] os: [darwin] - "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3": - resolution: { - integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==, - } - cpu: [arm64] - os: [linux] - "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": resolution: { integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==, @@ -2202,13 +2181,6 @@ packages: cpu: [arm64] os: [linux] - "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3": - resolution: { - integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==, - } - cpu: [arm] - os: [linux] - "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": resolution: { integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==, @@ -2216,13 +2188,6 @@ packages: cpu: [arm] os: [linux] - "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3": - resolution: { - integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==, - } - cpu: [x64] - os: [linux] - "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": resolution: { integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==, @@ -2230,13 +2195,6 @@ packages: cpu: [x64] os: [linux] - "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3": - resolution: { - integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==, - } - cpu: [x64] - os: [win32] - "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": resolution: { integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==, @@ -3367,6 +3325,52 @@ packages: } engines: { node: ">=12.11.0" } + "@redis/bloom@6.2.1": + resolution: { + integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==, + } + engines: { node: ">= 20.0.0" } + peerDependencies: + "@redis/client": ^6.2.1 + + "@redis/client@6.2.1": + resolution: { + integrity: sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==, + } + engines: { node: ">= 20.0.0" } + peerDependencies: + "@node-rs/xxhash": ^1.1.0 + "@opentelemetry/api": ">=1 <2" + peerDependenciesMeta: + "@node-rs/xxhash": + optional: true + "@opentelemetry/api": + optional: true + + "@redis/json@6.2.1": + resolution: { + integrity: sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ==, + } + engines: { node: ">= 20.0.0" } + peerDependencies: + "@redis/client": ^6.2.1 + + "@redis/search@6.2.1": + resolution: { + integrity: sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ==, + } + engines: { node: ">= 20.0.0" } + peerDependencies: + "@redis/client": ^6.2.1 + + "@redis/time-series@6.2.1": + resolution: { + integrity: sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw==, + } + engines: { node: ">= 20.0.0" } + peerDependencies: + "@redis/client": ^6.2.1 + "@rolldown/binding-android-arm64@1.0.3": resolution: { integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==, @@ -5730,14 +5734,14 @@ packages: engines: { node: ">=14" } hasBin: true - effect@4.0.0-beta.107: + effect@4.0.0-beta.86: resolution: { - integrity: sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==, + integrity: sha512-+Y16hy3LhAju/FM3dz3Xp+aGi8dw9AtEzswcvwxREpeEPq+rL8TFS1PgWAO1+/3kKKH4bq9NdBGtfhHzM2K7Ow==, } - effect@4.0.0-beta.86: + effect@4.0.0-rc.112: resolution: { - integrity: sha512-+Y16hy3LhAju/FM3dz3Xp+aGi8dw9AtEzswcvwxREpeEPq+rL8TFS1PgWAO1+/3kKKH4bq9NdBGtfhHzM2K7Ow==, + integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==, } electron-to-chromium@1.5.267: @@ -7480,12 +7484,6 @@ packages: integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, } - msgpackr-extract@3.0.3: - resolution: { - integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==, - } - hasBin: true - msgpackr-extract@3.0.4: resolution: { integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==, @@ -8238,6 +8236,12 @@ packages: } engines: { node: ">=10" } + redis@6.2.1: + resolution: { + integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==, + } + engines: { node: ">= 20.0.0" } + redlock@4.2.0: resolution: { integrity: sha512-j+oQlG+dOwcetUt2WJWttu4CZVeRzUrcVcISFmEmfyuwCVSJ93rDT7YSgg7H7rnxwoRyk/jU46kycVka5tW7jA==, @@ -9207,12 +9211,6 @@ packages: } hasBin: true - uuid@14.0.1: - resolution: { - integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==, - } - hasBin: true - validator@13.15.35: resolution: { integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==, @@ -10326,60 +10324,60 @@ snapshots: - ioredis - utf-8-validate - "@effect/atom-vue@4.0.0-beta.107(effect@4.0.0-beta.107)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)))": + "@effect/atom-vue@4.0.0-rc.112(effect@4.0.0-rc.112)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)))": dependencies: - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.112 vue: 3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)) "@effect/language-service@0.86.2": {} - "@effect/platform-browser@4.0.0-beta.107(effect@4.0.0-beta.107)": + "@effect/platform-browser@4.0.0-rc.112(effect@4.0.0-rc.112)": dependencies: - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.112 - "@effect/platform-node-shared@4.0.0-beta.107(effect@4.0.0-beta.107)": + "@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.107 + effect: 4.0.0-beta.86 ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate - "@effect/platform-node-shared@4.0.0-beta.86(effect@4.0.0-beta.86)": + "@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-rc.112)": dependencies: "@types/ws": 8.18.1 - effect: 4.0.0-beta.86 - ws: 8.20.0 + effect: 4.0.0-rc.112 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate - "@effect/platform-node@4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.9.3(supports-color@8.1.1))": + "@effect/platform-node@4.0.0-beta.86(effect@4.0.0-beta.86)(ioredis@5.9.3(supports-color@8.1.1))": dependencies: - "@effect/platform-node-shared": 4.0.0-beta.107(effect@4.0.0-beta.107) - effect: 4.0.0-beta.107 + "@effect/platform-node-shared": 4.0.0-beta.86(effect@4.0.0-beta.86) + effect: 4.0.0-beta.86 ioredis: 5.9.3(supports-color@8.1.1) mime: 4.1.0 - undici: 8.10.0 + undici: 8.3.0 transitivePeerDependencies: - bufferutil - utf-8-validate - "@effect/platform-node@4.0.0-beta.86(effect@4.0.0-beta.86)(ioredis@5.9.3(supports-color@8.1.1))": + "@effect/platform-node@4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1(@opentelemetry/api@1.9.1))": dependencies: - "@effect/platform-node-shared": 4.0.0-beta.86(effect@4.0.0-beta.86) - effect: 4.0.0-beta.86 - ioredis: 5.9.3(supports-color@8.1.1) + "@effect/platform-node-shared": 4.0.0-rc.112(effect@4.0.0-rc.112) + effect: 4.0.0-rc.112 mime: 4.1.0 - undici: 8.3.0 + redis: 6.2.1(@opentelemetry/api@1.9.1) + undici: 8.10.0 transitivePeerDependencies: - bufferutil - utf-8-validate - "@effect/sql-sqlite-node@4.0.0-beta.107(effect@4.0.0-beta.107)": + "@effect/sql-sqlite-node@4.0.0-rc.112(effect@4.0.0-rc.112)": dependencies: - effect: 4.0.0-beta.107 + effect: 4.0.0-rc.112 "@effect/tsgo-darwin-arm64@0.31.0": optional: true @@ -10412,9 +10410,9 @@ snapshots: "@effect/tsgo-win32-arm64": 0.31.0 "@effect/tsgo-win32-x64": 0.31.0 - "@effect/vitest@4.0.0-beta.107(effect@4.0.0-beta.107)(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.0-rc.112(effect@4.0.0-rc.112)(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-beta.107 + effect: 4.0.0-rc.112 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": @@ -10826,39 +10824,21 @@ snapshots: dependencies: sparse-bitfield: 3.0.3 - "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": - optional: true - "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": optional: true - "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3": - optional: true - "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3": - optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3": - optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": optional: true - "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3": - optional: true - "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": optional: true - "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3": - optional: true - "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": optional: true @@ -11355,6 +11335,28 @@ snapshots: transitivePeerDependencies: - vue + "@redis/bloom@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))": + dependencies: + "@redis/client": 6.2.1(@opentelemetry/api@1.9.1) + + "@redis/client@6.2.1(@opentelemetry/api@1.9.1)": + dependencies: + cluster-key-slot: 1.1.2 + optionalDependencies: + "@opentelemetry/api": 1.9.1 + + "@redis/json@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))": + dependencies: + "@redis/client": 6.2.1(@opentelemetry/api@1.9.1) + + "@redis/search@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))": + dependencies: + "@redis/client": 6.2.1(@opentelemetry/api@1.9.1) + + "@redis/time-series@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))": + dependencies: + "@redis/client": 6.2.1(@opentelemetry/api@1.9.1) + "@rolldown/binding-android-arm64@1.0.3": optional: true @@ -13043,14 +13045,6 @@ snapshots: minimatch: 9.0.1 semver: 7.7.3 - effect@4.0.0-beta.107: - dependencies: - "@standard-schema/spec": 1.1.0 - fast-check: 4.9.0 - kubernetes-types: 1.30.0 - msgpackr: 2.0.5 - uuid: 14.0.1 - effect@4.0.0-beta.86: dependencies: "@standard-schema/spec": 1.1.0 @@ -13064,6 +13058,11 @@ snapshots: uuid: 14.0.0 yaml: 2.9.0 + effect@4.0.0-rc.112: + dependencies: + fast-check: 4.9.0 + msgpackr: 2.0.5 + electron-to-chromium@1.5.267: optional: true @@ -14372,18 +14371,6 @@ snapshots: ms@2.1.3: {} - msgpackr-extract@3.0.3: - dependencies: - node-gyp-build-optional-packages: 5.2.2 - optionalDependencies: - "@msgpackr-extract/msgpackr-extract-darwin-arm64": 3.0.3 - "@msgpackr-extract/msgpackr-extract-darwin-x64": 3.0.3 - "@msgpackr-extract/msgpackr-extract-linux-arm": 3.0.3 - "@msgpackr-extract/msgpackr-extract-linux-arm64": 3.0.3 - "@msgpackr-extract/msgpackr-extract-linux-x64": 3.0.3 - "@msgpackr-extract/msgpackr-extract-win32-x64": 3.0.3 - optional: true - msgpackr-extract@3.0.4: dependencies: node-gyp-build-optional-packages: 5.2.2 @@ -14398,7 +14385,7 @@ snapshots: msgpackr@2.0.1: optionalDependencies: - msgpackr-extract: 3.0.3 + msgpackr-extract: 3.0.4 msgpackr@2.0.5: optionalDependencies: @@ -15034,6 +15021,17 @@ snapshots: redis-errors: 1.2.0 redis-parser: 3.0.0 + redis@6.2.1(@opentelemetry/api@1.9.1): + dependencies: + "@redis/bloom": 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + "@redis/client": 6.2.1(@opentelemetry/api@1.9.1) + "@redis/json": 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + "@redis/search": 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + "@redis/time-series": 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + transitivePeerDependencies: + - "@node-rs/xxhash" + - "@opentelemetry/api" + redlock@4.2.0: dependencies: bluebird: 3.7.2 @@ -15777,8 +15775,6 @@ snapshots: uuid@14.0.0: {} - uuid@14.0.1: {} - validator@13.15.35: {} vfile-message@4.0.3: diff --git a/repos/effect/.changeset/pre.json b/repos/effect/.changeset/pre.json index f6799931c5..d1deaa4cf1 100644 --- a/repos/effect/.changeset/pre.json +++ b/repos/effect/.changeset/pre.json @@ -1,4 +1,4 @@ { "mode": "pre", - "tag": "beta" + "tag": "rc" } diff --git a/repos/effect/.changeset/pre/add-mcp-2025-11-25-protocol.md b/repos/effect/.changeset/pre/add-mcp-2025-11-25-protocol.md new file mode 100644 index 0000000000..fbc935c0b9 --- /dev/null +++ b/repos/effect/.changeset/pre/add-mcp-2025-11-25-protocol.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +MCP servers can now use the 2025-11-25 protocol, including sampling with tools and both form- and URL-based elicitation. + +Enable it by adding `McpProtocol.v2025_11_25` to the server's `protocols` option. diff --git a/repos/effect/.changeset/pre/add-mcp-icons.md b/repos/effect/.changeset/pre/add-mcp-icons.md new file mode 100644 index 0000000000..8e2383a628 --- /dev/null +++ b/repos/effect/.changeset/pre/add-mcp-icons.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +MCP servers can now provide icons for server information, resources, resource templates, prompts, and tools using `McpSchema.Icon`. + +Each icon can specify its source URI, MIME type, supported sizes, and light or dark theme. diff --git a/repos/effect/.changeset/pre/anthropic-claude-5-capabilities.md b/repos/effect/.changeset/pre/anthropic-claude-5-capabilities.md new file mode 100644 index 0000000000..785acd5357 --- /dev/null +++ b/repos/effect/.changeset/pre/anthropic-claude-5-capabilities.md @@ -0,0 +1,7 @@ +--- +"@effect/ai-anthropic": patch +--- + +Default new Anthropic models to modern capabilities while preserving the limits of legacy Claude models. + +Unknown models now default to native structured outputs and 128K output tokens, so future model releases do not require capability-table updates. Use the new `structuredOutputs` model config option to override capability detection when needed. diff --git a/repos/effect/.changeset/pre/atomic-optional-rc-map.md b/repos/effect/.changeset/pre/atomic-optional-rc-map.md new file mode 100644 index 0000000000..2e6c8caa8e --- /dev/null +++ b/repos/effect/.changeset/pre/atomic-optional-rc-map.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Add `RcMap.getOption` and `LayerMap.contextEffectOption` for atomically retaining +entries only when they are already cached. diff --git a/repos/effect/.changeset/pre/authentication-error-description.md b/repos/effect/.changeset/pre/authentication-error-description.md new file mode 100644 index 0000000000..f5745934d5 --- /dev/null +++ b/repos/effect/.changeset/pre/authentication-error-description.md @@ -0,0 +1,9 @@ +--- +"effect": patch +"@effect/ai-anthropic": patch +"@effect/ai-openai": patch +"@effect/ai-openai-compat": patch +"@effect/ai-openrouter": patch +--- + +Add an optional `description` to `AiError.AuthenticationError`, rendered after the kind-based suggestion, and pass the provider's own error text through it on HTTP 401 and 403, so authentication failures report what actually went wrong instead of only a category. diff --git a/repos/effect/.changeset/pre/autocomplete-jk-filter.md b/repos/effect/.changeset/pre/autocomplete-jk-filter.md new file mode 100644 index 0000000000..00c1ec6689 --- /dev/null +++ b/repos/effect/.changeset/pre/autocomplete-jk-filter.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `Prompt.autoComplete` swallowing `j` and `k` while typing a filter query. diff --git a/repos/effect/.changeset/pre/big-masks-care.md b/repos/effect/.changeset/pre/big-masks-care.md new file mode 100644 index 0000000000..8b3a17254e --- /dev/null +++ b/repos/effect/.changeset/pre/big-masks-care.md @@ -0,0 +1,5 @@ +--- +"@effect/doctest": patch +--- + +Support `.mdx` files diff --git a/repos/effect/.changeset/pre/bright-docs-teach.md b/repos/effect/.changeset/pre/bright-docs-teach.md new file mode 100644 index 0000000000..3e26d3391e --- /dev/null +++ b/repos/effect/.changeset/pre/bright-docs-teach.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add SQL, HttpApi testing, and CLI schema examples to the published AI documentation. diff --git a/repos/effect/.changeset/pre/bright-graphs-calculate.md b/repos/effect/.changeset/pre/bright-graphs-calculate.md new file mode 100644 index 0000000000..86935a9957 --- /dev/null +++ b/repos/effect/.changeset/pre/bright-graphs-calculate.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Reject graph shortest-path calculations that overflow or underflow the finite number range. diff --git a/repos/effect/.changeset/pre/bright-graphs-trace.md b/repos/effect/.changeset/pre/bright-graphs-trace.md new file mode 100644 index 0000000000..1f08e6a7f5 --- /dev/null +++ b/repos/effect/.changeset/pre/bright-graphs-trace.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Include traversed edge indexes in graph shortest-path results. diff --git a/repos/effect/.changeset/pre/brown-glasses-thank.md b/repos/effect/.changeset/pre/brown-glasses-thank.md new file mode 100644 index 0000000000..0445b02de0 --- /dev/null +++ b/repos/effect/.changeset/pre/brown-glasses-thank.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Handle BigInt values safely and consistently across JSON diagnostics and logger formats. diff --git a/repos/effect/.changeset/pre/bun-websocket-outgoing-compression.md b/repos/effect/.changeset/pre/bun-websocket-outgoing-compression.md new file mode 100644 index 0000000000..9be7aeaf63 --- /dev/null +++ b/repos/effect/.changeset/pre/bun-websocket-outgoing-compression.md @@ -0,0 +1,7 @@ +--- +"@effect/platform-bun": patch +--- + +Compress outgoing Bun WebSocket messages when per-message deflate is configured and negotiated. Messages +smaller than 1 KiB are left uncompressed, matching the default threshold used by Node's `ws` server. +The threshold is configurable via the new `websocket.compressionThreshold` server option. diff --git a/repos/effect/.changeset/pre/calm-atoms-relax.md b/repos/effect/.changeset/pre/calm-atoms-relax.md new file mode 100644 index 0000000000..a7464d5434 --- /dev/null +++ b/repos/effect/.changeset/pre/calm-atoms-relax.md @@ -0,0 +1,5 @@ +--- +"@effect/atom-react": patch +--- + +Relax react peer dependency range diff --git a/repos/effect/.changeset/pre/calm-contexts-rest.md b/repos/effect/.changeset/pre/calm-contexts-rest.md new file mode 100644 index 0000000000..1dede3ad93 --- /dev/null +++ b/repos/effect/.changeset/pre/calm-contexts-rest.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve the `Context.mapUnsafe` accessor when code is compiled with loose object spread transforms. diff --git a/repos/effect/.changeset/pre/calm-graphs-span.md b/repos/effect/.changeset/pre/calm-graphs-span.md new file mode 100644 index 0000000000..c53042d543 --- /dev/null +++ b/repos/effect/.changeset/pre/calm-graphs-span.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add deterministic, index-preserving `Graph.minimumSpanningForest`. diff --git a/repos/effect/.changeset/pre/calm-graphs-travel.md b/repos/effect/.changeset/pre/calm-graphs-travel.md new file mode 100644 index 0000000000..85618505b7 --- /dev/null +++ b/repos/effect/.changeset/pre/calm-graphs-travel.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix graph index exhaustion, A* path consistency, snapshot validation, Mermaid line endings, and topological initials. diff --git a/repos/effect/.changeset/pre/calm-pages-hide.md b/repos/effect/.changeset/pre/calm-pages-hide.md new file mode 100644 index 0000000000..22600772a2 --- /dev/null +++ b/repos/effect/.changeset/pre/calm-pages-hide.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-browser": patch +--- + +Keep BrowserRuntime main programs running when navigation does not unload the document, while interrupting on non-bfcache pagehide events. diff --git a/repos/effect/.changeset/pre/calm-queues-await.md b/repos/effect/.changeset/pre/calm-queues-await.md new file mode 100644 index 0000000000..18ac80170c --- /dev/null +++ b/repos/effect/.changeset/pre/calm-queues-await.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `Queue.await` failing with `Cause.Done` when registered before the queue ends. diff --git a/repos/effect/.changeset/pre/clean-formatters-agree.md b/repos/effect/.changeset/pre/clean-formatters-agree.md new file mode 100644 index 0000000000..2fea77679a --- /dev/null +++ b/repos/effect/.changeset/pre/clean-formatters-agree.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Prioritize redacted representations in formatters and normalize text logger levels to uppercase. diff --git a/repos/effect/.changeset/pre/clear-graphs-reduce.md b/repos/effect/.changeset/pre/clear-graphs-reduce.md new file mode 100644 index 0000000000..0341ecbe6f --- /dev/null +++ b/repos/effect/.changeset/pre/clear-graphs-reduce.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add index-preserving transitive reduction for directed acyclic graphs. diff --git a/repos/effect/.changeset/pre/compact-json-schema-checks.md b/repos/effect/.changeset/pre/compact-json-schema-checks.md new file mode 100644 index 0000000000..957c589976 --- /dev/null +++ b/repos/effect/.changeset/pre/compact-json-schema-checks.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Compact JSON Schema check constraints when they can be safely merged without keyword collisions. diff --git a/repos/effect/.changeset/pre/configurable-schema-reference-policy.md b/repos/effect/.changeset/pre/configurable-schema-reference-policy.md new file mode 100644 index 0000000000..c46a7a543b --- /dev/null +++ b/repos/effect/.changeset/pre/configurable-schema-reference-policy.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add configurable schema representation reference policies and propagate them through JSON Schema and OpenAPI generation. By default, only schemas with resolved identifiers become references. Closes #7357. diff --git a/repos/effect/.changeset/pre/curly-graphs-connect.md b/repos/effect/.changeset/pre/curly-graphs-connect.md new file mode 100644 index 0000000000..37f344445b --- /dev/null +++ b/repos/effect/.changeset/pre/curly-graphs-connect.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add graph snapshots, low-link connectivity analysis, bipartite matching, maximum flow, and minimum cut APIs. diff --git a/repos/effect/.changeset/pre/curly-graphs-snapshot.md b/repos/effect/.changeset/pre/curly-graphs-snapshot.md new file mode 100644 index 0000000000..7c56a6daf2 --- /dev/null +++ b/repos/effect/.changeset/pre/curly-graphs-snapshot.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `Graph.Snapshot` and `Graph.fromSnapshot` for constructing immutable graphs with explicit node and edge indexes, and simplify `Graph.Edge` to a type-only structural interface. diff --git a/repos/effect/.changeset/pre/curvy-graphs-code.md b/repos/effect/.changeset/pre/curvy-graphs-code.md new file mode 100644 index 0000000000..758df43ffc --- /dev/null +++ b/repos/effect/.changeset/pre/curvy-graphs-code.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `Schema.Graph` for schema-based encoding and decoding of immutable directed and undirected graphs. diff --git a/repos/effect/.changeset/pre/curvy-otters-sync.md b/repos/effect/.changeset/pre/curvy-otters-sync.md new file mode 100644 index 0000000000..aa98e610d2 --- /dev/null +++ b/repos/effect/.changeset/pre/curvy-otters-sync.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Retry transient EventLog remote write failures so pending local entries are synchronized after recovery. diff --git a/repos/effect/.changeset/pre/custom-cli-prompt-prefix.md b/repos/effect/.changeset/pre/custom-cli-prompt-prefix.md new file mode 100644 index 0000000000..e6c00a55a9 --- /dev/null +++ b/repos/effect/.changeset/pre/custom-cli-prompt-prefix.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Allow customizing the prefix displayed by CLI prompts. diff --git a/repos/effect/.changeset/pre/dark-dancers-rule.md b/repos/effect/.changeset/pre/dark-dancers-rule.md new file mode 100644 index 0000000000..e46ea884db --- /dev/null +++ b/repos/effect/.changeset/pre/dark-dancers-rule.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Use Context mapUnsafe in less call sites diff --git a/repos/effect/.changeset/pre/deferred-cleanup-after-completion.md b/repos/effect/.changeset/pre/deferred-cleanup-after-completion.md new file mode 100644 index 0000000000..4434455d5e --- /dev/null +++ b/repos/effect/.changeset/pre/deferred-cleanup-after-completion.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `Deferred.await` dying with a `TypeError` when a waiter is interrupted after the `Deferred` has been completed. diff --git a/repos/effect/.changeset/pre/deferred-resume-starvation.md b/repos/effect/.changeset/pre/deferred-resume-starvation.md new file mode 100644 index 0000000000..9a30bd76de --- /dev/null +++ b/repos/effect/.changeset/pre/deferred-resume-starvation.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `Deferred` completion skipping waiters when an earlier waiter dies during resume. Completing a `Deferred` with an interrupt cause kills a suspended waiter synchronously inside its resume; the dying waiter's `await` cleanup spliced the shared `resumes` array mid-iteration, so the next waiter was never resumed and hung forever. Completion now clears `resumes` before resuming waiters. diff --git a/repos/effect/.changeset/pre/eff-700-httpapi-middleware-errors.md b/repos/effect/.changeset/pre/eff-700-httpapi-middleware-errors.md index e95a36007c..ef46ad705f 100644 --- a/repos/effect/.changeset/pre/eff-700-httpapi-middleware-errors.md +++ b/repos/effect/.changeset/pre/eff-700-httpapi-middleware-errors.md @@ -6,7 +6,7 @@ Improve unstable HttpApi runtime failures for missing server middleware and miss - HttpApiBuilder.applyMiddleware now resolves middleware services via Context.getUnsafe, so missing middleware fails with a clear "Service not found: " error instead of an opaque is not a function TypeError. - HttpApiBuilder.layer now reports missing groups with actionable context (group identifier, service key, suggested HttpApiBuilder.group(...) call, and available group keys). -- Added regression tests in packages/platform-node/test/HttpApi.test.ts covering: +- Added regression tests in packages/platform/node/test/HttpApi.test.ts covering: - addHttpApi + API-level middleware applied across merged groups - missing middleware service diagnostics - missing addHttpApi group layer diagnostics diff --git a/repos/effect/.changeset/pre/eff-798-schema-parser.md b/repos/effect/.changeset/pre/eff-798-schema-parser.md new file mode 100644 index 0000000000..e7ca179c97 --- /dev/null +++ b/repos/effect/.changeset/pre/eff-798-schema-parser.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Improve synchronous Schema decode and encode performance by preserving completed parser exits and using a direct loop for common struct parsers. diff --git a/repos/effect/.changeset/pre/eff-802-tagged-union-match-or-else.md b/repos/effect/.changeset/pre/eff-802-tagged-union-match-or-else.md new file mode 100644 index 0000000000..0336f28d21 --- /dev/null +++ b/repos/effect/.changeset/pre/eff-802-tagged-union-match-or-else.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `Schema.TaggedUnion.matchOrElse` for partial case matching with a typed fallback. diff --git a/repos/effect/.changeset/pre/eff-806-schema-error-stack.md b/repos/effect/.changeset/pre/eff-806-schema-error-stack.md new file mode 100644 index 0000000000..339f7f3676 --- /dev/null +++ b/repos/effect/.changeset/pre/eff-806-schema-error-stack.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Improve `SchemaError` construction performance by skipping stack frame capture. diff --git a/repos/effect/.changeset/pre/eff-825-pool-performance.md b/repos/effect/.changeset/pre/eff-825-pool-performance.md new file mode 100644 index 0000000000..8f9d71792b --- /dev/null +++ b/repos/effect/.changeset/pre/eff-825-pool-performance.md @@ -0,0 +1,8 @@ +--- +"effect": patch +--- + +Improve Pool acquisition and release performance. Pool now tracks usage +incrementally, stores available items in an intrusive FIFO, and skips work for +fixed and empty pools. This changes the public `Pool.State` and `Pool.PoolItem` +interfaces. diff --git a/repos/effect/.changeset/pre/eff-825-pool-use.md b/repos/effect/.changeset/pre/eff-825-pool-use.md new file mode 100644 index 0000000000..ddea9d1b05 --- /dev/null +++ b/repos/effect/.changeset/pre/eff-825-pool-use.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Add `Pool.use`, which borrows an item while an effect runs and returns it on any +exit. Unlike `Effect.scoped(Pool.get(pool))`, it does not require a `Scope`. diff --git a/repos/effect/.changeset/pre/eff-825-scope-single-finalizer.md b/repos/effect/.changeset/pre/eff-825-scope-single-finalizer.md new file mode 100644 index 0000000000..fb77cd78c1 --- /dev/null +++ b/repos/effect/.changeset/pre/eff-825-scope-single-finalizer.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Reduce scoped resource acquisition allocations by storing the first Scope +finalizer inline and allocating a Map only when a second is added. This changes +the public `Scope.State.Open` interface. diff --git a/repos/effect/.changeset/pre/eff-837-dependency-updates.md b/repos/effect/.changeset/pre/eff-837-dependency-updates.md new file mode 100644 index 0000000000..2d249ad2c9 --- /dev/null +++ b/repos/effect/.changeset/pre/eff-837-dependency-updates.md @@ -0,0 +1,8 @@ +--- +"@effect/doctest": patch +"@effect/sql-d1": patch +"@effect/sql-mysql2": patch +"@effect/sql-pglite": patch +--- + +Update production dependencies to their latest releases. diff --git a/repos/effect/.changeset/pre/eff-851-skip-empty-remote-write.md b/repos/effect/.changeset/pre/eff-851-skip-empty-remote-write.md new file mode 100644 index 0000000000..95de0f05e0 --- /dev/null +++ b/repos/effect/.changeset/pre/eff-851-skip-empty-remote-write.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Skip remote event journal write callbacks when there are no uncommitted entries and return an `Option` indicating +whether the callback ran. diff --git a/repos/effect/.changeset/pre/eff-861-indexeddb-equals.md b/repos/effect/.changeset/pre/eff-861-indexeddb-equals.md new file mode 100644 index 0000000000..c3f17f1c8b --- /dev/null +++ b/repos/effect/.changeset/pre/eff-861-indexeddb-equals.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-browser": patch +--- + +Fix IndexedDB `.equals` queries on tables without key paths to accept out-of-line `IDBValidKey` values. diff --git a/repos/effect/.changeset/pre/escape-completion-choice-values.md b/repos/effect/.changeset/pre/escape-completion-choice-values.md new file mode 100644 index 0000000000..2b3433363c --- /dev/null +++ b/repos/effect/.changeset/pre/escape-completion-choice-values.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Fix shell completion for choice values containing quotes, spaces, word-break characters, Unicode, and shell metacharacters. + +Bash now quotes candidates for readline, keeps choice values intact when reconstructing words, and supports Bash 3.2 without associative arrays. Fish and Zsh escape choices across both parsing rounds, and Fish hides value-taking flags after use without suppressing their value completions. diff --git a/repos/effect/.changeset/pre/file-prompt-jk-filter.md b/repos/effect/.changeset/pre/file-prompt-jk-filter.md new file mode 100644 index 0000000000..d4d204e6b3 --- /dev/null +++ b/repos/effect/.changeset/pre/file-prompt-jk-filter.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `Prompt.file` swallowing `j` and `k` while typing a filter query. diff --git a/repos/effect/.changeset/pre/fix-channel-effects-finalizer.md b/repos/effect/.changeset/pre/fix-channel-effects-finalizer.md new file mode 100644 index 0000000000..4c470c7fbd --- /dev/null +++ b/repos/effect/.changeset/pre/fix-channel-effects-finalizer.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Shut down the internal effects queue when ordered concurrent channel mapping closes. diff --git a/repos/effect/.changeset/pre/fix-cli-help-table-spacing.md b/repos/effect/.changeset/pre/fix-cli-help-table-spacing.md new file mode 100644 index 0000000000..e8b5ef3024 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-cli-help-table-spacing.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Keep long CLI subcommand and argument names separated from their descriptions in help output. diff --git a/repos/effect/.changeset/pre/fix-durable-deferred-race.md b/repos/effect/.changeset/pre/fix-durable-deferred-race.md new file mode 100644 index 0000000000..21b43f3601 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-durable-deferred-race.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `DurableDeferred.raceAll` so a completed deferred can wake an active workflow without changing success-biased race semantics diff --git a/repos/effect/.changeset/pre/fix-effect-fn-self.md b/repos/effect/.changeset/pre/fix-effect-fn-self.md new file mode 100644 index 0000000000..80eda7033f --- /dev/null +++ b/repos/effect/.changeset/pre/fix-effect-fn-self.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `Effect.fn` binding the final transform as the generator body when using the `{ self }` overload. diff --git a/repos/effect/.changeset/pre/fix-fiber-exit-observers.md b/repos/effect/.changeset/pre/fix-fiber-exit-observers.md new file mode 100644 index 0000000000..50aebaee14 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-fiber-exit-observers.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Ensure fiber observer cancellation during exit does not skip remaining observers. diff --git a/repos/effect/.changeset/pre/fix-from-option-inline-inference.md b/repos/effect/.changeset/pre/fix-from-option-inline-inference.md new file mode 100644 index 0000000000..8be078dddb --- /dev/null +++ b/repos/effect/.changeset/pre/fix-from-option-inline-inference.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `Effect.fromOption` data-first inference for inline `Option` expressions. diff --git a/repos/effect/.changeset/pre/fix-graph-dfs-astar-edge-cases.md b/repos/effect/.changeset/pre/fix-graph-dfs-astar-edge-cases.md new file mode 100644 index 0000000000..894c65d013 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-graph-dfs-astar-edge-cases.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve depth-first traversal order with finite radii and validate A* heuristics for trivial paths. diff --git a/repos/effect/.changeset/pre/fix-graph-mutation-errors.md b/repos/effect/.changeset/pre/fix-graph-mutation-errors.md new file mode 100644 index 0000000000..476d8c4019 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-graph-mutation-errors.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve scoped Graph mutation callback errors when the callback manually finalizes its mutable handle. diff --git a/repos/effect/.changeset/pre/fix-group-by-key-types.md b/repos/effect/.changeset/pre/fix-group-by-key-types.md new file mode 100644 index 0000000000..de711323af --- /dev/null +++ b/repos/effect/.changeset/pre/fix-group-by-key-types.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Preserve finite string and unique symbol key unions in the return types of `Array.groupBy` and `Iterable.groupBy`. + +Previously, grouping widened finite keys to `string` or `symbol`, which lost known-key autocomplete and allowed access to keys that the selector could never produce. The new `Record.ReadonlyRecord.GroupByResult` keeps finite keys and marks their properties optional because any group may be absent at runtime, while open `string` and `symbol` selectors retain their existing record index signatures. diff --git a/repos/effect/.changeset/pre/fix-httpapi-single-array-query.md b/repos/effect/.changeset/pre/fix-httpapi-single-array-query.md new file mode 100644 index 0000000000..6a8a671ebe --- /dev/null +++ b/repos/effect/.changeset/pre/fix-httpapi-single-array-query.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `HttpApi` query decoding for array parameters with a single value. diff --git a/repos/effect/.changeset/pre/fix-invalid-openapi-examples.md b/repos/effect/.changeset/pre/fix-invalid-openapi-examples.md new file mode 100644 index 0000000000..3e05529532 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-invalid-openapi-examples.md @@ -0,0 +1,5 @@ +--- +"@effect/openapi-generator": patch +--- + +Drop invalid OpenAPI schema examples from generated Effect Schema annotations. diff --git a/repos/effect/.changeset/pre/fix-json-schema-reference-resolution.md b/repos/effect/.changeset/pre/fix-json-schema-reference-resolution.md new file mode 100644 index 0000000000..52c87639a8 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-json-schema-reference-resolution.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Reject unsupported JSON Schema references instead of resolving them by their final path segment, closes #7409. diff --git a/repos/effect/.changeset/pre/fix-memory-workflow-interrupt.md b/repos/effect/.changeset/pre/fix-memory-workflow-interrupt.md new file mode 100644 index 0000000000..625013f2b1 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-memory-workflow-interrupt.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Align in-memory workflow interrupt finalization with the cluster workflow engine. diff --git a/repos/effect/.changeset/pre/fix-openapi-from-api-cache-copy.md b/repos/effect/.changeset/pre/fix-openapi-from-api-cache-copy.md new file mode 100644 index 0000000000..68a5c04f44 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-openapi-from-api-cache-copy.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Return fresh OpenAPI specs from cached `OpenApi.fromApi` calls. diff --git a/repos/effect/.changeset/pre/fix-schema-sentinel-declaration.md b/repos/effect/.changeset/pre/fix-schema-sentinel-declaration.md new file mode 100644 index 0000000000..f042aef646 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-schema-sentinel-declaration.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Mark the internal `~sentinels` Schema annotation as `@internal` so release declaration stripping removes it together with `SchemaAST.Sentinel`. This keeps the published declarations self-consistent for consumers that type-check dependencies with `skipLibCheck: false`. diff --git a/repos/effect/.changeset/pre/fix-sqlite-persisted-queue.md b/repos/effect/.changeset/pre/fix-sqlite-persisted-queue.md new file mode 100644 index 0000000000..bb6bcc1b16 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-sqlite-persisted-queue.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix SQL persisted queue delivery on SQLite builds without `SQLITE_ENABLE_UPDATE_DELETE_LIMIT`. diff --git a/repos/effect/.changeset/pre/fix-zero-max-bytes.md b/repos/effect/.changeset/pre/fix-zero-max-bytes.md new file mode 100644 index 0000000000..c38fd6e0b6 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-zero-max-bytes.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-node-shared": patch +--- + +Enforce zero-byte limits when consuming Node readable streams. diff --git a/repos/effect/.changeset/pre/fix-zsh-mixed-command-completions.md b/repos/effect/.changeset/pre/fix-zsh-mixed-command-completions.md new file mode 100644 index 0000000000..67978c2bd0 --- /dev/null +++ b/repos/effect/.changeset/pre/fix-zsh-mixed-command-completions.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix Zsh completions for CLI commands with both positional arguments and subcommands. diff --git a/repos/effect/.changeset/pre/flat-graphs-agree.md b/repos/effect/.changeset/pre/flat-graphs-agree.md new file mode 100644 index 0000000000..8873ed9b48 --- /dev/null +++ b/repos/effect/.changeset/pre/flat-graphs-agree.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Ignore removed allocator history when comparing and hashing immutable Graph values with the same active indexed structure. diff --git a/repos/effect/.changeset/pre/fresh-deps-update.md b/repos/effect/.changeset/pre/fresh-deps-update.md new file mode 100644 index 0000000000..c6701bb517 --- /dev/null +++ b/repos/effect/.changeset/pre/fresh-deps-update.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Update dependencies across the Effect workspace. diff --git a/repos/effect/.changeset/pre/fresh-graphs-cycle.md b/repos/effect/.changeset/pre/fresh-graphs-cycle.md new file mode 100644 index 0000000000..d57f637496 --- /dev/null +++ b/repos/effect/.changeset/pre/fresh-graphs-cycle.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `Graph.findCycle` with exact node and edge witnesses. diff --git a/repos/effect/.changeset/pre/fuzzy-graphs-transform.md b/repos/effect/.changeset/pre/fuzzy-graphs-transform.md new file mode 100644 index 0000000000..c5d8fae757 --- /dev/null +++ b/repos/effect/.changeset/pre/fuzzy-graphs-transform.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add bulk node and edge removal operations, and disallow graph mutations from callbacks that traverse or transform the same graph. diff --git a/repos/effect/.changeset/pre/general-json-schema-converters.md b/repos/effect/.changeset/pre/general-json-schema-converters.md new file mode 100644 index 0000000000..366962f93a --- /dev/null +++ b/repos/effect/.changeset/pre/general-json-schema-converters.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Make JSON Schema dialect conversions preserve custom keywords, translate conditionals, contains, dependencies, identifiers, and tuples where representable, relocate local references after structural changes, and throw instead of silently changing unsupported constraints. diff --git a/repos/effect/.changeset/pre/http-json-reviver.md b/repos/effect/.changeset/pre/http-json-reviver.md new file mode 100644 index 0000000000..51ceb6ddf9 --- /dev/null +++ b/repos/effect/.changeset/pre/http-json-reviver.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add custom reviver support to HTTP JSON parsing APIs. diff --git a/repos/effect/.changeset/pre/http-status-from-literal.md b/repos/effect/.changeset/pre/http-status-from-literal.md new file mode 100644 index 0000000000..e5942772a5 --- /dev/null +++ b/repos/effect/.changeset/pre/http-status-from-literal.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add a `HttpStatus` module to `effect/unstable/http` that centralizes the mapping from HTTP status literal names to numeric codes and exports `HttpStatus.fromLiteral`. `HttpApiSchema.status` now consumes the new module. diff --git a/repos/effect/.changeset/pre/late-sockets-rest.md b/repos/effect/.changeset/pre/late-sockets-rest.md new file mode 100644 index 0000000000..b2fecca5ea --- /dev/null +++ b/repos/effect/.changeset/pre/late-sockets-rest.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-node": patch +--- + +Return `Option.none()` when reading an incoming message's remote address after Node clears its socket. diff --git a/repos/effect/.changeset/pre/lazy-openapi-initialization.md b/repos/effect/.changeset/pre/lazy-openapi-initialization.md new file mode 100644 index 0000000000..dc6a553306 --- /dev/null +++ b/repos/effect/.changeset/pre/lazy-openapi-initialization.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Defer built-in OpenAPI response generation until the documentation route is first requested, retrying after generation defects. diff --git a/repos/effect/.changeset/pre/match-generic-value-terminals.md b/repos/effect/.changeset/pre/match-generic-value-terminals.md new file mode 100644 index 0000000000..f72279c350 --- /dev/null +++ b/repos/effect/.changeset/pre/match-generic-value-terminals.md @@ -0,0 +1,10 @@ +--- +"effect": patch +--- + +Fix `Match.value` terminal combinators failing to typecheck when the input +contains a generic type parameter. + +The fifth type argument of `Matcher` for value matchers is now `ValueFlavor`, +and `ValueMatcher` has a seventh flavor argument; update hand-written +annotations accordingly. diff --git a/repos/effect/.changeset/pre/mcp-http-initialize-version-header.md b/repos/effect/.changeset/pre/mcp-http-initialize-version-header.md new file mode 100644 index 0000000000..5c49c3ad9e --- /dev/null +++ b/repos/effect/.changeset/pre/mcp-http-initialize-version-header.md @@ -0,0 +1,16 @@ +--- +"effect": patch +--- + +Fix MCP initialize rejected over the protocol version header + +`McpServer.layerHttp` validated the `MCP-Protocol-Version` header on every POST, including +the `initialize` request. That header reports the version negotiated by an earlier +`initialize`, so on a fresh connection a client can only send its own default. Whenever +that default was not among the server's registered protocols the `initialize` returned +`400` and never reached version negotiation, even when the body offered a version the +server supports. + +The header check now applies only to requests after initialization, where the +specification requires it. An `initialize` negotiates from the version offered in its +body, through the protocol registry, and reports the selected version in the response. diff --git a/repos/effect/.changeset/pre/mcp-protocol-versions.md b/repos/effect/.changeset/pre/mcp-protocol-versions.md new file mode 100644 index 0000000000..6c20ac168c --- /dev/null +++ b/repos/effect/.changeset/pre/mcp-protocol-versions.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +MCP servers now support the 2024-11-05 and 2025-03-26 RPC revisions through version-specific protocol adapters. diff --git a/repos/effect/.changeset/pre/merge-resource-failures.md b/repos/effect/.changeset/pre/merge-resource-failures.md new file mode 100644 index 0000000000..0f5730493b --- /dev/null +++ b/repos/effect/.changeset/pre/merge-resource-failures.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Merge effect and finalizer failures during cleanup, preserving other failures alongside `Cause.Done`. diff --git a/repos/effect/.changeset/pre/migrate-persisted-queues.md b/repos/effect/.changeset/pre/migrate-persisted-queues.md new file mode 100644 index 0000000000..6fefa10782 --- /dev/null +++ b/repos/effect/.changeset/pre/migrate-persisted-queues.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Run SQL `PersistedQueue` table creation through versioned migrations so future schema changes can be applied safely. diff --git a/repos/effect/.changeset/pre/narrow-schedule-while.md b/repos/effect/.changeset/pre/narrow-schedule-while.md new file mode 100644 index 0000000000..85344bc326 --- /dev/null +++ b/repos/effect/.changeset/pre/narrow-schedule-while.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Support narrowing schedule input and output types with type guard predicates passed to `Schedule.while`. diff --git a/repos/effect/.changeset/pre/neat-graphs-induced.md b/repos/effect/.changeset/pre/neat-graphs-induced.md new file mode 100644 index 0000000000..74b21df264 --- /dev/null +++ b/repos/effect/.changeset/pre/neat-graphs-induced.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add index-preserving `Graph.inducedSubgraph`. diff --git a/repos/effect/.changeset/pre/nested-union-sentinels.md b/repos/effect/.changeset/pre/nested-union-sentinels.md new file mode 100644 index 0000000000..bba5671a16 --- /dev/null +++ b/repos/effect/.changeset/pre/nested-union-sentinels.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Improve Union candidate selection: a nested union member is dispatched by the sentinels common to all its members, and candidates whose sentinel the input contradicts are excluded. diff --git a/repos/effect/.changeset/pre/node-redis-client.md b/repos/effect/.changeset/pre/node-redis-client.md new file mode 100644 index 0000000000..1e869ad6df --- /dev/null +++ b/repos/effect/.changeset/pre/node-redis-client.md @@ -0,0 +1,9 @@ +--- +"@effect/platform-node": patch +--- + +Migrate `NodeRedis` from `ioredis` to `redis` (node-redis), replacing the peer dependency with `redis: >=5.0.0 <7.0.0`. + +`layer` and `layerConfig` now accept `RedisClientOptions`: socket settings move under `socket`, `db` becomes `database`, command methods are camelCase, and arbitrary commands use `sendCommand`. Protocol selection follows the installed node-redis version's default. + +Layers connect while being built and can fail with `RedisError`. Initial connections fail fast unless a `socket.reconnectStrategy` is provided; after `ready`, the default reconnect behavior applies. Scope finalization uses `close()`, so in-flight or blocking commands can delay closure. diff --git a/repos/effect/.changeset/pre/normalize-unbounded-pubsub-replay.md b/repos/effect/.changeset/pre/normalize-unbounded-pubsub-replay.md new file mode 100644 index 0000000000..8cc87d6653 --- /dev/null +++ b/repos/effect/.changeset/pre/normalize-unbounded-pubsub-replay.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Normalize unbounded PubSub replay capacities to positive integers. diff --git a/repos/effect/.changeset/pre/open-objects-intersect.md b/repos/effect/.changeset/pre/open-objects-intersect.md new file mode 100644 index 0000000000..fcc55d4b22 --- /dev/null +++ b/repos/effect/.changeset/pre/open-objects-intersect.md @@ -0,0 +1,7 @@ +--- +"effect": patch +"@effect/openapi-generator": patch +--- + +Emit mixed struct and record schema types as intersections, preventing optional +properties in open OpenAPI objects from conflicting with their index signature. diff --git a/repos/effect/.changeset/pre/openai-compat-null-tool-calls.md b/repos/effect/.changeset/pre/openai-compat-null-tool-calls.md new file mode 100644 index 0000000000..06b23aed40 --- /dev/null +++ b/repos/effect/.changeset/pre/openai-compat-null-tool-calls.md @@ -0,0 +1,5 @@ +--- +"@effect/ai-openai-compat": patch +--- + +Preserve streamed text from OpenAI-compatible providers that send `tool_calls: null` on text-only chunks. diff --git a/repos/effect/.changeset/pre/openai-compatible-quota-errors.md b/repos/effect/.changeset/pre/openai-compatible-quota-errors.md new file mode 100644 index 0000000000..a12a53a98a --- /dev/null +++ b/repos/effect/.changeset/pre/openai-compatible-quota-errors.md @@ -0,0 +1,5 @@ +--- +"@effect/ai-openai": patch +--- + +Support max reasoning effort and quota errors returned by OpenAI-compatible providers. diff --git a/repos/effect/.changeset/pre/openai-text-prompt-cache.md b/repos/effect/.changeset/pre/openai-text-prompt-cache.md new file mode 100644 index 0000000000..f0f17bc012 --- /dev/null +++ b/repos/effect/.changeset/pre/openai-text-prompt-cache.md @@ -0,0 +1,6 @@ +--- +"@effect/ai-openai": patch +"effect": patch +--- + +Add support for explicit cache breakpoints on the OpenAI responses API for GPT-5.6-or-later. diff --git a/repos/effect/.changeset/pre/openai-tolerant-error-stream-event.md b/repos/effect/.changeset/pre/openai-tolerant-error-stream-event.md new file mode 100644 index 0000000000..ee2e518a04 --- /dev/null +++ b/repos/effect/.changeset/pre/openai-tolerant-error-stream-event.md @@ -0,0 +1,5 @@ +--- +"@effect/ai-openai": patch +--- + +Decode nested OpenAI Responses API error events without changing their decoded type. diff --git a/repos/effect/.changeset/pre/openapi-path-parameters.md b/repos/effect/.changeset/pre/openapi-path-parameters.md new file mode 100644 index 0000000000..fcf771ed2c --- /dev/null +++ b/repos/effect/.changeset/pre/openapi-path-parameters.md @@ -0,0 +1,6 @@ +--- +"effect": patch +"@effect/openapi-generator": patch +--- + +Allow path-level common parameters in OpenAPI generator input types. diff --git a/repos/effect/.changeset/pre/openapi-pointer-references.md b/repos/effect/.changeset/pre/openapi-pointer-references.md new file mode 100644 index 0000000000..e7ef509957 --- /dev/null +++ b/repos/effect/.changeset/pre/openapi-pointer-references.md @@ -0,0 +1,5 @@ +--- +"@effect/openapi-generator": patch +--- + +Decode JSON Pointer escapes when resolving local OpenAPI references. diff --git a/repos/effect/.changeset/pre/openapi-response-variants.md b/repos/effect/.changeset/pre/openapi-response-variants.md new file mode 100644 index 0000000000..60a248f8ce --- /dev/null +++ b/repos/effect/.changeset/pre/openapi-response-variants.md @@ -0,0 +1,6 @@ +--- +"@effect/openapi-generator": patch +--- + +Fix HttpClient response generation for mixed JSON-compatible representations, +binary success bodies, and bodiless error statuses. diff --git a/repos/effect/.changeset/pre/optic-standalone-functions.md b/repos/effect/.changeset/pre/optic-standalone-functions.md new file mode 100644 index 0000000000..fc0ebd947c --- /dev/null +++ b/repos/effect/.changeset/pre/optic-standalone-functions.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add dual standalone functions for reading and updating values through optics, closes #7299. diff --git a/repos/effect/.changeset/pre/precise-test-clock-nanos.md b/repos/effect/.changeset/pre/precise-test-clock-nanos.md new file mode 100644 index 0000000000..aec813c1a1 --- /dev/null +++ b/repos/effect/.changeset/pre/precise-test-clock-nanos.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve nanosecond precision when adjusting `TestClock` with large durations. diff --git a/repos/effect/.changeset/pre/preserve-openapi-raw-json.md b/repos/effect/.changeset/pre/preserve-openapi-raw-json.md new file mode 100644 index 0000000000..8718904d1c --- /dev/null +++ b/repos/effect/.changeset/pre/preserve-openapi-raw-json.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve `JSON.rawJSON` values when cloning cached OpenAPI specs. diff --git a/repos/effect/.changeset/pre/preserve-unsafe-workflow-interrupts.md b/repos/effect/.changeset/pre/preserve-unsafe-workflow-interrupts.md new file mode 100644 index 0000000000..eaff87e4b0 --- /dev/null +++ b/repos/effect/.changeset/pre/preserve-unsafe-workflow-interrupts.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve unsafe in-memory workflow interrupts across replay. diff --git a/repos/effect/.changeset/pre/proud-ears-say.md b/repos/effect/.changeset/pre/proud-ears-say.md new file mode 100644 index 0000000000..01a09eae46 --- /dev/null +++ b/repos/effect/.changeset/pre/proud-ears-say.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Support standalone Effect.forEach data-last usage + diff --git a/repos/effect/.changeset/pre/quick-graphs-paths.md b/repos/effect/.changeset/pre/quick-graphs-paths.md new file mode 100644 index 0000000000..2465d0a83c --- /dev/null +++ b/repos/effect/.changeset/pre/quick-graphs-paths.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add bounded lazy enumeration of simple paths and all tied shortest paths. diff --git a/repos/effect/.changeset/pre/quick-schedulers-promise.md b/repos/effect/.changeset/pre/quick-schedulers-promise.md new file mode 100644 index 0000000000..0cddcb3f34 --- /dev/null +++ b/repos/effect/.changeset/pre/quick-schedulers-promise.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Use Promise microtasks for synchronous Scheduler dispatch. diff --git a/repos/effect/.changeset/pre/quiet-graphs-guard.md b/repos/effect/.changeset/pre/quiet-graphs-guard.md new file mode 100644 index 0000000000..ababf77f08 --- /dev/null +++ b/repos/effect/.changeset/pre/quiet-graphs-guard.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Keep graph caches consistent during bulk removals and validate graph kinds at runtime. diff --git a/repos/effect/.changeset/pre/quiet-graphs-search.md b/repos/effect/.changeset/pre/quiet-graphs-search.md new file mode 100644 index 0000000000..7c4a6c3ac7 --- /dev/null +++ b/repos/effect/.changeset/pre/quiet-graphs-search.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Prevent graph edge reads from exposing internal edge records and reject non-finite A* heuristic values. diff --git a/repos/effect/.changeset/pre/quiet-rpcs-buffer.md b/repos/effect/.changeset/pre/quiet-rpcs-buffer.md new file mode 100644 index 0000000000..852a1f2025 --- /dev/null +++ b/repos/effect/.changeset/pre/quiet-rpcs-buffer.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Bound framed RPC server HTTP response streams to 16 items by default, with a configurable buffer size or an unbounded opt-out. diff --git a/repos/effect/.changeset/pre/random-hex-encoding.md b/repos/effect/.changeset/pre/random-hex-encoding.md new file mode 100644 index 0000000000..9d894f62d5 --- /dev/null +++ b/repos/effect/.changeset/pre/random-hex-encoding.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `Encoding.randomHex`, a lightweight non-cryptographic generator that coerces lengths to unsigned 32-bit multiples of 8. diff --git a/repos/effect/.changeset/pre/redact-cli-wizard-values.md b/repos/effect/.changeset/pre/redact-cli-wizard-values.md new file mode 100644 index 0000000000..b5be1435f3 --- /dev/null +++ b/repos/effect/.changeset/pre/redact-cli-wizard-values.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Redact password prompt values from CLI wizard command output. diff --git a/repos/effect/.changeset/pre/redis-subscribe-api.md b/repos/effect/.changeset/pre/redis-subscribe-api.md new file mode 100644 index 0000000000..b6203f7e4a --- /dev/null +++ b/repos/effect/.changeset/pre/redis-subscribe-api.md @@ -0,0 +1,8 @@ +--- +"effect": patch +"@effect/platform-bun": patch +"@effect/platform-deno": patch +"@effect/platform-node": patch +--- + +Add scoped Redis pub/sub subscriptions that expose received messages through an Effect queue. diff --git a/repos/effect/.changeset/pre/remove-kubernetes-types.md b/repos/effect/.changeset/pre/remove-kubernetes-types.md new file mode 100644 index 0000000000..395085e4f1 --- /dev/null +++ b/repos/effect/.changeset/pre/remove-kubernetes-types.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Remove the `kubernetes-types` dependency by vendoring the Kubernetes Pod declarations used by the cluster helpers and exporting them from `effect/unstable/cluster/K8sTypes`. diff --git a/repos/effect/.changeset/pre/required-boolean-flags.md b/repos/effect/.changeset/pre/required-boolean-flags.md new file mode 100644 index 0000000000..c49bea5ed2 --- /dev/null +++ b/repos/effect/.changeset/pre/required-boolean-flags.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Make unstable CLI boolean flags required when omitted, allowing optional, default, config, and prompt fallbacks to handle absence consistently. diff --git a/repos/effect/.changeset/pre/restore-effect-head.md b/repos/effect/.changeset/pre/restore-effect-head.md new file mode 100644 index 0000000000..0c2f7d3e8f --- /dev/null +++ b/repos/effect/.changeset/pre/restore-effect-head.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `Effect.head` for retrieving the first element of an iterable produced by an effect. diff --git a/repos/effect/.changeset/pre/rpc-server-notifications.md b/repos/effect/.changeset/pre/rpc-server-notifications.md new file mode 100644 index 0000000000..d4be791ee5 --- /dev/null +++ b/repos/effect/.changeset/pre/rpc-server-notifications.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Add support for server-originated RPC requests and notifications. Buffered +JSON-RPC HTTP drops notifications until streaming responses are available. diff --git a/repos/effect/.changeset/pre/schema-aware-rpc-serialization.md b/repos/effect/.changeset/pre/schema-aware-rpc-serialization.md new file mode 100644 index 0000000000..bef86f0ae2 --- /dev/null +++ b/repos/effect/.changeset/pre/schema-aware-rpc-serialization.md @@ -0,0 +1,9 @@ +--- +"effect": minor +--- + +Make RPC serialization schema-aware. + +Add `codecFor` to RPC serialization and client/server protocols so RPC and cluster +network payloads use the transport's schema codec. Framing, cluster storage, and +existing built-in wire formats remain unchanged. diff --git a/repos/effect/.changeset/pre/schema-binary-codec.md b/repos/effect/.changeset/pre/schema-binary-codec.md new file mode 100644 index 0000000000..b2628cb3ac --- /dev/null +++ b/repos/effect/.changeset/pre/schema-binary-codec.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `SchemaBinary`, a compact schema-derived codec with streaming, optional fingerprints and dictionaries, and RPC support. diff --git a/repos/effect/.changeset/pre/sql-begin-failure-typed.md b/repos/effect/.changeset/pre/sql-begin-failure-typed.md new file mode 100644 index 0000000000..adfdb341ad --- /dev/null +++ b/repos/effect/.changeset/pre/sql-begin-failure-typed.md @@ -0,0 +1,16 @@ +--- +"effect": patch +--- + +Propagate a failed `BEGIN` or `SAVEPOINT` from `SqlClient.withTransaction` as a typed `SqlError`. + +`makeWithTransaction` wrapped the `begin` step together with the transaction body, so a +failed `BEGIN` took the rollback branch. No transaction was active at that point, the +`ROLLBACK` failed, and its `Effect.orDie` wrapper replaced the original typed error with a +defect (`cannot rollback - no transaction is active`). Callers could no longer classify the +failure as retryable. The path became reachable when the sqlite client started using +`BEGIN IMMEDIATE`, which acquires a write lock and can fail with `SQLITE_BUSY`. + +Commit and rollback now run only after `begin` or `savepoint` succeeds. A failed `begin` or +`savepoint` fails with its original `SqlError`, leaves the wrapped effect unexecuted, and +still closes the acquired connection scope. diff --git a/repos/effect/.changeset/pre/stable-graph-traversal-config.md b/repos/effect/.changeset/pre/stable-graph-traversal-config.md new file mode 100644 index 0000000000..a4663f38af --- /dev/null +++ b/repos/effect/.changeset/pre/stable-graph-traversal-config.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Validate Graph traversal radii, isolate traversal start configuration, and prioritize the first supplied DFS root. diff --git a/repos/effect/.changeset/pre/standard-schema-module.md b/repos/effect/.changeset/pre/standard-schema-module.md new file mode 100644 index 0000000000..b3ac3eb109 --- /dev/null +++ b/repos/effect/.changeset/pre/standard-schema-module.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add a public `StandardSchema` module containing the vendored Standard Schema V1 specification and remove the direct dependency on `@standard-schema/spec`. diff --git a/repos/effect/.changeset/pre/tall-melons-shave.md b/repos/effect/.changeset/pre/tall-melons-shave.md new file mode 100644 index 0000000000..cdb764935b --- /dev/null +++ b/repos/effect/.changeset/pre/tall-melons-shave.md @@ -0,0 +1,9 @@ +--- +"@effect/sql-pg": patch +--- + +Add low-level PostgreSQL protocol, binary type, and authentication codecs to `@effect/sql-pg`. + +`PgProtocol` encodes protocol 3.0 messages and incrementally parses backend frames. Its stateful parser throws terminal errors. `PgTypes` handles binary scalar and one-dimensional array OIDs; its public codecs return typed `Result` failures, while parser field readers use an internal throwing fast path. `PgAuth` implements MD5 and SCRAM-SHA-256 with typed `Result` failures. + +Encoded frames and decoded byte fields are stable views over internal buffers. Copy data that must outlive its message. `PgClient` remains unchanged and still uses `pg` at runtime. diff --git a/repos/effect/.changeset/pre/tame-json-schema-intersections.md b/repos/effect/.changeset/pre/tame-json-schema-intersections.md new file mode 100644 index 0000000000..49d9af01cf --- /dev/null +++ b/repos/effect/.changeset/pre/tame-json-schema-intersections.md @@ -0,0 +1,14 @@ +--- +"effect": patch +--- + +Fix JSON Schema imports: + +- Type-specific keywords no longer imply a type. For example, `minLength` validates strings without rejecting + non-string values. +- Constraints next to `const`, `enum`, and `$ref` are now applied instead of being ignored. +- Disjoint and linear union intersections are imported without a Cartesian expansion. Other overlapping union + intersections fail with an explicit error. +- References to definitions without unions no longer make otherwise linear intersections fail. +- Imported `oneOf` schemas remain `oneOf` when exported again. +- `minItems` is preserved when `prefixItems` does not fully enforce it. diff --git a/repos/effect/.changeset/pre/theme-cli-prompts.md b/repos/effect/.changeset/pre/theme-cli-prompts.md new file mode 100644 index 0000000000..45ea5758b2 --- /dev/null +++ b/repos/effect/.changeset/pre/theme-cli-prompts.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Replace per-prompt prefix options with a context-based theme for CLI prompt symbols and colors. diff --git a/repos/effect/.changeset/pre/thirty-forks-march.md b/repos/effect/.changeset/pre/thirty-forks-march.md new file mode 100644 index 0000000000..0085d3d149 --- /dev/null +++ b/repos/effect/.changeset/pre/thirty-forks-march.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Drop unreachable concurrency guard in iteratorEagerImpl + \ No newline at end of file diff --git a/repos/effect/.changeset/pre/tidy-codecs-inherit.md b/repos/effect/.changeset/pre/tidy-codecs-inherit.md new file mode 100644 index 0000000000..137e05fd86 --- /dev/null +++ b/repos/effect/.changeset/pre/tidy-codecs-inherit.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +When canonical JSON derivation adds a transformation for a schema without a direct JSON representation, keep source checks and annotations on the source side. This prevents duplicate check execution and ensures generated JSON Schema documents describe only the encoded target, closes #7192. diff --git a/repos/effect/.changeset/pre/tidy-graphs-cycle.md b/repos/effect/.changeset/pre/tidy-graphs-cycle.md new file mode 100644 index 0000000000..9d4cb2d1a6 --- /dev/null +++ b/repos/effect/.changeset/pre/tidy-graphs-cycle.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Throw `GraphError` when a negative cycle affects a Bellman-Ford target, reserving `Option.none()` for unreachable paths. diff --git a/repos/effect/.changeset/pre/tidy-json-objects.md b/repos/effect/.changeset/pre/tidy-json-objects.md new file mode 100644 index 0000000000..80e7463706 --- /dev/null +++ b/repos/effect/.changeset/pre/tidy-json-objects.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Add `Schema.JsonObject` for readonly string-keyed records containing JSON-compatible values. This provides a canonical, +reusable schema instead of requiring callers to repeatedly compose `Schema.Record(Schema.String, Schema.Json)`. diff --git a/repos/effect/.changeset/pre/tidy-json-schema-object-scopes.md b/repos/effect/.changeset/pre/tidy-json-schema-object-scopes.md new file mode 100644 index 0000000000..2d3f1b7b41 --- /dev/null +++ b/repos/effect/.changeset/pre/tidy-json-schema-object-scopes.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve JSON Schema object keyword scopes when importing `allOf` intersections, including closed empty objects and required-only keys. Emit intersecting index signatures without weakening their constraints, and reject object scope intersections that cannot be represented faithfully. diff --git a/repos/effect/.changeset/pre/tidy-matches-select.md b/repos/effect/.changeset/pre/tidy-matches-select.md new file mode 100644 index 0000000000..985f71da0b --- /dev/null +++ b/repos/effect/.changeset/pre/tidy-matches-select.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `Match.fn` for reusable matchers that select a value from multiple arguments. diff --git a/repos/effect/.changeset/pre/tidy-ravens-import.md b/repos/effect/.changeset/pre/tidy-ravens-import.md new file mode 100644 index 0000000000..58265b76b7 --- /dev/null +++ b/repos/effect/.changeset/pre/tidy-ravens-import.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Reject unsupported JSON Schema validation keywords and object or array `const` / `enum` values during import instead of +silently weakening validation. diff --git a/repos/effect/.changeset/pre/tidy-schema-errors.md b/repos/effect/.changeset/pre/tidy-schema-errors.md new file mode 100644 index 0000000000..1ad564cebd --- /dev/null +++ b/repos/effect/.changeset/pre/tidy-schema-errors.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Move `SchemaError` into the `Schema` module and remove the standalone `SchemaError` module. diff --git a/repos/effect/.changeset/pre/tidy-tools-handle.md b/repos/effect/.changeset/pre/tidy-tools-handle.md new file mode 100644 index 0000000000..155071c9f5 --- /dev/null +++ b/repos/effect/.changeset/pre/tidy-tools-handle.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Preserve encoded AI tool call parameters when automatic tool call resolution is disabled, and update +`Toolkit.handle` to accept the encoded parameter type it decodes at runtime. diff --git a/repos/effect/.changeset/pre/tidy-tools-juggle.md b/repos/effect/.changeset/pre/tidy-tools-juggle.md new file mode 100644 index 0000000000..439548407f --- /dev/null +++ b/repos/effect/.changeset/pre/tidy-tools-juggle.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Ensure discarded non-persisted cluster messages complete without waiting for the entity reply. diff --git a/repos/effect/.changeset/pre/tough-graphs-cache.md b/repos/effect/.changeset/pre/tough-graphs-cache.md new file mode 100644 index 0000000000..010a1f8311 --- /dev/null +++ b/repos/effect/.changeset/pre/tough-graphs-cache.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix mutable graph cache consistency and guard weighted pathfinding against inconsistent snapshots and numeric overflow. diff --git a/repos/effect/.changeset/pre/tracer-perf.md b/repos/effect/.changeset/pre/tracer-perf.md new file mode 100644 index 0000000000..3a85e6d80e --- /dev/null +++ b/repos/effect/.changeset/pre/tracer-perf.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Improve tracing performance in span creation and HTTP middleware. diff --git a/repos/effect/.changeset/pre/twenty-garlics-marry.md b/repos/effect/.changeset/pre/twenty-garlics-marry.md new file mode 100644 index 0000000000..96087da85b --- /dev/null +++ b/repos/effect/.changeset/pre/twenty-garlics-marry.md @@ -0,0 +1,6 @@ +--- +"effect": patch +--- + +Fix `HttpRouter.Middleware.layer` to provide request error services for errors declared in `handles`, and expose global +middleware errors from `HttpRouter.toHttpEffect`. diff --git a/repos/effect/.changeset/pre/unique-directed-neighbors.md b/repos/effect/.changeset/pre/unique-directed-neighbors.md new file mode 100644 index 0000000000..fd62b1615e --- /dev/null +++ b/repos/effect/.changeset/pre/unique-directed-neighbors.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Deduplicate directed neighbor-node queries while preserving first edge occurrence order. diff --git a/repos/effect/.changeset/pre/warm-graphs-degree.md b/repos/effect/.changeset/pre/warm-graphs-degree.md new file mode 100644 index 0000000000..f140e4d846 --- /dev/null +++ b/repos/effect/.changeset/pre/warm-graphs-degree.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add incident-edge, edges-between, and directed and undirected degree queries to `Graph`. diff --git a/repos/effect/.changeset/pre/wild-donuts-brake.md b/repos/effect/.changeset/pre/wild-donuts-brake.md new file mode 100644 index 0000000000..4bfeecea7f --- /dev/null +++ b/repos/effect/.changeset/pre/wild-donuts-brake.md @@ -0,0 +1,31 @@ +--- +"effect": patch +--- + +Bound cluster runner entity residency and storage reads. + +`ShardingConfig` gains two knobs: + +- `maxResidentEntities` (default `10_000`): the maximum number of entities + that can be resident on a runner at the same time. At the cap, the storage + read loop stops admitting messages for new entity addresses (they stay in + storage until a slot frees up) and volatile sends to new addresses fail with + `MailboxFull`. Persisted sends still succeed. `"unbounded"` restores the + previous behaviour and can only be set programmatically. +- `unprocessedMessageBatchSize` (default `1024`): the maximum number of + unprocessed messages read from storage in a single poll. + +`MessageStorage.unprocessedMessages` accepts an optional +`{ limit, addresses }` argument, and only claims the messages it actually +returns. The memory implementation now applies the same ten-minute claim +window as SQL, so bounded reads advance past in-flight requests; resetting an +address or shard makes its claimed messages immediately eligible again. + +The encoded driver contract replaces `Encoded.resetAddress` with the batched +`Encoded.resetAddresses` operation. `SqlMessageStorage.makeEncoded` constructs +the SQL encoded driver directly for custom storage composition. + +`ClusterWorkflowEngine` entities (workflows and the durable clock) now use a +fixed ten-second idle time, so completed and suspended executions release their +entity slots quickly. Their state is durable, so an evicted execution is +rebuilt from storage when its next message arrives. diff --git a/repos/effect/.changeset/pre/wise-graphs-connect.md b/repos/effect/.changeset/pre/wise-graphs-connect.md new file mode 100644 index 0000000000..8ac3f4b7a7 --- /dev/null +++ b/repos/effect/.changeset/pre/wise-graphs-connect.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add unweighted reachability, explicit weak and strong connectivity predicates, weak components, and tree detection to `Graph`. diff --git a/repos/effect/.changeset/pre/worker-runner-multi-disconnect.md b/repos/effect/.changeset/pre/worker-runner-multi-disconnect.md new file mode 100644 index 0000000000..2d49ff0cdb --- /dev/null +++ b/repos/effect/.changeset/pre/worker-runner-multi-disconnect.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Forward every worker-runner client disconnect to the RPC server, not just the first one. diff --git a/repos/effect/.changeset/pre/workflow-proxy-discard-execution-id.md b/repos/effect/.changeset/pre/workflow-proxy-discard-execution-id.md new file mode 100644 index 0000000000..24b90519bc --- /dev/null +++ b/repos/effect/.changeset/pre/workflow-proxy-discard-execution-id.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Return workflow execution IDs from generated RPC and HTTP discard endpoints. diff --git a/repos/effect/.changeset/pre/zero-node-socket-timeout.md b/repos/effect/.changeset/pre/zero-node-socket-timeout.md new file mode 100644 index 0000000000..8123778659 --- /dev/null +++ b/repos/effect/.changeset/pre/zero-node-socket-timeout.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-node-shared": patch +--- + +Respect zero-duration open timeouts when opening Node sockets. diff --git a/repos/effect/.github/workflows/pullfrog.yml b/repos/effect/.github/workflows/pullfrog.yml deleted file mode 100644 index 9d9d612ee6..0000000000 --- a/repos/effect/.github/workflows/pullfrog.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Pullfrog - -run-name: ${{ inputs.name || github.workflow }} - -on: - workflow_dispatch: - inputs: - prompt: - type: string - description: Agent prompt - name: - type: string - description: Run name - -permissions: - contents: read - -jobs: - pullfrog: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - steps: - - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - fetch-depth: 1 - - name: Run agent - uses: pullfrog/pullfrog@1e3fe4d7969597d2bcf84922ac136e5936b37d8e # v0 - with: - prompt: ${{ inputs.prompt }} - status_checks: enabled diff --git a/repos/effect/.github/workflows/release-queue.yml b/repos/effect/.github/workflows/release-queue.yml index 65eccb3d41..cbf7d24721 100644 --- a/repos/effect/.github/workflows/release-queue.yml +++ b/repos/effect/.github/workflows/release-queue.yml @@ -35,7 +35,7 @@ jobs: if: github.event.pull_request env: GITHUB_TOKEN: ${{ secrets.EFFECT_BOT_GH }} - - uses: Effect-TS/next-release-action@0901387026995718742a42e0f6bf7743d0d83c2f + - uses: Effect-TS/next-release-action@63daaa626282bc42fad44dcb89c7f7db9c53b786 with: github_token: ${{ secrets.EFFECT_BOT_GH }} base_branch: main diff --git a/repos/effect/.github/workflows/snapshot.yml b/repos/effect/.github/workflows/snapshot.yml index 2910c5a66f..30fca3d8a3 100644 --- a/repos/effect/.github/workflows/snapshot.yml +++ b/repos/effect/.github/workflows/snapshot.yml @@ -42,4 +42,4 @@ jobs: run: pnpm build - name: Create snapshot id: snapshot - run: pnpm exec pkg-pr-new publish --pnpm --comment=off ./packages/* ./packages/atom/* ./packages/ai/* ./packages/sql/* ./packages/tools/* + run: pnpm exec pkg-pr-new publish --pnpm --comment=off ./packages/* ./packages/atom/* ./packages/ai/* ./packages/platform/* ./packages/sql/* ./packages/tools/* diff --git a/repos/effect/.oxlintrc.json b/repos/effect/.oxlintrc.json index 645b341739..b27dad5455 100644 --- a/repos/effect/.oxlintrc.json +++ b/repos/effect/.oxlintrc.json @@ -8,8 +8,10 @@ "**/docs", "**/.tsbuildinfo", "packages/effect/typeperf/**/*", + "packages/effect/src/StandardSchema.ts", "packages/**/CHANGELOG.md", "!scratchpad/**/*", + ".changeset/**/*", ".agents/**/*", ".context/**/*", ".specs/**/*" diff --git a/repos/effect/.vscode/settings.json b/repos/effect/.vscode/settings.json index 62a3e48170..721746cf3a 100644 --- a/repos/effect/.vscode/settings.json +++ b/repos/effect/.vscode/settings.json @@ -42,7 +42,7 @@ "deno.enable": true, "deno.lint": false, "deno.enablePaths": [ - "./packages/platform-deno" + "./packages/platform/deno" ], "js/ts.tsdk.path": "./node_modules/typescript/lib", "js/ts.tsdk.additionalLocations": ["./node_modules/typescript/lib"], diff --git a/repos/effect/LLMS.md b/repos/effect/LLMS.md index cce10204b7..b3021967ef 100644 --- a/repos/effect/LLMS.md +++ b/repos/effect/LLMS.md @@ -327,6 +327,17 @@ if (Predicate.isObject(thing)) { } ``` +## Working with SQL databases + +Use the `effect/unstable/sql` modules together with a driver package such as +`@effect/sql-sqlite-node` to access SQL databases. Define domain models with +`Model.Class` to derive schemas for the database and JSON boundaries, run +migrations, and write type-safe queries. + +- **[Getting started with SQL](./ai-docs/src/40_sql/10_basics.ts)**: + Define a schema-backed domain model, run migrations against a SQLite + database, and expose a derived repository through a service. + ## Effect HttpClient Build http clients with the `HttpClient` module. @@ -340,6 +351,9 @@ Build http clients with the `HttpClient` module. - **[Getting started with HttpApi](./ai-docs/src/51_http-server/10_basics.ts)**: Define a schema-first API, implement handlers, secure endpoints with middleware, serve it over HTTP, and call it using a generated typed client. +- **[Testing HttpApi implementations](./ai-docs/src/51_http-server/20_testing.ts)**: + Test handlers through an in-memory typed client with `HttpApiTest`, without + starting an HTTP server or touching a real database. ## Working with child processes diff --git a/repos/effect/README.md b/repos/effect/README.md index efdb0b12f6..93973efa24 100644 --- a/repos/effect/README.md +++ b/repos/effect/README.md @@ -4,25 +4,62 @@ # Effect -Effect is a library for building robust, maintainable, type-safe, and production grade applications in TypeScript. +Effect is a library for building robust, maintainable, type-safe, and production grade applications in TypeScript. It helps you handle the hard problems at scale: typed errors, dependency injection, structured concurrency, scheduling, tracing, and unified schema validation. -> **Effect V4 is currently in beta.** The `main` branch contains v4 development. +> **Effect V4 is currently a release candidate.** The `main` branch contains v4 development. -## Install V4 Beta +## Install V4 RC ```sh -npm install effect@beta +npm install effect@rc ``` +## Requirements + +- **TypeScript 5.9 or newer.** TypeScript 7 is recommended for the best performance and compatibility with [Effect's TypeScript tooling](https://github.com/Effect-TS/tsgo#installation). +- **Node.js 18 or newer** is the general minimum for running Effect on Node.js. Some integration packages require newer runtimes; for example, `@effect/sql-sqlite-node` requires Node.js 22.16 or newer. +- **Strict type-checking:** the `strict` flag must be enabled in your `tsconfig.json`. + ## Effect v3 -The Effect v3 source code is available on the [`v3`](https://github.com/Effect-TS/effect/tree/v3) branch. +The Effect v3 source code is available on the [`v3`](https://github.com/Effect-TS/effect/tree/v3) branch, which is also where issues and pull requests meant for Effect v3 should be targeted. -```sh -npm install effect@latest -``` +## Packages + +This monorepo contains the core `effect` package alongside integration packages that extend it. All v4 packages are published under the `rc` tag on npm. -Issues and pull requests meant for Effect v3 should target the [`v3`](https://github.com/Effect-TS/effect/tree/v3) branch. +| Package | Description | API Reference | +| --------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------ | +| [`effect`](packages/effect) | The core package | [docs](https://effect.website/docs/v4/api/effect) | +| [`@effect/platform-browser`](packages/platform/browser) | Platform services for the browser | [docs](https://effect.website/docs/v4/api/platform-browser) | +| [`@effect/platform-bun`](packages/platform/bun) | Platform services for [Bun](https://bun.sh) | [docs](https://effect.website/docs/v4/api/platform-bun) | +| [`@effect/platform-deno`](packages/platform/deno) | Platform services for [Deno](https://deno.com) | [docs](https://effect.website/docs/v4/api/platform-deno) | +| [`@effect/platform-node`](packages/platform/node) | Platform services for [Node.js](https://nodejs.org) | [docs](https://effect.website/docs/v4/api/platform-node) | +| [`@effect/platform-node-shared`](packages/platform/node-shared) | Shared services for Node.js-compatible runtimes | [docs](https://effect.website/docs/v4/api/platform-node-shared) | +| [`@effect/sql-clickhouse`](packages/sql/clickhouse) | SQL client for [ClickHouse](https://clickhouse.com) | [docs](https://effect.website/docs/v4/api/sql-clickhouse) | +| [`@effect/sql-d1`](packages/sql/d1) | SQL client for Cloudflare D1 | [docs](https://effect.website/docs/v4/api/sql-d1) | +| [`@effect/sql-libsql`](packages/sql/libsql) | SQL client for libSQL | [docs](https://effect.website/docs/v4/api/sql-libsql) | +| [`@effect/sql-mssql`](packages/sql/mssql) | SQL client for Microsoft SQL Server | [docs](https://effect.website/docs/v4/api/sql-mssql) | +| [`@effect/sql-mysql2`](packages/sql/mysql2) | SQL client for MySQL | [docs](https://effect.website/docs/v4/api/sql-mysql2) | +| [`@effect/sql-pg`](packages/sql/pg) | SQL client for PostgreSQL | [docs](https://effect.website/docs/v4/api/sql-pg) | +| [`@effect/sql-pglite`](packages/sql/pglite) | SQL client for [PGlite](https://pglite.dev) | [docs](https://effect.website/docs/v4/api/sql-pglite) | +| [`@effect/sql-sqlite-bun`](packages/sql/sqlite-bun) | SQL client for SQLite via `bun:sqlite` | [docs](https://effect.website/docs/v4/api/sql-sqlite-bun) | +| [`@effect/sql-sqlite-do`](packages/sql/sqlite-do) | SQL client for Cloudflare Durable Objects SQLite | [docs](https://effect.website/docs/v4/api/sql-sqlite-do) | +| [`@effect/sql-sqlite-node`](packages/sql/sqlite-node) | SQL client for SQLite via `node:sqlite` | [docs](https://effect.website/docs/v4/api/sql-sqlite-node) | +| [`@effect/sql-sqlite-react-native`](packages/sql/sqlite-react-native) | SQL client for SQLite in React Native | [docs](https://effect.website/docs/v4/api/sql-sqlite-react-native) | +| [`@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-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) | +| [`@effect/atom-react`](packages/atom/react) | React bindings for Effect Atom | [docs](https://effect.website/docs/v4/api/atom-react) | +| [`@effect/atom-solid`](packages/atom/solid) | SolidJS bindings for Effect Atom | [docs](https://effect.website/docs/v4/api/atom-solid) | +| [`@effect/atom-vue`](packages/atom/vue) | Vue bindings for Effect Atom | [docs](https://effect.website/docs/v4/api/atom-vue) | +| [`@effect/opentelemetry`](packages/opentelemetry) | [OpenTelemetry](https://opentelemetry.io) integration | [docs](https://effect.website/docs/v4/api/opentelemetry) | +| [`@effect/vitest`](packages/vitest) | Helpers for testing with [Vitest](https://vitest.dev) | [docs](https://effect.website/docs/v4/api/vitest) | +| [`@effect/docgen`](packages/tools/docgen) | Documentation generator for Effect projects | [docs](https://effect.website/docs/v4/api/docgen) | +| [`@effect/doctest`](packages/tools/doctest) | Runs JSDoc examples as Vitest tests | [docs](https://effect.website/docs/v4/api/doctest) | +| [`@effect/openapi-generator`](packages/tools/openapi-generator) | Generate Effect code from OpenAPI specifications | [docs](https://effect.website/docs/v4/api/openapi-generator) | ## Resources diff --git a/repos/effect/ai-docs/package.json b/repos/effect/ai-docs/package.json index 10fef9d9fd..90a59f266f 100644 --- a/repos/effect/ai-docs/package.json +++ b/repos/effect/ai-docs/package.json @@ -27,8 +27,8 @@ "@effect/sql-sqlite-wasm": "workspace:*", "@effect/vitest": "workspace:*", "effect": "workspace:*", - "hono": "^4.12.28", - "nodemailer": "^9.0.3" + "hono": "^4.13.3", + "nodemailer": "^9.0.5" }, "devDependencies": { "@types/nodemailer": "^8.0.1" diff --git a/repos/effect/ai-docs/src/40_sql/10_basics.ts b/repos/effect/ai-docs/src/40_sql/10_basics.ts new file mode 100644 index 0000000000..59a590ae2c --- /dev/null +++ b/repos/effect/ai-docs/src/40_sql/10_basics.ts @@ -0,0 +1,175 @@ +/** + * @title Getting started with SQL + * + * Define a schema-backed domain model, run migrations against a SQLite + * database, and expose a derived repository through a service. + */ +import { NodeRuntime } from "@effect/platform-node" +import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node" +import { Context, Effect, Layer, Schema } from "effect" +import { Model } from "effect/unstable/schema" +import { SqlClient, SqlModel, SqlSchema } from "effect/unstable/sql" + +// Use a branded string for the group id, so it cannot be mixed up with other +// string ids in the application. +export const GroupId = Schema.String.pipe(Schema.brand("GroupId")) +export type GroupId = typeof GroupId.Type + +// `Model.Class` defines a domain model with variants for the database and JSON +// boundaries. The single field declaration is the source of truth, and each +// variant only contains the fields that make sense for that operation: +// +// - `Group` / `Group.insert` / `Group.update` for the database +// - `Group.json` / `Group.jsonCreate` / `Group.jsonUpdate` for JSON APIs +export class Group extends Model.Class("Group")({ + // A UUID v4 primary key that is generated by the application on insert + id: Model.UuidV4Insert(GroupId), + name: Schema.NonEmptyString, + // `Model.FieldExcept` removes a field from the given variants. The slug is + // chosen when the group is created and is immutable afterwards, so it is + // removed from the update variants. + slug: Schema.NonEmptyString.pipe(Model.FieldExcept(["update", "jsonUpdate"])), + // `Model.FieldOnly` keeps a field in only the given variants. Internal notes + // live in the database and are never exposed through the JSON variants. + notes: Schema.NullOr(Schema.String).pipe(Model.FieldOnly(["select", "insert"])), + // `Model.Field` gives full control over the individual variants. The member + // count is maintained by the database, so the application can read it but + // never writes it. + memberCount: Model.Field({ + select: Schema.Int, + json: Schema.Int + }), + // `createdAt` is set to the current time on insert, and `updatedAt` is + // refreshed on every update. Both are stored as strings, which suits SQLite. + createdAt: Model.DateTimeInsert, + updatedAt: Model.DateTimeUpdate +}) {} + +export class GroupNotFound extends Schema.TaggedError()("GroupNotFound", { + id: GroupId +}) {} + +// The SqlClient layer determines which database you are talking to. Swap this +// layer for `@effect/sql-pg`, `@effect/sql-mysql2` etc. to target another +// database without changing the rest of the code. +const SqlLayer = SqliteClient.layer({ filename: ":memory:" }) + +// Migrations are effects keyed by `_` that run once, in id order. A +// real application would keep each migration in its own file and load them +// with `SqliteMigrator.fromFileSystem` instead of an inline record. +const MigratorLayer = SqliteMigrator.layer({ + loader: SqliteMigrator.fromRecord({ + "0001_create_groups": Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient + yield* sql` + CREATE TABLE groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL, + notes TEXT, + memberCount INTEGER NOT NULL DEFAULT 0, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + ` + }) + }) +}) + +// Combine the database client with the migrations, so anything built on top of +// `SqlLive` sees a fully migrated database. +const SqlLive = MigratorLayer.pipe(Layer.provideMerge(SqlLayer)) + +// Wrap data access in a service, so the rest of the application depends on +// `Groups` instead of the database directly. +export class Groups extends Context.Service + rename(id: GroupId, name: string): Effect.Effect + findById(id: GroupId): Effect.Effect + readonly list: Effect.Effect> +}>()("app/Groups") { + static readonly layer = Layer.effect( + Groups, + Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient + + // `SqlModel.makeRepository` derives insert / update / findById / delete + // operations from the model, using the matching variant schema for each + // operation. + const repo = yield* SqlModel.makeRepository(Group, { + tableName: "groups", + spanPrefix: "Groups", + idColumn: "id" + }) + + // For queries the repository does not cover, combine the `sql` tag with + // `SqlSchema` to decode the rows using the model schema. + const listAll = SqlSchema.findAll({ + Request: Schema.Void, + Result: Group, + execute: () => sql`SELECT * FROM groups ORDER BY createdAt` + }) + + // Use `Effect.fn` to give each method a named span for observability. + const create = Effect.fn("Groups.create")((name: string, slug: string) => + // `Group.insert.makeEffect` fills in the generated id and timestamps + // using the Effect clock, so tests can control them with `TestClock`. + Group.insert.makeEffect({ name, slug, notes: null }).pipe( + Effect.flatMap(repo.insert), + // Database and encoding failures are unexpected here, so treat + // them as defects to keep the service interface focused on domain + // errors. + Effect.orDie + ) + ) + + const rename = Effect.fn("Groups.rename")((id: GroupId, name: string) => + Group.update.makeEffect({ id, name }).pipe( + Effect.flatMap(repo.update), + Effect.orDie + ) + ) + + const findById = Effect.fn("Groups.findById")((id: GroupId) => + repo.findById(id).pipe( + Effect.catchTags({ + NoSuchElementError: () => new GroupNotFound({ id }), + SchemaError: Effect.die, + SqlError: Effect.die + }) + ) + ) + + const list = listAll().pipe( + Effect.orDie, + Effect.withSpan("Groups.list") + ) + + return Groups.of({ create, rename, findById, list }) + }) + ).pipe( + // Provide the layers locally, so lots of messy wiring doesn't need to + // happen in the "main" entrypoint of the application. + Layer.provide(SqlLive) + ) +} + +const program = Effect.gen(function*() { + const groups = yield* Groups + + const engineering = yield* groups.create("Engineering", "engineering") + const design = yield* groups.create("Design", "design") + + yield* groups.rename(design.id, "Product Design") + + const found = yield* groups.findById(engineering.id) + yield* Effect.log("found group", found) + + const all = yield* groups.list + yield* Effect.log(`total groups: ${all.length}`) +}) + +program.pipe( + Effect.provide(Groups.layer), + NodeRuntime.runMain +) diff --git a/repos/effect/ai-docs/src/40_sql/index.md b/repos/effect/ai-docs/src/40_sql/index.md new file mode 100644 index 0000000000..cf026b2c87 --- /dev/null +++ b/repos/effect/ai-docs/src/40_sql/index.md @@ -0,0 +1,6 @@ +## Working with SQL databases + +Use the `effect/unstable/sql` modules together with a driver package such as +`@effect/sql-sqlite-node` to access SQL databases. Define domain models with +`Model.Class` to derive schemas for the database and JSON boundaries, run +migrations, and write type-safe queries. diff --git a/repos/effect/ai-docs/src/51_http-server/10_basics.ts b/repos/effect/ai-docs/src/51_http-server/10_basics.ts index 79e92c9089..0edea44653 100644 --- a/repos/effect/ai-docs/src/51_http-server/10_basics.ts +++ b/repos/effect/ai-docs/src/51_http-server/10_basics.ts @@ -24,7 +24,9 @@ const SystemApiHandlers = HttpApiBuilder.group( Api, "system", Effect.fn(function*(handlers) { - return handlers.handle("health", () => Effect.void) + return handlers.handleAll({ + health: () => Effect.void + }) }) ) diff --git a/repos/effect/ai-docs/src/51_http-server/20_testing.ts b/repos/effect/ai-docs/src/51_http-server/20_testing.ts new file mode 100644 index 0000000000..e34d13d22b --- /dev/null +++ b/repos/effect/ai-docs/src/51_http-server/20_testing.ts @@ -0,0 +1,100 @@ +/** + * @title Testing HttpApi implementations + * + * Test handlers through an in-memory typed client with `HttpApiTest`, without + * starting an HTTP server or touching a real database. + */ +import { assert, layer } from "@effect/vitest" +import { Effect, Layer } from "effect" +import { HttpClientRequest, HttpServer } from "effect/unstable/http" +import { HttpApiMiddleware, HttpApiTest } from "effect/unstable/httpapi" +import { Api } from "./fixtures/api/Api.ts" +import { Authorization } from "./fixtures/api/Authorization.ts" +import { UserId } from "./fixtures/domain/User.ts" +import { AuthorizationLayer } from "./fixtures/server/Authorization.ts" +import { Users } from "./fixtures/server/Users.ts" +import { UsersApiHandlersNoDeps } from "./fixtures/server/Users/http.ts" + +// Provide the handlers with the in-memory `Users` implementation, so the full +// HTTP pipeline is exercised without any SQL. The Authorization middleware is +// provided with `Layer.provideMerge`, because the HTTP pipeline also resolves +// it when the routes are built. +const HandlersLayer = UsersApiHandlersNoDeps.pipe( + Layer.provide(Users.layerMemory), + Layer.provideMerge(AuthorizationLayer) +) + +// The client-side Authorization middleware supplies the bearer token. +// Providing different middleware implementations lets the tests cover both +// authorized and unauthorized requests. +const AuthorizationMiddlewareGood = HttpApiMiddleware.layerClient( + Authorization, + ({ next, request }) => next(HttpClientRequest.bearerToken(request, "dev-token")) +) + +const AuthorizationMiddlewareBad = HttpApiMiddleware.layerClient( + Authorization, + // Forward the request without attaching a token + ({ next, request }) => next(request) +) + +// `HttpApiTest.groups` builds a typed client wired directly to the handlers of +// the selected groups, using the same request encoding, routing, and response +// decoding as a real server. +const makeClient = HttpApiTest.groups(Api, ["users"]) + +// `HttpServer.layerServices` provides the platform services the HTTP pipeline +// needs in tests. +layer(Layer.mergeAll(HandlersLayer, HttpServer.layerServices))("UsersApi", (it) => { + it.effect("lists, fetches, and creates users", () => + Effect.gen(function*() { + const client = yield* makeClient + + const created = yield* client.users.create({ + payload: { name: "Alice", email: "alice@acme.dev" } + }) + assert.strictEqual(created.name, "Alice") + + const fetched = yield* client.users.getById({ + params: { id: created.id } + }) + assert.deepStrictEqual(fetched, created) + + const all = yield* client.users.list({ query: {} }) + assert.isTrue(all.some((user) => user.id === created.id)) + }).pipe(Effect.provide(AuthorizationMiddlewareGood))) + + it.effect("returns a 404 for a missing user", () => + Effect.gen(function*() { + const client = yield* makeClient + + // Use Effect.flip to assert on the error channel + const error = yield* client.users.getById({ + params: { id: UserId.make("019845e1-682f-4b02-a706-3b2422d13aec") } + }).pipe(Effect.flip) + assert.strictEqual(error._tag, "UserNotFound") + }).pipe(Effect.provide(AuthorizationMiddlewareGood))) + + it.effect("rejects requests without a valid bearer token", () => + Effect.gen(function*() { + const client = yield* makeClient + + const error = yield* client.users.list({ query: {} }).pipe(Effect.flip) + assert.strictEqual(error._tag, "Unauthorized") + }).pipe(Effect.provide(AuthorizationMiddlewareBad))) + + it.effect("rejects requests with an invalid bearer token", () => + Effect.gen(function*() { + const client = yield* makeClient + + const error = yield* client.users.getById({ + params: { id: UserId.make("019845e1-682f-4b02-a706-3b2422d13aec") } + }).pipe(Effect.flip) + assert.strictEqual(error._tag, "Unauthorized") + }).pipe( + Effect.provide(HttpApiMiddleware.layerClient( + Authorization, + ({ next, request }) => next(HttpClientRequest.bearerToken(request, "wrong-token")) + )) + )) +}) diff --git a/repos/effect/ai-docs/src/51_http-server/fixtures/api/Users.ts b/repos/effect/ai-docs/src/51_http-server/fixtures/api/Users.ts index f3b56d4ef8..ad370e1631 100644 --- a/repos/effect/ai-docs/src/51_http-server/fixtures/api/Users.ts +++ b/repos/effect/ai-docs/src/51_http-server/fixtures/api/Users.ts @@ -10,7 +10,10 @@ export class UsersApiGroup extends HttpApiGroup.make("users") query: { search: Schema.optional(Schema.String) }, - success: Schema.Array(User) + // Use the `json` variant of the model for API responses. It shares the + // field declarations with the database variants, but can encode values + // differently where needed. + success: Schema.Array(User.json) }), HttpApiEndpoint.get("search", "/search", { // For get requests, payload uses the query string @@ -18,7 +21,7 @@ export class UsersApiGroup extends HttpApiGroup.make("users") search: Schema.String }, success: [ - Schema.Array(User), + Schema.Array(User.json), Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/csv" })) @@ -39,13 +42,12 @@ export class UsersApiGroup extends HttpApiGroup.make("users") }), HttpApiEndpoint.get("getById", "/:id", { params: { - // Path parameter schemas need to be able to decode from strings. - // Schema.decodeTo can be used to "bridge" between schemas - id: Schema.FiniteFromString.pipe( - Schema.decodeTo(UserId) - ) + // Path parameter values are automatically coerced from their string + // form using `Schema.toCodecStringTree`, so schemas that decode from + // other types (like numbers) work here as well. + id: UserId }, - success: User, + success: User.json, error: UserNotFound.pipe( // If you want an error to return no content, you can use // `HttpApiSchema.asNoContent` and provide a decoder that transforms the @@ -59,14 +61,28 @@ export class UsersApiGroup extends HttpApiGroup.make("users") // For post requests, payload uses the request body. It defaults to JSON, // but you can specify other content types as well using // `HttpApiSchema.asText`, `HttpApiSchema.asMultipart`, etc. - payload: Schema.Struct({ - name: Schema.String, - email: Schema.String - }), - success: User + // + // The `jsonCreate` variant only exposes the fields clients are allowed + // to provide, so the generated id and timestamps cannot be set here. + payload: User.jsonCreate, + success: User.json + }), + HttpApiEndpoint.patch("update", "/:id", { + params: { + id: UserId + }, + // The `jsonUpdate` variant similarly excludes the id and the managed + // timestamps from the update payload. + payload: User.jsonUpdate, + success: User.json, + error: UserNotFound.pipe( + HttpApiSchema.asNoContent({ + decode: () => new UserNotFound() + }) + ) }), HttpApiEndpoint.get("me", "/me", { - success: User, + success: User.json, error: UserNotFound.pipe(HttpApiSchema.status(404)) }) ) diff --git a/repos/effect/ai-docs/src/51_http-server/fixtures/domain/User.ts b/repos/effect/ai-docs/src/51_http-server/fixtures/domain/User.ts index 852e2011e5..43ac4f5cff 100644 --- a/repos/effect/ai-docs/src/51_http-server/fixtures/domain/User.ts +++ b/repos/effect/ai-docs/src/51_http-server/fixtures/domain/User.ts @@ -1,12 +1,21 @@ import { Schema } from "effect" +import { Model } from "effect/unstable/schema" -export const UserId = Schema.Int.pipe( - Schema.brand("UserId") -) +export const UserId = Schema.String.pipe(Schema.brand("UserId")) export type UserId = typeof UserId.Type -export class User extends Schema.Class("User")({ - id: UserId, +// `Model.Class` derives variants for the database (`User`, `User.insert`, +// `User.update`) and the JSON API (`User.json`, `User.jsonCreate`, +// `User.jsonUpdate`) from a single field declaration. +export class User extends Model.Class("User")({ + // A UUID v4 primary key generated by the application on insert. It is + // excluded from the `jsonCreate` / `jsonUpdate` variants, so API clients can + // never set it. + id: Model.UuidV4Insert(UserId), name: Schema.String, - email: Schema.String + email: Schema.String, + // Timestamps are managed by the model: set on insert, refreshed on update, + // and also excluded from the JSON create / update variants. + createdAt: Model.DateTimeInsert, + updatedAt: Model.DateTimeUpdate }) {} diff --git a/repos/effect/ai-docs/src/51_http-server/fixtures/server/Authorization.ts b/repos/effect/ai-docs/src/51_http-server/fixtures/server/Authorization.ts index bbdbed39bb..51ae21dc0e 100644 --- a/repos/effect/ai-docs/src/51_http-server/fixtures/server/Authorization.ts +++ b/repos/effect/ai-docs/src/51_http-server/fixtures/server/Authorization.ts @@ -1,7 +1,16 @@ -import { Effect, Layer, Redacted } from "effect" +import { DateTime, Effect, Layer, Redacted } from "effect" import { Authorization, CurrentUser, Unauthorized } from "../api/Authorization.ts" import { User, UserId } from "../domain/User.ts" +const fixedTimestamp = DateTime.makeUnsafe("2026-01-01T00:00:00Z") +const devUser = new User({ + id: UserId.make("bf3dbe33-0ad2-4c9c-9c9e-733e57bdcbee"), + name: "Dev User", + email: "dev@acme.com", + createdAt: fixedTimestamp, + updatedAt: fixedTimestamp +}) + // The implementation of the Authorization middleware. It is seperate from the // service definition to avoid leaking it into a client. export const AuthorizationLayer = Layer.effect( @@ -21,15 +30,7 @@ export const AuthorizationLayer = Layer.effect( // Provide the current user to the rest of the stack. This will be // available in any endpoint or middleware that runs after this one. - return yield* Effect.provideService( - httpEffect, - CurrentUser, - new User({ - id: UserId.make(1), - name: "Dev User", - email: "dev@acme.com" - }) - ) + return yield* Effect.provideService(httpEffect, CurrentUser, devUser) }) }) }) diff --git a/repos/effect/ai-docs/src/51_http-server/fixtures/server/Users.ts b/repos/effect/ai-docs/src/51_http-server/fixtures/server/Users.ts index a9833762c2..485d1e3482 100644 --- a/repos/effect/ai-docs/src/51_http-server/fixtures/server/Users.ts +++ b/repos/effect/ai-docs/src/51_http-server/fixtures/server/Users.ts @@ -1,28 +1,142 @@ -import { Context, Effect, Layer, Ref } from "effect" -import { User, UserId } from "../domain/User.ts" +import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node" +import { Context, Effect, Layer, Schema } from "effect" +import { SqlClient, SqlModel, SqlSchema } from "effect/unstable/sql" +import { User } from "../domain/User.ts" +import type { UserId } from "../domain/User.ts" import { SearchQueryTooShort, UserNotFound, UsersError } from "../domain/UserErrors.ts" +// The SqlClient layer determines which database the SQL implementation talks +// to. Swap it for another driver package to target a different database. +const SqlLayer = SqliteClient.layer({ filename: ":memory:" }) + +// Migrations are effects keyed by `_` that run once, in id order. A +// real application would keep each migration in its own file and load them +// with `SqliteMigrator.fromFileSystem` instead of an inline record. +const MigratorLayer = SqliteMigrator.layer({ + loader: SqliteMigrator.fromRecord({ + "0001_create_users": Effect.gen(function*() { + const sql = yield* SqlClient.SqlClient + yield* sql` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + ` + }) + }) +}) + export class Users extends Context.Service, UsersError> getById(id: UserId): Effect.Effect - create(input: { readonly name: string; readonly email: string }): Effect.Effect + create(input: typeof User.jsonCreate.Type): Effect.Effect + update(id: UserId, input: typeof User.jsonUpdate.Type): Effect.Effect }>()("acme/Users") { - static readonly layer = Layer.effect( + // The SQL implementation only requires a `SqlClient`, so entrypoints and + // tests decide how the database is provided. + static readonly layerNoDeps = Layer.effect( Users, Effect.gen(function*() { - const users = new Map([ - [ - 1, - new User({ - id: UserId.make(1), - name: "Admin", - email: "admin@acme.dev" + const sql = yield* SqlClient.SqlClient + + // CRUD goes through a repository derived from the `User` model. Each + // operation uses the matching model variant to encode its input and + // decodes rows with the full model schema. + const repo = yield* SqlModel.makeRepository(User, { + tableName: "users", + spanPrefix: "Users", + idColumn: "id" + }) + + // Queries the repository does not cover are written with the `sql` tag + // and decoded with the model schema. + const listAll = SqlSchema.findAll({ + Request: Schema.Void, + Result: User, + execute: () => sql`SELECT * FROM users ORDER BY createdAt` + }) + + const searchUsers = SqlSchema.findAll({ + Request: Schema.String, + Result: User, + execute: (search) => { + const pattern = `%${search}%` + return sql`SELECT * FROM users WHERE name LIKE ${pattern} OR email LIKE ${pattern}` + } + }) + + const list = Effect.fn("Users.list")(function*(search: string | undefined) { + if (search === undefined || search.length === 0) { + return yield* Effect.orDie(listAll()) + } else if (search.length < SearchQueryTooShort.minimumLength) { + return yield* new UsersError({ + reason: new SearchQueryTooShort() }) - ] - ]) - const nextId = yield* Ref.make(2) + } + yield* Effect.annotateCurrentSpan({ search }) + return yield* Effect.orDie(searchUsers(search)) + }) - const list = Effect.fn("UsersRepo.list")(function*(search: string | undefined) { + const getById = Effect.fn("Users.getById")((id: UserId) => + repo.findById(id).pipe( + Effect.catchTags({ + NoSuchElementError: () => new UsersError({ reason: new UserNotFound() }), + // Database and encoding failures are unexpected, so treat them as + // defects to keep the service interface focused on domain errors. + SchemaError: Effect.die, + SqlError: Effect.die + }) + ) + ) + + const create = Effect.fn("Users.create")((input: typeof User.jsonCreate.Type) => + // `User.insert.makeEffect` fills in the generated id and timestamps + // using the Effect clock, so tests can control them with `TestClock`. + User.insert.makeEffect(input).pipe( + Effect.flatMap(repo.insert), + Effect.orDie + ) + ) + + const update = Effect.fn("Users.update")(function*(id: UserId, input: typeof User.jsonUpdate.Type) { + // Ensure the user exists first, so a missing id fails with the domain + // error instead of a defect. + yield* getById(id) + const update = yield* User.update.makeEffect({ id, ...input }).pipe(Effect.orDie) + return yield* repo.update(update).pipe(Effect.orDie) + }) + + return Users.of({ list, getById, create, update }) + }) + ) + + // The fully provided SQL implementation: the database client and migrations + // are implementation details, so this layer requires nothing. + static readonly layer: Layer.Layer = this.layerNoDeps.pipe( + Layer.provide(MigratorLayer.pipe(Layer.provideMerge(SqlLayer))), + Layer.orDie + ) + + // An in-memory implementation for tests, so the HTTP stack can be exercised + // without a database. + static readonly layerMemory = Layer.effect( + Users, + Effect.gen(function*() { + const users = new Map() + + const makeUser = (input: typeof User.jsonCreate.Type) => + User.insert.makeEffect(input).pipe( + Effect.map((user) => new User(user)), + Effect.orDie + ) + + const admin = yield* makeUser({ name: "Admin", email: "admin@acme.dev" }) + users.set(admin.id, admin) + + const list = Effect.fn("Users.list")(function*(search: string | undefined) { const allUsers = Array.from(users.values()) if (search === undefined || search.length === 0) { return allUsers @@ -38,7 +152,7 @@ export class Users extends Context.Service current + 1) - const user = new User({ id: UserId.make(id), ...input }) + const create = Effect.fn("Users.create")(function*(input: typeof User.jsonCreate.Type) { + const user = yield* makeUser(input) users.set(user.id, user) return user }) - return Users.of({ list, getById, create }) + const update = Effect.fn("Users.update")(function*(id: UserId, input: typeof User.jsonUpdate.Type) { + const existing = yield* getById(id) + const update = yield* User.update.makeEffect({ id, ...input }).pipe(Effect.orDie) + const updated = new User({ ...existing, ...update }) + users.set(id, updated) + return updated + }) + + return Users.of({ list, getById, create, update }) }) ) } diff --git a/repos/effect/ai-docs/src/51_http-server/fixtures/server/Users/http.ts b/repos/effect/ai-docs/src/51_http-server/fixtures/server/Users/http.ts index 5d778f69a0..d60563d83d 100644 --- a/repos/effect/ai-docs/src/51_http-server/fixtures/server/Users/http.ts +++ b/repos/effect/ai-docs/src/51_http-server/fixtures/server/Users/http.ts @@ -5,49 +5,48 @@ import { CurrentUser } from "../../api/Authorization.ts" import { AuthorizationLayer } from "../Authorization.ts" import { Users } from "../Users.ts" -export const UsersApiHandlers = HttpApiBuilder.group( +// The handlers without their dependencies provided, so tests can supply an +// alternative `Users` implementation. +export const UsersApiHandlersNoDeps = HttpApiBuilder.group( Api, "users", Effect.fn(function*(handlers) { const users = yield* Users - return handlers - .handle("list", ({ query }) => + return handlers.handleAll({ + list: ({ query }) => users.list(query.search).pipe( // The list endpoint expects no errors, so we convert any potential // errors into a 500 Internal Server Error. Effect.orDie - )) - .handle( - "search", - Effect.fn(function*({ payload }) { - if (payload.search === "bad-request") { - // You can use the built in error types like any other - // Schema.TaggedError - return yield* new HttpApiError.RequestTimeout() - } - return yield* users.list(payload.search).pipe( - Effect.catchReason( - "UsersError", - "SearchQueryTooShort", - // Re-fail the "SearchQueryTooShort" reason - Effect.fail, - // All other reasons are unexpected, so we convert them into a 500 - // Internal Server Error. - Effect.die - ) + ), + search: Effect.fn(function*({ payload }) { + if (payload.search === "bad-request") { + // You can use the built in error types like any other + // Schema.TaggedError + return yield* new HttpApiError.RequestTimeout() + } + return yield* users.list(payload.search).pipe( + Effect.catchReason( + "UsersError", + "SearchQueryTooShort", + // Re-fail the "SearchQueryTooShort" reason + Effect.fail, + // All other reasons are unexpected, so we convert them into a 500 + // Internal Server Error. + Effect.die ) - }) - ) - .handle("getById", ({ params }) => + ) + }), + getById: ({ params }) => users.getById(params.id).pipe( // You can also use Effect.catchReasons to handle multiple error // reasons at once Effect.catchReasons("UsersError", { UserNotFound: (e) => Effect.fail(e) }, Effect.die) - )) - .handle("create", ({ payload }) => + ), + create: ({ payload }) => users.create(payload).pipe( Effect.orDie // You could alse use Effect.unwrapReason to moves rror reasons up to @@ -59,13 +58,23 @@ export const UsersApiHandlers = HttpApiBuilder.group( // UserNotFound: Effect.die, // SearchQueryTooShort: Effect.die // }) - )) - .handle("me", () => + ), + update: ({ params, payload }) => + users.update(params.id, payload).pipe( + Effect.catchReasons("UsersError", { + UserNotFound: (e) => Effect.fail(e) + }, Effect.die) + ), + me: () => // The Authorization middleware provides the CurrentUser service, so we // can access it here. - CurrentUser) + CurrentUser + }) }) -).pipe( - // Provide the dependencies for the handlers. +) + +// The handlers with all dependencies provided, ready to serve. The SQL-backed +// `Users.layer` keeps the database wiring out of the server entrypoint. +export const UsersApiHandlers = UsersApiHandlersNoDeps.pipe( Layer.provide([Users.layer, AuthorizationLayer]) ) diff --git a/repos/effect/ai-docs/src/70_cli/10_basics.ts b/repos/effect/ai-docs/src/70_cli/10_basics.ts index a4fe368acc..9d33b9db5f 100644 --- a/repos/effect/ai-docs/src/70_cli/10_basics.ts +++ b/repos/effect/ai-docs/src/70_cli/10_basics.ts @@ -5,7 +5,7 @@ * handlers into a single executable command. */ import { NodeRuntime, NodeServices } from "@effect/platform-node" -import { Console, Effect } from "effect" +import { Console, Effect, Option, Schema } from "effect" import { Argument, Command, Flag } from "effect/unstable/cli" // You can define flags outside of commands and reuse them across multiple @@ -23,24 +23,41 @@ const tasks = Command.make("tasks").pipe( workspace, verbose: Flag.boolean("verbose").pipe( Flag.withAlias("v"), - Flag.withDescription("Print diagnostic output") + Flag.withDescription("Print diagnostic output"), + Flag.withDefault(false) ) }), Command.withDescription("Track and manage tasks") ) +// Arguments and flags parse plain strings; use `withSchema` to validate or +// transform the parsed value with any schema. +const Email = Schema.String.pipe( + Schema.check(Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { + message: "Expected a valid email address" + })) +) + const create = Command.make( "create", { title: Argument.string("title").pipe( - Argument.withDescription("Task title") + Argument.withDescription("Task title"), + // Reject empty titles at parse time, so the handler only ever sees + // valid input + Argument.withSchema(Schema.NonEmptyString) ), priority: Flag.choice("priority", ["low", "normal", "high"]).pipe( Flag.withDescription("Priority for the new task"), Flag.withDefault("normal") + ), + assignee: Flag.string("assignee").pipe( + Flag.withDescription("Email address of the person to assign"), + Flag.withSchema(Email), + Flag.optional ) }, - Effect.fn(function*({ title, priority }) { + Effect.fn(function*({ assignee, priority, title }) { // Subcommands can read parent command input by yielding the parent command. const root = yield* tasks @@ -49,6 +66,10 @@ const create = Command.make( } yield* Console.log(`Created "${title}" in ${root.workspace} with ${priority} priority`) + + if (Option.isSome(assignee)) { + yield* Console.log(`Assigned to ${assignee.value}`) + } }) ).pipe( Command.withDescription("Create a task"), @@ -56,6 +77,10 @@ const create = Command.make( { command: "tasks create \"Ship 4.0\" --priority high", description: "Create a high-priority task" + }, + { + command: "tasks create \"Ship 4.0\" --assignee dev@acme.com", + description: "Create a task assigned to a team member" } ]) ) @@ -68,7 +93,8 @@ const list = Command.make( Flag.withDefault("open") ), json: Flag.boolean("json").pipe( - Flag.withDescription("Print machine-readable output") + Flag.withDescription("Print machine-readable output"), + Flag.withDefault(false) ) }, Effect.fn(function*({ status, json }) { diff --git a/repos/effect/deno.json b/repos/effect/deno.json index 42857d84cd..38de0651a0 100644 --- a/repos/effect/deno.json +++ b/repos/effect/deno.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", "nodeModulesDir": "manual", "unstable": ["bare-node-builtins", "node-globals"], - "workspace": ["./packages/*"], + "workspace": ["./packages/*", "./packages/platform/*"], "exclude": [ "**/*.mjs", "**/*.cjs", @@ -25,10 +25,10 @@ "packages/atom", "packages/effect/test/cluster/", "packages/opentelemetry/", - "packages/platform-browser/", - "packages/platform-bun/", - "packages/platform-node/", - "packages/platform-node-shared/", + "packages/platform/browser/", + "packages/platform/bun/", + "packages/platform/node/", + "packages/platform/node-shared/", "packages/tools/", "packages/sql" ] diff --git a/repos/effect/dprint.json b/repos/effect/dprint.json index a500b54d78..a47e1f94a0 100644 --- a/repos/effect/dprint.json +++ b/repos/effect/dprint.json @@ -19,7 +19,9 @@ "**/docs", "**/coverage", "packages/**/CHANGELOG.md", + "packages/effect/src/StandardSchema.ts", "!scratchpad/**/*", + ".changeset", ".agents", ".context", ".specs" diff --git a/repos/effect/flake.lock b/repos/effect/flake.lock index 44f1b5b9cf..de3033b7a6 100644 --- a/repos/effect/flake.lock +++ b/repos/effect/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1783279667, - "narHash": "sha256-/NAkDSsve+GNM0Bt6tleJdCGfsTlK89nPjkVOzZMo0s=", + "lastModified": 1786534138, + "narHash": "sha256-fBJMdnKUTUDtfi/BYLr71HLaC9dG382arxLF2Egg2uo=", "owner": "nixos", "repo": "nixpkgs", - "rev": "f205b5574fd0cb7da5b702a2da51507b7f4fdd1b", + "rev": "044bfe75bfe4c7bbe043dc17b5e42ea823b84a09", "type": "github" }, "original": { diff --git a/repos/effect/flake.nix b/repos/effect/flake.nix index c9c8616e11..664d14810a 100644 --- a/repos/effect/flake.nix +++ b/repos/effect/flake.nix @@ -14,8 +14,8 @@ packages = with pkgs; [ bun deno - (corepack.override {nodejs-slim = nodejs-slim_26;}) - nodejs_26 + nodejs_latest + pnpm python3 ]; }; diff --git a/repos/effect/jsdocs.config.json b/repos/effect/jsdocs.config.json index 07ea6b9995..f31bd337cc 100644 --- a/repos/effect/jsdocs.config.json +++ b/repos/effect/jsdocs.config.json @@ -8,6 +8,7 @@ "**/node_modules/**", "packages/tools/**", "packages/**/src/index.ts", + "packages/effect/src/StandardSchema.ts", "packages/**/src/*Generated.ts", "packages/**/src/internal/**", "packages/**/src/**/internal/**" diff --git a/repos/effect/migration/annotations/effect__Arbitrary.yaml b/repos/effect/migration/annotations/effect__Arbitrary.yaml index 9f52a3b51f..8410a4a184 100644 --- a/repos/effect/migration/annotations/effect__Arbitrary.yaml +++ b/repos/effect/migration/annotations/effect__Arbitrary.yaml @@ -5,13 +5,13 @@ replacement: "Schema.Annotations.ToArbitrary.Context" note: "Use the v4 arbitrary-derivation context type from Schema.Annotations." "effect/Arbitrary#LazyArbitrary": - replacement: "Schema.LazyArbitrary" - note: "The lazy arbitrary type moved onto Schema." + replacement: "Schema.Arbitrary" + note: "The arbitrary factory type moved onto Schema." "effect/Arbitrary#make": replacement: "Schema.toArbitrary" note: "Arbitrary derivation is now exposed directly by Schema." - example: "Schema.toArbitrary(schema)" + example: "Schema.toArbitrary(schema)(FastCheck)" "effect/Arbitrary#makeLazy": - replacement: "Schema.toArbitraryLazy" + replacement: "Schema.toArbitrary" note: "Lazy arbitrary derivation is now exposed directly by Schema." - example: "Schema.toArbitraryLazy(schema)" + example: "Schema.toArbitrary(schema)" diff --git a/repos/effect/migration/annotations/effect__Effect.yaml b/repos/effect/migration/annotations/effect__Effect.yaml index 3306212788..4a7ee9816c 100644 --- a/repos/effect/migration/annotations/effect__Effect.yaml +++ b/repos/effect/migration/annotations/effect__Effect.yaml @@ -238,9 +238,6 @@ effect/Effect#getFiberRefs: effect/Effect#getRuntimeFlags: replacement: "none" note: "RuntimeFlags are no longer a public Effect service; use supported high-level runtime options. No direct public replacement exists in v4; rewrite the call site around the stated v4 primitive." -effect/Effect#head: - replacement: "Effect.flatMap + Array.head + Effect.fromOption" - note: "Inspect the produced iterable explicitly and fail when it is empty. Adapt arguments and imports to the v4 API." effect/Effect#if: replacement: "Effect.suspend" note: "Select the branch lazily with a JavaScript conditional inside `Effect.suspend`. Adapt arguments and imports to the v4 API." diff --git a/repos/effect/migration/annotations/effect__Graph.yaml b/repos/effect/migration/annotations/effect__Graph.yaml index eda727c4c5..4b1997f9da 100644 --- a/repos/effect/migration/annotations/effect__Graph.yaml +++ b/repos/effect/migration/annotations/effect__Graph.yaml @@ -1,3 +1,6 @@ +"effect/Graph#Edge": + replacement: "Graph.Edge" + note: "The type remains as a structural interface, but its Data.Class constructor/value export was removed. Replace new Graph.Edge({ source, target, data }) with an object literal." "effect/Graph#Graph": replacement: "Graph.Graph" note: "The immutable type remains, but storage is opaque; replace field access with Graph nodes, edges, count, lookup, neighbor, and acyclicity APIs." diff --git a/repos/effect/migration/annotations/effect__Inspectable.yaml b/repos/effect/migration/annotations/effect__Inspectable.yaml index f6475e068b..3d3ae012b8 100644 --- a/repos/effect/migration/annotations/effect__Inspectable.yaml +++ b/repos/effect/migration/annotations/effect__Inspectable.yaml @@ -1,3 +1,6 @@ +"effect/Inspectable#format": + replacement: "Formatter.formatJson" + note: "Use Formatter.formatJson(input, { space: 2 }) to preserve the v3 helper's pretty-printed JSON output." "effect/Inspectable#redact": replacement: "Redactable.redact" note: "The redaction protocol moved to Redactable and now receives the current fiber Context." diff --git a/repos/effect/migration/annotations/effect__Match.yaml b/repos/effect/migration/annotations/effect__Match.yaml index b04a3a0baa..2ed399401d 100644 --- a/repos/effect/migration/annotations/effect__Match.yaml +++ b/repos/effect/migration/annotations/effect__Match.yaml @@ -4,6 +4,12 @@ "effect/Match#MatcherTypeId": replacement: "none" note: "The public matcher brand was internalized. Obtain matchers from Match.type or Match.value and use their public _tag when discrimination is required." +"effect/Match#Matcher": + replacement: "Match.Matcher" + note: "The type is retained, but its fifth argument is now a flavor marker (ValueFlavor for Match.value and never for Match.type or Match.fn) rather than the provided value; an optional seventh Args tuple tracks Match.fn selector arguments. Prefer inference from Match.type, Match.value, or Match.fn and update hand-written Matcher annotations." +"effect/Match#Not": + replacement: "Match.Not" + note: "The case type is retained. Its evaluate method now receives any Match.fn selector arguments after the selected input; update custom case implementations that consume those arguments." "effect/Match#SafeRefinementId": replacement: "none" note: "The public safe-refinement brand was internalized. Use Predicate.Refinement, Predicate.Predicate, or a built-in Match refinement instead of constructing the brand." @@ -40,3 +46,6 @@ "effect/Match#ValueMatcher": replacement: "Match.ValueMatcher" note: "The type is retained, but value now uses Result instead of Either and the brand is private; create values with Match.value." +"effect/Match#When": + replacement: "Match.When" + note: "The case type is retained. Its evaluate method now receives any Match.fn selector arguments after the selected input; update custom case implementations that consume those arguments." diff --git a/repos/effect/migration/annotations/effect__ai__McpSchema.yaml b/repos/effect/migration/annotations/effect__ai__McpSchema.yaml index c2557430e7..2c7a565d17 100644 --- a/repos/effect/migration/annotations/effect__ai__McpSchema.yaml +++ b/repos/effect/migration/annotations/effect__ai__McpSchema.yaml @@ -1,6 +1,9 @@ "@effect/ai/McpSchema#ContentBlock": replacement: "McpSchema.ContentBlock" - note: "Moved to effect/unstable/ai/McpSchema. It remains the MCP content-block union, but v4 exports it as a const schema rather than a Schema.Union subclass." + note: "Moved to effect/unstable/ai/McpSchema. It remains the MCP content-block union, but v4 exports it as a const schema rather than a Schema.Union subclass. Binary image, audio, and blob data still use Uint8Array values with base64 wire encoding." +"@effect/ai/McpSchema#ElicitResult": + replacement: "McpSchema.ElicitResult" + note: "Moved to effect/unstable/ai/McpSchema. It remains discriminated by action, but accepted content is now optional and, when present, is a record of strings, finite numbers, booleans, or string arrays; declined and canceled responses still omit content." "@effect/ai/McpSchema#FailureEncoded": replacement: "McpSchema.FailureEncoded" note: "Moved to effect/unstable/ai/McpSchema and still derives an encoded JSON-RPC failure union from an RpcGroup." diff --git a/repos/effect/migration/annotations/effect__cli__Options.yaml b/repos/effect/migration/annotations/effect__cli__Options.yaml index 9992b4ac85..22a76c724d 100644 --- a/repos/effect/migration/annotations/effect__cli__Options.yaml +++ b/repos/effect/migration/annotations/effect__cli__Options.yaml @@ -17,8 +17,8 @@ replacement: "Flag.between" note: "Use the moved combinator; v4 validates bounds when constructing the parameter." "@effect/cli/Options#boolean": - replacement: "Flag.boolean" - note: "Use the moved constructor; --no-name is automatic and aliases are added with Flag.withAlias." + replacement: "Flag.boolean + Flag.withDefault" + note: "Use Flag.boolean(name).pipe(Flag.withDefault(false)) to preserve v3's omitted-flag default; bare Flag.boolean is now required. --no-name is automatic and aliases are added with Flag.withAlias." "@effect/cli/Options#choice": replacement: "Flag.choice" note: "Use the moved constructor." diff --git a/repos/effect/migration/annotations/effect__cluster__Message.yaml b/repos/effect/migration/annotations/effect__cluster__Message.yaml index c7611dcb8b..e41b871a43 100644 --- a/repos/effect/migration/annotations/effect__cluster__Message.yaml +++ b/repos/effect/migration/annotations/effect__cluster__Message.yaml @@ -1,3 +1,3 @@ "@effect/cluster/Message#serialize": replacement: "effect/unstable/cluster/Message#serialize" - note: "Moved into core Effect. It now returns Envelope.Partial; use serializeEnvelope for the JSON Envelope.Encoded form." + note: "Moved into core Effect. Pass the transport's codecFor as the second argument; use serializeEnvelope for the JSON Envelope.Encoded form." diff --git a/repos/effect/migration/annotations/effect__cluster__MessageStorage.yaml b/repos/effect/migration/annotations/effect__cluster__MessageStorage.yaml index 1ebf92ecf1..11f3b99c31 100644 --- a/repos/effect/migration/annotations/effect__cluster__MessageStorage.yaml +++ b/repos/effect/migration/annotations/effect__cluster__MessageStorage.yaml @@ -6,7 +6,10 @@ note: "Moved into core Effect with the same dependency-free no-op implementation." "@effect/cluster/MessageStorage#make": replacement: "effect/unstable/cluster/MessageStorage#make" - note: "Moved into core Effect. Context service projections now use the Service property instead of Type." + note: "Moved into core Effect. Context service projections now use the Service property instead of Type. Custom service implementations must also provide resetAddresses for batched mailbox resets." +"@effect/cluster/MessageStorage#makeEncoded": + replacement: "effect/unstable/cluster/MessageStorage#makeEncoded" + note: "Moved into core Effect. Custom encoded drivers must replace resetAddress with resetAddresses and may use the new limit and addresses options passed to unprocessedMessages." "@effect/cluster/MessageStorage#Encoded": replacement: "effect/unstable/cluster/MessageStorage#Encoded" - note: "Moved into core Effect; use the v4 Envelope.Encoded and Reply.Encoded aliases in custom encoded storage implementations." + note: "Moved into core Effect; use the v4 Envelope.Encoded and Reply.Encoded aliases. Custom drivers now implement batched resetAddresses, and unprocessedMessages receives optional limit and address filters." diff --git a/repos/effect/migration/annotations/effect__cluster__Reply.yaml b/repos/effect/migration/annotations/effect__cluster__Reply.yaml index 8f0c4e831c..aecd2243b8 100644 --- a/repos/effect/migration/annotations/effect__cluster__Reply.yaml +++ b/repos/effect/migration/annotations/effect__cluster__Reply.yaml @@ -1,9 +1,9 @@ "@effect/cluster/Reply#ReplyEncoded": replacement: "effect/unstable/cluster/Reply#Encoded" - note: "Renamed to Encoded and no longer parameterized by an Rpc; payload fields are unknown and validated by Reply.Reply(rpc)." + note: "Renamed to Encoded and no longer parameterized by an Rpc; payload fields are unknown and validated by Reply.Reply(rpc, codecFor) with the transport's codec." "@effect/cluster/Reply#serialize": replacement: "effect/unstable/cluster/Reply#serialize" - note: "Moved into core Effect and now returns the non-generic Reply.Encoded wire union." + note: "Moved into core Effect and now returns the non-generic Reply.Encoded wire union. Pass the transport's codecFor as the second argument." "@effect/cluster/Reply#TypeId": replacement: "none" note: "The reply marker is private in v4. Use Reply.isReply for runtime refinement." diff --git a/repos/effect/migration/annotations/effect__cluster__Runners.yaml b/repos/effect/migration/annotations/effect__cluster__Runners.yaml index a8f9bff703..e380f964df 100644 --- a/repos/effect/migration/annotations/effect__cluster__Runners.yaml +++ b/repos/effect/migration/annotations/effect__cluster__Runners.yaml @@ -3,7 +3,7 @@ note: "Moved into core Effect with the same no-op runner communication layer." "@effect/cluster/Runners#make": replacement: "effect/unstable/cluster/Runners#make" - note: "Moved into core Effect with the same callbacks and requirements; Context service projections now use Service instead of Type." + note: "Moved into core Effect. Its options now require codecFor; pass the codec used by the remote runner transport, such as RpcSerialization.json.codecFor for JSON. Context service projections now use Service instead of Type." "@effect/cluster/Runners#makeNoop": replacement: "effect/unstable/cluster/Runners#makeNoop" note: "Moved into core Effect; it returns the Context.Service implementation through the Service projection instead of Type." diff --git a/repos/effect/migration/annotations/effect__cluster__ShardingConfig.yaml b/repos/effect/migration/annotations/effect__cluster__ShardingConfig.yaml index 192d7dd6a1..f429966f0e 100644 --- a/repos/effect/migration/annotations/effect__cluster__ShardingConfig.yaml +++ b/repos/effect/migration/annotations/effect__cluster__ShardingConfig.yaml @@ -3,7 +3,7 @@ note: "Moved into core Effect; its Context service value type now uses the Service property instead of Type." "@effect/cluster/ShardingConfig#defaults": replacement: "effect/unstable/cluster/ShardingConfig#defaults" - note: "Moved into core Effect with the same complete defaults; service type projections now use Service instead of Type." + note: "Moved into core Effect; service type projections now use Service instead of Type. V4 also defaults maxResidentEntities to 10,000 and unprocessedMessageBatchSize to 1,024." "@effect/cluster/ShardingConfig#layer": replacement: "effect/unstable/cluster/ShardingConfig#layer" note: "Moved into core Effect with the same shallow default merge; service type projections now use Service instead of Type." diff --git a/repos/effect/migration/annotations/effect__rpc__RpcMessage.yaml b/repos/effect/migration/annotations/effect__rpc__RpcMessage.yaml index 16dd4c082a..6694182b52 100644 --- a/repos/effect/migration/annotations/effect__rpc__RpcMessage.yaml +++ b/repos/effect/migration/annotations/effect__rpc__RpcMessage.yaml @@ -1,3 +1,6 @@ +"@effect/rpc/RpcMessage#FromServerEncoded": + replacement: "effect/unstable/rpc/RpcMessage#FromServerEncoded" + note: "The union is retained and now also includes RequestEncoded for server-originated requests and notifications. Handle _tag: \"Request\" when matching exhaustively; isNotification identifies notifications." "@effect/rpc/RpcMessage#RequestIdTypeId": replacement: "effect/unstable/rpc/RpcMessage#RequestId" note: "The public symbol marker was removed; use the branded RequestId type and RequestId constructor rather than inspecting its brand." diff --git a/repos/effect/migration/annotations/effect__rpc__RpcServer.yaml b/repos/effect/migration/annotations/effect__rpc__RpcServer.yaml index 2a2258dd51..fdba786855 100644 --- a/repos/effect/migration/annotations/effect__rpc__RpcServer.yaml +++ b/repos/effect/migration/annotations/effect__rpc__RpcServer.yaml @@ -36,7 +36,7 @@ note: "HttpApp became HttpEffect; the result contains the WebSocket protocol and upgrade effect." "@effect/rpc/RpcServer#Protocol": replacement: "effect/unstable/rpc/RpcServer#Protocol" - note: "Retained as a Context.Service; custom transports now expose a disconnect queue and explicit capability flags." + note: "Retained as a Context.Service; custom transports now expose a disconnect queue, explicit capability flags, and codecFor for schema-aware payload and exit encoding." "@effect/rpc/RpcServer#toHttpApp": replacement: "effect/unstable/rpc/RpcServer#toHttpEffect" note: "Renamed for the v4 HTTP effect model; it starts the RPC server and returns the request effect." diff --git a/repos/effect/migration/schema.md b/repos/effect/migration/schema.md index d2d4db1c76..bc5ead3678 100644 --- a/repos/effect/migration/schema.md +++ b/repos/effect/migration/schema.md @@ -31,7 +31,6 @@ This document maps v3 Schema APIs to their v4 equivalents. Simple renames and ar | `EitherFromSelf` | `Result` | rename | | `DateFromNumber` | `DateFromMillis` | rename | | `Date` | `DateFromString` | restructure | -| `TaggedError` | `TaggedErrorClass` | rename | | `decodeUnknown` | `decodeUnknownEffect` | rename | | `decode` | `decodeEffect` | rename | | `decodeUnknownEither` | `decodeUnknownExit` | rename | diff --git a/repos/effect/migration/v3-to-v4.md b/repos/effect/migration/v3-to-v4.md index 459cdc4609..1d6c697be5 100644 --- a/repos/effect/migration/v3-to-v4.md +++ b/repos/effect/migration/v3-to-v4.md @@ -4,7 +4,7 @@ Base: `3d390f232bdbc3f0d3d6a2ae3c775084f494b547` (`3d390f232bdbc3f0d3d6a2ae3c775084f494b547`) -Head: `main` (`b938c8ad2823bd88493187922f7d9090eff037b6`) +Head: `origin/main` (`20cb4f260e45d37fa417c292c57be015314efe16`) This file is generated from the API diff and `migration/annotations/*.yaml`. @@ -4926,7 +4926,9 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ### `@effect/ai/McpSchema` -- `McpSchema.ContentBlock` -> `McpSchema.ContentBlock`: Moved to effect/unstable/ai/McpSchema. It remains the MCP content-block union, but v4 exports it as a const schema rather than a Schema.Union subclass. +- `McpSchema.ContentBlock` -> `McpSchema.ContentBlock`: Moved to effect/unstable/ai/McpSchema. It remains the MCP content-block union, but v4 exports it as a const schema rather than a Schema.Union subclass. Binary image, audio, and blob data still use Uint8Array values with base64 wire encoding. + +- `McpSchema.ElicitResult` -> `McpSchema.ElicitResult`: Moved to effect/unstable/ai/McpSchema. It remains discriminated by action, but accepted content is now optional and, when present, is a record of strings, finite numbers, booleans, or string arrays; declined and canceled responses still omit content. - `McpSchema.McpError` -> `McpSchema.McpError`: Moved, but changed from a constructable base class to a union schema of standard tagged protocol errors plus McpErrorBase. Use McpErrorBase to construct a generic MCP error. @@ -5372,7 +5374,7 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) - `Options.between` -> `Flag.between`: Use the moved combinator; v4 validates bounds when constructing the parameter. -- `Options.boolean` -> `Flag.boolean`: Use the moved constructor; --no-name is automatic and aliases are added with Flag.withAlias. +- `Options.boolean` -> `Flag.boolean + Flag.withDefault`: Use Flag.boolean(name).pipe(Flag.withDefault(false)) to preserve v3's omitted-flag default; bare Flag.boolean is now required. --no-name is automatic and aliases are added with Flag.withAlias. - `Options.choice` -> `Flag.choice`: Use the moved constructor. @@ -5672,21 +5674,23 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ### `@effect/cluster/Message` -- `Message.serialize` -> `effect/unstable/cluster/Message#serialize`: Moved into core Effect. It now returns Envelope.Partial; use serializeEnvelope for the JSON Envelope.Encoded form. +- `Message.serialize` -> `effect/unstable/cluster/Message#serialize`: Moved into core Effect. Pass the transport's codecFor as the second argument; use serializeEnvelope for the JSON Envelope.Encoded form. ### `@effect/cluster/MessageStorage` -- `MessageStorage.Encoded` -> `effect/unstable/cluster/MessageStorage#Encoded`: Moved into core Effect; use the v4 Envelope.Encoded and Reply.Encoded aliases in custom encoded storage implementations. +- `MessageStorage.Encoded` -> `effect/unstable/cluster/MessageStorage#Encoded`: Moved into core Effect; use the v4 Envelope.Encoded and Reply.Encoded aliases. Custom drivers now implement batched resetAddresses, and unprocessedMessages receives optional limit and address filters. + +- `MessageStorage.make` -> `effect/unstable/cluster/MessageStorage#make`: Moved into core Effect. Context service projections now use the Service property instead of Type. Custom service implementations must also provide resetAddresses for batched mailbox resets. -- `MessageStorage.make` -> `effect/unstable/cluster/MessageStorage#make`: Moved into core Effect. Context service projections now use the Service property instead of Type. +- `MessageStorage.makeEncoded` -> `effect/unstable/cluster/MessageStorage#makeEncoded`: Moved into core Effect. Custom encoded drivers must replace resetAddress with resetAddresses and may use the new limit and addresses options passed to unprocessedMessages. ### `@effect/cluster/Reply` -- `Reply.ReplyEncoded` -> `effect/unstable/cluster/Reply#Encoded`: Renamed to Encoded and no longer parameterized by an Rpc; payload fields are unknown and validated by Reply.Reply(rpc). +- `Reply.ReplyEncoded` -> `effect/unstable/cluster/Reply#Encoded`: Renamed to Encoded and no longer parameterized by an Rpc; payload fields are unknown and validated by Reply.Reply(rpc, codecFor) with the transport's codec. - `Reply.TypeId` -> `none`: The reply marker is private in v4. Use Reply.isReply for runtime refinement. -- `Reply.serialize` -> `effect/unstable/cluster/Reply#serialize`: Moved into core Effect and now returns the non-generic Reply.Encoded wire union. +- `Reply.serialize` -> `effect/unstable/cluster/Reply#serialize`: Moved into core Effect and now returns the non-generic Reply.Encoded wire union. Pass the transport's codecFor as the second argument. ### `@effect/cluster/Runner` @@ -5702,7 +5706,7 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ### `@effect/cluster/Runners` -- `Runners.make` -> `effect/unstable/cluster/Runners#make`: Moved into core Effect with the same callbacks and requirements; Context service projections now use Service instead of Type. +- `Runners.make` -> `effect/unstable/cluster/Runners#make`: Moved into core Effect. Its options now require codecFor; pass the codec used by the remote runner transport, such as RpcSerialization.json.codecFor for JSON. Context service projections now use Service instead of Type. - `Runners.makeNoop` -> `effect/unstable/cluster/Runners#makeNoop`: Moved into core Effect; it returns the Context.Service implementation through the Service projection instead of Type. @@ -5716,7 +5720,7 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) - `ShardingConfig.config` -> `effect/unstable/cluster/ShardingConfig#config`: Moved into core Effect; its Context service value type now uses the Service property instead of Type. -- `ShardingConfig.defaults` -> `effect/unstable/cluster/ShardingConfig#defaults`: Moved into core Effect with the same complete defaults; service type projections now use Service instead of Type. +- `ShardingConfig.defaults` -> `effect/unstable/cluster/ShardingConfig#defaults`: Moved into core Effect; service type projections now use Service instead of Type. V4 also defaults maxResidentEntities to 10,000 and unprocessedMessageBatchSize to 1,024. - `ShardingConfig.layer` -> `effect/unstable/cluster/ShardingConfig#layer`: Moved into core Effect with the same shallow default merge; service type projections now use Service instead of Type. @@ -7586,6 +7590,8 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ### `@effect/rpc/RpcMessage` +- `RpcMessage.FromServerEncoded` -> `effect/unstable/rpc/RpcMessage#FromServerEncoded`: The union is retained and now also includes RequestEncoded for server-originated requests and notifications. Handle \_tag: "Request" when matching exhaustively; isNotification identifies notifications. + - `RpcMessage.RequestId` -> `effect/unstable/rpc/RpcMessage#RequestId`: Request ids are now branded string or number values; convert bigint ids before calling the retained RequestId constructor. - `RpcMessage.RequestIdTypeId` -> `effect/unstable/rpc/RpcMessage#RequestId`: The public symbol marker was removed; use the branded RequestId type and RequestId constructor rather than inspecting its brand. @@ -7634,7 +7640,7 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ### `@effect/rpc/RpcServer` -- `RpcServer.Protocol` -> `effect/unstable/rpc/RpcServer#Protocol`: Retained as a Context.Service; custom transports now expose a disconnect queue and explicit capability flags. +- `RpcServer.Protocol` -> `effect/unstable/rpc/RpcServer#Protocol`: Retained as a Context.Service; custom transports now expose a disconnect queue, explicit capability flags, and codecFor for schema-aware payload and exit encoding. - `RpcServer.fiberIdClientInterrupt` -> `effect/unstable/rpc/RpcSchema#ClientAbort`: The sentinel FiberId was replaced by a Cause annotation; inspect ClientAbort in the interruption cause when client cancellation must be distinguished. @@ -9698,8 +9704,6 @@ Schema.toArbitrary(schema) - `Effect.getRuntimeFlags` -> `none`: RuntimeFlags are no longer a public Effect service; use supported high-level runtime options. No direct public replacement exists in v4; rewrite the call site around the stated v4 primitive. -- `Effect.head` -> `Effect.flatMap + Array.head + Effect.fromOption`: Inspect the produced iterable explicitly and fail when it is empty. Adapt arguments and imports to the v4 API. - - `Effect.if` -> `Effect.suspend`: Select the branch lazily with a JavaScript conditional inside `Effect.suspend`. Adapt arguments and imports to the v4 API. - `Effect.ignoreLogged` -> `Effect.ignore`: Pass `{ log: true }` to the consolidated ignore combinator. Adapt arguments and imports to the v4 API. @@ -10694,6 +10698,8 @@ FastCheck.uuid({ version: 4 }) ### `effect/Graph` +- `Graph.Edge` -> `Graph.Edge`: The type remains as a structural interface, but its Data.Class constructor/value export was removed. Replace new Graph.Edge({ source, target, data }) with an object literal. + - `Graph.Graph` -> `Graph.Graph`: The immutable type remains, but storage is opaque; replace field access with Graph nodes, edges, count, lookup, neighbor, and acyclicity APIs. - `Graph.MutableGraph` -> `Graph.MutableGraph`: The mutable type remains but no longer extends Graph.Proto; obtain it through Graph.mutate or Graph.beginMutation and use public mutation/query functions. @@ -10775,6 +10781,8 @@ stream.pipe( ### `effect/Inspectable` +- `Inspectable.format` -> `Formatter.formatJson`: Use Formatter.formatJson(input, { space: 2 }) to preserve the v3 helper's pretty-printed JSON output. + - `Inspectable.redact` -> `Redactable.redact`: The redaction protocol moved to Redactable and now receives the current fiber Context. - `Inspectable.stringifyCircular` -> `Formatter.formatJson`: Use Formatter.formatJson(input, { space: whitespace }); it handles redaction and ancestor cycles. @@ -11261,8 +11269,12 @@ JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(schema)) ### `effect/Match` +- `Match.Matcher` -> `Match.Matcher`: The type is retained, but its fifth argument is now a flavor marker (ValueFlavor for Match.value and never for Match.type or Match.fn) rather than the provided value; an optional seventh Args tuple tracks Match.fn selector arguments. Prefer inference from Match.type, Match.value, or Match.fn and update hand-written Matcher annotations. + - `Match.MatcherTypeId` -> `none`: The public matcher brand was internalized. Obtain matchers from Match.type or Match.value and use their public \_tag when discrimination is required. +- `Match.Not` -> `Match.Not`: The case type is retained. Its evaluate method now receives any Match.fn selector arguments after the selected input; update custom case implementations that consume those arguments. + - `Match.SafeRefinementId` -> `none`: The public safe-refinement brand was internalized. Use Predicate.Refinement, Predicate.Predicate, or a built-in Match refinement instead of constructing the brand. - `Match.TypeMatcher` -> `Match.TypeMatcher`: The public type is retained, but its brand is private; create values with Match.type rather than implementing the interface. @@ -11287,6 +11299,8 @@ JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(schema)) - `Match.ValueMatcher` -> `Match.ValueMatcher`: The type is retained, but value now uses Result instead of Either and the brand is private; create values with Match.value. +- `Match.When` -> `Match.When`: The case type is retained. Its evaluate method now receives any Match.fn selector arguments after the selected input; update custom case implementations that consume those arguments. + - `Match.either` -> `Match.result`: Renamed finalizer with a container change: matched Right and unmatched Left become Result.Success and Result.Failure. ### `effect/MergeDecision` diff --git a/repos/effect/package.json b/repos/effect/package.json index 60c4f2617c..8dbc701c7d 100644 --- a/repos/effect/package.json +++ b/repos/effect/package.json @@ -1,7 +1,7 @@ { "private": true, "type": "module", - "packageManager": "pnpm@10.17.1", + "packageManager": "pnpm@11.20.0", "scripts": { "prepare": "node scripts/setup-agents.mjs && effect-tsgo patch", "clean": "node scripts/clean.mjs", @@ -37,47 +37,47 @@ "@babel/core": "^8.0.1", "@babel/plugin-transform-export-namespace-from": "^8.0.1", "@babel/plugin-transform-modules-commonjs": "^8.0.1", - "@changesets/changelog-github": "1.0.0-next.9", - "@changesets/cli": "3.0.0-next.11", + "@changesets/changelog-github": "1.0.0", + "@changesets/cli": "3.0.1", "@effect/ai-docgen": "workspace:^", "@effect/bundle": "workspace:^", "@effect/docgen": "workspace:^", "@effect/doctest": "workspace:^", "@effect/jsdocs": "workspace:^", "@effect/oxc": "workspace:^", - "@effect/tsgo": "^0.21.0", + "@effect/tsgo": "^0.36.5", "@effect/utils": "workspace:^", "@effect/vitest": "workspace:^", - "@faker-js/faker": "^10.5.0", + "@faker-js/faker": "^10.6.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^1.0.0", "@types/jscodeshift": "^17.3.0", - "@types/node": "^26.1.2", - "@vitest/browser": "^4.1.10", - "@vitest/coverage-v8": "^4.1.10", - "@vitest/expect": "^4.1.10", - "@vitest/web-worker": "^4.1.10", + "@types/node": "^26.2.0", + "@vitest/browser": "^4.1.11", + "@vitest/coverage-v8": "^4.1.11", + "@vitest/expect": "^4.1.11", + "@vitest/web-worker": "^4.1.11", "ast-types": "^0.14.2", "babel-plugin-annotate-pure-calls": "^0.5.0", - "dprint": "^0.55.2", + "dprint": "^0.56.1", "glob": "^13.0.6", - "happy-dom": "^20.11.1", + "happy-dom": "^20.11.6", "jscodeshift": "^17.4.0", "lalph": "^0.3.139", "madge": "^8.0.0", - "oxlint": "^1.76.0", - "pkg-pr-new": "0.0.78", - "playwright": "^1.62.0", - "rollup": "^4.62.3", - "rollup-plugin-bundle-stats": "^4.22.2", + "oxlint": "^1.79.0", + "pkg-pr-new": "0.0.88", + "playwright": "^1.62.1", + "rollup": "^4.62.5", + "rollup-plugin-bundle-stats": "^4.22.3", "rollup-plugin-esbuild": "^6.2.1", - "rollup-plugin-visualizer": "^7.0.1", - "terser": "^5.49.0", - "tstyche": "^7.2.2", + "rollup-plugin-visualizer": "^7.1.1", + "terser": "^5.50.0", + "tstyche": "^7.2.3", "typescript": "^7.0.2", - "vite": "^8.1.5", - "vitest": "^4.1.10", + "vite": "^8.2.2", + "vitest": "^4.1.11", "vitest-websocket-mock": "^0.7.0", "zod": "^4.4.3" } diff --git a/repos/effect/packages/ai/anthropic/CHANGELOG.md b/repos/effect/packages/ai/anthropic/CHANGELOG.md index 04e12fce9f..d1b848563e 100644 --- a/repos/effect/packages/ai/anthropic/CHANGELOG.md +++ b/repos/effect/packages/ai/anthropic/CHANGELOG.md @@ -1,5 +1,44 @@ # @effect/ai-anthropic +## 4.0.0-rc.112 + +### Patch Changes + +- [#7437](https://github.com/Effect-TS/effect/pull/7437) [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb) Thanks @wmaurer! - Add an optional `description` to `AiError.AuthenticationError`, rendered after the kind-based suggestion, and pass the provider's own error text through it on HTTP 401 and 403, so authentication failures report what actually went wrong instead of only a category. +- Updated dependencies [[`20cb4f2`](https://github.com/Effect-TS/effect/commit/20cb4f260e45d37fa417c292c57be015314efe16), [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb), [`b6bf5e1`](https://github.com/Effect-TS/effect/commit/b6bf5e14492643076454131148f97cde24ad5306), [`0b9f780`](https://github.com/Effect-TS/effect/commit/0b9f780ff28b71042241791a9e8bcb5b631be2bd), [`150e92c`](https://github.com/Effect-TS/effect/commit/150e92c4169c245e701da02575eef0b69c3ecd64), [`6740db2`](https://github.com/Effect-TS/effect/commit/6740db247ed20cb85da43c9f48ade8fecfd8c1ae), [`d57bba1`](https://github.com/Effect-TS/effect/commit/d57bba1486fa60971b6e0bf7459a329cfd5acdc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`02a5146`](https://github.com/Effect-TS/effect/commit/02a5146d6933c7f6052553550bce5658225e4100), [`15272a6`](https://github.com/Effect-TS/effect/commit/15272a66adf02501e7747761e2a3c41bff67bb46), [`436f10d`](https://github.com/Effect-TS/effect/commit/436f10d1efccec308426532ff3f88df9a96434f3), [`058fb15`](https://github.com/Effect-TS/effect/commit/058fb15647fa01ad771277bd368783fcf5f262e8), [`4d89bb8`](https://github.com/Effect-TS/effect/commit/4d89bb8ffb4cf567a1d11072246b6161ce638712), [`480fb15`](https://github.com/Effect-TS/effect/commit/480fb156590785cf98f67bdec4fc282a608e2d87), [`f77ec19`](https://github.com/Effect-TS/effect/commit/f77ec19cff1cbbeeae928e3bd0ece00a7d22bab8), [`925b82a`](https://github.com/Effect-TS/effect/commit/925b82a81f59a4d459b488621030f24ba99d6a27), [`7455246`](https://github.com/Effect-TS/effect/commit/7455246f352385f5cbbdd8299555265ee289490e), [`118124d`](https://github.com/Effect-TS/effect/commit/118124d913d0a02ac5c1f7799a39bd90031769d9), [`a5f78d3`](https://github.com/Effect-TS/effect/commit/a5f78d3fcbaa792d49e80d103ab438e0b50812fd), [`0dd7825`](https://github.com/Effect-TS/effect/commit/0dd7825e4da4d3a00fa9bd410a1d55f3d4874d07), [`b722eca`](https://github.com/Effect-TS/effect/commit/b722eca6d283a88970ad0efba0b4e921915eca78), [`811d579`](https://github.com/Effect-TS/effect/commit/811d579c432856a9e3fc05b517fd8e924cbf991a), [`043b587`](https://github.com/Effect-TS/effect/commit/043b587e6e93f6624bf974bcd7ed976eaa17f0e1), [`8583727`](https://github.com/Effect-TS/effect/commit/85837274fa929a921985464585513a68c261e365), [`d9d2cfc`](https://github.com/Effect-TS/effect/commit/d9d2cfcb732754001b7323cf8afaccc48539bb74), [`5c4b7a0`](https://github.com/Effect-TS/effect/commit/5c4b7a0b17931cd1538c6595a54b21ffe9c1e906)]: + - effect@4.0.0-rc.112 + +## 4.0.0-rc.111 + +### Patch Changes + +- Updated dependencies [[`0ce3b00`](https://github.com/Effect-TS/effect/commit/0ce3b007531563ce33f94c6a057492d379cfd9e8), [`d846331`](https://github.com/Effect-TS/effect/commit/d84633171b4333e87a05f8d74c02c95d83d1b50d), [`f93616f`](https://github.com/Effect-TS/effect/commit/f93616f31f42345af844b8396fb19259556ae236), [`16bf1ef`](https://github.com/Effect-TS/effect/commit/16bf1efdf41ba1d000b8588a8cc052a394856ba4), [`d568968`](https://github.com/Effect-TS/effect/commit/d5689682e963f43157571cd71d4b422247ae09b9), [`bc06292`](https://github.com/Effect-TS/effect/commit/bc062920ac47f956786d12c6d0bacb9468fa66e2), [`e03ea90`](https://github.com/Effect-TS/effect/commit/e03ea907720b58d19d99929695df159c42fff101), [`9b10fc8`](https://github.com/Effect-TS/effect/commit/9b10fc85dde7866fb60e8142a93a6ed6a184537c), [`770c6d0`](https://github.com/Effect-TS/effect/commit/770c6d0f0ddcab7dbdde9fe701aded08deaee3fd), [`7425bcb`](https://github.com/Effect-TS/effect/commit/7425bcb7f74bdccc1941883bc7f9472fbe6ae8b4), [`563815a`](https://github.com/Effect-TS/effect/commit/563815a250e10a02e2a719c7d9b159478b0da933), [`1e83ca1`](https://github.com/Effect-TS/effect/commit/1e83ca110d5cb74641bae148a08e7eb6b3c2e2ed), [`550a41a`](https://github.com/Effect-TS/effect/commit/550a41a2547ce0dd5f085f1637a6f8a5794acba7), [`45d79c7`](https://github.com/Effect-TS/effect/commit/45d79c7cbd9c56c643361c6e6d1689f1aa62ae32), [`aac8584`](https://github.com/Effect-TS/effect/commit/aac8584fd997f3ce3341aeb077b816219832de58), [`7f87022`](https://github.com/Effect-TS/effect/commit/7f870225bb10a7ee7cb54b4835cb3bf64c0cacd8), [`425457c`](https://github.com/Effect-TS/effect/commit/425457c1e9eec7c176df130f7ec6ad3d616c086e), [`008c423`](https://github.com/Effect-TS/effect/commit/008c423e413fe311085c4bfc07c19b9c70529dfa), [`4f6ae04`](https://github.com/Effect-TS/effect/commit/4f6ae041a2886c27e941c5d9e4916aa2a32e0a1c), [`b6b63e1`](https://github.com/Effect-TS/effect/commit/b6b63e151595b5599ad2d65069ab6d298cfd7be7), [`92922ee`](https://github.com/Effect-TS/effect/commit/92922ee7de0a2d932121e37677f3e75ea38482f1), [`859c02f`](https://github.com/Effect-TS/effect/commit/859c02fe9020b0f28f713aa85910a0cc2d002560), [`ffc8235`](https://github.com/Effect-TS/effect/commit/ffc823537a0925171cf41d0841643a0b26895a4c), [`a29eb70`](https://github.com/Effect-TS/effect/commit/a29eb702ffe3fc58bd28c4d7857298cd65d73668), [`0be2303`](https://github.com/Effect-TS/effect/commit/0be2303dd39f9f2f7d8f96a28e25c938cd914e1a), [`b44636f`](https://github.com/Effect-TS/effect/commit/b44636ff7233763fad060bc0f9de10512454a29b), [`b19ccc7`](https://github.com/Effect-TS/effect/commit/b19ccc7325469a5dea091349bda4ae2ec00ec865), [`ff98f0b`](https://github.com/Effect-TS/effect/commit/ff98f0b0e2beb331209e37e42095d8d6e8e0b6c2), [`a47cbf1`](https://github.com/Effect-TS/effect/commit/a47cbf1feafd10e305c9753fa02a5f5f977d2c37), [`39b55f8`](https://github.com/Effect-TS/effect/commit/39b55f850aecbecf2e3b0c5f3ae2d1a77e6da949), [`c6c49c9`](https://github.com/Effect-TS/effect/commit/c6c49c96990d009341341a53ed26beb140c267a9), [`bf23ba7`](https://github.com/Effect-TS/effect/commit/bf23ba7033a4b426caf379bdcf84c58bb64d7010)]: + - effect@4.0.0-rc.111 + +## 4.0.0-rc.110 + +### Patch Changes + +- Updated dependencies [[`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`0a127b8`](https://github.com/Effect-TS/effect/commit/0a127b8847fb213d46621a5018c069019414c00d), [`e491deb`](https://github.com/Effect-TS/effect/commit/e491deb1cd1717bc442aaee96e5d7b4001710cc9), [`f99c508`](https://github.com/Effect-TS/effect/commit/f99c508a09e9fccddb09edd16fa3393891c55d48), [`7e3f07c`](https://github.com/Effect-TS/effect/commit/7e3f07cf3e4b6a907840aface36ae5ee67ca8979), [`a894fe1`](https://github.com/Effect-TS/effect/commit/a894fe1df8974077293d9c1b924a7f80fc7fe7f1), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`7e9923b`](https://github.com/Effect-TS/effect/commit/7e9923b1a0adf1f842216b298d7542bed980c9a5), [`f064121`](https://github.com/Effect-TS/effect/commit/f0641210f960563f813ac6f728804e5447c03341), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`b660bf0`](https://github.com/Effect-TS/effect/commit/b660bf01d9c427552296cd9e0195e21504f45e1f), [`f4fbe9c`](https://github.com/Effect-TS/effect/commit/f4fbe9ca0209cdfaaaff54fc3805221be80f1ffa), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`e811353`](https://github.com/Effect-TS/effect/commit/e811353e3b79ba392af1d9fb8d7f93fd729dabf9), [`9761c3c`](https://github.com/Effect-TS/effect/commit/9761c3c4787b3815346c1b650d8984efec8f1051), [`baa99fc`](https://github.com/Effect-TS/effect/commit/baa99fc176efed4986c19c78726d1dbe7bd17765), [`7fd79b2`](https://github.com/Effect-TS/effect/commit/7fd79b2cc95973f85b3db43b0cf3c5cb7d2f69c4), [`a82ffc0`](https://github.com/Effect-TS/effect/commit/a82ffc03df0fb34e02f16773b96bfa3e0208e427), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`4026e2d`](https://github.com/Effect-TS/effect/commit/4026e2dde3b098e70ffb3f1fcddd200176acdefd), [`397bf1e`](https://github.com/Effect-TS/effect/commit/397bf1ebd95c0d6d58dc53e4f33c8ad3f34746f6), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f)]: + - effect@4.0.0-rc.110 + +## 4.0.0-rc.109 + +### Patch Changes + +- [#7208](https://github.com/Effect-TS/effect/pull/7208) [`0303139`](https://github.com/Effect-TS/effect/commit/03031395d3ddee197217f826e7d9ef68b0674823) Thanks @moishinetzer! - Default new Anthropic models to modern capabilities while preserving the limits of legacy Claude models. + + Unknown models now default to native structured outputs and 128K output tokens, so future model releases do not require capability-table updates. Use the new `structuredOutputs` model config option to override capability detection when needed. +- Updated dependencies [[`a0743f2`](https://github.com/Effect-TS/effect/commit/a0743f2b9f20fb5d150f35510e68819f01630bac), [`17892e7`](https://github.com/Effect-TS/effect/commit/17892e75a8d584f79127805506c42b19320990a7), [`4d8a230`](https://github.com/Effect-TS/effect/commit/4d8a2306dfff8fd5406ab75d0c8d22e5300d1faa), [`f21f9c9`](https://github.com/Effect-TS/effect/commit/f21f9c9f73dc59db922bda411b6d5245dae37cdb), [`18270dd`](https://github.com/Effect-TS/effect/commit/18270ddeaf4ad28723ca8a40aff04a5d4707b575), [`26db404`](https://github.com/Effect-TS/effect/commit/26db404a3284cfdbf4a3f351ccd05afc0de743b8), [`2670398`](https://github.com/Effect-TS/effect/commit/26703982612e12954360382e4b1d177002699d1c), [`3702bed`](https://github.com/Effect-TS/effect/commit/3702bedd8f6bcb3f603b87c640c521878d824eb3), [`ccae60e`](https://github.com/Effect-TS/effect/commit/ccae60e5edb2bef553f4af52afb509dfd443cd03), [`6ff5396`](https://github.com/Effect-TS/effect/commit/6ff53968138bbd7d4728ce8014e35eae8d6ca5d0)]: + - effect@4.0.0-rc.109 + +## 4.0.0-rc.108 + +### Patch Changes + +- Updated dependencies [[`dfb173e`](https://github.com/Effect-TS/effect/commit/dfb173efffd20c4feded4efe409018dd55acdca8), [`005e090`](https://github.com/Effect-TS/effect/commit/005e0902cace9f8960a4f43573665a3a9b53b6fa), [`c82c532`](https://github.com/Effect-TS/effect/commit/c82c53228dc1c50cc99654ce6de7766b4de09e75), [`22b579f`](https://github.com/Effect-TS/effect/commit/22b579f6c582e6e2d951784791fea6f1802517ed), [`3e19539`](https://github.com/Effect-TS/effect/commit/3e19539205082b1006d84553045d1b03db9cc8a1), [`08a3c74`](https://github.com/Effect-TS/effect/commit/08a3c74133206fc1cc728e0aa96d02e672fd80bd), [`eb0bae0`](https://github.com/Effect-TS/effect/commit/eb0bae08d543d58754c9bb7a57e67c1e2bb3f55a), [`97b544d`](https://github.com/Effect-TS/effect/commit/97b544d8b636587647b90691d669305c0eb4fc66), [`4f6d131`](https://github.com/Effect-TS/effect/commit/4f6d131e85d74ab0ec0300e52e503a5f943fc576), [`fad4b7c`](https://github.com/Effect-TS/effect/commit/fad4b7c5138b3f38c2427436da2e0685c1ca4e9b), [`accf447`](https://github.com/Effect-TS/effect/commit/accf4474513064e2a21d14b1937503261b4f34dc), [`31b27e4`](https://github.com/Effect-TS/effect/commit/31b27e49903c351588435f666c953aaac28f6120), [`8458951`](https://github.com/Effect-TS/effect/commit/84589518c3966c63d7f3679a5296d380eb1ba887)]: + - effect@4.0.0-rc.108 + ## 4.0.0-beta.107 ### Patch Changes diff --git a/repos/effect/packages/ai/anthropic/README.md b/repos/effect/packages/ai/anthropic/README.md new file mode 100644 index 0000000000..da2336285d --- /dev/null +++ b/repos/effect/packages/ai/anthropic/README.md @@ -0,0 +1,14 @@ +# @effect/ai-anthropic + +An [Anthropic](https://www.anthropic.com) provider for the Effect AI modules. Includes a typed Anthropic API client, language model layers, tools, and telemetry helpers. + +## Installation + +```sh +npm install effect@rc @effect/ai-anthropic@rc +``` + +## Documentation + +- [Effect website](https://effect.website) +- [API reference](https://effect.website/docs/v4/api/ai-anthropic) diff --git a/repos/effect/packages/ai/anthropic/package.json b/repos/effect/packages/ai/anthropic/package.json index edf7a0d47e..6e60982cbd 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-beta.107", + "version": "4.0.0-rc.112", "type": "module", "license": "MIT", "description": "An Anthropic provider integration for Effect AI SDK", diff --git a/repos/effect/packages/ai/anthropic/src/AnthropicConfig.ts b/repos/effect/packages/ai/anthropic/src/AnthropicConfig.ts index df27b55817..f4578de36c 100644 --- a/repos/effect/packages/ai/anthropic/src/AnthropicConfig.ts +++ b/repos/effect/packages/ai/anthropic/src/AnthropicConfig.ts @@ -35,7 +35,7 @@ export class AnthropicConfig extends Context.Service< */ static readonly getOrUndefined: Effect.Effect = Effect.map( Effect.context(), - (services) => services.mapUnsafe.get(AnthropicConfig.key) + Context.getOrUndefined(AnthropicConfig) ) } diff --git a/repos/effect/packages/ai/anthropic/src/AnthropicLanguageModel.ts b/repos/effect/packages/ai/anthropic/src/AnthropicLanguageModel.ts index e65a7c9bdf..7ba02b588d 100644 --- a/repos/effect/packages/ai/anthropic/src/AnthropicLanguageModel.ts +++ b/repos/effect/packages/ai/anthropic/src/AnthropicLanguageModel.ts @@ -88,6 +88,12 @@ export class Config extends Context.Service< * Disables Claude's ability to use multiple tools to respond to a query. */ readonly disableParallelToolCalls?: boolean | undefined + /** + * Whether the model supports native structured outputs. + * + * Overrides automatic capability detection based on the model identifier. + */ + readonly structuredOutputs?: boolean | undefined /** * Whether to use strict JSON schema validation for tool calls. * @@ -678,10 +684,13 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig }): Effect.fn.Return { const client = yield* AnthropicClient - const makeConfig: Effect.Effect = Effect.gen(function*() { - const services = yield* Effect.context() - return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) } - }) + const makeConfig: Effect.Effect = Effect.contextWith((services) => + Effect.succeed({ + model, + ...providerConfig, + ...Context.getOrUndefined(services, Config) + }) + ) const makeRequest = Effect.fnUntraced( function*>({ config, options, toolNameMapper }: { @@ -693,7 +702,10 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig readonly payload: typeof Generated.BetaCreateMessageParams.Encoded }, AiError.AiError> { const betas = new Set() - const capabilities = getModelCapabilities(config.model!) + 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 outputFormat = yield* getOutputFormat({ capabilities, options }) const { tools, toolChoice } = yield* prepareTools({ betas, capabilities, config, options }) @@ -701,7 +713,8 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig if (betas.size > 0) { params["anthropic-beta"] = Array.from(betas).join(",") } - const { disableParallelToolCalls: _, output_config, ...requestConfig } = config + const { disableParallelToolCalls: _, output_config, structuredOutputs: _structuredOutputs, ...requestConfig } = + config const payload: Mutable = { ...requestConfig, max_tokens: requestConfig.max_tokens ?? capabilities.maxOutputTokens, @@ -2973,75 +2986,61 @@ const processCitation = Effect.fnUntraced( interface ModelCapabilities { readonly maxOutputTokens: number readonly supportsStructuredOutput: boolean - readonly isKnownModel: boolean } /** * 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. * * @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 */ const getModelCapabilities = (modelId: string): ModelCapabilities => { if ( - modelId.includes("claude-opus-4-6") || - modelId.includes("claude-sonnet-4-6") || - modelId.includes("claude-opus-4-7") || - modelId.includes("claude-opus-4-8") - ) { - return { - maxOutputTokens: 128000, - supportsStructuredOutput: true, - isKnownModel: true - } - } else if ( modelId.includes("claude-sonnet-4-5") || modelId.includes("claude-opus-4-5") || modelId.includes("claude-haiku-4-5") ) { return { maxOutputTokens: 64000, - supportsStructuredOutput: true, - isKnownModel: true + supportsStructuredOutput: true } } else if (modelId.includes("claude-opus-4-1")) { return { maxOutputTokens: 32000, - supportsStructuredOutput: true, - isKnownModel: true + supportsStructuredOutput: true } } else if ( - modelId.includes("claude-sonnet-4-") || + modelId.includes("claude-sonnet-4-0") || + modelId.includes("claude-sonnet-4-20250514") || modelId.includes("claude-3-7-sonnet") ) { return { maxOutputTokens: 64000, - supportsStructuredOutput: false, - isKnownModel: true + supportsStructuredOutput: false } - } else if (modelId.includes("claude-opus-4-")) { + } else if ( + modelId.includes("claude-opus-4-0") || + modelId.includes("claude-opus-4-20250514") + ) { return { maxOutputTokens: 32000, - supportsStructuredOutput: false, - isKnownModel: true + supportsStructuredOutput: false } } else if (modelId.includes("claude-3-5-haiku")) { return { maxOutputTokens: 8192, - supportsStructuredOutput: false, - isKnownModel: true + supportsStructuredOutput: false } - } else if (modelId.includes("claude-3-haiku")) { + } else if (modelId.includes("claude-3-")) { return { maxOutputTokens: 4096, - supportsStructuredOutput: false, - isKnownModel: true + supportsStructuredOutput: false } } else { return { - maxOutputTokens: 4096, - supportsStructuredOutput: false, - isKnownModel: false + maxOutputTokens: 128000, + supportsStructuredOutput: true } } } diff --git a/repos/effect/packages/ai/anthropic/src/internal/errors.ts b/repos/effect/packages/ai/anthropic/src/internal/errors.ts index 235a50be74..e0b2e95a92 100644 --- a/repos/effect/packages/ai/anthropic/src/internal/errors.ts +++ b/repos/effect/packages/ai/anthropic/src/internal/errors.ts @@ -260,43 +260,6 @@ export const buildHttpContext = (params: { // HTTP Status Code // ============================================================================= -const buildInvalidRequestDescription = (params: { - readonly status: number - readonly message: string | undefined - readonly method: string - readonly url: string - readonly errorType: string | null - readonly requestId: string | null - readonly body: string | undefined -}): string => { - const parts: Array = [] - - if (params.message) { - parts.push(params.message) - } else { - parts.push(`HTTP ${params.status}`) - } - - parts.push(`(${params.method} ${params.url})`) - - if (params.errorType) { - parts.push(`[type: ${params.errorType}]`) - } - - if (params.requestId) { - parts.push(`[requestId: ${params.requestId}]`) - } - - if (!params.message && params.body) { - const truncated = params.body.length > 200 - ? params.body.slice(0, 200) + "..." - : params.body - parts.push(`Response: ${truncated}`) - } - - return parts.join(" ") -} - /** @internal */ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http }: { readonly status: number @@ -305,7 +268,7 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http readonly metadata: AnthropicErrorMetadata readonly http: typeof AiError.HttpContext.Type }): AiError.AiErrorReason => { - const invalidRequestDescription = buildInvalidRequestDescription({ + const errorDescription = AiError.buildErrorDescription({ status, message, method: http.request.method, @@ -318,31 +281,33 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http switch (status) { case 400: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { anthropic: metadata }, http }) case 401: return new AiError.AuthenticationError({ kind: "InvalidKey", + description: errorDescription, metadata: { anthropic: metadata }, http }) case 403: return new AiError.AuthenticationError({ kind: "InsufficientPermissions", + description: errorDescription, metadata: { anthropic: metadata }, http }) case 404: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { anthropic: metadata }, http }) case 422: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { anthropic: metadata }, http }) diff --git a/repos/effect/packages/ai/anthropic/test/AnthropicClient.test.ts b/repos/effect/packages/ai/anthropic/test/AnthropicClient.test.ts index aa9861c815..7eeb0ea366 100644 --- a/repos/effect/packages/ai/anthropic/test/AnthropicClient.test.ts +++ b/repos/effect/packages/ai/anthropic/test/AnthropicClient.test.ts @@ -1,4 +1,5 @@ import { AnthropicClient } from "@effect/ai-anthropic" +import * as Errors from "@effect/ai-anthropic/internal/errors" import { assert, describe, it } from "@effect/vitest" import { Context, Effect, Layer, Redacted, type Schema } from "effect" import { @@ -44,6 +45,94 @@ describe("AnthropicClient", () => { request_id: null } })))) + + it.effect("surfaces the provider message on 401 AuthenticationError", () => + Effect.gen(function*() { + const client = yield* AnthropicClient.AnthropicClient + + const result = yield* client.createMessage({ + payload: { + model: "claude-sonnet-4-20250514", + max_tokens: 1, + messages: [{ role: "user", content: "hello" }] + } + }).pipe(Effect.flip) + + assert.strictEqual(result.reason._tag, "AuthenticationError") + if (result.reason._tag !== "AuthenticationError") { + return yield* Effect.die(new Error("Expected AuthenticationError")) + } + assert.strictEqual(result.reason.kind, "InvalidKey") + assert.strictEqual( + result.reason.description, + "invalid x-api-key (POST https://api.anthropic.com/v1/messages?beta=true) [type: authentication_error] [requestId: req_anthropic]" + ) + assert.include(result.reason.message, "invalid x-api-key") + assert.strictEqual( + result.reason.message, + "InvalidKey: Verify your API key is correct. invalid x-api-key (POST https://api.anthropic.com/v1/messages?beta=true) [type: authentication_error] [requestId: req_anthropic]" + ) + }).pipe(Effect.provide(makeTestLayer({ + _tag: "Json", + status: 401, + body: { + type: "error", + error: { type: "authentication_error", message: "invalid x-api-key" }, + request_id: "req_anthropic" + } + })))) + + it("preserves and truncates a fallback HTTP response", () => { + const body = `${"a".repeat(200)}b` + const reason = Errors.mapStatusCodeToReason({ + status: 400, + headers: {}, + message: undefined, + metadata: { errorType: null, requestId: null }, + http: makeHttpContext("https://api.anthropic.com/v1/messages?beta=true", body) + }) + + assert.strictEqual(reason._tag, "InvalidRequestError") + if (reason._tag !== "InvalidRequestError") { + throw new Error("Expected InvalidRequestError") + } + assert.strictEqual( + reason.description, + `HTTP 400 (POST https://api.anthropic.com/v1/messages?beta=true) Response: ${"a".repeat(200)}...` + ) + }) + + it.effect("surfaces the provider message on 403 AuthenticationError", () => + Effect.gen(function*() { + const client = yield* AnthropicClient.AnthropicClient + + const result = yield* client.createMessage({ + payload: { + model: "claude-sonnet-4-20250514", + max_tokens: 1, + messages: [{ role: "user", content: "hello" }] + } + }).pipe(Effect.flip) + + assert.strictEqual(result.reason._tag, "AuthenticationError") + if (result.reason._tag !== "AuthenticationError") { + return yield* Effect.die(new Error("Expected AuthenticationError")) + } + assert.strictEqual(result.reason.kind, "InsufficientPermissions") + assert.include(result.reason.description ?? "", "not available for this account") + assert.include(result.reason.message, "not available for this account") + }).pipe(Effect.provide(makeTestLayer({ + _tag: "Json", + status: 403, + body: { + type: "error", + error: { + type: "permission_error", + message: "claude-sonnet-4-20250514 is not available for this account" + }, + request_id: null + } + })))) }) type MockResponse = @@ -125,3 +214,14 @@ const makeResponse = ( }) ) } + +const makeHttpContext = (url: string, body: string) => ({ + request: { + method: "POST" as const, + url, + urlParams: [], + hash: undefined, + headers: {} + }, + body +}) diff --git a/repos/effect/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts b/repos/effect/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts index 32ebded702..bd8b190232 100644 --- a/repos/effect/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts +++ b/repos/effect/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts @@ -552,9 +552,10 @@ describe("AnthropicLanguageModel", () => { }) describe("generateObject", () => { - // A model that supports native structured output requests it via `output_config.format` (json_schema) - // rather than falling back to a forced JSON tool. - const assertNativeStructuredOutput = (model: string) => + const getRequest = ( + model: string, + config?: { readonly structuredOutputs?: boolean | undefined } + ) => Effect.gen(function*() { let capturedRequest: HttpClientRequest.HttpClientRequest | undefined = undefined const layer = AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe( @@ -589,25 +590,60 @@ describe("AnthropicLanguageModel", () => { prompt: "Give me a person", schema: Schema.Struct({ name: Schema.String, age: Schema.Number }) }).pipe( - Effect.provide(AnthropicLanguageModel.model(model)), + Effect.provide(AnthropicLanguageModel.model(model, config)), Effect.provide(layer), Effect.ignore ) assert.isDefined(capturedRequest) if (capturedRequest === undefined) { - return + return yield* Effect.die(new Error("Expected a captured request")) } - const body = yield* getRequestBody(capturedRequest) - assert.strictEqual(body.output_config?.format?.type, "json_schema") + return yield* getRequestBody(capturedRequest) }) - it.effect("uses native json_schema output for claude-opus-4-6", () => - assertNativeStructuredOutput("claude-opus-4-6")) + it.effect("uses native structured output and 128K for Claude 4.6", () => + Effect.gen(function*() { + const body = yield* getRequest("claude-opus-4-6") + + assert.strictEqual(body.max_tokens, 128000) + assert.strictEqual(body.output_config?.format?.type, "json_schema") + })) + + it.effect("uses optimistic modern defaults for an unknown future model", () => + Effect.gen(function*() { + const body = yield* getRequest("claude-sonnet-6-0") + + assert.strictEqual(body.max_tokens, 128000) + assert.strictEqual(body.output_config?.format?.type, "json_schema") + })) + + it.effect("preserves frozen legacy model exceptions", () => + Effect.gen(function*() { + const body = yield* getRequest("claude-sonnet-4-20250514") + + assert.strictEqual(body.max_tokens, 64000) + assert.isUndefined(body.output_config) + })) + + it.effect("can disable structured outputs for a modern model", () => + Effect.gen(function*() { + const body = yield* getRequest("claude-sonnet-6-0", { structuredOutputs: false }) + + assert.strictEqual(body.max_tokens, 128000) + assert.isUndefined(body.output_config) + assert.notProperty(body, "structuredOutputs") + })) + + it.effect("can enable structured outputs for a legacy model", () => + Effect.gen(function*() { + const body = yield* getRequest("claude-sonnet-4-20250514", { structuredOutputs: true }) - it.effect("uses native json_schema output for claude-sonnet-4-6", () => - assertNativeStructuredOutput("claude-sonnet-4-6")) + assert.strictEqual(body.max_tokens, 64000) + assert.strictEqual(body.output_config?.format?.type, "json_schema") + assert.notProperty(body, "structuredOutputs") + })) }) // The packaged `Memory_20250818` tool ships `customName: "AnthropicMemory"` / diff --git a/repos/effect/packages/ai/openai-compat/CHANGELOG.md b/repos/effect/packages/ai/openai-compat/CHANGELOG.md index 93d08734a9..363f05ad6f 100644 --- a/repos/effect/packages/ai/openai-compat/CHANGELOG.md +++ b/repos/effect/packages/ai/openai-compat/CHANGELOG.md @@ -1,5 +1,42 @@ # @effect/ai-openai-compat +## 4.0.0-rc.112 + +### Patch Changes + +- [#7437](https://github.com/Effect-TS/effect/pull/7437) [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb) Thanks @wmaurer! - Add an optional `description` to `AiError.AuthenticationError`, rendered after the kind-based suggestion, and pass the provider's own error text through it on HTTP 401 and 403, so authentication failures report what actually went wrong instead of only a category. +- Updated dependencies [[`20cb4f2`](https://github.com/Effect-TS/effect/commit/20cb4f260e45d37fa417c292c57be015314efe16), [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb), [`b6bf5e1`](https://github.com/Effect-TS/effect/commit/b6bf5e14492643076454131148f97cde24ad5306), [`0b9f780`](https://github.com/Effect-TS/effect/commit/0b9f780ff28b71042241791a9e8bcb5b631be2bd), [`150e92c`](https://github.com/Effect-TS/effect/commit/150e92c4169c245e701da02575eef0b69c3ecd64), [`6740db2`](https://github.com/Effect-TS/effect/commit/6740db247ed20cb85da43c9f48ade8fecfd8c1ae), [`d57bba1`](https://github.com/Effect-TS/effect/commit/d57bba1486fa60971b6e0bf7459a329cfd5acdc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`02a5146`](https://github.com/Effect-TS/effect/commit/02a5146d6933c7f6052553550bce5658225e4100), [`15272a6`](https://github.com/Effect-TS/effect/commit/15272a66adf02501e7747761e2a3c41bff67bb46), [`436f10d`](https://github.com/Effect-TS/effect/commit/436f10d1efccec308426532ff3f88df9a96434f3), [`058fb15`](https://github.com/Effect-TS/effect/commit/058fb15647fa01ad771277bd368783fcf5f262e8), [`4d89bb8`](https://github.com/Effect-TS/effect/commit/4d89bb8ffb4cf567a1d11072246b6161ce638712), [`480fb15`](https://github.com/Effect-TS/effect/commit/480fb156590785cf98f67bdec4fc282a608e2d87), [`f77ec19`](https://github.com/Effect-TS/effect/commit/f77ec19cff1cbbeeae928e3bd0ece00a7d22bab8), [`925b82a`](https://github.com/Effect-TS/effect/commit/925b82a81f59a4d459b488621030f24ba99d6a27), [`7455246`](https://github.com/Effect-TS/effect/commit/7455246f352385f5cbbdd8299555265ee289490e), [`118124d`](https://github.com/Effect-TS/effect/commit/118124d913d0a02ac5c1f7799a39bd90031769d9), [`a5f78d3`](https://github.com/Effect-TS/effect/commit/a5f78d3fcbaa792d49e80d103ab438e0b50812fd), [`0dd7825`](https://github.com/Effect-TS/effect/commit/0dd7825e4da4d3a00fa9bd410a1d55f3d4874d07), [`b722eca`](https://github.com/Effect-TS/effect/commit/b722eca6d283a88970ad0efba0b4e921915eca78), [`811d579`](https://github.com/Effect-TS/effect/commit/811d579c432856a9e3fc05b517fd8e924cbf991a), [`043b587`](https://github.com/Effect-TS/effect/commit/043b587e6e93f6624bf974bcd7ed976eaa17f0e1), [`8583727`](https://github.com/Effect-TS/effect/commit/85837274fa929a921985464585513a68c261e365), [`d9d2cfc`](https://github.com/Effect-TS/effect/commit/d9d2cfcb732754001b7323cf8afaccc48539bb74), [`5c4b7a0`](https://github.com/Effect-TS/effect/commit/5c4b7a0b17931cd1538c6595a54b21ffe9c1e906)]: + - effect@4.0.0-rc.112 + +## 4.0.0-rc.111 + +### Patch Changes + +- Updated dependencies [[`0ce3b00`](https://github.com/Effect-TS/effect/commit/0ce3b007531563ce33f94c6a057492d379cfd9e8), [`d846331`](https://github.com/Effect-TS/effect/commit/d84633171b4333e87a05f8d74c02c95d83d1b50d), [`f93616f`](https://github.com/Effect-TS/effect/commit/f93616f31f42345af844b8396fb19259556ae236), [`16bf1ef`](https://github.com/Effect-TS/effect/commit/16bf1efdf41ba1d000b8588a8cc052a394856ba4), [`d568968`](https://github.com/Effect-TS/effect/commit/d5689682e963f43157571cd71d4b422247ae09b9), [`bc06292`](https://github.com/Effect-TS/effect/commit/bc062920ac47f956786d12c6d0bacb9468fa66e2), [`e03ea90`](https://github.com/Effect-TS/effect/commit/e03ea907720b58d19d99929695df159c42fff101), [`9b10fc8`](https://github.com/Effect-TS/effect/commit/9b10fc85dde7866fb60e8142a93a6ed6a184537c), [`770c6d0`](https://github.com/Effect-TS/effect/commit/770c6d0f0ddcab7dbdde9fe701aded08deaee3fd), [`7425bcb`](https://github.com/Effect-TS/effect/commit/7425bcb7f74bdccc1941883bc7f9472fbe6ae8b4), [`563815a`](https://github.com/Effect-TS/effect/commit/563815a250e10a02e2a719c7d9b159478b0da933), [`1e83ca1`](https://github.com/Effect-TS/effect/commit/1e83ca110d5cb74641bae148a08e7eb6b3c2e2ed), [`550a41a`](https://github.com/Effect-TS/effect/commit/550a41a2547ce0dd5f085f1637a6f8a5794acba7), [`45d79c7`](https://github.com/Effect-TS/effect/commit/45d79c7cbd9c56c643361c6e6d1689f1aa62ae32), [`aac8584`](https://github.com/Effect-TS/effect/commit/aac8584fd997f3ce3341aeb077b816219832de58), [`7f87022`](https://github.com/Effect-TS/effect/commit/7f870225bb10a7ee7cb54b4835cb3bf64c0cacd8), [`425457c`](https://github.com/Effect-TS/effect/commit/425457c1e9eec7c176df130f7ec6ad3d616c086e), [`008c423`](https://github.com/Effect-TS/effect/commit/008c423e413fe311085c4bfc07c19b9c70529dfa), [`4f6ae04`](https://github.com/Effect-TS/effect/commit/4f6ae041a2886c27e941c5d9e4916aa2a32e0a1c), [`b6b63e1`](https://github.com/Effect-TS/effect/commit/b6b63e151595b5599ad2d65069ab6d298cfd7be7), [`92922ee`](https://github.com/Effect-TS/effect/commit/92922ee7de0a2d932121e37677f3e75ea38482f1), [`859c02f`](https://github.com/Effect-TS/effect/commit/859c02fe9020b0f28f713aa85910a0cc2d002560), [`ffc8235`](https://github.com/Effect-TS/effect/commit/ffc823537a0925171cf41d0841643a0b26895a4c), [`a29eb70`](https://github.com/Effect-TS/effect/commit/a29eb702ffe3fc58bd28c4d7857298cd65d73668), [`0be2303`](https://github.com/Effect-TS/effect/commit/0be2303dd39f9f2f7d8f96a28e25c938cd914e1a), [`b44636f`](https://github.com/Effect-TS/effect/commit/b44636ff7233763fad060bc0f9de10512454a29b), [`b19ccc7`](https://github.com/Effect-TS/effect/commit/b19ccc7325469a5dea091349bda4ae2ec00ec865), [`ff98f0b`](https://github.com/Effect-TS/effect/commit/ff98f0b0e2beb331209e37e42095d8d6e8e0b6c2), [`a47cbf1`](https://github.com/Effect-TS/effect/commit/a47cbf1feafd10e305c9753fa02a5f5f977d2c37), [`39b55f8`](https://github.com/Effect-TS/effect/commit/39b55f850aecbecf2e3b0c5f3ae2d1a77e6da949), [`c6c49c9`](https://github.com/Effect-TS/effect/commit/c6c49c96990d009341341a53ed26beb140c267a9), [`bf23ba7`](https://github.com/Effect-TS/effect/commit/bf23ba7033a4b426caf379bdcf84c58bb64d7010)]: + - effect@4.0.0-rc.111 + +## 4.0.0-rc.110 + +### Patch Changes + +- [#7269](https://github.com/Effect-TS/effect/pull/7269) [`ee06c9c`](https://github.com/Effect-TS/effect/commit/ee06c9c1eed73ebcf282541ceb1615ff1ba1730d) Thanks @tim-smart! - Preserve streamed text from OpenAI-compatible providers that send `tool_calls: null` on text-only chunks. +- Updated dependencies [[`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`0a127b8`](https://github.com/Effect-TS/effect/commit/0a127b8847fb213d46621a5018c069019414c00d), [`e491deb`](https://github.com/Effect-TS/effect/commit/e491deb1cd1717bc442aaee96e5d7b4001710cc9), [`f99c508`](https://github.com/Effect-TS/effect/commit/f99c508a09e9fccddb09edd16fa3393891c55d48), [`7e3f07c`](https://github.com/Effect-TS/effect/commit/7e3f07cf3e4b6a907840aface36ae5ee67ca8979), [`a894fe1`](https://github.com/Effect-TS/effect/commit/a894fe1df8974077293d9c1b924a7f80fc7fe7f1), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`7e9923b`](https://github.com/Effect-TS/effect/commit/7e9923b1a0adf1f842216b298d7542bed980c9a5), [`f064121`](https://github.com/Effect-TS/effect/commit/f0641210f960563f813ac6f728804e5447c03341), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`b660bf0`](https://github.com/Effect-TS/effect/commit/b660bf01d9c427552296cd9e0195e21504f45e1f), [`f4fbe9c`](https://github.com/Effect-TS/effect/commit/f4fbe9ca0209cdfaaaff54fc3805221be80f1ffa), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`e811353`](https://github.com/Effect-TS/effect/commit/e811353e3b79ba392af1d9fb8d7f93fd729dabf9), [`9761c3c`](https://github.com/Effect-TS/effect/commit/9761c3c4787b3815346c1b650d8984efec8f1051), [`baa99fc`](https://github.com/Effect-TS/effect/commit/baa99fc176efed4986c19c78726d1dbe7bd17765), [`7fd79b2`](https://github.com/Effect-TS/effect/commit/7fd79b2cc95973f85b3db43b0cf3c5cb7d2f69c4), [`a82ffc0`](https://github.com/Effect-TS/effect/commit/a82ffc03df0fb34e02f16773b96bfa3e0208e427), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`4026e2d`](https://github.com/Effect-TS/effect/commit/4026e2dde3b098e70ffb3f1fcddd200176acdefd), [`397bf1e`](https://github.com/Effect-TS/effect/commit/397bf1ebd95c0d6d58dc53e4f33c8ad3f34746f6), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f)]: + - effect@4.0.0-rc.110 + +## 4.0.0-rc.109 + +### Patch Changes + +- Updated dependencies [[`a0743f2`](https://github.com/Effect-TS/effect/commit/a0743f2b9f20fb5d150f35510e68819f01630bac), [`17892e7`](https://github.com/Effect-TS/effect/commit/17892e75a8d584f79127805506c42b19320990a7), [`4d8a230`](https://github.com/Effect-TS/effect/commit/4d8a2306dfff8fd5406ab75d0c8d22e5300d1faa), [`f21f9c9`](https://github.com/Effect-TS/effect/commit/f21f9c9f73dc59db922bda411b6d5245dae37cdb), [`18270dd`](https://github.com/Effect-TS/effect/commit/18270ddeaf4ad28723ca8a40aff04a5d4707b575), [`26db404`](https://github.com/Effect-TS/effect/commit/26db404a3284cfdbf4a3f351ccd05afc0de743b8), [`2670398`](https://github.com/Effect-TS/effect/commit/26703982612e12954360382e4b1d177002699d1c), [`3702bed`](https://github.com/Effect-TS/effect/commit/3702bedd8f6bcb3f603b87c640c521878d824eb3), [`ccae60e`](https://github.com/Effect-TS/effect/commit/ccae60e5edb2bef553f4af52afb509dfd443cd03), [`6ff5396`](https://github.com/Effect-TS/effect/commit/6ff53968138bbd7d4728ce8014e35eae8d6ca5d0)]: + - effect@4.0.0-rc.109 + +## 4.0.0-rc.108 + +### Patch Changes + +- Updated dependencies [[`dfb173e`](https://github.com/Effect-TS/effect/commit/dfb173efffd20c4feded4efe409018dd55acdca8), [`005e090`](https://github.com/Effect-TS/effect/commit/005e0902cace9f8960a4f43573665a3a9b53b6fa), [`c82c532`](https://github.com/Effect-TS/effect/commit/c82c53228dc1c50cc99654ce6de7766b4de09e75), [`22b579f`](https://github.com/Effect-TS/effect/commit/22b579f6c582e6e2d951784791fea6f1802517ed), [`3e19539`](https://github.com/Effect-TS/effect/commit/3e19539205082b1006d84553045d1b03db9cc8a1), [`08a3c74`](https://github.com/Effect-TS/effect/commit/08a3c74133206fc1cc728e0aa96d02e672fd80bd), [`eb0bae0`](https://github.com/Effect-TS/effect/commit/eb0bae08d543d58754c9bb7a57e67c1e2bb3f55a), [`97b544d`](https://github.com/Effect-TS/effect/commit/97b544d8b636587647b90691d669305c0eb4fc66), [`4f6d131`](https://github.com/Effect-TS/effect/commit/4f6d131e85d74ab0ec0300e52e503a5f943fc576), [`fad4b7c`](https://github.com/Effect-TS/effect/commit/fad4b7c5138b3f38c2427436da2e0685c1ca4e9b), [`accf447`](https://github.com/Effect-TS/effect/commit/accf4474513064e2a21d14b1937503261b4f34dc), [`31b27e4`](https://github.com/Effect-TS/effect/commit/31b27e49903c351588435f666c953aaac28f6120), [`8458951`](https://github.com/Effect-TS/effect/commit/84589518c3966c63d7f3679a5296d380eb1ba887)]: + - effect@4.0.0-rc.108 + ## 4.0.0-beta.107 ### Patch Changes diff --git a/repos/effect/packages/ai/openai-compat/README.md b/repos/effect/packages/ai/openai-compat/README.md new file mode 100644 index 0000000000..68bcbb7765 --- /dev/null +++ b/repos/effect/packages/ai/openai-compat/README.md @@ -0,0 +1,14 @@ +# @effect/ai-openai-compat + +Connects the Effect AI modules to any OpenAI-compatible API, with support for chat completions and embeddings. + +## Installation + +```sh +npm install effect@rc @effect/ai-openai-compat@rc +``` + +## Documentation + +- [Effect website](https://effect.website) +- [API reference](https://effect.website/docs/v4/api/ai-openai-compat) diff --git a/repos/effect/packages/ai/openai-compat/package.json b/repos/effect/packages/ai/openai-compat/package.json index aba0fdcde4..e68e72f5cc 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-beta.107", + "version": "4.0.0-rc.112", "type": "module", "license": "MIT", "description": "An OpenAI compat integration for Effect", diff --git a/repos/effect/packages/ai/openai-compat/src/OpenAiClient.ts b/repos/effect/packages/ai/openai-compat/src/OpenAiClient.ts index f8f7207ac6..c8ecad004a 100644 --- a/repos/effect/packages/ai/openai-compat/src/OpenAiClient.ts +++ b/repos/effect/packages/ai/openai-compat/src/OpenAiClient.ts @@ -1123,7 +1123,10 @@ const ChatCompletionDelta = Schema.Struct({ content: Schema.optionalKey(Schema.NullOr(Schema.String)), reasoning: Schema.optionalKey(Schema.NullOr(Schema.String)), reasoning_content: Schema.optionalKey(Schema.NullOr(Schema.String)), - tool_calls: Schema.optionalKey(Schema.Array(ChatCompletionToolCallDelta)) + // Some OpenAI-compatible providers send `tool_calls: null` when a streamed + // chunk contains only text. Accepting null keeps the text-bearing chunk from + // being classified as an unknown event. + tool_calls: Schema.optionalKey(Schema.NullOr(Schema.Array(ChatCompletionToolCallDelta))) }) const ChatCompletionChoice = Schema.Struct({ diff --git a/repos/effect/packages/ai/openai-compat/src/OpenAiConfig.ts b/repos/effect/packages/ai/openai-compat/src/OpenAiConfig.ts index 8291966403..004b155da2 100644 --- a/repos/effect/packages/ai/openai-compat/src/OpenAiConfig.ts +++ b/repos/effect/packages/ai/openai-compat/src/OpenAiConfig.ts @@ -36,7 +36,7 @@ export class OpenAiConfig extends Context.Service< */ static readonly getOrUndefined: Effect.Effect = Effect.map( Effect.context(), - (context) => context.mapUnsafe.get(OpenAiConfig.key) + Context.getOrUndefined(OpenAiConfig) ) } diff --git a/repos/effect/packages/ai/openai-compat/src/OpenAiEmbeddingModel.ts b/repos/effect/packages/ai/openai-compat/src/OpenAiEmbeddingModel.ts index bfe251c4f9..0cd5393aa8 100644 --- a/repos/effect/packages/ai/openai-compat/src/OpenAiEmbeddingModel.ts +++ b/repos/effect/packages/ai/openai-compat/src/OpenAiEmbeddingModel.ts @@ -121,10 +121,9 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig }): Effect.fn.Return { const client = yield* OpenAiClient - const makeConfig = Effect.gen(function*() { - const services = yield* Effect.context() - return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) } - }) + const makeConfig = Effect.contextWith((services: Context.Context) => + Effect.succeed({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) }) + ) return yield* EmbeddingModel.make({ embedMany: Effect.fnUntraced(function*({ inputs }) { diff --git a/repos/effect/packages/ai/openai-compat/src/OpenAiLanguageModel.ts b/repos/effect/packages/ai/openai-compat/src/OpenAiLanguageModel.ts index 5418bd17f4..71e1e586aa 100644 --- a/repos/effect/packages/ai/openai-compat/src/OpenAiLanguageModel.ts +++ b/repos/effect/packages/ai/openai-compat/src/OpenAiLanguageModel.ts @@ -578,10 +578,9 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig }): Effect.fn.Return { const client = yield* OpenAiClient - const makeConfig = Effect.gen(function*() { - const services = yield* Effect.context() - return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) } - }) + const makeConfig = Effect.contextWith((services: Context.Context) => + Effect.succeed({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) }) + ) const makeRequest = Effect.fnUntraced( function*>({ config, options, toolNameMapper }: { @@ -1288,7 +1287,7 @@ const makeStreamResponse = Effect.fnUntraced( parts.push({ type: "text-delta", id: textId, delta: choice.delta.content }) } - if (choice.delta?.tool_calls !== undefined) { + if (Predicate.isNotNullish(choice.delta?.tool_calls)) { hasToolCalls = hasToolCalls || choice.delta.tool_calls.length > 0 choice.delta.tool_calls.forEach((deltaTool, indexInChunk) => { const toolIndex = deltaTool.index ?? indexInChunk diff --git a/repos/effect/packages/ai/openai-compat/src/internal/errors.ts b/repos/effect/packages/ai/openai-compat/src/internal/errors.ts index 67e42868b8..500289c5c6 100644 --- a/repos/effect/packages/ai/openai-compat/src/internal/errors.ts +++ b/repos/effect/packages/ai/openai-compat/src/internal/errors.ts @@ -207,46 +207,6 @@ export const buildHttpContext = (params: { body: params.body }) -const buildInvalidRequestDescription = (params: { - readonly status: number - readonly message: string | undefined - readonly method: string - readonly url: string - readonly errorCode: string | null - readonly errorType: string | null - readonly requestId: string | null - readonly body: string | undefined -}): string => { - const parts: Array = [] - - if (params.message) { - parts.push(params.message) - } else { - parts.push(`HTTP ${params.status}`) - } - - parts.push(`(${params.method} ${params.url})`) - - if (params.errorCode) { - parts.push(`[code: ${params.errorCode}]`) - } else if (params.errorType) { - parts.push(`[type: ${params.errorType}]`) - } - - if (params.requestId) { - parts.push(`[requestId: ${params.requestId}]`) - } - - if (!params.message && params.body) { - const truncated = params.body.length > 200 - ? params.body.slice(0, 200) + "..." - : params.body - parts.push(`Response: ${truncated}`) - } - - return parts.join(" ") -} - /** @internal */ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http }: { readonly status: number @@ -255,7 +215,7 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http readonly metadata: OpenAiErrorMetadata readonly http: typeof AiError.HttpContext.Type }): AiError.AiErrorReason => { - const invalidRequestDescription = buildInvalidRequestDescription({ + const errorDescription = AiError.buildErrorDescription({ status, message, method: http.request.method, @@ -269,32 +229,34 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http switch (status) { case 400: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { openai: metadata }, http }) case 401: return new AiError.AuthenticationError({ kind: "InvalidKey", + description: errorDescription, metadata, http }) case 403: return new AiError.AuthenticationError({ kind: "InsufficientPermissions", + description: errorDescription, metadata, http }) case 404: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { openai: metadata }, http }) case 409: case 422: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { openai: metadata }, http }) diff --git a/repos/effect/packages/ai/openai-compat/test/OpenAiClient.test.ts b/repos/effect/packages/ai/openai-compat/test/OpenAiClient.test.ts index 423ce3b539..7cae883a95 100644 --- a/repos/effect/packages/ai/openai-compat/test/OpenAiClient.test.ts +++ b/repos/effect/packages/ai/openai-compat/test/OpenAiClient.test.ts @@ -1,4 +1,5 @@ import { OpenAiClient } from "@effect/ai-openai-compat" +import * as Errors from "@effect/ai-openai-compat/internal/errors" import { assert, describe, it } from "@effect/vitest" import { Context, Effect, Layer, Redacted, type Schema, Stream } from "effect" import { @@ -376,6 +377,86 @@ describe("OpenAiClient", () => { } })))) + it.effect("surfaces the provider message on 401 AuthenticationError", () => + Effect.gen(function*() { + const client = yield* OpenAiClient.OpenAiClient + + const error = yield* client.createResponse({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "hello" }] + }).pipe(Effect.flip) + + assert.strictEqual(error.reason._tag, "AuthenticationError") + if (error.reason._tag !== "AuthenticationError") { + return yield* Effect.die(new Error("Expected AuthenticationError")) + } + assert.strictEqual(error.reason.kind, "InvalidKey") + assert.strictEqual( + error.reason.description, + "Incorrect API key provided (POST https://compat.example.test/v1/chat/completions) [code: invalid_api_key] [requestId: req_openai_compat]" + ) + assert.include(error.reason.message, "Incorrect API key provided") + }).pipe(Effect.provide(makeTestLayer({ + _tag: "Json", + status: 401, + body: { + error: { + message: "Incorrect API key provided", + type: "invalid_request_error", + code: "invalid_api_key" + } + }, + headers: { "x-request-id": "req_openai_compat" } + })))) + + it("preserves and truncates a fallback HTTP response", () => { + const body = `${"a".repeat(200)}b` + const reason = Errors.mapStatusCodeToReason({ + status: 400, + headers: {}, + message: undefined, + metadata: { errorCode: null, errorType: null, requestId: null }, + http: makeHttpContext("https://compat.example.test/v1/chat/completions", body) + }) + + assert.strictEqual(reason._tag, "InvalidRequestError") + if (reason._tag !== "InvalidRequestError") { + throw new Error("Expected InvalidRequestError") + } + assert.strictEqual( + reason.description, + `HTTP 400 (POST https://compat.example.test/v1/chat/completions) Response: ${"a".repeat(200)}...` + ) + }) + + it.effect("surfaces the provider message on 403 AuthenticationError", () => + Effect.gen(function*() { + const client = yield* OpenAiClient.OpenAiClient + + const error = yield* client.createResponse({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "hello" }] + }).pipe(Effect.flip) + + assert.strictEqual(error.reason._tag, "AuthenticationError") + if (error.reason._tag !== "AuthenticationError") { + return yield* Effect.die(new Error("Expected AuthenticationError")) + } + assert.strictEqual(error.reason.kind, "InsufficientPermissions") + assert.include(error.reason.description ?? "", "Country, region, or territory not supported") + assert.include(error.reason.message, "Country, region, or territory not supported") + }).pipe(Effect.provide(makeTestLayer({ + _tag: "Json", + status: 403, + body: { + error: { + message: "Country, region, or territory not supported", + type: "permission_error", + code: null + } + } + })))) + it.effect("maps insufficient quota errors to QuotaExhaustedError", () => Effect.gen(function*() { const client = yield* OpenAiClient.OpenAiClient @@ -535,3 +616,14 @@ const toSseBody = (events: ReadonlyArray): string => const data = event === "[DONE]" ? event : JSON.stringify(event) return `data: ${data}\n\n` }).join("") + +const makeHttpContext = (url: string, body: string) => ({ + request: { + method: "POST" as const, + url, + urlParams: [], + hash: undefined, + headers: {} + }, + body +}) diff --git a/repos/effect/packages/ai/openai-compat/test/OpenAiLanguageModel.test.ts b/repos/effect/packages/ai/openai-compat/test/OpenAiLanguageModel.test.ts index e968ba2c2b..df6edf9234 100644 --- a/repos/effect/packages/ai/openai-compat/test/OpenAiLanguageModel.test.ts +++ b/repos/effect/packages/ai/openai-compat/test/OpenAiLanguageModel.test.ts @@ -901,6 +901,48 @@ describe("OpenAiLanguageModel", () => { } })) + it.effect("emits text when streamed tool_calls is null", () => + Effect.gen(function*() { + const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe( + Layer.provide(Layer.succeed( + HttpClient.HttpClient, + makeHttpClient((request) => + Effect.succeed(sseResponse(request, [ + { + id: "chatcmpl_nullable_tool_calls", + object: "chat.completion.chunk", + model: "inception/mercury-2", + created: 1, + choices: [{ + index: 0, + delta: { + content: "Hello", + role: "assistant", + tool_calls: null + }, + finish_reason: null + }] + }, + "[DONE]" + ])) + ) + )) + ) + + const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe( + Stream.runCollect, + Effect.provide(OpenAiLanguageModel.model("inception/mercury-2")), + Effect.provide(layer) + ) + + const text = Array.from(partsChunk) + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + + assert.strictEqual(text, "Hello") + })) + it.effect("decodes streamed tool call params with the OpenAI codec", () => Effect.gen(function*() { const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe( diff --git a/repos/effect/packages/ai/openai/CHANGELOG.md b/repos/effect/packages/ai/openai/CHANGELOG.md index 8ef718f408..cb5fb0a475 100644 --- a/repos/effect/packages/ai/openai/CHANGELOG.md +++ b/repos/effect/packages/ai/openai/CHANGELOG.md @@ -1,5 +1,45 @@ # @effect/ai-openai +## 4.0.0-rc.112 + +### Patch Changes + +- [#7437](https://github.com/Effect-TS/effect/pull/7437) [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb) Thanks @wmaurer! - Add an optional `description` to `AiError.AuthenticationError`, rendered after the kind-based suggestion, and pass the provider's own error text through it on HTTP 401 and 403, so authentication failures report what actually went wrong instead of only a category. + +- [#7403](https://github.com/Effect-TS/effect/pull/7403) [`7455246`](https://github.com/Effect-TS/effect/commit/7455246f352385f5cbbdd8299555265ee289490e) Thanks @hsyntax! - Add support for explicit cache breakpoints on the OpenAI responses API for GPT-5.6-or-later. +- Updated dependencies [[`20cb4f2`](https://github.com/Effect-TS/effect/commit/20cb4f260e45d37fa417c292c57be015314efe16), [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb), [`b6bf5e1`](https://github.com/Effect-TS/effect/commit/b6bf5e14492643076454131148f97cde24ad5306), [`0b9f780`](https://github.com/Effect-TS/effect/commit/0b9f780ff28b71042241791a9e8bcb5b631be2bd), [`150e92c`](https://github.com/Effect-TS/effect/commit/150e92c4169c245e701da02575eef0b69c3ecd64), [`6740db2`](https://github.com/Effect-TS/effect/commit/6740db247ed20cb85da43c9f48ade8fecfd8c1ae), [`d57bba1`](https://github.com/Effect-TS/effect/commit/d57bba1486fa60971b6e0bf7459a329cfd5acdc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`02a5146`](https://github.com/Effect-TS/effect/commit/02a5146d6933c7f6052553550bce5658225e4100), [`15272a6`](https://github.com/Effect-TS/effect/commit/15272a66adf02501e7747761e2a3c41bff67bb46), [`436f10d`](https://github.com/Effect-TS/effect/commit/436f10d1efccec308426532ff3f88df9a96434f3), [`058fb15`](https://github.com/Effect-TS/effect/commit/058fb15647fa01ad771277bd368783fcf5f262e8), [`4d89bb8`](https://github.com/Effect-TS/effect/commit/4d89bb8ffb4cf567a1d11072246b6161ce638712), [`480fb15`](https://github.com/Effect-TS/effect/commit/480fb156590785cf98f67bdec4fc282a608e2d87), [`f77ec19`](https://github.com/Effect-TS/effect/commit/f77ec19cff1cbbeeae928e3bd0ece00a7d22bab8), [`925b82a`](https://github.com/Effect-TS/effect/commit/925b82a81f59a4d459b488621030f24ba99d6a27), [`7455246`](https://github.com/Effect-TS/effect/commit/7455246f352385f5cbbdd8299555265ee289490e), [`118124d`](https://github.com/Effect-TS/effect/commit/118124d913d0a02ac5c1f7799a39bd90031769d9), [`a5f78d3`](https://github.com/Effect-TS/effect/commit/a5f78d3fcbaa792d49e80d103ab438e0b50812fd), [`0dd7825`](https://github.com/Effect-TS/effect/commit/0dd7825e4da4d3a00fa9bd410a1d55f3d4874d07), [`b722eca`](https://github.com/Effect-TS/effect/commit/b722eca6d283a88970ad0efba0b4e921915eca78), [`811d579`](https://github.com/Effect-TS/effect/commit/811d579c432856a9e3fc05b517fd8e924cbf991a), [`043b587`](https://github.com/Effect-TS/effect/commit/043b587e6e93f6624bf974bcd7ed976eaa17f0e1), [`8583727`](https://github.com/Effect-TS/effect/commit/85837274fa929a921985464585513a68c261e365), [`d9d2cfc`](https://github.com/Effect-TS/effect/commit/d9d2cfcb732754001b7323cf8afaccc48539bb74), [`5c4b7a0`](https://github.com/Effect-TS/effect/commit/5c4b7a0b17931cd1538c6595a54b21ffe9c1e906)]: + - effect@4.0.0-rc.112 + +## 4.0.0-rc.111 + +### Patch Changes + +- [#7262](https://github.com/Effect-TS/effect/pull/7262) [`b755950`](https://github.com/Effect-TS/effect/commit/b7559505c831c779eb2c3a974e88d35cb1f2fae5) Thanks @danieljvdm! - Decode nested OpenAI Responses API error events without changing their decoded type. +- Updated dependencies [[`0ce3b00`](https://github.com/Effect-TS/effect/commit/0ce3b007531563ce33f94c6a057492d379cfd9e8), [`d846331`](https://github.com/Effect-TS/effect/commit/d84633171b4333e87a05f8d74c02c95d83d1b50d), [`f93616f`](https://github.com/Effect-TS/effect/commit/f93616f31f42345af844b8396fb19259556ae236), [`16bf1ef`](https://github.com/Effect-TS/effect/commit/16bf1efdf41ba1d000b8588a8cc052a394856ba4), [`d568968`](https://github.com/Effect-TS/effect/commit/d5689682e963f43157571cd71d4b422247ae09b9), [`bc06292`](https://github.com/Effect-TS/effect/commit/bc062920ac47f956786d12c6d0bacb9468fa66e2), [`e03ea90`](https://github.com/Effect-TS/effect/commit/e03ea907720b58d19d99929695df159c42fff101), [`9b10fc8`](https://github.com/Effect-TS/effect/commit/9b10fc85dde7866fb60e8142a93a6ed6a184537c), [`770c6d0`](https://github.com/Effect-TS/effect/commit/770c6d0f0ddcab7dbdde9fe701aded08deaee3fd), [`7425bcb`](https://github.com/Effect-TS/effect/commit/7425bcb7f74bdccc1941883bc7f9472fbe6ae8b4), [`563815a`](https://github.com/Effect-TS/effect/commit/563815a250e10a02e2a719c7d9b159478b0da933), [`1e83ca1`](https://github.com/Effect-TS/effect/commit/1e83ca110d5cb74641bae148a08e7eb6b3c2e2ed), [`550a41a`](https://github.com/Effect-TS/effect/commit/550a41a2547ce0dd5f085f1637a6f8a5794acba7), [`45d79c7`](https://github.com/Effect-TS/effect/commit/45d79c7cbd9c56c643361c6e6d1689f1aa62ae32), [`aac8584`](https://github.com/Effect-TS/effect/commit/aac8584fd997f3ce3341aeb077b816219832de58), [`7f87022`](https://github.com/Effect-TS/effect/commit/7f870225bb10a7ee7cb54b4835cb3bf64c0cacd8), [`425457c`](https://github.com/Effect-TS/effect/commit/425457c1e9eec7c176df130f7ec6ad3d616c086e), [`008c423`](https://github.com/Effect-TS/effect/commit/008c423e413fe311085c4bfc07c19b9c70529dfa), [`4f6ae04`](https://github.com/Effect-TS/effect/commit/4f6ae041a2886c27e941c5d9e4916aa2a32e0a1c), [`b6b63e1`](https://github.com/Effect-TS/effect/commit/b6b63e151595b5599ad2d65069ab6d298cfd7be7), [`92922ee`](https://github.com/Effect-TS/effect/commit/92922ee7de0a2d932121e37677f3e75ea38482f1), [`859c02f`](https://github.com/Effect-TS/effect/commit/859c02fe9020b0f28f713aa85910a0cc2d002560), [`ffc8235`](https://github.com/Effect-TS/effect/commit/ffc823537a0925171cf41d0841643a0b26895a4c), [`a29eb70`](https://github.com/Effect-TS/effect/commit/a29eb702ffe3fc58bd28c4d7857298cd65d73668), [`0be2303`](https://github.com/Effect-TS/effect/commit/0be2303dd39f9f2f7d8f96a28e25c938cd914e1a), [`b44636f`](https://github.com/Effect-TS/effect/commit/b44636ff7233763fad060bc0f9de10512454a29b), [`b19ccc7`](https://github.com/Effect-TS/effect/commit/b19ccc7325469a5dea091349bda4ae2ec00ec865), [`ff98f0b`](https://github.com/Effect-TS/effect/commit/ff98f0b0e2beb331209e37e42095d8d6e8e0b6c2), [`a47cbf1`](https://github.com/Effect-TS/effect/commit/a47cbf1feafd10e305c9753fa02a5f5f977d2c37), [`39b55f8`](https://github.com/Effect-TS/effect/commit/39b55f850aecbecf2e3b0c5f3ae2d1a77e6da949), [`c6c49c9`](https://github.com/Effect-TS/effect/commit/c6c49c96990d009341341a53ed26beb140c267a9), [`bf23ba7`](https://github.com/Effect-TS/effect/commit/bf23ba7033a4b426caf379bdcf84c58bb64d7010)]: + - effect@4.0.0-rc.111 + +## 4.0.0-rc.110 + +### Patch Changes + +- [#7258](https://github.com/Effect-TS/effect/pull/7258) [`2b6a6f5`](https://github.com/Effect-TS/effect/commit/2b6a6f5dbff2795a57cf3d7f04bfa635f9d5892b) Thanks @K-Mistele! - Support max reasoning effort and quota errors returned by OpenAI-compatible providers. +- Updated dependencies [[`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`0a127b8`](https://github.com/Effect-TS/effect/commit/0a127b8847fb213d46621a5018c069019414c00d), [`e491deb`](https://github.com/Effect-TS/effect/commit/e491deb1cd1717bc442aaee96e5d7b4001710cc9), [`f99c508`](https://github.com/Effect-TS/effect/commit/f99c508a09e9fccddb09edd16fa3393891c55d48), [`7e3f07c`](https://github.com/Effect-TS/effect/commit/7e3f07cf3e4b6a907840aface36ae5ee67ca8979), [`a894fe1`](https://github.com/Effect-TS/effect/commit/a894fe1df8974077293d9c1b924a7f80fc7fe7f1), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`7e9923b`](https://github.com/Effect-TS/effect/commit/7e9923b1a0adf1f842216b298d7542bed980c9a5), [`f064121`](https://github.com/Effect-TS/effect/commit/f0641210f960563f813ac6f728804e5447c03341), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`b660bf0`](https://github.com/Effect-TS/effect/commit/b660bf01d9c427552296cd9e0195e21504f45e1f), [`f4fbe9c`](https://github.com/Effect-TS/effect/commit/f4fbe9ca0209cdfaaaff54fc3805221be80f1ffa), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`e811353`](https://github.com/Effect-TS/effect/commit/e811353e3b79ba392af1d9fb8d7f93fd729dabf9), [`9761c3c`](https://github.com/Effect-TS/effect/commit/9761c3c4787b3815346c1b650d8984efec8f1051), [`baa99fc`](https://github.com/Effect-TS/effect/commit/baa99fc176efed4986c19c78726d1dbe7bd17765), [`7fd79b2`](https://github.com/Effect-TS/effect/commit/7fd79b2cc95973f85b3db43b0cf3c5cb7d2f69c4), [`a82ffc0`](https://github.com/Effect-TS/effect/commit/a82ffc03df0fb34e02f16773b96bfa3e0208e427), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`4026e2d`](https://github.com/Effect-TS/effect/commit/4026e2dde3b098e70ffb3f1fcddd200176acdefd), [`397bf1e`](https://github.com/Effect-TS/effect/commit/397bf1ebd95c0d6d58dc53e4f33c8ad3f34746f6), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f)]: + - effect@4.0.0-rc.110 + +## 4.0.0-rc.109 + +### Patch Changes + +- Updated dependencies [[`a0743f2`](https://github.com/Effect-TS/effect/commit/a0743f2b9f20fb5d150f35510e68819f01630bac), [`17892e7`](https://github.com/Effect-TS/effect/commit/17892e75a8d584f79127805506c42b19320990a7), [`4d8a230`](https://github.com/Effect-TS/effect/commit/4d8a2306dfff8fd5406ab75d0c8d22e5300d1faa), [`f21f9c9`](https://github.com/Effect-TS/effect/commit/f21f9c9f73dc59db922bda411b6d5245dae37cdb), [`18270dd`](https://github.com/Effect-TS/effect/commit/18270ddeaf4ad28723ca8a40aff04a5d4707b575), [`26db404`](https://github.com/Effect-TS/effect/commit/26db404a3284cfdbf4a3f351ccd05afc0de743b8), [`2670398`](https://github.com/Effect-TS/effect/commit/26703982612e12954360382e4b1d177002699d1c), [`3702bed`](https://github.com/Effect-TS/effect/commit/3702bedd8f6bcb3f603b87c640c521878d824eb3), [`ccae60e`](https://github.com/Effect-TS/effect/commit/ccae60e5edb2bef553f4af52afb509dfd443cd03), [`6ff5396`](https://github.com/Effect-TS/effect/commit/6ff53968138bbd7d4728ce8014e35eae8d6ca5d0)]: + - effect@4.0.0-rc.109 + +## 4.0.0-rc.108 + +### Patch Changes + +- Updated dependencies [[`dfb173e`](https://github.com/Effect-TS/effect/commit/dfb173efffd20c4feded4efe409018dd55acdca8), [`005e090`](https://github.com/Effect-TS/effect/commit/005e0902cace9f8960a4f43573665a3a9b53b6fa), [`c82c532`](https://github.com/Effect-TS/effect/commit/c82c53228dc1c50cc99654ce6de7766b4de09e75), [`22b579f`](https://github.com/Effect-TS/effect/commit/22b579f6c582e6e2d951784791fea6f1802517ed), [`3e19539`](https://github.com/Effect-TS/effect/commit/3e19539205082b1006d84553045d1b03db9cc8a1), [`08a3c74`](https://github.com/Effect-TS/effect/commit/08a3c74133206fc1cc728e0aa96d02e672fd80bd), [`eb0bae0`](https://github.com/Effect-TS/effect/commit/eb0bae08d543d58754c9bb7a57e67c1e2bb3f55a), [`97b544d`](https://github.com/Effect-TS/effect/commit/97b544d8b636587647b90691d669305c0eb4fc66), [`4f6d131`](https://github.com/Effect-TS/effect/commit/4f6d131e85d74ab0ec0300e52e503a5f943fc576), [`fad4b7c`](https://github.com/Effect-TS/effect/commit/fad4b7c5138b3f38c2427436da2e0685c1ca4e9b), [`accf447`](https://github.com/Effect-TS/effect/commit/accf4474513064e2a21d14b1937503261b4f34dc), [`31b27e4`](https://github.com/Effect-TS/effect/commit/31b27e49903c351588435f666c953aaac28f6120), [`8458951`](https://github.com/Effect-TS/effect/commit/84589518c3966c63d7f3679a5296d380eb1ba887)]: + - effect@4.0.0-rc.108 + ## 4.0.0-beta.107 ### Patch Changes diff --git a/repos/effect/packages/ai/openai/README.md b/repos/effect/packages/ai/openai/README.md new file mode 100644 index 0000000000..2ef377e345 --- /dev/null +++ b/repos/effect/packages/ai/openai/README.md @@ -0,0 +1,14 @@ +# @effect/ai-openai + +An [OpenAI](https://openai.com) provider for the Effect AI modules. Includes a typed OpenAI API client, language model and embedding model layers, tools, and telemetry helpers. + +## Installation + +```sh +npm install effect@rc @effect/ai-openai@rc +``` + +## Documentation + +- [Effect website](https://effect.website) +- [API reference](https://effect.website/docs/v4/api/ai-openai) diff --git a/repos/effect/packages/ai/openai/package.json b/repos/effect/packages/ai/openai/package.json index f51d8f1efa..52e0410418 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-beta.107", + "version": "4.0.0-rc.112", "type": "module", "license": "MIT", "description": "An OpenAI provider integration for Effect AI SDK", diff --git a/repos/effect/packages/ai/openai/src/OpenAiConfig.ts b/repos/effect/packages/ai/openai/src/OpenAiConfig.ts index 37f18d7acc..7d723974ed 100644 --- a/repos/effect/packages/ai/openai/src/OpenAiConfig.ts +++ b/repos/effect/packages/ai/openai/src/OpenAiConfig.ts @@ -35,7 +35,7 @@ export class OpenAiConfig extends Context.Service< */ static readonly getOrUndefined: Effect.Effect = Effect.map( Effect.context(), - (context) => context.mapUnsafe.get(OpenAiConfig.key) + Context.getOrUndefined(OpenAiConfig) ) } diff --git a/repos/effect/packages/ai/openai/src/OpenAiEmbeddingModel.ts b/repos/effect/packages/ai/openai/src/OpenAiEmbeddingModel.ts index 76443785a2..fa466ca792 100644 --- a/repos/effect/packages/ai/openai/src/OpenAiEmbeddingModel.ts +++ b/repos/effect/packages/ai/openai/src/OpenAiEmbeddingModel.ts @@ -130,10 +130,9 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig }): Effect.fn.Return { const client = yield* OpenAiClient - const makeConfig = Effect.gen(function*() { - const services = yield* Effect.context() - return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) } - }) + const makeConfig = Effect.contextWith((services: Context.Context) => + Effect.succeed({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) }) + ) return yield* EmbeddingModel.make({ embedMany: Effect.fnUntraced(function*({ inputs }) { diff --git a/repos/effect/packages/ai/openai/src/OpenAiLanguageModel.ts b/repos/effect/packages/ai/openai/src/OpenAiLanguageModel.ts index c78d3e2af6..4addf9f5bf 100644 --- a/repos/effect/packages/ai/openai/src/OpenAiLanguageModel.ts +++ b/repos/effect/packages/ai/openai/src/OpenAiLanguageModel.ts @@ -58,6 +58,8 @@ export type Model = typeof ResponseModelIds.Encoded | typeof SharedModelIds.Enco */ type ImageDetail = "auto" | "low" | "high" +type PromptCacheBreakpoint = { readonly mode: "explicit" } + // ============================================================================= // Configuration // ============================================================================= @@ -127,6 +129,27 @@ export class Config extends Context.Service< // ============================================================================= declare module "effect/unstable/ai/Prompt" { + /** + * OpenAI-specific options for system messages. + * + * @category models + * @since 4.0.0 + */ + export interface SystemMessageOptions extends ProviderOptions { + /** + * Provider-specific system message options for the OpenAI Responses API. + */ + readonly openai?: { + /** + * Marks the system input text as the end of a reusable prompt prefix. + * + * Requires GPT-5.6 or later. OpenAI may reject requests that use this + * option with earlier models. + */ + readonly promptCacheBreakpoint?: PromptCacheBreakpoint | null + } | null + } + /** * OpenAI-specific options for file prompt parts. * @@ -244,6 +267,13 @@ declare module "effect/unstable/ai/Prompt" { * A list of annotations that apply to the output text. */ readonly annotations?: ReadonlyArray | null + /** + * Marks the input text as the end of a reusable prompt prefix. + * + * Requires GPT-5.6 or later. OpenAI may reject requests that use this + * option with earlier models. + */ + readonly promptCacheBreakpoint?: PromptCacheBreakpoint | null } | null } } @@ -590,7 +620,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig const makeConfig = Effect.gen(function*() { const services = yield* Effect.context() - return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) } + return { model, ...providerConfig, ...Context.getOrUndefined(services, Config) } }) const makeRequest = Effect.fnUntraced( @@ -819,7 +849,11 @@ const prepareMessages = Effect.fnUntraced( case "system": { messages.push({ role: getSystemMessageMode(config.model as string), - content: [{ type: "input_text", text: message.content }] + content: [{ + type: "input_text", + text: message.content, + ...getPromptCacheBreakpoint(message) + }] }) break } @@ -832,7 +866,11 @@ const prepareMessages = Effect.fnUntraced( switch (part.type) { case "text": { - content.push({ type: "input_text", text: part.text }) + content.push({ + type: "input_text", + text: part.text, + ...getPromptCacheBreakpoint(part) + }) break } @@ -2910,6 +2948,13 @@ const getEncryptedContent = ( const getImageDetail = (part: Prompt.FilePart): ImageDetail => part.options.openai?.imageDetail ?? "auto" +const getPromptCacheBreakpoint = ( + input: Prompt.SystemMessage | Prompt.TextPart +) => { + const promptCacheBreakpoint = input.options.openai?.promptCacheBreakpoint + return Predicate.isNotNullish(promptCacheBreakpoint) ? { prompt_cache_breakpoint: promptCacheBreakpoint } : undefined +} + const makeItemIdMetadata = (itemId: string | undefined) => Predicate.isNotUndefined(itemId) ? { itemId } : {} const makeEncryptedContentMetadata = (encryptedContent: string | null | undefined) => diff --git a/repos/effect/packages/ai/openai/src/OpenAiSchema.ts b/repos/effect/packages/ai/openai/src/OpenAiSchema.ts index a4672afb3f..2714c893a1 100644 --- a/repos/effect/packages/ai/openai/src/OpenAiSchema.ts +++ b/repos/effect/packages/ai/openai/src/OpenAiSchema.ts @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect" import * as Predicate from "effect/Predicate" import * as Schema from "effect/Schema" +import * as SchemaTransformation from "effect/SchemaTransformation" const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown) @@ -18,6 +19,10 @@ const MessageRole = Schema.Literals(["system", "developer", "user", "assistant"] const ImageDetail = Schema.Literals(["low", "high", "auto"]) +const PromptCacheBreakpoint = Schema.Struct({ + mode: Schema.Literal("explicit") +}) + /** * Schema for optional `include` values supported by the local handwritten * Responses client schema. @@ -75,7 +80,8 @@ export type MessageStatus = typeof MessageStatus.Type const InputTextContent = Schema.Struct({ type: Schema.Literal("input_text"), - text: Schema.String + text: Schema.String, + prompt_cache_breakpoint: Schema.optional(PromptCacheBreakpoint) }) const InputImageContent = Schema.Struct({ @@ -639,7 +645,7 @@ export type TextResponseFormatConfiguration = typeof TextResponseFormatConfigura * Validates the Responses API request payload, including input content, model * selection, instructions, reasoning options, text output format, tools, * `tool_choice`, streaming, storage, response continuation, sampling options, - * and optional response fields requested through `include`. + * prompt caching, and optional response fields requested through `include`. * * **Gotchas** * @@ -658,11 +664,16 @@ export const CreateResponse = Schema.Struct({ temperature: Schema.optional(Schema.Finite), top_p: Schema.optional(Schema.Finite), user: Schema.optional(Schema.String), + 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")) + })), service_tier: Schema.optional(Schema.String), previous_response_id: Schema.optional(Schema.String), model: Schema.optional(Schema.String), reasoning: Schema.optional(Schema.Struct({ - effort: Schema.optional(Schema.Literals(["none", "minimal", "low", "medium", "high", "xhigh"])), + effort: Schema.optional(Schema.Literals(["none", "minimal", "low", "medium", "high", "xhigh", "max"])), summary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])), generate_summary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])) @@ -1041,6 +1052,26 @@ const ResponseErrorEvent = Schema.Struct({ status: Schema.optionalKey(Schema.Int) }) +// OpenAI can nest stream error details under `error`. +const NestedResponseErrorEvent = Schema.Struct({ + type: Schema.Literal("error"), + error: Schema.Struct({ + code: Schema.NullOr(Schema.String), + message: Schema.String, + param: Schema.NullOr(Schema.String) + }), + sequence_number: Schema.Int, + status: Schema.optionalKey(Schema.Int) +}).pipe( + Schema.decodeTo( + ResponseErrorEvent, + SchemaTransformation.transform({ + decode: ({ error, ...rest }) => ({ ...rest, ...error }), + encode: ({ code, message, param, ...rest }) => ({ ...rest, error: { code, message, param } }) + }) + ) +) + const knownResponseStreamEventTypes = new Set([ "response.created", "response.completed", @@ -1129,6 +1160,7 @@ export const ResponseStreamEvent = Schema.Union([ ResponseApplyPatchCallOperationDiffDoneEvent, ResponseImageGenerationCallPartialImageEvent, ResponseErrorEvent, + NestedResponseErrorEvent, UnknownResponseStreamEvent ]) diff --git a/repos/effect/packages/ai/openai/src/internal/errors.ts b/repos/effect/packages/ai/openai/src/internal/errors.ts index 19bd28e5cc..fc93181b21 100644 --- a/repos/effect/packages/ai/openai/src/internal/errors.ts +++ b/repos/effect/packages/ai/openai/src/internal/errors.ts @@ -28,6 +28,11 @@ export const OpenAiErrorBody = Schema.Struct({ }) }) +const OpenAiCompatibleErrorBody = Schema.Struct({ + error: Schema.String, + code: Schema.optional(Schema.String) +}) + // ============================================================================= // Error Mappers // ============================================================================= @@ -147,14 +152,25 @@ const mapStatusCodeError = Effect.fnUntraced(function*( json = undefined } const decoded = Schema.decodeUnknownOption(OpenAiErrorBody)(json) + const compatibleDecoded = Schema.decodeUnknownOption(OpenAiCompatibleErrorBody)(json) + const message = Option.isSome(decoded) + ? decoded.value.error.message + : Option.isSome(compatibleDecoded) + ? compatibleDecoded.value.error + : undefined + const errorCode = Option.isSome(decoded) + ? decoded.value.error.code ?? null + : Option.isSome(compatibleDecoded) + ? compatibleDecoded.value.code ?? null + : null const reason = mapStatusCodeToReason({ status, headers, - message: Option.isSome(decoded) ? decoded.value.error.message : undefined, + message, http: buildHttpContext({ request, response, body }), metadata: { - errorCode: Option.isSome(decoded) ? decoded.value.error.code ?? null : null, + errorCode, errorType: Option.isSome(decoded) ? decoded.value.error.type ?? null : null, requestId: requestId ?? null } @@ -225,51 +241,6 @@ export const buildHttpContext = (params: { // HTTP Status Code // ============================================================================= -const buildInvalidRequestDescription = (params: { - readonly status: number - readonly message: string | undefined - readonly method: string - readonly url: string - readonly errorCode: string | null - readonly errorType: string | null - readonly requestId: string | null - readonly body: string | undefined -}): string => { - const parts: Array = [] - - // Primary message or status description - if (params.message) { - parts.push(params.message) - } else { - parts.push(`HTTP ${params.status}`) - } - - // Request context - parts.push(`(${params.method} ${params.url})`) - - // Error code/type if available - if (params.errorCode) { - parts.push(`[code: ${params.errorCode}]`) - } else if (params.errorType) { - parts.push(`[type: ${params.errorType}]`) - } - - // Request ID for debugging - if (params.requestId) { - parts.push(`[requestId: ${params.requestId}]`) - } - - // If no message and we have body, show truncated body - if (!params.message && params.body) { - const truncated = params.body.length > 200 - ? params.body.slice(0, 200) + "..." - : params.body - parts.push(`Response: ${truncated}`) - } - - return parts.join(" ") -} - /** @internal */ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http }: { readonly status: number @@ -278,7 +249,7 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http readonly metadata: OpenAiErrorMetadata readonly http: typeof AiError.HttpContext.Type }): AiError.AiErrorReason => { - const invalidRequestDescription = buildInvalidRequestDescription({ + const errorDescription = AiError.buildErrorDescription({ status, message, method: http.request.method, @@ -292,32 +263,39 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http switch (status) { case 400: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { openai: metadata }, http }) case 401: return new AiError.AuthenticationError({ kind: "InvalidKey", + description: errorDescription, metadata, http }) case 403: return new AiError.AuthenticationError({ kind: "InsufficientPermissions", + description: errorDescription, metadata, http }) case 404: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { openai: metadata }, http }) case 409: case 422: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, + metadata: { openai: metadata }, + http + }) + case 402: + return new AiError.QuotaExhaustedError({ metadata: { openai: metadata }, http }) @@ -325,7 +303,9 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http // Best-effort detection: OpenAI returns insufficient_quota for billing/quota issues if ( metadata.errorCode === "insufficient_quota" || - metadata.errorType === "insufficient_quota" + metadata.errorType === "insufficient_quota" || + metadata.errorCode === "billing_insufficient_balance" || + metadata.errorType === "billing_insufficient_balance" ) { return new AiError.QuotaExhaustedError({ metadata: { openai: metadata }, diff --git a/repos/effect/packages/ai/openai/test/OpenAiClient.test.ts b/repos/effect/packages/ai/openai/test/OpenAiClient.test.ts index 9c5471c99d..d027d86d05 100644 --- a/repos/effect/packages/ai/openai/test/OpenAiClient.test.ts +++ b/repos/effect/packages/ai/openai/test/OpenAiClient.test.ts @@ -291,13 +291,39 @@ describe("OpenAiClient", () => { assert.strictEqual(result.reason._tag, "AuthenticationError") if (result.reason._tag === "AuthenticationError") { assert.strictEqual(result.reason.kind, "InvalidKey") + assert.strictEqual( + result.reason.description, + "Invalid API key (POST https://api.openai.com/v1/responses) [code: invalid_api_key] [requestId: req_openai]" + ) + assert.include(result.reason.message, "Invalid API key") } }).pipe(Effect.provide(makeTestLayer(undefined, { _tag: "Json", status: 401, - body: { error: { message: "Invalid API key" } } + body: { error: { message: "Invalid API key", type: "invalid_request_error", code: "invalid_api_key" } }, + headers: { "x-request-id": "req_openai" } })))) + it("preserves and truncates a fallback HTTP response", () => { + const body = `${"a".repeat(200)}b` + const reason = Errors.mapStatusCodeToReason({ + status: 400, + headers: {}, + message: undefined, + metadata: { errorCode: null, errorType: null, requestId: null }, + http: makeHttpContext("https://api.openai.com/v1/responses", body) + }) + + assert.strictEqual(reason._tag, "InvalidRequestError") + if (reason._tag !== "InvalidRequestError") { + throw new Error("Expected InvalidRequestError") + } + assert.strictEqual( + reason.description, + `HTTP 400 (POST https://api.openai.com/v1/responses) Response: ${"a".repeat(200)}...` + ) + }) + it.effect("maps 403 status to AuthenticationError with InsufficientPermissions", () => Effect.gen(function*() { const client = yield* OpenAiClient.OpenAiClient @@ -307,6 +333,8 @@ describe("OpenAiClient", () => { assert.strictEqual(result.reason._tag, "AuthenticationError") if (result.reason._tag === "AuthenticationError") { assert.strictEqual(result.reason.kind, "InsufficientPermissions") + assert.include(result.reason.description ?? "", "Access denied") + assert.include(result.reason.message, "Access denied") } }).pipe(Effect.provide(makeTestLayer(undefined, { _tag: "Json", @@ -348,6 +376,34 @@ describe("OpenAiClient", () => { } })))) + it.effect("maps OpenAI-compatible 402 errors to QuotaExhaustedError", () => + Effect.gen(function*() { + const client = yield* OpenAiClient.OpenAiClient + const result = yield* client.createResponse({ model: "grok-4", input: "test" }).pipe(Effect.flip) + + assert.strictEqual(result._tag, "AiError") + assert.strictEqual(result.reason._tag, "QuotaExhaustedError") + assert.isFalse(result.isRetryable) + }).pipe(Effect.provide(makeTestLayer(undefined, { + _tag: "Json", + status: 402, + body: { error: "Your balance is too low", code: "billing_insufficient_balance" } + })))) + + it.effect("maps OpenAI-compatible insufficient balance errors to QuotaExhaustedError", () => + Effect.gen(function*() { + const client = yield* OpenAiClient.OpenAiClient + const result = yield* client.createResponse({ model: "grok-4", input: "test" }).pipe(Effect.flip) + + assert.strictEqual(result._tag, "AiError") + assert.strictEqual(result.reason._tag, "QuotaExhaustedError") + assert.isFalse(result.isRetryable) + }).pipe(Effect.provide(makeTestLayer(undefined, { + _tag: "Json", + status: 429, + body: { error: "Your balance is too low", code: "billing_insufficient_balance" } + })))) + it("mapStatusCodeToReason detects insufficient_quota as QuotaExhaustedError", () => { const http = { request: { @@ -585,6 +641,9 @@ type MockResponse = readonly type?: string readonly code?: string | null } + } | { + readonly error: string + readonly code?: string } readonly status?: number | undefined readonly headers?: Record | undefined @@ -738,3 +797,14 @@ const makeResponse = ( } }) } + +const makeHttpContext = (url: string, body: string) => ({ + request: { + method: "POST" as const, + url, + urlParams: [], + hash: undefined, + headers: {} + }, + body +}) diff --git a/repos/effect/packages/ai/openai/test/OpenAiLanguageModel.test.ts b/repos/effect/packages/ai/openai/test/OpenAiLanguageModel.test.ts index 711733d669..19b1e45279 100644 --- a/repos/effect/packages/ai/openai/test/OpenAiLanguageModel.test.ts +++ b/repos/effect/packages/ai/openai/test/OpenAiLanguageModel.test.ts @@ -31,6 +31,69 @@ describe("OpenAiLanguageModel", () => { describe("generateText", () => { describe("message preparation", () => { + it.effect("forwards prompt cache configuration and text breakpoints", () => + Effect.gen(function*() { + const breakpoint = { mode: "explicit" } as const + yield* LanguageModel.generateText({ + prompt: Prompt.make([ + Prompt.systemMessage({ + content: "Stable instructions", + options: { openai: { promptCacheBreakpoint: breakpoint } } + }), + Prompt.userMessage({ + content: [Prompt.textPart({ + text: "Stable context", + options: { openai: { promptCacheBreakpoint: breakpoint } } + })] + }) + ]) + }).pipe( + Effect.provide(OpenAiLanguageModel.model("gpt-5.6", { + prompt_cache_key: "assistant:v1", + prompt_cache_options: { mode: "explicit", ttl: "30m" } + })) + ) + + const requests = yield* MockHttpClient.requests + const body = yield* getRequestBody(requests[0]) + + strictEqual(body.prompt_cache_key, "assistant:v1") + deepStrictEqual(body.prompt_cache_options, { mode: "explicit", ttl: "30m" }) + deepStrictEqual(body.input, [{ + role: "developer", + content: [{ + type: "input_text", + text: "Stable instructions", + prompt_cache_breakpoint: breakpoint + }] + }, { + role: "user", + content: [{ + type: "input_text", + text: "Stable context", + prompt_cache_breakpoint: breakpoint + }] + }]) + }).pipe(Effect.provide(makeTestLayer({ body: { model: "gpt-5.6" as any } })))) + + it.effect("forwards implicit prompt cache mode without text breakpoints", () => + Effect.gen(function*() { + yield* LanguageModel.generateText({ prompt: "Stable context" }).pipe( + Effect.provide(OpenAiLanguageModel.model("gpt-5.6", { + prompt_cache_options: { mode: "implicit" } + })) + ) + + const requests = yield* MockHttpClient.requests + const body = yield* getRequestBody(requests[0]) + + deepStrictEqual(body.prompt_cache_options, { mode: "implicit" }) + deepStrictEqual(body.input, [{ + role: "user", + content: [{ type: "input_text", text: "Stable context" }] + }]) + }).pipe(Effect.provide(makeTestLayer({ body: { model: "gpt-5.6" as any } })))) + describe("system messages", () => { it.effect("uses system role for standard models", () => Effect.gen(function*() { diff --git a/repos/effect/packages/ai/openai/test/OpenAiSchema.test.ts b/repos/effect/packages/ai/openai/test/OpenAiSchema.test.ts index 26e56c4ace..8cdd06a1bb 100644 --- a/repos/effect/packages/ai/openai/test/OpenAiSchema.test.ts +++ b/repos/effect/packages/ai/openai/test/OpenAiSchema.test.ts @@ -17,6 +17,14 @@ const makeResponse = (overrides: Record = {}) => ({ }) describe("OpenAiSchema", () => { + it("accepts max reasoning effort", () => { + const decoded = Schema.decodeUnknownSync(OpenAiSchema.CreateResponse)({ + reasoning: { effort: "max" } + }) + + assert.strictEqual(decoded.reasoning?.effort, "max") + }) + it("decodes a representative response payload", () => { const decoded = Schema.decodeUnknownSync(OpenAiSchema.Response)({ ...makeResponse(), @@ -275,6 +283,110 @@ describe("OpenAiSchema", () => { assert.isDefined(malformed) })) + it("decodes the error event whether the payload is flat or nested under `error`", () => { + const flat = Schema.decodeUnknownSync(OpenAiSchema.ResponseStreamEvent)({ + type: "error", + code: "ERR", + message: "boom", + param: null, + sequence_number: 1 + }) + assert.deepStrictEqual(flat, { + type: "error", + code: "ERR", + message: "boom", + param: null, + sequence_number: 1 + }) + + const nested = Schema.decodeUnknownSync(OpenAiSchema.ResponseStreamEvent)({ + type: "error", + error: { + type: "insufficient_quota", + code: "credit_balance_exhausted", + message: "You have no credits remaining.", + param: null + }, + sequence_number: 2 + }) + assert.deepStrictEqual(nested, { + type: "error", + code: "credit_balance_exhausted", + message: "You have no credits remaining.", + param: null, + sequence_number: 2 + }) + + const nestedWithStatus = Schema.decodeUnknownSync(OpenAiSchema.ResponseStreamEvent)({ + type: "error", + error: { + code: "rate_limited", + message: "Too many requests.", + param: null + }, + sequence_number: 3, + status: 429 + }) + assert.deepStrictEqual(nestedWithStatus, { + type: "error", + code: "rate_limited", + message: "Too many requests.", + param: null, + sequence_number: 3, + status: 429 + }) + }) + + it.effect("rejects error events missing spec-required fields", () => + Effect.gen(function*() { + const malformed = [ + { type: "error" }, + { type: "error", error: {}, sequence_number: 3 }, + { type: "error", error: { code: "x" }, sequence_number: 3 } + ] + for (const event of malformed) { + const failure = yield* Schema.decodeUnknownEffect(OpenAiSchema.ResponseStreamEvent)(event).pipe(Effect.flip) + assert.isDefined(failure) + } + })) + + it.effect("surfaces nested error events in SSE decoding instead of aborting the stream", () => + Effect.gen(function*() { + const sseBody = [ + { + type: "response.created", + sequence_number: 1, + response: makeResponse({ status: "in_progress" }) + }, + { + type: "error", + error: { + type: "insufficient_quota", + code: "credit_balance_exhausted", + message: "You have no credits remaining.", + param: null + }, + sequence_number: 2 + } + ].map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") + + const events = yield* Stream.fromIterable([sseBody]).pipe( + Stream.pipeThroughChannel(Sse.decodeDataSchema(OpenAiSchema.ResponseStreamEvent)), + Stream.map((event) => event.data), + Stream.runCollect + ) + + const decoded = globalThis.Array.from(events) + assert.strictEqual(decoded.length, 2) + assert.deepStrictEqual(decoded[1], { + type: "error", + code: "credit_balance_exhausted", + message: "You have no credits remaining.", + param: null, + sequence_number: 2 + }) + })) + it("decodes embedding response variants (numeric + string/base64)", () => { const numeric = Schema.decodeUnknownSync(OpenAiSchema.CreateEmbeddingResponse)({ object: "list", diff --git a/repos/effect/packages/ai/openrouter/CHANGELOG.md b/repos/effect/packages/ai/openrouter/CHANGELOG.md index eecc3ec466..6c9f47b0a4 100644 --- a/repos/effect/packages/ai/openrouter/CHANGELOG.md +++ b/repos/effect/packages/ai/openrouter/CHANGELOG.md @@ -1,5 +1,41 @@ # @effect/ai-openrouter +## 4.0.0-rc.112 + +### Patch Changes + +- [#7437](https://github.com/Effect-TS/effect/pull/7437) [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb) Thanks @wmaurer! - Add an optional `description` to `AiError.AuthenticationError`, rendered after the kind-based suggestion, and pass the provider's own error text through it on HTTP 401 and 403, so authentication failures report what actually went wrong instead of only a category. +- Updated dependencies [[`20cb4f2`](https://github.com/Effect-TS/effect/commit/20cb4f260e45d37fa417c292c57be015314efe16), [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb), [`b6bf5e1`](https://github.com/Effect-TS/effect/commit/b6bf5e14492643076454131148f97cde24ad5306), [`0b9f780`](https://github.com/Effect-TS/effect/commit/0b9f780ff28b71042241791a9e8bcb5b631be2bd), [`150e92c`](https://github.com/Effect-TS/effect/commit/150e92c4169c245e701da02575eef0b69c3ecd64), [`6740db2`](https://github.com/Effect-TS/effect/commit/6740db247ed20cb85da43c9f48ade8fecfd8c1ae), [`d57bba1`](https://github.com/Effect-TS/effect/commit/d57bba1486fa60971b6e0bf7459a329cfd5acdc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`02a5146`](https://github.com/Effect-TS/effect/commit/02a5146d6933c7f6052553550bce5658225e4100), [`15272a6`](https://github.com/Effect-TS/effect/commit/15272a66adf02501e7747761e2a3c41bff67bb46), [`436f10d`](https://github.com/Effect-TS/effect/commit/436f10d1efccec308426532ff3f88df9a96434f3), [`058fb15`](https://github.com/Effect-TS/effect/commit/058fb15647fa01ad771277bd368783fcf5f262e8), [`4d89bb8`](https://github.com/Effect-TS/effect/commit/4d89bb8ffb4cf567a1d11072246b6161ce638712), [`480fb15`](https://github.com/Effect-TS/effect/commit/480fb156590785cf98f67bdec4fc282a608e2d87), [`f77ec19`](https://github.com/Effect-TS/effect/commit/f77ec19cff1cbbeeae928e3bd0ece00a7d22bab8), [`925b82a`](https://github.com/Effect-TS/effect/commit/925b82a81f59a4d459b488621030f24ba99d6a27), [`7455246`](https://github.com/Effect-TS/effect/commit/7455246f352385f5cbbdd8299555265ee289490e), [`118124d`](https://github.com/Effect-TS/effect/commit/118124d913d0a02ac5c1f7799a39bd90031769d9), [`a5f78d3`](https://github.com/Effect-TS/effect/commit/a5f78d3fcbaa792d49e80d103ab438e0b50812fd), [`0dd7825`](https://github.com/Effect-TS/effect/commit/0dd7825e4da4d3a00fa9bd410a1d55f3d4874d07), [`b722eca`](https://github.com/Effect-TS/effect/commit/b722eca6d283a88970ad0efba0b4e921915eca78), [`811d579`](https://github.com/Effect-TS/effect/commit/811d579c432856a9e3fc05b517fd8e924cbf991a), [`043b587`](https://github.com/Effect-TS/effect/commit/043b587e6e93f6624bf974bcd7ed976eaa17f0e1), [`8583727`](https://github.com/Effect-TS/effect/commit/85837274fa929a921985464585513a68c261e365), [`d9d2cfc`](https://github.com/Effect-TS/effect/commit/d9d2cfcb732754001b7323cf8afaccc48539bb74), [`5c4b7a0`](https://github.com/Effect-TS/effect/commit/5c4b7a0b17931cd1538c6595a54b21ffe9c1e906)]: + - effect@4.0.0-rc.112 + +## 4.0.0-rc.111 + +### Patch Changes + +- Updated dependencies [[`0ce3b00`](https://github.com/Effect-TS/effect/commit/0ce3b007531563ce33f94c6a057492d379cfd9e8), [`d846331`](https://github.com/Effect-TS/effect/commit/d84633171b4333e87a05f8d74c02c95d83d1b50d), [`f93616f`](https://github.com/Effect-TS/effect/commit/f93616f31f42345af844b8396fb19259556ae236), [`16bf1ef`](https://github.com/Effect-TS/effect/commit/16bf1efdf41ba1d000b8588a8cc052a394856ba4), [`d568968`](https://github.com/Effect-TS/effect/commit/d5689682e963f43157571cd71d4b422247ae09b9), [`bc06292`](https://github.com/Effect-TS/effect/commit/bc062920ac47f956786d12c6d0bacb9468fa66e2), [`e03ea90`](https://github.com/Effect-TS/effect/commit/e03ea907720b58d19d99929695df159c42fff101), [`9b10fc8`](https://github.com/Effect-TS/effect/commit/9b10fc85dde7866fb60e8142a93a6ed6a184537c), [`770c6d0`](https://github.com/Effect-TS/effect/commit/770c6d0f0ddcab7dbdde9fe701aded08deaee3fd), [`7425bcb`](https://github.com/Effect-TS/effect/commit/7425bcb7f74bdccc1941883bc7f9472fbe6ae8b4), [`563815a`](https://github.com/Effect-TS/effect/commit/563815a250e10a02e2a719c7d9b159478b0da933), [`1e83ca1`](https://github.com/Effect-TS/effect/commit/1e83ca110d5cb74641bae148a08e7eb6b3c2e2ed), [`550a41a`](https://github.com/Effect-TS/effect/commit/550a41a2547ce0dd5f085f1637a6f8a5794acba7), [`45d79c7`](https://github.com/Effect-TS/effect/commit/45d79c7cbd9c56c643361c6e6d1689f1aa62ae32), [`aac8584`](https://github.com/Effect-TS/effect/commit/aac8584fd997f3ce3341aeb077b816219832de58), [`7f87022`](https://github.com/Effect-TS/effect/commit/7f870225bb10a7ee7cb54b4835cb3bf64c0cacd8), [`425457c`](https://github.com/Effect-TS/effect/commit/425457c1e9eec7c176df130f7ec6ad3d616c086e), [`008c423`](https://github.com/Effect-TS/effect/commit/008c423e413fe311085c4bfc07c19b9c70529dfa), [`4f6ae04`](https://github.com/Effect-TS/effect/commit/4f6ae041a2886c27e941c5d9e4916aa2a32e0a1c), [`b6b63e1`](https://github.com/Effect-TS/effect/commit/b6b63e151595b5599ad2d65069ab6d298cfd7be7), [`92922ee`](https://github.com/Effect-TS/effect/commit/92922ee7de0a2d932121e37677f3e75ea38482f1), [`859c02f`](https://github.com/Effect-TS/effect/commit/859c02fe9020b0f28f713aa85910a0cc2d002560), [`ffc8235`](https://github.com/Effect-TS/effect/commit/ffc823537a0925171cf41d0841643a0b26895a4c), [`a29eb70`](https://github.com/Effect-TS/effect/commit/a29eb702ffe3fc58bd28c4d7857298cd65d73668), [`0be2303`](https://github.com/Effect-TS/effect/commit/0be2303dd39f9f2f7d8f96a28e25c938cd914e1a), [`b44636f`](https://github.com/Effect-TS/effect/commit/b44636ff7233763fad060bc0f9de10512454a29b), [`b19ccc7`](https://github.com/Effect-TS/effect/commit/b19ccc7325469a5dea091349bda4ae2ec00ec865), [`ff98f0b`](https://github.com/Effect-TS/effect/commit/ff98f0b0e2beb331209e37e42095d8d6e8e0b6c2), [`a47cbf1`](https://github.com/Effect-TS/effect/commit/a47cbf1feafd10e305c9753fa02a5f5f977d2c37), [`39b55f8`](https://github.com/Effect-TS/effect/commit/39b55f850aecbecf2e3b0c5f3ae2d1a77e6da949), [`c6c49c9`](https://github.com/Effect-TS/effect/commit/c6c49c96990d009341341a53ed26beb140c267a9), [`bf23ba7`](https://github.com/Effect-TS/effect/commit/bf23ba7033a4b426caf379bdcf84c58bb64d7010)]: + - effect@4.0.0-rc.111 + +## 4.0.0-rc.110 + +### Patch Changes + +- Updated dependencies [[`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`0a127b8`](https://github.com/Effect-TS/effect/commit/0a127b8847fb213d46621a5018c069019414c00d), [`e491deb`](https://github.com/Effect-TS/effect/commit/e491deb1cd1717bc442aaee96e5d7b4001710cc9), [`f99c508`](https://github.com/Effect-TS/effect/commit/f99c508a09e9fccddb09edd16fa3393891c55d48), [`7e3f07c`](https://github.com/Effect-TS/effect/commit/7e3f07cf3e4b6a907840aface36ae5ee67ca8979), [`a894fe1`](https://github.com/Effect-TS/effect/commit/a894fe1df8974077293d9c1b924a7f80fc7fe7f1), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`7e9923b`](https://github.com/Effect-TS/effect/commit/7e9923b1a0adf1f842216b298d7542bed980c9a5), [`f064121`](https://github.com/Effect-TS/effect/commit/f0641210f960563f813ac6f728804e5447c03341), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`b660bf0`](https://github.com/Effect-TS/effect/commit/b660bf01d9c427552296cd9e0195e21504f45e1f), [`f4fbe9c`](https://github.com/Effect-TS/effect/commit/f4fbe9ca0209cdfaaaff54fc3805221be80f1ffa), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`e811353`](https://github.com/Effect-TS/effect/commit/e811353e3b79ba392af1d9fb8d7f93fd729dabf9), [`9761c3c`](https://github.com/Effect-TS/effect/commit/9761c3c4787b3815346c1b650d8984efec8f1051), [`baa99fc`](https://github.com/Effect-TS/effect/commit/baa99fc176efed4986c19c78726d1dbe7bd17765), [`7fd79b2`](https://github.com/Effect-TS/effect/commit/7fd79b2cc95973f85b3db43b0cf3c5cb7d2f69c4), [`a82ffc0`](https://github.com/Effect-TS/effect/commit/a82ffc03df0fb34e02f16773b96bfa3e0208e427), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`4026e2d`](https://github.com/Effect-TS/effect/commit/4026e2dde3b098e70ffb3f1fcddd200176acdefd), [`397bf1e`](https://github.com/Effect-TS/effect/commit/397bf1ebd95c0d6d58dc53e4f33c8ad3f34746f6), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f)]: + - effect@4.0.0-rc.110 + +## 4.0.0-rc.109 + +### Patch Changes + +- Updated dependencies [[`a0743f2`](https://github.com/Effect-TS/effect/commit/a0743f2b9f20fb5d150f35510e68819f01630bac), [`17892e7`](https://github.com/Effect-TS/effect/commit/17892e75a8d584f79127805506c42b19320990a7), [`4d8a230`](https://github.com/Effect-TS/effect/commit/4d8a2306dfff8fd5406ab75d0c8d22e5300d1faa), [`f21f9c9`](https://github.com/Effect-TS/effect/commit/f21f9c9f73dc59db922bda411b6d5245dae37cdb), [`18270dd`](https://github.com/Effect-TS/effect/commit/18270ddeaf4ad28723ca8a40aff04a5d4707b575), [`26db404`](https://github.com/Effect-TS/effect/commit/26db404a3284cfdbf4a3f351ccd05afc0de743b8), [`2670398`](https://github.com/Effect-TS/effect/commit/26703982612e12954360382e4b1d177002699d1c), [`3702bed`](https://github.com/Effect-TS/effect/commit/3702bedd8f6bcb3f603b87c640c521878d824eb3), [`ccae60e`](https://github.com/Effect-TS/effect/commit/ccae60e5edb2bef553f4af52afb509dfd443cd03), [`6ff5396`](https://github.com/Effect-TS/effect/commit/6ff53968138bbd7d4728ce8014e35eae8d6ca5d0)]: + - effect@4.0.0-rc.109 + +## 4.0.0-rc.108 + +### Patch Changes + +- Updated dependencies [[`dfb173e`](https://github.com/Effect-TS/effect/commit/dfb173efffd20c4feded4efe409018dd55acdca8), [`005e090`](https://github.com/Effect-TS/effect/commit/005e0902cace9f8960a4f43573665a3a9b53b6fa), [`c82c532`](https://github.com/Effect-TS/effect/commit/c82c53228dc1c50cc99654ce6de7766b4de09e75), [`22b579f`](https://github.com/Effect-TS/effect/commit/22b579f6c582e6e2d951784791fea6f1802517ed), [`3e19539`](https://github.com/Effect-TS/effect/commit/3e19539205082b1006d84553045d1b03db9cc8a1), [`08a3c74`](https://github.com/Effect-TS/effect/commit/08a3c74133206fc1cc728e0aa96d02e672fd80bd), [`eb0bae0`](https://github.com/Effect-TS/effect/commit/eb0bae08d543d58754c9bb7a57e67c1e2bb3f55a), [`97b544d`](https://github.com/Effect-TS/effect/commit/97b544d8b636587647b90691d669305c0eb4fc66), [`4f6d131`](https://github.com/Effect-TS/effect/commit/4f6d131e85d74ab0ec0300e52e503a5f943fc576), [`fad4b7c`](https://github.com/Effect-TS/effect/commit/fad4b7c5138b3f38c2427436da2e0685c1ca4e9b), [`accf447`](https://github.com/Effect-TS/effect/commit/accf4474513064e2a21d14b1937503261b4f34dc), [`31b27e4`](https://github.com/Effect-TS/effect/commit/31b27e49903c351588435f666c953aaac28f6120), [`8458951`](https://github.com/Effect-TS/effect/commit/84589518c3966c63d7f3679a5296d380eb1ba887)]: + - effect@4.0.0-rc.108 + ## 4.0.0-beta.107 ### Patch Changes diff --git a/repos/effect/packages/ai/openrouter/README.md b/repos/effect/packages/ai/openrouter/README.md new file mode 100644 index 0000000000..cc120b6a40 --- /dev/null +++ b/repos/effect/packages/ai/openrouter/README.md @@ -0,0 +1,14 @@ +# @effect/ai-openrouter + +An [OpenRouter](https://openrouter.ai) provider for the Effect AI modules. Includes a typed OpenRouter API client and language model layers. + +## Installation + +```sh +npm install effect@rc @effect/ai-openrouter@rc +``` + +## Documentation + +- [Effect website](https://effect.website) +- [API reference](https://effect.website/docs/v4/api/ai-openrouter) diff --git a/repos/effect/packages/ai/openrouter/package.json b/repos/effect/packages/ai/openrouter/package.json index b7ffb08ee8..deda898f10 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-beta.107", + "version": "4.0.0-rc.112", "type": "module", "license": "MIT", "description": "An OpenRouter provider integration for Effect AI SDK", diff --git a/repos/effect/packages/ai/openrouter/src/OpenRouterConfig.ts b/repos/effect/packages/ai/openrouter/src/OpenRouterConfig.ts index 11df7e7e13..e58445718b 100644 --- a/repos/effect/packages/ai/openrouter/src/OpenRouterConfig.ts +++ b/repos/effect/packages/ai/openrouter/src/OpenRouterConfig.ts @@ -36,7 +36,7 @@ export class OpenRouterConfig extends Context.Service< */ static readonly getOrUndefined: Effect.Effect = Effect.map( Effect.context(), - (services) => services.mapUnsafe.get(OpenRouterConfig.key) + Context.getOrUndefined(OpenRouterConfig) ) } diff --git a/repos/effect/packages/ai/openrouter/src/OpenRouterLanguageModel.ts b/repos/effect/packages/ai/openrouter/src/OpenRouterLanguageModel.ts index ffe75b739f..b32664cd9d 100644 --- a/repos/effect/packages/ai/openrouter/src/OpenRouterLanguageModel.ts +++ b/repos/effect/packages/ai/openrouter/src/OpenRouterLanguageModel.ts @@ -545,10 +545,9 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig const client = yield* OpenRouterClient const codecTransformer = getCodecTransformer(model) - const makeConfig = Effect.gen(function*() { - const services = yield* Effect.context() - return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) } - }) + const makeConfig = Effect.contextWith((services: Context.Context) => + Effect.succeed({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) }) + ) const makeRequest = Effect.fnUntraced( function*({ config, options }: { diff --git a/repos/effect/packages/ai/openrouter/src/internal/errors.ts b/repos/effect/packages/ai/openrouter/src/internal/errors.ts index 65c10cdf4f..27caabb28e 100644 --- a/repos/effect/packages/ai/openrouter/src/internal/errors.ts +++ b/repos/effect/packages/ai/openrouter/src/internal/errors.ts @@ -256,46 +256,6 @@ export const buildHttpContext = (params: { // HTTP Status Code // ============================================================================= -const buildInvalidRequestDescription = (params: { - readonly status: number - readonly message: string | undefined - readonly method: string - readonly url: string - readonly errorCode: string | number | null - readonly errorType: string | null - readonly requestId: string | null - readonly body: string | undefined -}): string => { - const parts: Array = [] - - if (params.message) { - parts.push(params.message) - } else { - parts.push(`HTTP ${params.status}`) - } - - parts.push(`(${params.method} ${params.url})`) - - if (params.errorCode) { - parts.push(`[code: ${params.errorCode}]`) - } else if (params.errorType) { - parts.push(`[type: ${params.errorType}]`) - } - - if (params.requestId) { - parts.push(`[requestId: ${params.requestId}]`) - } - - if (!params.message && params.body) { - const truncated = params.body.length > 200 - ? params.body.slice(0, 200) + "..." - : params.body - parts.push(`Response: ${truncated}`) - } - - return parts.join(" ") -} - /** @internal */ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http }: { readonly status: number @@ -304,7 +264,7 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http readonly metadata: OpenRouterErrorMetadata readonly http: typeof AiError.HttpContext.Type }): AiError.AiErrorReason => { - const invalidRequestDescription = buildInvalidRequestDescription({ + const errorDescription = AiError.buildErrorDescription({ status, message, method: http.request.method, @@ -318,19 +278,21 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http switch (status) { case 400: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { openrouter: metadata }, http }) case 401: return new AiError.AuthenticationError({ kind: "InvalidKey", + description: errorDescription, metadata: { openrouter: metadata }, http }) case 403: return new AiError.AuthenticationError({ kind: "InsufficientPermissions", + description: errorDescription, metadata: { openrouter: metadata }, http }) @@ -338,7 +300,7 @@ export const mapStatusCodeToReason = ({ status, headers, message, metadata, http case 409: case 422: return new AiError.InvalidRequestError({ - description: invalidRequestDescription, + description: errorDescription, metadata: { openrouter: metadata }, http }) diff --git a/repos/effect/packages/ai/openrouter/test/OpenRouterClient.test.ts b/repos/effect/packages/ai/openrouter/test/OpenRouterClient.test.ts index b9fef344cc..3e5eac5a4b 100644 --- a/repos/effect/packages/ai/openrouter/test/OpenRouterClient.test.ts +++ b/repos/effect/packages/ai/openrouter/test/OpenRouterClient.test.ts @@ -1,4 +1,5 @@ import { OpenRouterClient } from "@effect/ai-openrouter" +import * as Errors from "@effect/ai-openrouter/internal/errors" import { assert, describe, it } from "@effect/vitest" import { Context, Effect, Layer, Redacted, type Schema } from "effect" import { HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -30,6 +31,84 @@ describe("OpenRouterClient", () => { } } })))) + + it.effect("surfaces the provider message on 401 AuthenticationError", () => + Effect.gen(function*() { + const client = yield* OpenRouterClient.OpenRouterClient + + const result = yield* client.createChatCompletion({ + model: "openai/gpt-4o-mini", + messages: [{ role: "user", content: "hello" }] + }).pipe(Effect.flip) + + assert.strictEqual(result.reason._tag, "AuthenticationError") + if (result.reason._tag !== "AuthenticationError") { + return yield* Effect.die(new Error("Expected AuthenticationError")) + } + assert.strictEqual(result.reason.kind, "InvalidKey") + assert.strictEqual( + result.reason.description, + "No auth credentials found (POST https://openrouter.ai/api/v1/chat/completions) [code: 401] [requestId: req_openrouter]" + ) + assert.include(result.reason.message, "No auth credentials found") + }).pipe(Effect.provide(makeTestLayer({ + _tag: "Json", + status: 401, + body: { + error: { + code: 401, + message: "No auth credentials found" + } + }, + headers: { "x-request-id": "req_openrouter" } + })))) + + it("preserves and truncates a fallback HTTP response", () => { + const body = `${"a".repeat(200)}b` + const reason = Errors.mapStatusCodeToReason({ + status: 400, + headers: {}, + message: undefined, + metadata: { errorCode: null, errorType: null, requestId: null }, + http: makeHttpContext("https://openrouter.ai/api/v1/chat/completions", body) + }) + + assert.strictEqual(reason._tag, "InvalidRequestError") + if (reason._tag !== "InvalidRequestError") { + throw new Error("Expected InvalidRequestError") + } + assert.strictEqual( + reason.description, + `HTTP 400 (POST https://openrouter.ai/api/v1/chat/completions) Response: ${"a".repeat(200)}...` + ) + }) + + it.effect("surfaces the provider message on 403 AuthenticationError", () => + Effect.gen(function*() { + const client = yield* OpenRouterClient.OpenRouterClient + + const result = yield* client.createChatCompletion({ + model: "openai/gpt-4o-mini", + messages: [{ role: "user", content: "hello" }] + }).pipe(Effect.flip) + + assert.strictEqual(result.reason._tag, "AuthenticationError") + if (result.reason._tag !== "AuthenticationError") { + return yield* Effect.die(new Error("Expected AuthenticationError")) + } + assert.strictEqual(result.reason.kind, "InsufficientPermissions") + assert.include(result.reason.description ?? "", "Key does not have permission") + assert.include(result.reason.message, "Key does not have permission") + }).pipe(Effect.provide(makeTestLayer({ + _tag: "Json", + status: 403, + body: { + error: { + code: 403, + message: "Key does not have permission" + } + } + })))) }) type MockResponse = @@ -111,3 +190,14 @@ const makeResponse = ( }) ) } + +const makeHttpContext = (url: string, body: string) => ({ + request: { + method: "POST" as const, + url, + urlParams: [], + hash: undefined, + headers: {} + }, + body +}) diff --git a/repos/effect/packages/atom/react/CHANGELOG.md b/repos/effect/packages/atom/react/CHANGELOG.md index d59c7e7467..ba66b0a150 100644 --- a/repos/effect/packages/atom/react/CHANGELOG.md +++ b/repos/effect/packages/atom/react/CHANGELOG.md @@ -1,5 +1,41 @@ # @effect/atom-react +## 4.0.0-rc.112 + +### Patch Changes + +- [#7435](https://github.com/Effect-TS/effect/pull/7435) [`4148e21`](https://github.com/Effect-TS/effect/commit/4148e21eb5f86ef37e07086ec9f3cc7e55d24e90) Thanks @mattrobrob! - Relax react peer dependency range +- Updated dependencies [[`20cb4f2`](https://github.com/Effect-TS/effect/commit/20cb4f260e45d37fa417c292c57be015314efe16), [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb), [`b6bf5e1`](https://github.com/Effect-TS/effect/commit/b6bf5e14492643076454131148f97cde24ad5306), [`0b9f780`](https://github.com/Effect-TS/effect/commit/0b9f780ff28b71042241791a9e8bcb5b631be2bd), [`150e92c`](https://github.com/Effect-TS/effect/commit/150e92c4169c245e701da02575eef0b69c3ecd64), [`6740db2`](https://github.com/Effect-TS/effect/commit/6740db247ed20cb85da43c9f48ade8fecfd8c1ae), [`d57bba1`](https://github.com/Effect-TS/effect/commit/d57bba1486fa60971b6e0bf7459a329cfd5acdc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`02a5146`](https://github.com/Effect-TS/effect/commit/02a5146d6933c7f6052553550bce5658225e4100), [`15272a6`](https://github.com/Effect-TS/effect/commit/15272a66adf02501e7747761e2a3c41bff67bb46), [`436f10d`](https://github.com/Effect-TS/effect/commit/436f10d1efccec308426532ff3f88df9a96434f3), [`058fb15`](https://github.com/Effect-TS/effect/commit/058fb15647fa01ad771277bd368783fcf5f262e8), [`4d89bb8`](https://github.com/Effect-TS/effect/commit/4d89bb8ffb4cf567a1d11072246b6161ce638712), [`480fb15`](https://github.com/Effect-TS/effect/commit/480fb156590785cf98f67bdec4fc282a608e2d87), [`f77ec19`](https://github.com/Effect-TS/effect/commit/f77ec19cff1cbbeeae928e3bd0ece00a7d22bab8), [`925b82a`](https://github.com/Effect-TS/effect/commit/925b82a81f59a4d459b488621030f24ba99d6a27), [`7455246`](https://github.com/Effect-TS/effect/commit/7455246f352385f5cbbdd8299555265ee289490e), [`118124d`](https://github.com/Effect-TS/effect/commit/118124d913d0a02ac5c1f7799a39bd90031769d9), [`a5f78d3`](https://github.com/Effect-TS/effect/commit/a5f78d3fcbaa792d49e80d103ab438e0b50812fd), [`0dd7825`](https://github.com/Effect-TS/effect/commit/0dd7825e4da4d3a00fa9bd410a1d55f3d4874d07), [`b722eca`](https://github.com/Effect-TS/effect/commit/b722eca6d283a88970ad0efba0b4e921915eca78), [`811d579`](https://github.com/Effect-TS/effect/commit/811d579c432856a9e3fc05b517fd8e924cbf991a), [`043b587`](https://github.com/Effect-TS/effect/commit/043b587e6e93f6624bf974bcd7ed976eaa17f0e1), [`8583727`](https://github.com/Effect-TS/effect/commit/85837274fa929a921985464585513a68c261e365), [`d9d2cfc`](https://github.com/Effect-TS/effect/commit/d9d2cfcb732754001b7323cf8afaccc48539bb74), [`5c4b7a0`](https://github.com/Effect-TS/effect/commit/5c4b7a0b17931cd1538c6595a54b21ffe9c1e906)]: + - effect@4.0.0-rc.112 + +## 4.0.0-rc.111 + +### Patch Changes + +- Updated dependencies [[`0ce3b00`](https://github.com/Effect-TS/effect/commit/0ce3b007531563ce33f94c6a057492d379cfd9e8), [`d846331`](https://github.com/Effect-TS/effect/commit/d84633171b4333e87a05f8d74c02c95d83d1b50d), [`f93616f`](https://github.com/Effect-TS/effect/commit/f93616f31f42345af844b8396fb19259556ae236), [`16bf1ef`](https://github.com/Effect-TS/effect/commit/16bf1efdf41ba1d000b8588a8cc052a394856ba4), [`d568968`](https://github.com/Effect-TS/effect/commit/d5689682e963f43157571cd71d4b422247ae09b9), [`bc06292`](https://github.com/Effect-TS/effect/commit/bc062920ac47f956786d12c6d0bacb9468fa66e2), [`e03ea90`](https://github.com/Effect-TS/effect/commit/e03ea907720b58d19d99929695df159c42fff101), [`9b10fc8`](https://github.com/Effect-TS/effect/commit/9b10fc85dde7866fb60e8142a93a6ed6a184537c), [`770c6d0`](https://github.com/Effect-TS/effect/commit/770c6d0f0ddcab7dbdde9fe701aded08deaee3fd), [`7425bcb`](https://github.com/Effect-TS/effect/commit/7425bcb7f74bdccc1941883bc7f9472fbe6ae8b4), [`563815a`](https://github.com/Effect-TS/effect/commit/563815a250e10a02e2a719c7d9b159478b0da933), [`1e83ca1`](https://github.com/Effect-TS/effect/commit/1e83ca110d5cb74641bae148a08e7eb6b3c2e2ed), [`550a41a`](https://github.com/Effect-TS/effect/commit/550a41a2547ce0dd5f085f1637a6f8a5794acba7), [`45d79c7`](https://github.com/Effect-TS/effect/commit/45d79c7cbd9c56c643361c6e6d1689f1aa62ae32), [`aac8584`](https://github.com/Effect-TS/effect/commit/aac8584fd997f3ce3341aeb077b816219832de58), [`7f87022`](https://github.com/Effect-TS/effect/commit/7f870225bb10a7ee7cb54b4835cb3bf64c0cacd8), [`425457c`](https://github.com/Effect-TS/effect/commit/425457c1e9eec7c176df130f7ec6ad3d616c086e), [`008c423`](https://github.com/Effect-TS/effect/commit/008c423e413fe311085c4bfc07c19b9c70529dfa), [`4f6ae04`](https://github.com/Effect-TS/effect/commit/4f6ae041a2886c27e941c5d9e4916aa2a32e0a1c), [`b6b63e1`](https://github.com/Effect-TS/effect/commit/b6b63e151595b5599ad2d65069ab6d298cfd7be7), [`92922ee`](https://github.com/Effect-TS/effect/commit/92922ee7de0a2d932121e37677f3e75ea38482f1), [`859c02f`](https://github.com/Effect-TS/effect/commit/859c02fe9020b0f28f713aa85910a0cc2d002560), [`ffc8235`](https://github.com/Effect-TS/effect/commit/ffc823537a0925171cf41d0841643a0b26895a4c), [`a29eb70`](https://github.com/Effect-TS/effect/commit/a29eb702ffe3fc58bd28c4d7857298cd65d73668), [`0be2303`](https://github.com/Effect-TS/effect/commit/0be2303dd39f9f2f7d8f96a28e25c938cd914e1a), [`b44636f`](https://github.com/Effect-TS/effect/commit/b44636ff7233763fad060bc0f9de10512454a29b), [`b19ccc7`](https://github.com/Effect-TS/effect/commit/b19ccc7325469a5dea091349bda4ae2ec00ec865), [`ff98f0b`](https://github.com/Effect-TS/effect/commit/ff98f0b0e2beb331209e37e42095d8d6e8e0b6c2), [`a47cbf1`](https://github.com/Effect-TS/effect/commit/a47cbf1feafd10e305c9753fa02a5f5f977d2c37), [`39b55f8`](https://github.com/Effect-TS/effect/commit/39b55f850aecbecf2e3b0c5f3ae2d1a77e6da949), [`c6c49c9`](https://github.com/Effect-TS/effect/commit/c6c49c96990d009341341a53ed26beb140c267a9), [`bf23ba7`](https://github.com/Effect-TS/effect/commit/bf23ba7033a4b426caf379bdcf84c58bb64d7010)]: + - effect@4.0.0-rc.111 + +## 4.0.0-rc.110 + +### Patch Changes + +- Updated dependencies [[`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`0a127b8`](https://github.com/Effect-TS/effect/commit/0a127b8847fb213d46621a5018c069019414c00d), [`e491deb`](https://github.com/Effect-TS/effect/commit/e491deb1cd1717bc442aaee96e5d7b4001710cc9), [`f99c508`](https://github.com/Effect-TS/effect/commit/f99c508a09e9fccddb09edd16fa3393891c55d48), [`7e3f07c`](https://github.com/Effect-TS/effect/commit/7e3f07cf3e4b6a907840aface36ae5ee67ca8979), [`a894fe1`](https://github.com/Effect-TS/effect/commit/a894fe1df8974077293d9c1b924a7f80fc7fe7f1), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`7e9923b`](https://github.com/Effect-TS/effect/commit/7e9923b1a0adf1f842216b298d7542bed980c9a5), [`f064121`](https://github.com/Effect-TS/effect/commit/f0641210f960563f813ac6f728804e5447c03341), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`b660bf0`](https://github.com/Effect-TS/effect/commit/b660bf01d9c427552296cd9e0195e21504f45e1f), [`f4fbe9c`](https://github.com/Effect-TS/effect/commit/f4fbe9ca0209cdfaaaff54fc3805221be80f1ffa), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`e811353`](https://github.com/Effect-TS/effect/commit/e811353e3b79ba392af1d9fb8d7f93fd729dabf9), [`9761c3c`](https://github.com/Effect-TS/effect/commit/9761c3c4787b3815346c1b650d8984efec8f1051), [`baa99fc`](https://github.com/Effect-TS/effect/commit/baa99fc176efed4986c19c78726d1dbe7bd17765), [`7fd79b2`](https://github.com/Effect-TS/effect/commit/7fd79b2cc95973f85b3db43b0cf3c5cb7d2f69c4), [`a82ffc0`](https://github.com/Effect-TS/effect/commit/a82ffc03df0fb34e02f16773b96bfa3e0208e427), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`4026e2d`](https://github.com/Effect-TS/effect/commit/4026e2dde3b098e70ffb3f1fcddd200176acdefd), [`397bf1e`](https://github.com/Effect-TS/effect/commit/397bf1ebd95c0d6d58dc53e4f33c8ad3f34746f6), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f)]: + - effect@4.0.0-rc.110 + +## 4.0.0-rc.109 + +### Patch Changes + +- Updated dependencies [[`a0743f2`](https://github.com/Effect-TS/effect/commit/a0743f2b9f20fb5d150f35510e68819f01630bac), [`17892e7`](https://github.com/Effect-TS/effect/commit/17892e75a8d584f79127805506c42b19320990a7), [`4d8a230`](https://github.com/Effect-TS/effect/commit/4d8a2306dfff8fd5406ab75d0c8d22e5300d1faa), [`f21f9c9`](https://github.com/Effect-TS/effect/commit/f21f9c9f73dc59db922bda411b6d5245dae37cdb), [`18270dd`](https://github.com/Effect-TS/effect/commit/18270ddeaf4ad28723ca8a40aff04a5d4707b575), [`26db404`](https://github.com/Effect-TS/effect/commit/26db404a3284cfdbf4a3f351ccd05afc0de743b8), [`2670398`](https://github.com/Effect-TS/effect/commit/26703982612e12954360382e4b1d177002699d1c), [`3702bed`](https://github.com/Effect-TS/effect/commit/3702bedd8f6bcb3f603b87c640c521878d824eb3), [`ccae60e`](https://github.com/Effect-TS/effect/commit/ccae60e5edb2bef553f4af52afb509dfd443cd03), [`6ff5396`](https://github.com/Effect-TS/effect/commit/6ff53968138bbd7d4728ce8014e35eae8d6ca5d0)]: + - effect@4.0.0-rc.109 + +## 4.0.0-rc.108 + +### Patch Changes + +- Updated dependencies [[`dfb173e`](https://github.com/Effect-TS/effect/commit/dfb173efffd20c4feded4efe409018dd55acdca8), [`005e090`](https://github.com/Effect-TS/effect/commit/005e0902cace9f8960a4f43573665a3a9b53b6fa), [`c82c532`](https://github.com/Effect-TS/effect/commit/c82c53228dc1c50cc99654ce6de7766b4de09e75), [`22b579f`](https://github.com/Effect-TS/effect/commit/22b579f6c582e6e2d951784791fea6f1802517ed), [`3e19539`](https://github.com/Effect-TS/effect/commit/3e19539205082b1006d84553045d1b03db9cc8a1), [`08a3c74`](https://github.com/Effect-TS/effect/commit/08a3c74133206fc1cc728e0aa96d02e672fd80bd), [`eb0bae0`](https://github.com/Effect-TS/effect/commit/eb0bae08d543d58754c9bb7a57e67c1e2bb3f55a), [`97b544d`](https://github.com/Effect-TS/effect/commit/97b544d8b636587647b90691d669305c0eb4fc66), [`4f6d131`](https://github.com/Effect-TS/effect/commit/4f6d131e85d74ab0ec0300e52e503a5f943fc576), [`fad4b7c`](https://github.com/Effect-TS/effect/commit/fad4b7c5138b3f38c2427436da2e0685c1ca4e9b), [`accf447`](https://github.com/Effect-TS/effect/commit/accf4474513064e2a21d14b1937503261b4f34dc), [`31b27e4`](https://github.com/Effect-TS/effect/commit/31b27e49903c351588435f666c953aaac28f6120), [`8458951`](https://github.com/Effect-TS/effect/commit/84589518c3966c63d7f3679a5296d380eb1ba887)]: + - effect@4.0.0-rc.108 + ## 4.0.0-beta.107 ### Patch Changes diff --git a/repos/effect/packages/atom/react/README.md b/repos/effect/packages/atom/react/README.md index 1cd2f2005d..f3b2b6c49b 100644 --- a/repos/effect/packages/atom/react/README.md +++ b/repos/effect/packages/atom/react/README.md @@ -1,7 +1,14 @@ -# `@effect/atom-react` +# @effect/atom-react -React bindings for the Effect Atom modules. +[React](https://react.dev) bindings for Atom, the reactive state management modules for Effect. Includes hooks for reading and updating atoms, and helpers for server-side rendering hydration. + +## Installation + +```sh +npm install effect@rc @effect/atom-react@rc +``` ## Documentation -- **API Reference**: [View the full documentation](https://effect-ts.github.io/effect/docs/atom-react). +- [Effect website](https://effect.website) +- [API reference](https://effect.website/docs/v4/api/atom-react) diff --git a/repos/effect/packages/atom/react/package.json b/repos/effect/packages/atom/react/package.json index c17c6a753d..f838a7d6fa 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-beta.107", + "version": "4.0.0-rc.112", "type": "module", "license": "MIT", "description": "React bindings for the Effect Atom modules", @@ -61,21 +61,21 @@ }, "peerDependencies": { "effect": "workspace:^", - "react": ">=19.2.7 <20.0.0", - "scheduler": ">=0.27.0 <0.28.0" + "react": ">=19.0.0 <20.0.0", + "scheduler": ">=0.25.0 <0.28.0" }, "devDependencies": { "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^7.0.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "@types/scheduler": "^0.26.0", "effect": "workspace:^", - "jsdom": "^30.0.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-error-boundary": "^6.1.2", + "jsdom": "^30.0.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-error-boundary": "^6.1.3", "scheduler": "^0.27.0" } } diff --git a/repos/effect/packages/atom/solid/CHANGELOG.md b/repos/effect/packages/atom/solid/CHANGELOG.md index f36f787405..8498299704 100644 --- a/repos/effect/packages/atom/solid/CHANGELOG.md +++ b/repos/effect/packages/atom/solid/CHANGELOG.md @@ -1,5 +1,40 @@ # @effect/atom-solid +## 4.0.0-rc.112 + +### Patch Changes + +- Updated dependencies [[`20cb4f2`](https://github.com/Effect-TS/effect/commit/20cb4f260e45d37fa417c292c57be015314efe16), [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb), [`b6bf5e1`](https://github.com/Effect-TS/effect/commit/b6bf5e14492643076454131148f97cde24ad5306), [`0b9f780`](https://github.com/Effect-TS/effect/commit/0b9f780ff28b71042241791a9e8bcb5b631be2bd), [`150e92c`](https://github.com/Effect-TS/effect/commit/150e92c4169c245e701da02575eef0b69c3ecd64), [`6740db2`](https://github.com/Effect-TS/effect/commit/6740db247ed20cb85da43c9f48ade8fecfd8c1ae), [`d57bba1`](https://github.com/Effect-TS/effect/commit/d57bba1486fa60971b6e0bf7459a329cfd5acdc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`02a5146`](https://github.com/Effect-TS/effect/commit/02a5146d6933c7f6052553550bce5658225e4100), [`15272a6`](https://github.com/Effect-TS/effect/commit/15272a66adf02501e7747761e2a3c41bff67bb46), [`436f10d`](https://github.com/Effect-TS/effect/commit/436f10d1efccec308426532ff3f88df9a96434f3), [`058fb15`](https://github.com/Effect-TS/effect/commit/058fb15647fa01ad771277bd368783fcf5f262e8), [`4d89bb8`](https://github.com/Effect-TS/effect/commit/4d89bb8ffb4cf567a1d11072246b6161ce638712), [`480fb15`](https://github.com/Effect-TS/effect/commit/480fb156590785cf98f67bdec4fc282a608e2d87), [`f77ec19`](https://github.com/Effect-TS/effect/commit/f77ec19cff1cbbeeae928e3bd0ece00a7d22bab8), [`925b82a`](https://github.com/Effect-TS/effect/commit/925b82a81f59a4d459b488621030f24ba99d6a27), [`7455246`](https://github.com/Effect-TS/effect/commit/7455246f352385f5cbbdd8299555265ee289490e), [`118124d`](https://github.com/Effect-TS/effect/commit/118124d913d0a02ac5c1f7799a39bd90031769d9), [`a5f78d3`](https://github.com/Effect-TS/effect/commit/a5f78d3fcbaa792d49e80d103ab438e0b50812fd), [`0dd7825`](https://github.com/Effect-TS/effect/commit/0dd7825e4da4d3a00fa9bd410a1d55f3d4874d07), [`b722eca`](https://github.com/Effect-TS/effect/commit/b722eca6d283a88970ad0efba0b4e921915eca78), [`811d579`](https://github.com/Effect-TS/effect/commit/811d579c432856a9e3fc05b517fd8e924cbf991a), [`043b587`](https://github.com/Effect-TS/effect/commit/043b587e6e93f6624bf974bcd7ed976eaa17f0e1), [`8583727`](https://github.com/Effect-TS/effect/commit/85837274fa929a921985464585513a68c261e365), [`d9d2cfc`](https://github.com/Effect-TS/effect/commit/d9d2cfcb732754001b7323cf8afaccc48539bb74), [`5c4b7a0`](https://github.com/Effect-TS/effect/commit/5c4b7a0b17931cd1538c6595a54b21ffe9c1e906)]: + - effect@4.0.0-rc.112 + +## 4.0.0-rc.111 + +### Patch Changes + +- Updated dependencies [[`0ce3b00`](https://github.com/Effect-TS/effect/commit/0ce3b007531563ce33f94c6a057492d379cfd9e8), [`d846331`](https://github.com/Effect-TS/effect/commit/d84633171b4333e87a05f8d74c02c95d83d1b50d), [`f93616f`](https://github.com/Effect-TS/effect/commit/f93616f31f42345af844b8396fb19259556ae236), [`16bf1ef`](https://github.com/Effect-TS/effect/commit/16bf1efdf41ba1d000b8588a8cc052a394856ba4), [`d568968`](https://github.com/Effect-TS/effect/commit/d5689682e963f43157571cd71d4b422247ae09b9), [`bc06292`](https://github.com/Effect-TS/effect/commit/bc062920ac47f956786d12c6d0bacb9468fa66e2), [`e03ea90`](https://github.com/Effect-TS/effect/commit/e03ea907720b58d19d99929695df159c42fff101), [`9b10fc8`](https://github.com/Effect-TS/effect/commit/9b10fc85dde7866fb60e8142a93a6ed6a184537c), [`770c6d0`](https://github.com/Effect-TS/effect/commit/770c6d0f0ddcab7dbdde9fe701aded08deaee3fd), [`7425bcb`](https://github.com/Effect-TS/effect/commit/7425bcb7f74bdccc1941883bc7f9472fbe6ae8b4), [`563815a`](https://github.com/Effect-TS/effect/commit/563815a250e10a02e2a719c7d9b159478b0da933), [`1e83ca1`](https://github.com/Effect-TS/effect/commit/1e83ca110d5cb74641bae148a08e7eb6b3c2e2ed), [`550a41a`](https://github.com/Effect-TS/effect/commit/550a41a2547ce0dd5f085f1637a6f8a5794acba7), [`45d79c7`](https://github.com/Effect-TS/effect/commit/45d79c7cbd9c56c643361c6e6d1689f1aa62ae32), [`aac8584`](https://github.com/Effect-TS/effect/commit/aac8584fd997f3ce3341aeb077b816219832de58), [`7f87022`](https://github.com/Effect-TS/effect/commit/7f870225bb10a7ee7cb54b4835cb3bf64c0cacd8), [`425457c`](https://github.com/Effect-TS/effect/commit/425457c1e9eec7c176df130f7ec6ad3d616c086e), [`008c423`](https://github.com/Effect-TS/effect/commit/008c423e413fe311085c4bfc07c19b9c70529dfa), [`4f6ae04`](https://github.com/Effect-TS/effect/commit/4f6ae041a2886c27e941c5d9e4916aa2a32e0a1c), [`b6b63e1`](https://github.com/Effect-TS/effect/commit/b6b63e151595b5599ad2d65069ab6d298cfd7be7), [`92922ee`](https://github.com/Effect-TS/effect/commit/92922ee7de0a2d932121e37677f3e75ea38482f1), [`859c02f`](https://github.com/Effect-TS/effect/commit/859c02fe9020b0f28f713aa85910a0cc2d002560), [`ffc8235`](https://github.com/Effect-TS/effect/commit/ffc823537a0925171cf41d0841643a0b26895a4c), [`a29eb70`](https://github.com/Effect-TS/effect/commit/a29eb702ffe3fc58bd28c4d7857298cd65d73668), [`0be2303`](https://github.com/Effect-TS/effect/commit/0be2303dd39f9f2f7d8f96a28e25c938cd914e1a), [`b44636f`](https://github.com/Effect-TS/effect/commit/b44636ff7233763fad060bc0f9de10512454a29b), [`b19ccc7`](https://github.com/Effect-TS/effect/commit/b19ccc7325469a5dea091349bda4ae2ec00ec865), [`ff98f0b`](https://github.com/Effect-TS/effect/commit/ff98f0b0e2beb331209e37e42095d8d6e8e0b6c2), [`a47cbf1`](https://github.com/Effect-TS/effect/commit/a47cbf1feafd10e305c9753fa02a5f5f977d2c37), [`39b55f8`](https://github.com/Effect-TS/effect/commit/39b55f850aecbecf2e3b0c5f3ae2d1a77e6da949), [`c6c49c9`](https://github.com/Effect-TS/effect/commit/c6c49c96990d009341341a53ed26beb140c267a9), [`bf23ba7`](https://github.com/Effect-TS/effect/commit/bf23ba7033a4b426caf379bdcf84c58bb64d7010)]: + - effect@4.0.0-rc.111 + +## 4.0.0-rc.110 + +### Patch Changes + +- Updated dependencies [[`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`0a127b8`](https://github.com/Effect-TS/effect/commit/0a127b8847fb213d46621a5018c069019414c00d), [`e491deb`](https://github.com/Effect-TS/effect/commit/e491deb1cd1717bc442aaee96e5d7b4001710cc9), [`f99c508`](https://github.com/Effect-TS/effect/commit/f99c508a09e9fccddb09edd16fa3393891c55d48), [`7e3f07c`](https://github.com/Effect-TS/effect/commit/7e3f07cf3e4b6a907840aface36ae5ee67ca8979), [`a894fe1`](https://github.com/Effect-TS/effect/commit/a894fe1df8974077293d9c1b924a7f80fc7fe7f1), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`7e9923b`](https://github.com/Effect-TS/effect/commit/7e9923b1a0adf1f842216b298d7542bed980c9a5), [`f064121`](https://github.com/Effect-TS/effect/commit/f0641210f960563f813ac6f728804e5447c03341), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`b660bf0`](https://github.com/Effect-TS/effect/commit/b660bf01d9c427552296cd9e0195e21504f45e1f), [`f4fbe9c`](https://github.com/Effect-TS/effect/commit/f4fbe9ca0209cdfaaaff54fc3805221be80f1ffa), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`e811353`](https://github.com/Effect-TS/effect/commit/e811353e3b79ba392af1d9fb8d7f93fd729dabf9), [`9761c3c`](https://github.com/Effect-TS/effect/commit/9761c3c4787b3815346c1b650d8984efec8f1051), [`baa99fc`](https://github.com/Effect-TS/effect/commit/baa99fc176efed4986c19c78726d1dbe7bd17765), [`7fd79b2`](https://github.com/Effect-TS/effect/commit/7fd79b2cc95973f85b3db43b0cf3c5cb7d2f69c4), [`a82ffc0`](https://github.com/Effect-TS/effect/commit/a82ffc03df0fb34e02f16773b96bfa3e0208e427), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`4026e2d`](https://github.com/Effect-TS/effect/commit/4026e2dde3b098e70ffb3f1fcddd200176acdefd), [`397bf1e`](https://github.com/Effect-TS/effect/commit/397bf1ebd95c0d6d58dc53e4f33c8ad3f34746f6), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f)]: + - effect@4.0.0-rc.110 + +## 4.0.0-rc.109 + +### Patch Changes + +- Updated dependencies [[`a0743f2`](https://github.com/Effect-TS/effect/commit/a0743f2b9f20fb5d150f35510e68819f01630bac), [`17892e7`](https://github.com/Effect-TS/effect/commit/17892e75a8d584f79127805506c42b19320990a7), [`4d8a230`](https://github.com/Effect-TS/effect/commit/4d8a2306dfff8fd5406ab75d0c8d22e5300d1faa), [`f21f9c9`](https://github.com/Effect-TS/effect/commit/f21f9c9f73dc59db922bda411b6d5245dae37cdb), [`18270dd`](https://github.com/Effect-TS/effect/commit/18270ddeaf4ad28723ca8a40aff04a5d4707b575), [`26db404`](https://github.com/Effect-TS/effect/commit/26db404a3284cfdbf4a3f351ccd05afc0de743b8), [`2670398`](https://github.com/Effect-TS/effect/commit/26703982612e12954360382e4b1d177002699d1c), [`3702bed`](https://github.com/Effect-TS/effect/commit/3702bedd8f6bcb3f603b87c640c521878d824eb3), [`ccae60e`](https://github.com/Effect-TS/effect/commit/ccae60e5edb2bef553f4af52afb509dfd443cd03), [`6ff5396`](https://github.com/Effect-TS/effect/commit/6ff53968138bbd7d4728ce8014e35eae8d6ca5d0)]: + - effect@4.0.0-rc.109 + +## 4.0.0-rc.108 + +### Patch Changes + +- Updated dependencies [[`dfb173e`](https://github.com/Effect-TS/effect/commit/dfb173efffd20c4feded4efe409018dd55acdca8), [`005e090`](https://github.com/Effect-TS/effect/commit/005e0902cace9f8960a4f43573665a3a9b53b6fa), [`c82c532`](https://github.com/Effect-TS/effect/commit/c82c53228dc1c50cc99654ce6de7766b4de09e75), [`22b579f`](https://github.com/Effect-TS/effect/commit/22b579f6c582e6e2d951784791fea6f1802517ed), [`3e19539`](https://github.com/Effect-TS/effect/commit/3e19539205082b1006d84553045d1b03db9cc8a1), [`08a3c74`](https://github.com/Effect-TS/effect/commit/08a3c74133206fc1cc728e0aa96d02e672fd80bd), [`eb0bae0`](https://github.com/Effect-TS/effect/commit/eb0bae08d543d58754c9bb7a57e67c1e2bb3f55a), [`97b544d`](https://github.com/Effect-TS/effect/commit/97b544d8b636587647b90691d669305c0eb4fc66), [`4f6d131`](https://github.com/Effect-TS/effect/commit/4f6d131e85d74ab0ec0300e52e503a5f943fc576), [`fad4b7c`](https://github.com/Effect-TS/effect/commit/fad4b7c5138b3f38c2427436da2e0685c1ca4e9b), [`accf447`](https://github.com/Effect-TS/effect/commit/accf4474513064e2a21d14b1937503261b4f34dc), [`31b27e4`](https://github.com/Effect-TS/effect/commit/31b27e49903c351588435f666c953aaac28f6120), [`8458951`](https://github.com/Effect-TS/effect/commit/84589518c3966c63d7f3679a5296d380eb1ba887)]: + - effect@4.0.0-rc.108 + ## 4.0.0-beta.107 ### Patch Changes diff --git a/repos/effect/packages/atom/solid/README.md b/repos/effect/packages/atom/solid/README.md index a4239bf9c1..4d984b0de1 100644 --- a/repos/effect/packages/atom/solid/README.md +++ b/repos/effect/packages/atom/solid/README.md @@ -1,7 +1,14 @@ -# `@effect/atom-solid` +# @effect/atom-solid -SolidJS bindings for the Effect Atom modules. +[SolidJS](https://www.solidjs.com) bindings for Atom, the reactive state management modules for Effect. + +## Installation + +```sh +npm install effect@rc @effect/atom-solid@rc +``` ## Documentation -- **API Reference**: [View the full documentation](https://effect-ts.github.io/effect/docs/atom-solid). +- [Effect website](https://effect.website) +- [API reference](https://effect.website/docs/v4/api/atom-solid) diff --git a/repos/effect/packages/atom/solid/package.json b/repos/effect/packages/atom/solid/package.json index efc81d4128..0bfe68b26f 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-beta.107", + "version": "4.0.0-rc.112", "type": "module", "license": "MIT", "description": "SolidJS bindings for the Effect Atom modules", @@ -64,11 +64,11 @@ "solid-js": ">=1.9.14 <2.0.0" }, "devDependencies": { - "@solidjs/testing-library": "^0.8.0", + "@solidjs/testing-library": "^0.8.10", "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^7.0.0", + "@testing-library/jest-dom": "^7.0.1", "effect": "workspace:^", - "jsdom": "^30.0.0", - "solid-js": "^1.9.14" + "jsdom": "^30.0.1", + "solid-js": "^1.9.15" } } diff --git a/repos/effect/packages/atom/vue/CHANGELOG.md b/repos/effect/packages/atom/vue/CHANGELOG.md index f3a8c4baa5..0cfa844621 100644 --- a/repos/effect/packages/atom/vue/CHANGELOG.md +++ b/repos/effect/packages/atom/vue/CHANGELOG.md @@ -1,5 +1,40 @@ # @effect/atom-vue +## 4.0.0-rc.112 + +### Patch Changes + +- Updated dependencies [[`20cb4f2`](https://github.com/Effect-TS/effect/commit/20cb4f260e45d37fa417c292c57be015314efe16), [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb), [`b6bf5e1`](https://github.com/Effect-TS/effect/commit/b6bf5e14492643076454131148f97cde24ad5306), [`0b9f780`](https://github.com/Effect-TS/effect/commit/0b9f780ff28b71042241791a9e8bcb5b631be2bd), [`150e92c`](https://github.com/Effect-TS/effect/commit/150e92c4169c245e701da02575eef0b69c3ecd64), [`6740db2`](https://github.com/Effect-TS/effect/commit/6740db247ed20cb85da43c9f48ade8fecfd8c1ae), [`d57bba1`](https://github.com/Effect-TS/effect/commit/d57bba1486fa60971b6e0bf7459a329cfd5acdc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4), [`02a5146`](https://github.com/Effect-TS/effect/commit/02a5146d6933c7f6052553550bce5658225e4100), [`15272a6`](https://github.com/Effect-TS/effect/commit/15272a66adf02501e7747761e2a3c41bff67bb46), [`436f10d`](https://github.com/Effect-TS/effect/commit/436f10d1efccec308426532ff3f88df9a96434f3), [`058fb15`](https://github.com/Effect-TS/effect/commit/058fb15647fa01ad771277bd368783fcf5f262e8), [`4d89bb8`](https://github.com/Effect-TS/effect/commit/4d89bb8ffb4cf567a1d11072246b6161ce638712), [`480fb15`](https://github.com/Effect-TS/effect/commit/480fb156590785cf98f67bdec4fc282a608e2d87), [`f77ec19`](https://github.com/Effect-TS/effect/commit/f77ec19cff1cbbeeae928e3bd0ece00a7d22bab8), [`925b82a`](https://github.com/Effect-TS/effect/commit/925b82a81f59a4d459b488621030f24ba99d6a27), [`7455246`](https://github.com/Effect-TS/effect/commit/7455246f352385f5cbbdd8299555265ee289490e), [`118124d`](https://github.com/Effect-TS/effect/commit/118124d913d0a02ac5c1f7799a39bd90031769d9), [`a5f78d3`](https://github.com/Effect-TS/effect/commit/a5f78d3fcbaa792d49e80d103ab438e0b50812fd), [`0dd7825`](https://github.com/Effect-TS/effect/commit/0dd7825e4da4d3a00fa9bd410a1d55f3d4874d07), [`b722eca`](https://github.com/Effect-TS/effect/commit/b722eca6d283a88970ad0efba0b4e921915eca78), [`811d579`](https://github.com/Effect-TS/effect/commit/811d579c432856a9e3fc05b517fd8e924cbf991a), [`043b587`](https://github.com/Effect-TS/effect/commit/043b587e6e93f6624bf974bcd7ed976eaa17f0e1), [`8583727`](https://github.com/Effect-TS/effect/commit/85837274fa929a921985464585513a68c261e365), [`d9d2cfc`](https://github.com/Effect-TS/effect/commit/d9d2cfcb732754001b7323cf8afaccc48539bb74), [`5c4b7a0`](https://github.com/Effect-TS/effect/commit/5c4b7a0b17931cd1538c6595a54b21ffe9c1e906)]: + - effect@4.0.0-rc.112 + +## 4.0.0-rc.111 + +### Patch Changes + +- Updated dependencies [[`0ce3b00`](https://github.com/Effect-TS/effect/commit/0ce3b007531563ce33f94c6a057492d379cfd9e8), [`d846331`](https://github.com/Effect-TS/effect/commit/d84633171b4333e87a05f8d74c02c95d83d1b50d), [`f93616f`](https://github.com/Effect-TS/effect/commit/f93616f31f42345af844b8396fb19259556ae236), [`16bf1ef`](https://github.com/Effect-TS/effect/commit/16bf1efdf41ba1d000b8588a8cc052a394856ba4), [`d568968`](https://github.com/Effect-TS/effect/commit/d5689682e963f43157571cd71d4b422247ae09b9), [`bc06292`](https://github.com/Effect-TS/effect/commit/bc062920ac47f956786d12c6d0bacb9468fa66e2), [`e03ea90`](https://github.com/Effect-TS/effect/commit/e03ea907720b58d19d99929695df159c42fff101), [`9b10fc8`](https://github.com/Effect-TS/effect/commit/9b10fc85dde7866fb60e8142a93a6ed6a184537c), [`770c6d0`](https://github.com/Effect-TS/effect/commit/770c6d0f0ddcab7dbdde9fe701aded08deaee3fd), [`7425bcb`](https://github.com/Effect-TS/effect/commit/7425bcb7f74bdccc1941883bc7f9472fbe6ae8b4), [`563815a`](https://github.com/Effect-TS/effect/commit/563815a250e10a02e2a719c7d9b159478b0da933), [`1e83ca1`](https://github.com/Effect-TS/effect/commit/1e83ca110d5cb74641bae148a08e7eb6b3c2e2ed), [`550a41a`](https://github.com/Effect-TS/effect/commit/550a41a2547ce0dd5f085f1637a6f8a5794acba7), [`45d79c7`](https://github.com/Effect-TS/effect/commit/45d79c7cbd9c56c643361c6e6d1689f1aa62ae32), [`aac8584`](https://github.com/Effect-TS/effect/commit/aac8584fd997f3ce3341aeb077b816219832de58), [`7f87022`](https://github.com/Effect-TS/effect/commit/7f870225bb10a7ee7cb54b4835cb3bf64c0cacd8), [`425457c`](https://github.com/Effect-TS/effect/commit/425457c1e9eec7c176df130f7ec6ad3d616c086e), [`008c423`](https://github.com/Effect-TS/effect/commit/008c423e413fe311085c4bfc07c19b9c70529dfa), [`4f6ae04`](https://github.com/Effect-TS/effect/commit/4f6ae041a2886c27e941c5d9e4916aa2a32e0a1c), [`b6b63e1`](https://github.com/Effect-TS/effect/commit/b6b63e151595b5599ad2d65069ab6d298cfd7be7), [`92922ee`](https://github.com/Effect-TS/effect/commit/92922ee7de0a2d932121e37677f3e75ea38482f1), [`859c02f`](https://github.com/Effect-TS/effect/commit/859c02fe9020b0f28f713aa85910a0cc2d002560), [`ffc8235`](https://github.com/Effect-TS/effect/commit/ffc823537a0925171cf41d0841643a0b26895a4c), [`a29eb70`](https://github.com/Effect-TS/effect/commit/a29eb702ffe3fc58bd28c4d7857298cd65d73668), [`0be2303`](https://github.com/Effect-TS/effect/commit/0be2303dd39f9f2f7d8f96a28e25c938cd914e1a), [`b44636f`](https://github.com/Effect-TS/effect/commit/b44636ff7233763fad060bc0f9de10512454a29b), [`b19ccc7`](https://github.com/Effect-TS/effect/commit/b19ccc7325469a5dea091349bda4ae2ec00ec865), [`ff98f0b`](https://github.com/Effect-TS/effect/commit/ff98f0b0e2beb331209e37e42095d8d6e8e0b6c2), [`a47cbf1`](https://github.com/Effect-TS/effect/commit/a47cbf1feafd10e305c9753fa02a5f5f977d2c37), [`39b55f8`](https://github.com/Effect-TS/effect/commit/39b55f850aecbecf2e3b0c5f3ae2d1a77e6da949), [`c6c49c9`](https://github.com/Effect-TS/effect/commit/c6c49c96990d009341341a53ed26beb140c267a9), [`bf23ba7`](https://github.com/Effect-TS/effect/commit/bf23ba7033a4b426caf379bdcf84c58bb64d7010)]: + - effect@4.0.0-rc.111 + +## 4.0.0-rc.110 + +### Patch Changes + +- Updated dependencies [[`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179), [`0a127b8`](https://github.com/Effect-TS/effect/commit/0a127b8847fb213d46621a5018c069019414c00d), [`e491deb`](https://github.com/Effect-TS/effect/commit/e491deb1cd1717bc442aaee96e5d7b4001710cc9), [`f99c508`](https://github.com/Effect-TS/effect/commit/f99c508a09e9fccddb09edd16fa3393891c55d48), [`7e3f07c`](https://github.com/Effect-TS/effect/commit/7e3f07cf3e4b6a907840aface36ae5ee67ca8979), [`a894fe1`](https://github.com/Effect-TS/effect/commit/a894fe1df8974077293d9c1b924a7f80fc7fe7f1), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`7e9923b`](https://github.com/Effect-TS/effect/commit/7e9923b1a0adf1f842216b298d7542bed980c9a5), [`f064121`](https://github.com/Effect-TS/effect/commit/f0641210f960563f813ac6f728804e5447c03341), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`b660bf0`](https://github.com/Effect-TS/effect/commit/b660bf01d9c427552296cd9e0195e21504f45e1f), [`f4fbe9c`](https://github.com/Effect-TS/effect/commit/f4fbe9ca0209cdfaaaff54fc3805221be80f1ffa), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`e811353`](https://github.com/Effect-TS/effect/commit/e811353e3b79ba392af1d9fb8d7f93fd729dabf9), [`9761c3c`](https://github.com/Effect-TS/effect/commit/9761c3c4787b3815346c1b650d8984efec8f1051), [`baa99fc`](https://github.com/Effect-TS/effect/commit/baa99fc176efed4986c19c78726d1dbe7bd17765), [`7fd79b2`](https://github.com/Effect-TS/effect/commit/7fd79b2cc95973f85b3db43b0cf3c5cb7d2f69c4), [`a82ffc0`](https://github.com/Effect-TS/effect/commit/a82ffc03df0fb34e02f16773b96bfa3e0208e427), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`4026e2d`](https://github.com/Effect-TS/effect/commit/4026e2dde3b098e70ffb3f1fcddd200176acdefd), [`397bf1e`](https://github.com/Effect-TS/effect/commit/397bf1ebd95c0d6d58dc53e4f33c8ad3f34746f6), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f), [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f)]: + - effect@4.0.0-rc.110 + +## 4.0.0-rc.109 + +### Patch Changes + +- Updated dependencies [[`a0743f2`](https://github.com/Effect-TS/effect/commit/a0743f2b9f20fb5d150f35510e68819f01630bac), [`17892e7`](https://github.com/Effect-TS/effect/commit/17892e75a8d584f79127805506c42b19320990a7), [`4d8a230`](https://github.com/Effect-TS/effect/commit/4d8a2306dfff8fd5406ab75d0c8d22e5300d1faa), [`f21f9c9`](https://github.com/Effect-TS/effect/commit/f21f9c9f73dc59db922bda411b6d5245dae37cdb), [`18270dd`](https://github.com/Effect-TS/effect/commit/18270ddeaf4ad28723ca8a40aff04a5d4707b575), [`26db404`](https://github.com/Effect-TS/effect/commit/26db404a3284cfdbf4a3f351ccd05afc0de743b8), [`2670398`](https://github.com/Effect-TS/effect/commit/26703982612e12954360382e4b1d177002699d1c), [`3702bed`](https://github.com/Effect-TS/effect/commit/3702bedd8f6bcb3f603b87c640c521878d824eb3), [`ccae60e`](https://github.com/Effect-TS/effect/commit/ccae60e5edb2bef553f4af52afb509dfd443cd03), [`6ff5396`](https://github.com/Effect-TS/effect/commit/6ff53968138bbd7d4728ce8014e35eae8d6ca5d0)]: + - effect@4.0.0-rc.109 + +## 4.0.0-rc.108 + +### Patch Changes + +- Updated dependencies [[`dfb173e`](https://github.com/Effect-TS/effect/commit/dfb173efffd20c4feded4efe409018dd55acdca8), [`005e090`](https://github.com/Effect-TS/effect/commit/005e0902cace9f8960a4f43573665a3a9b53b6fa), [`c82c532`](https://github.com/Effect-TS/effect/commit/c82c53228dc1c50cc99654ce6de7766b4de09e75), [`22b579f`](https://github.com/Effect-TS/effect/commit/22b579f6c582e6e2d951784791fea6f1802517ed), [`3e19539`](https://github.com/Effect-TS/effect/commit/3e19539205082b1006d84553045d1b03db9cc8a1), [`08a3c74`](https://github.com/Effect-TS/effect/commit/08a3c74133206fc1cc728e0aa96d02e672fd80bd), [`eb0bae0`](https://github.com/Effect-TS/effect/commit/eb0bae08d543d58754c9bb7a57e67c1e2bb3f55a), [`97b544d`](https://github.com/Effect-TS/effect/commit/97b544d8b636587647b90691d669305c0eb4fc66), [`4f6d131`](https://github.com/Effect-TS/effect/commit/4f6d131e85d74ab0ec0300e52e503a5f943fc576), [`fad4b7c`](https://github.com/Effect-TS/effect/commit/fad4b7c5138b3f38c2427436da2e0685c1ca4e9b), [`accf447`](https://github.com/Effect-TS/effect/commit/accf4474513064e2a21d14b1937503261b4f34dc), [`31b27e4`](https://github.com/Effect-TS/effect/commit/31b27e49903c351588435f666c953aaac28f6120), [`8458951`](https://github.com/Effect-TS/effect/commit/84589518c3966c63d7f3679a5296d380eb1ba887)]: + - effect@4.0.0-rc.108 + ## 4.0.0-beta.107 ### Patch Changes diff --git a/repos/effect/packages/atom/vue/README.md b/repos/effect/packages/atom/vue/README.md index 5ef604acc2..c6e27a4852 100644 --- a/repos/effect/packages/atom/vue/README.md +++ b/repos/effect/packages/atom/vue/README.md @@ -1,7 +1,14 @@ -# `@effect/atom-vue` +# @effect/atom-vue -Vue bindings for the Effect Atom modules. +[Vue](https://vuejs.org) bindings for Atom, the reactive state management modules for Effect. + +## Installation + +```sh +npm install effect@rc @effect/atom-vue@rc +``` ## Documentation -- **API Reference**: [View the full documentation](https://effect-ts.github.io/effect/docs/atom-vue). +- [Effect website](https://effect.website) +- [API reference](https://effect.website/docs/v4/api/atom-vue) diff --git a/repos/effect/packages/atom/vue/package.json b/repos/effect/packages/atom/vue/package.json index 05151f06c6..20c039cc1d 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-beta.107", + "version": "4.0.0-rc.112", "type": "module", "license": "MIT", "description": "Vue bindings for the Effect Atom modules", @@ -61,7 +61,7 @@ }, "devDependencies": { "effect": "workspace:^", - "vue": "^3.5.39" + "vue": "^3.5.41" }, "peerDependencies": { "effect": "workspace:^", diff --git a/repos/effect/packages/effect/CHANGELOG.md b/repos/effect/packages/effect/CHANGELOG.md index 23f3a40753..39af4f04fb 100644 --- a/repos/effect/packages/effect/CHANGELOG.md +++ b/repos/effect/packages/effect/CHANGELOG.md @@ -1,5 +1,339 @@ # effect +## 4.0.0-rc.112 + +### Minor Changes + +- [#7390](https://github.com/Effect-TS/effect/pull/7390) [`a5f78d3`](https://github.com/Effect-TS/effect/commit/a5f78d3fcbaa792d49e80d103ab438e0b50812fd) Thanks @tim-smart! - Make RPC serialization schema-aware. + + Add `codecFor` to RPC serialization and client/server protocols so RPC and cluster + network payloads use the transport's schema codec. Framing, cluster storage, and + existing built-in wire formats remain unchanged. + +### Patch Changes + +- [#7411](https://github.com/Effect-TS/effect/pull/7411) [`20cb4f2`](https://github.com/Effect-TS/effect/commit/20cb4f260e45d37fa417c292c57be015314efe16) Thanks @altendky! - Add `RcMap.getOption` and `LayerMap.contextEffectOption` for atomically retaining + entries only when they are already cached. + +- [#7437](https://github.com/Effect-TS/effect/pull/7437) [`44675cb`](https://github.com/Effect-TS/effect/commit/44675cbce3dabfb85c68a3703b5de525768336fb) Thanks @wmaurer! - Add an optional `description` to `AiError.AuthenticationError`, rendered after the kind-based suggestion, and pass the provider's own error text through it on HTTP 401 and 403, so authentication failures report what actually went wrong instead of only a category. + +- [#7393](https://github.com/Effect-TS/effect/pull/7393) [`b6bf5e1`](https://github.com/Effect-TS/effect/commit/b6bf5e14492643076454131148f97cde24ad5306) Thanks @wmaurer! - Fix `Prompt.autoComplete` swallowing `j` and `k` while typing a filter query. + +- [#7401](https://github.com/Effect-TS/effect/pull/7401) [`0b9f780`](https://github.com/Effect-TS/effect/commit/0b9f780ff28b71042241791a9e8bcb5b631be2bd) Thanks @gjermundgaraba! - Retry transient EventLog remote write failures so pending local entries are synchronized after recovery. + +- [#7384](https://github.com/Effect-TS/effect/pull/7384) [`150e92c`](https://github.com/Effect-TS/effect/commit/150e92c4169c245e701da02575eef0b69c3ecd64) Thanks @tim-smart! - Improve synchronous Schema decode and encode performance by preserving completed parser exits and using a direct loop for common struct parsers. + +- [#7386](https://github.com/Effect-TS/effect/pull/7386) [`6740db2`](https://github.com/Effect-TS/effect/commit/6740db247ed20cb85da43c9f48ade8fecfd8c1ae) Thanks @tim-smart! - Add `Schema.TaggedUnion.matchOrElse` for partial case matching with a typed fallback. + +- [#7389](https://github.com/Effect-TS/effect/pull/7389) [`d57bba1`](https://github.com/Effect-TS/effect/commit/d57bba1486fa60971b6e0bf7459a329cfd5acdc4) Thanks @tim-smart! - Improve `SchemaError` construction performance by skipping stack frame capture. + +- [#7402](https://github.com/Effect-TS/effect/pull/7402) [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4) Thanks @tim-smart! - Improve Pool acquisition and release performance. Pool now tracks usage + incrementally, stores available items in an intrusive FIFO, and skips work for + fixed and empty pools. This changes the public `Pool.State` and `Pool.PoolItem` + interfaces. + +- [#7402](https://github.com/Effect-TS/effect/pull/7402) [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4) Thanks @tim-smart! - Add `Pool.use`, which borrows an item while an effect runs and returns it on any + exit. Unlike `Effect.scoped(Pool.get(pool))`, it does not require a `Scope`. + +- [#7402](https://github.com/Effect-TS/effect/pull/7402) [`be75d5e`](https://github.com/Effect-TS/effect/commit/be75d5ea6e516c25e3affec25806d31c2b203bc4) Thanks @tim-smart! - Reduce scoped resource acquisition allocations by storing the first Scope + finalizer inline and allocating a Map only when a second is added. This changes + the public `Scope.State.Open` interface. + +- [#7424](https://github.com/Effect-TS/effect/pull/7424) [`02a5146`](https://github.com/Effect-TS/effect/commit/02a5146d6933c7f6052553550bce5658225e4100) Thanks @tim-smart! - Skip remote event journal write callbacks when there are no uncommitted entries and return an `Option` indicating + whether the callback ran. + +- [#7312](https://github.com/Effect-TS/effect/pull/7312) [`15272a6`](https://github.com/Effect-TS/effect/commit/15272a66adf02501e7747761e2a3c41bff67bb46) Thanks @godu! - Fix shell completion for choice values containing quotes, spaces, word-break characters, Unicode, and shell metacharacters. + + Bash now quotes candidates for readline, keeps choice values intact when reconstructing words, and supports Bash 3.2 without associative arrays. Fish and Zsh escape choices across both parsing rounds, and Fish hides value-taking flags after use without suppressing their value completions. + +- [#7395](https://github.com/Effect-TS/effect/pull/7395) [`436f10d`](https://github.com/Effect-TS/effect/commit/436f10d1efccec308426532ff3f88df9a96434f3) Thanks @wmaurer! - Fix `Prompt.file` swallowing `j` and `k` while typing a filter query. + +- [#7406](https://github.com/Effect-TS/effect/pull/7406) [`058fb15`](https://github.com/Effect-TS/effect/commit/058fb15647fa01ad771277bd368783fcf5f262e8) Thanks @gcanti! - Preserve finite string and unique symbol key unions in the return types of `Array.groupBy` and `Iterable.groupBy`. + + Previously, grouping widened finite keys to `string` or `symbol`, which lost known-key autocomplete and allowed access to keys that the selector could never produce. The new `Record.ReadonlyRecord.GroupByResult` keeps finite keys and marks their properties optional because any group may be absent at runtime, while open `string` and `symbol` selectors retain their existing record index signatures. + +- [#7415](https://github.com/Effect-TS/effect/pull/7415) [`4d89bb8`](https://github.com/Effect-TS/effect/commit/4d89bb8ffb4cf567a1d11072246b6161ce638712) Thanks @gcanti! - Reject unsupported JSON Schema references instead of resolving them by their final path segment, closes [#7409](https://github.com/Effect-TS/effect/issues/7409). + +- [#7420](https://github.com/Effect-TS/effect/pull/7420) [`480fb15`](https://github.com/Effect-TS/effect/commit/480fb156590785cf98f67bdec4fc282a608e2d87) Thanks @gcanti! - Make JSON Schema dialect conversions preserve custom keywords, translate conditionals, contains, dependencies, identifiers, and tuples where representable, relocate local references after structural changes, and throw instead of silently changing unsupported constraints. + +- [#7417](https://github.com/Effect-TS/effect/pull/7417) [`f77ec19`](https://github.com/Effect-TS/effect/commit/f77ec19cff1cbbeeae928e3bd0ece00a7d22bab8) Thanks @Makisuo! - Defer built-in OpenAPI response generation until the documentation route is first requested, retrying after generation defects. + +- [#7388](https://github.com/Effect-TS/effect/pull/7388) [`925b82a`](https://github.com/Effect-TS/effect/commit/925b82a81f59a4d459b488621030f24ba99d6a27) Thanks @ebramanti! - Fix MCP initialize rejected over the protocol version header + + `McpServer.layerHttp` validated the `MCP-Protocol-Version` header on every POST, including + the `initialize` request. That header reports the version negotiated by an earlier + `initialize`, so on a fresh connection a client can only send its own default. Whenever + that default was not among the server's registered protocols the `initialize` returned + `400` and never reached version negotiation, even when the body offered a version the + server supports. + + The header check now applies only to requests after initialization, where the + specification requires it. An `initialize` negotiates from the version offered in its + body, through the protocol registry, and reports the selected version in the response. + +- [#7403](https://github.com/Effect-TS/effect/pull/7403) [`7455246`](https://github.com/Effect-TS/effect/commit/7455246f352385f5cbbdd8299555265ee289490e) Thanks @hsyntax! - Add support for explicit cache breakpoints on the OpenAI responses API for GPT-5.6-or-later. + +- [#7442](https://github.com/Effect-TS/effect/pull/7442) [`118124d`](https://github.com/Effect-TS/effect/commit/118124d913d0a02ac5c1f7799a39bd90031769d9) Thanks @tim-smart! - Redact password prompt values from CLI wizard command output. + +- [#7366](https://github.com/Effect-TS/effect/pull/7366) [`0dd7825`](https://github.com/Effect-TS/effect/commit/0dd7825e4da4d3a00fa9bd410a1d55f3d4874d07) Thanks @tim-smart! - Add `SchemaBinary`, a compact schema-derived codec with streaming, optional fingerprints and dictionaries, and RPC support. + +- [#7404](https://github.com/Effect-TS/effect/pull/7404) [`b722eca`](https://github.com/Effect-TS/effect/commit/b722eca6d283a88970ad0efba0b4e921915eca78) Thanks @gcanti! - Add a public `StandardSchema` module containing the vendored Standard Schema V1 specification and remove the direct dependency on `@standard-schema/spec`. + +- [#7436](https://github.com/Effect-TS/effect/pull/7436) [`811d579`](https://github.com/Effect-TS/effect/commit/811d579c432856a9e3fc05b517fd8e924cbf991a) Thanks @gcanti! - Fix JSON Schema imports: + + - Type-specific keywords no longer imply a type. For example, `minLength` validates strings without rejecting + non-string values. + - Constraints next to `const`, `enum`, and `$ref` are now applied instead of being ignored. + - Disjoint and linear union intersections are imported without a Cartesian expansion. Other overlapping union + intersections fail with an explicit error. + - References to definitions without unions no longer make otherwise linear intersections fail. + - Imported `oneOf` schemas remain `oneOf` when exported again. + - `minItems` is preserved when `prefixItems` does not fully enforce it. + +- [#7382](https://github.com/Effect-TS/effect/pull/7382) [`043b587`](https://github.com/Effect-TS/effect/commit/043b587e6e93f6624bf974bcd7ed976eaa17f0e1) Thanks @tim-smart! - Replace per-prompt prefix options with a context-based theme for CLI prompt symbols and colors. + +- [#7373](https://github.com/Effect-TS/effect/pull/7373) [`8583727`](https://github.com/Effect-TS/effect/commit/85837274fa929a921985464585513a68c261e365) Thanks @ChubbyDuck! - Drop unreachable concurrency guard in iteratorEagerImpl + +- [#7429](https://github.com/Effect-TS/effect/pull/7429) [`d9d2cfc`](https://github.com/Effect-TS/effect/commit/d9d2cfcb732754001b7323cf8afaccc48539bb74) Thanks @gcanti! - Reject unsupported JSON Schema validation keywords and object or array `const` / `enum` values during import instead of + silently weakening validation. + +- [#7428](https://github.com/Effect-TS/effect/pull/7428) [`5c4b7a0`](https://github.com/Effect-TS/effect/commit/5c4b7a0b17931cd1538c6595a54b21ffe9c1e906) Thanks @ebramanti! - Return workflow execution IDs from generated RPC and HTTP discard endpoints. + +## 4.0.0-rc.111 + +### Patch Changes + +- [#7311](https://github.com/Effect-TS/effect/pull/7311) [`0ce3b00`](https://github.com/Effect-TS/effect/commit/0ce3b007531563ce33f94c6a057492d379cfd9e8) Thanks @fubhy! - Reject graph shortest-path calculations that overflow or underflow the finite number range. + +- [#7352](https://github.com/Effect-TS/effect/pull/7352) [`d846331`](https://github.com/Effect-TS/effect/commit/d84633171b4333e87a05f8d74c02c95d83d1b50d) Thanks @nikhilsnayak! - Preserve the `Context.mapUnsafe` accessor when code is compiled with loose object spread transforms. + +- [#7300](https://github.com/Effect-TS/effect/pull/7300) [`f93616f`](https://github.com/Effect-TS/effect/commit/f93616f31f42345af844b8396fb19259556ae236) Thanks @fubhy! - Fix graph index exhaustion, A* path consistency, snapshot validation, Mermaid line endings, and topological initials. + +- [#7336](https://github.com/Effect-TS/effect/pull/7336) [`16bf1ef`](https://github.com/Effect-TS/effect/commit/16bf1efdf41ba1d000b8588a8cc052a394856ba4) Thanks @gcanti! - Compact JSON Schema check constraints when they can be safely merged without keyword collisions. + +- [#7360](https://github.com/Effect-TS/effect/pull/7360) [`d568968`](https://github.com/Effect-TS/effect/commit/d5689682e963f43157571cd71d4b422247ae09b9) Thanks @gcanti! - Add configurable schema representation reference policies and propagate them through JSON Schema and OpenAPI generation. By default, only schemas with resolved identifiers become references. Closes [#7357](https://github.com/Effect-TS/effect/issues/7357). + +- [#7304](https://github.com/Effect-TS/effect/pull/7304) [`bc06292`](https://github.com/Effect-TS/effect/commit/bc062920ac47f956786d12c6d0bacb9468fa66e2) Thanks @fubhy! - Add graph snapshots, low-link connectivity analysis, bipartite matching, maximum flow, and minimum cut APIs. + +- [#7364](https://github.com/Effect-TS/effect/pull/7364) [`e03ea90`](https://github.com/Effect-TS/effect/commit/e03ea907720b58d19d99929695df159c42fff101) Thanks @kitlangton! - Fix `Deferred` completion skipping waiters when an earlier waiter dies during resume. Completing a `Deferred` with an interrupt cause kills a suspended waiter synchronously inside its resume; the dying waiter's `await` cleanup spliced the shared `resumes` array mid-iteration, so the next waiter was never resumed and hung forever. Completion now clears `resumes` before resuming waiters. + +- [#7347](https://github.com/Effect-TS/effect/pull/7347) [`9b10fc8`](https://github.com/Effect-TS/effect/commit/9b10fc85dde7866fb60e8142a93a6ed6a184537c) Thanks @tim-smart! - Shut down the internal effects queue when ordered concurrent channel mapping closes. + +- [#7335](https://github.com/Effect-TS/effect/pull/7335) [`770c6d0`](https://github.com/Effect-TS/effect/commit/770c6d0f0ddcab7dbdde9fe701aded08deaee3fd) Thanks @tim-smart! - Fix `Effect.fn` binding the final transform as the generator body when using the `{ self }` overload. + +- [#7344](https://github.com/Effect-TS/effect/pull/7344) [`7425bcb`](https://github.com/Effect-TS/effect/commit/7425bcb7f74bdccc1941883bc7f9472fbe6ae8b4) Thanks @tim-smart! - Ensure fiber observer cancellation during exit does not skip remaining observers. + +- [#7301](https://github.com/Effect-TS/effect/pull/7301) [`563815a`](https://github.com/Effect-TS/effect/commit/563815a250e10a02e2a719c7d9b159478b0da933) Thanks @fubhy! - Preserve depth-first traversal order with finite radii and validate A* heuristics for trivial paths. + +- [#7350](https://github.com/Effect-TS/effect/pull/7350) [`1e83ca1`](https://github.com/Effect-TS/effect/commit/1e83ca110d5cb74641bae148a08e7eb6b3c2e2ed) Thanks @tim-smart! - Align in-memory workflow interrupt finalization with the cluster workflow engine. + +- [#7316](https://github.com/Effect-TS/effect/pull/7316) [`550a41a`](https://github.com/Effect-TS/effect/commit/550a41a2547ce0dd5f085f1637a6f8a5794acba7) Thanks @tim-smart! - Update dependencies across the Effect workspace. + +- [#7306](https://github.com/Effect-TS/effect/pull/7306) [`45d79c7`](https://github.com/Effect-TS/effect/commit/45d79c7cbd9c56c643361c6e6d1689f1aa62ae32) Thanks @fubhy! - Add bulk node and edge removal operations, and disallow graph mutations from callbacks that traverse or transform the same graph. + +- [#7317](https://github.com/Effect-TS/effect/pull/7317) [`aac8584`](https://github.com/Effect-TS/effect/commit/aac8584fd997f3ce3341aeb077b816219832de58) Thanks @tim-smart! - Fix `Match.value` terminal combinators failing to typecheck when the input + contains a generic type parameter. + + The fifth type argument of `Matcher` for value matchers is now `ValueFlavor`, + and `ValueMatcher` has a seventh flavor argument; update hand-written + annotations accordingly. + +- [#7361](https://github.com/Effect-TS/effect/pull/7361) [`7f87022`](https://github.com/Effect-TS/effect/commit/7f870225bb10a7ee7cb54b4835cb3bf64c0cacd8) Thanks @tim-smart! - Merge effect and finalizer failures during cleanup, preserving other failures alongside `Cause.Done`. + +- [#7326](https://github.com/Effect-TS/effect/pull/7326) [`425457c`](https://github.com/Effect-TS/effect/commit/425457c1e9eec7c176df130f7ec6ad3d616c086e) Thanks @tim-smart! - Emit mixed struct and record schema types as intersections, preventing optional + properties in open OpenAPI objects from conflicting with their index signature. + +- [#7324](https://github.com/Effect-TS/effect/pull/7324) [`008c423`](https://github.com/Effect-TS/effect/commit/008c423e413fe311085c4bfc07c19b9c70529dfa) Thanks @tim-smart! - Allow path-level common parameters in OpenAPI generator input types. + +- [#7359](https://github.com/Effect-TS/effect/pull/7359) [`4f6ae04`](https://github.com/Effect-TS/effect/commit/4f6ae041a2886c27e941c5d9e4916aa2a32e0a1c) Thanks @gcanti! - Add dual standalone functions for reading and updating values through optics, closes [#7299](https://github.com/Effect-TS/effect/issues/7299). + +- [#7250](https://github.com/Effect-TS/effect/pull/7250) [`b6b63e1`](https://github.com/Effect-TS/effect/commit/b6b63e151595b5599ad2d65069ab6d298cfd7be7) Thanks @xianjianlf2! - Preserve `JSON.rawJSON` values when cloning cached OpenAPI specs. + +- [#7351](https://github.com/Effect-TS/effect/pull/7351) [`92922ee`](https://github.com/Effect-TS/effect/commit/92922ee7de0a2d932121e37677f3e75ea38482f1) Thanks @tim-smart! - Preserve unsafe in-memory workflow interrupts across replay. + +- [#7328](https://github.com/Effect-TS/effect/pull/7328) [`859c02f`](https://github.com/Effect-TS/effect/commit/859c02fe9020b0f28f713aa85910a0cc2d002560) Thanks @fubhy! - Keep graph caches consistent during bulk removals and validate graph kinds at runtime. + +- [#7358](https://github.com/Effect-TS/effect/pull/7358) [`ffc8235`](https://github.com/Effect-TS/effect/commit/ffc823537a0925171cf41d0841643a0b26895a4c) Thanks @tim-smart! - Bound framed RPC server HTTP response streams to 16 items by default, with a configurable buffer size or an unbounded opt-out. + +- [#6324](https://github.com/Effect-TS/effect/pull/6324) [`a29eb70`](https://github.com/Effect-TS/effect/commit/a29eb702ffe3fc58bd28c4d7857298cd65d73668) Thanks @tim-smart! - Add scoped Redis pub/sub subscriptions that expose received messages through an Effect queue. + +- [#7354](https://github.com/Effect-TS/effect/pull/7354) [`0be2303`](https://github.com/Effect-TS/effect/commit/0be2303dd39f9f2f7d8f96a28e25c938cd914e1a) Thanks @tim-smart! - Add support for server-originated RPC requests and notifications. Buffered + JSON-RPC HTTP drops notifications until streaming responses are available. + +- [#7349](https://github.com/Effect-TS/effect/pull/7349) [`b44636f`](https://github.com/Effect-TS/effect/commit/b44636ff7233763fad060bc0f9de10512454a29b) Thanks @gcanti! - When canonical JSON derivation adds a transformation for a schema without a direct JSON representation, keep source checks and annotations on the source side. This prevents duplicate check execution and ensures generated JSON Schema documents describe only the encoded target, closes [#7192](https://github.com/Effect-TS/effect/issues/7192). + +- [#7337](https://github.com/Effect-TS/effect/pull/7337) [`b19ccc7`](https://github.com/Effect-TS/effect/commit/b19ccc7325469a5dea091349bda4ae2ec00ec865) Thanks @gcanti! - Add `Schema.JsonObject` for readonly string-keyed records containing JSON-compatible values. This provides a canonical, + reusable schema instead of requiring callers to repeatedly compose `Schema.Record(Schema.String, Schema.Json)`. + +- [#7330](https://github.com/Effect-TS/effect/pull/7330) [`ff98f0b`](https://github.com/Effect-TS/effect/commit/ff98f0b0e2beb331209e37e42095d8d6e8e0b6c2) Thanks @gcanti! - Preserve JSON Schema object keyword scopes when importing `allOf` intersections, including closed empty objects and required-only keys. Emit intersecting index signatures without weakening their constraints, and reject object scope intersections that cannot be represented faithfully. + +- [#7363](https://github.com/Effect-TS/effect/pull/7363) [`a47cbf1`](https://github.com/Effect-TS/effect/commit/a47cbf1feafd10e305c9753fa02a5f5f977d2c37) Thanks @tim-smart! - Add `Match.fn` for reusable matchers that select a value from multiple arguments. + +- [#7362](https://github.com/Effect-TS/effect/pull/7362) [`39b55f8`](https://github.com/Effect-TS/effect/commit/39b55f850aecbecf2e3b0c5f3ae2d1a77e6da949) Thanks @tim-smart! - Preserve encoded AI tool call parameters when automatic tool call resolution is disabled, and update + `Toolkit.handle` to accept the encoded parameter type it decodes at runtime. + +- [#7305](https://github.com/Effect-TS/effect/pull/7305) [`c6c49c9`](https://github.com/Effect-TS/effect/commit/c6c49c96990d009341341a53ed26beb140c267a9) Thanks @fubhy! - Fix mutable graph cache consistency and guard weighted pathfinding against inconsistent snapshots and numeric overflow. + +- [#7342](https://github.com/Effect-TS/effect/pull/7342) [`bf23ba7`](https://github.com/Effect-TS/effect/commit/bf23ba7033a4b426caf379bdcf84c58bb64d7010) Thanks @misterclayt0n! - Forward every worker-runner client disconnect to the RPC server, not just the first one. + +## 4.0.0-rc.110 + +### Patch Changes + +- [#7234](https://github.com/Effect-TS/effect/pull/7234) [`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d) Thanks @lloydrichards! - MCP servers can now use the 2025-11-25 protocol, including sampling with tools and both form- and URL-based elicitation. + + Enable it by adding `McpProtocol.v2025_11_25` to the server's `protocols` option. + +- [#7234](https://github.com/Effect-TS/effect/pull/7234) [`6eebd0a`](https://github.com/Effect-TS/effect/commit/6eebd0a618308a91f95947bae6e0fb206ae3939d) Thanks @lloydrichards! - MCP servers can now provide icons for server information, resources, resource templates, prompts, and tools using `McpSchema.Icon`. + + Each icon can specify its source URI, MIME type, supported sizes, and light or dark theme. + +- [#7291](https://github.com/Effect-TS/effect/pull/7291) [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f) Thanks @fubhy! - Include traversed edge indexes in graph shortest-path results. + +- [#7291](https://github.com/Effect-TS/effect/pull/7291) [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f) Thanks @fubhy! - Add deterministic, index-preserving `Graph.minimumSpanningForest`. + +- [#7291](https://github.com/Effect-TS/effect/pull/7291) [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f) Thanks @fubhy! - Add index-preserving transitive reduction for directed acyclic graphs. + +- [#7261](https://github.com/Effect-TS/effect/pull/7261) [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179) Thanks @fubhy! - Add `Graph.Snapshot` and `Graph.fromSnapshot` for constructing immutable graphs with explicit node and edge indexes, and simplify `Graph.Edge` to a type-only structural interface. + +- [#7261](https://github.com/Effect-TS/effect/pull/7261) [`189b003`](https://github.com/Effect-TS/effect/commit/189b003a2367fa44dd4b8544aa62979f0345d179) Thanks @fubhy! - Add `Schema.Graph` for schema-based encoding and decoding of immutable directed and undirected graphs. + +- [#7267](https://github.com/Effect-TS/effect/pull/7267) [`0a127b8`](https://github.com/Effect-TS/effect/commit/0a127b8847fb213d46621a5018c069019414c00d) Thanks @tim-smart! - Allow customizing the prefix displayed by CLI prompts. + +- [#7272](https://github.com/Effect-TS/effect/pull/7272) [`e491deb`](https://github.com/Effect-TS/effect/commit/e491deb1cd1717bc442aaee96e5d7b4001710cc9) Thanks @fubhy! - Preserve scoped Graph mutation callback errors when the callback manually finalizes its mutable handle. + +- [#7266](https://github.com/Effect-TS/effect/pull/7266) [`f99c508`](https://github.com/Effect-TS/effect/commit/f99c508a09e9fccddb09edd16fa3393891c55d48) Thanks @tim-smart! - Fix SQL persisted queue delivery on SQLite builds without `SQLITE_ENABLE_UPDATE_DELETE_LIMIT`. + +- [#7199](https://github.com/Effect-TS/effect/pull/7199) [`7e3f07c`](https://github.com/Effect-TS/effect/commit/7e3f07cf3e4b6a907840aface36ae5ee67ca8979) Thanks @rekram1-node! - Fix Zsh completions for CLI commands with both positional arguments and subcommands. + +- [#7274](https://github.com/Effect-TS/effect/pull/7274) [`a894fe1`](https://github.com/Effect-TS/effect/commit/a894fe1df8974077293d9c1b924a7f80fc7fe7f1) Thanks @fubhy! - Ignore removed allocator history when comparing and hashing immutable Graph values with the same active indexed structure. + +- [#7291](https://github.com/Effect-TS/effect/pull/7291) [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f) Thanks @fubhy! - Add `Graph.findCycle` with exact node and edge witnesses. + +- [#7294](https://github.com/Effect-TS/effect/pull/7294) [`7e9923b`](https://github.com/Effect-TS/effect/commit/7e9923b1a0adf1f842216b298d7542bed980c9a5) Thanks @tim-smart! - Add custom reviver support to HTTP JSON parsing APIs. + +- [#7200](https://github.com/Effect-TS/effect/pull/7200) [`f064121`](https://github.com/Effect-TS/effect/commit/f0641210f960563f813ac6f728804e5447c03341) Thanks @mikearnaldi! - Support narrowing schedule input and output types with type guard predicates passed to `Schedule.while`. + +- [#7291](https://github.com/Effect-TS/effect/pull/7291) [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f) Thanks @fubhy! - Add index-preserving `Graph.inducedSubgraph`. + +- [#7244](https://github.com/Effect-TS/effect/pull/7244) [`b660bf0`](https://github.com/Effect-TS/effect/commit/b660bf01d9c427552296cd9e0195e21504f45e1f) Thanks @AnnaSuSu! - Normalize unbounded PubSub replay capacities to positive integers. + +- [#7293](https://github.com/Effect-TS/effect/pull/7293) [`f4fbe9c`](https://github.com/Effect-TS/effect/commit/f4fbe9ca0209cdfaaaff54fc3805221be80f1ffa) Thanks @tim-smart! - Support standalone Effect.forEach data-last usage + +- [#7291](https://github.com/Effect-TS/effect/pull/7291) [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f) Thanks @fubhy! - Add bounded lazy enumeration of simple paths and all tied shortest paths. + +- [#7259](https://github.com/Effect-TS/effect/pull/7259) [`e811353`](https://github.com/Effect-TS/effect/commit/e811353e3b79ba392af1d9fb8d7f93fd729dabf9) Thanks @fubhy! - Prevent graph edge reads from exposing internal edge records and reject non-finite A* heuristic values. + +- [#7251](https://github.com/Effect-TS/effect/pull/7251) [`9761c3c`](https://github.com/Effect-TS/effect/commit/9761c3c4787b3815346c1b650d8984efec8f1051) Thanks @tim-smart! - Add `Encoding.randomHex`, a lightweight non-cryptographic generator that coerces lengths to unsigned 32-bit multiples of 8. + +- [#7296](https://github.com/Effect-TS/effect/pull/7296) [`baa99fc`](https://github.com/Effect-TS/effect/commit/baa99fc176efed4986c19c78726d1dbe7bd17765) Thanks @tim-smart! - Make unstable CLI boolean flags required when omitted, allowing optional, default, config, and prompt fallbacks to handle absence consistently. + +- [#7246](https://github.com/Effect-TS/effect/pull/7246) [`7fd79b2`](https://github.com/Effect-TS/effect/commit/7fd79b2cc95973f85b3db43b0cf3c5cb7d2f69c4) Thanks @tim-smart! - Add `Effect.head` for retrieving the first element of an iterable produced by an effect. + +- [#7273](https://github.com/Effect-TS/effect/pull/7273) [`a82ffc0`](https://github.com/Effect-TS/effect/commit/a82ffc03df0fb34e02f16773b96bfa3e0208e427) Thanks @fubhy! - Validate Graph traversal radii, isolate traversal start configuration, and prioritize the first supplied DFS root. + +- [#7291](https://github.com/Effect-TS/effect/pull/7291) [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f) Thanks @fubhy! - Throw `GraphError` when a negative cycle affects a Bellman-Ford target, reserving `Option.none()` for unreachable paths. + +- [#7248](https://github.com/Effect-TS/effect/pull/7248) [`4026e2d`](https://github.com/Effect-TS/effect/commit/4026e2dde3b098e70ffb3f1fcddd200176acdefd) Thanks @tim-smart! - Improve tracing performance in span creation and HTTP middleware. + +- [#7276](https://github.com/Effect-TS/effect/pull/7276) [`397bf1e`](https://github.com/Effect-TS/effect/commit/397bf1ebd95c0d6d58dc53e4f33c8ad3f34746f6) Thanks @fubhy! - Deduplicate directed neighbor-node queries while preserving first edge occurrence order. + +- [#7291](https://github.com/Effect-TS/effect/pull/7291) [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f) Thanks @fubhy! - Add incident-edge, edges-between, and directed and undirected degree queries to `Graph`. + +- [#7291](https://github.com/Effect-TS/effect/pull/7291) [`d10ceb0`](https://github.com/Effect-TS/effect/commit/d10ceb06d56108c11100868f591d2b42ddff5e9f) Thanks @fubhy! - Add unweighted reachability, explicit weak and strong connectivity predicates, weak components, and tree detection to `Graph`. + +## 4.0.0-rc.109 + +### Patch Changes + +- [#7219](https://github.com/Effect-TS/effect/pull/7219) [`a0743f2`](https://github.com/Effect-TS/effect/commit/a0743f2b9f20fb5d150f35510e68819f01630bac) Thanks @tim-smart! - Add SQL, HttpApi testing, and CLI schema examples to the published AI documentation. + +- [#7241](https://github.com/Effect-TS/effect/pull/7241) [`17892e7`](https://github.com/Effect-TS/effect/commit/17892e75a8d584f79127805506c42b19320990a7) Thanks @tim-smart! - Use Context mapUnsafe in less call sites + +- [#7240](https://github.com/Effect-TS/effect/pull/7240) [`4d8a230`](https://github.com/Effect-TS/effect/commit/4d8a2306dfff8fd5406ab75d0c8d22e5300d1faa) Thanks @tim-smart! - Fix `Effect.fromOption` data-first inference for inline `Option` expressions. + +- [#7216](https://github.com/Effect-TS/effect/pull/7216) [`f21f9c9`](https://github.com/Effect-TS/effect/commit/f21f9c9f73dc59db922bda411b6d5245dae37cdb) Thanks @tim-smart! - Add a `HttpStatus` module to `effect/unstable/http` that centralizes the mapping from HTTP status literal names to numeric codes and exports `HttpStatus.fromLiteral`. `HttpApiSchema.status` now consumes the new module. + +- [#6829](https://github.com/Effect-TS/effect/pull/6829) [`18270dd`](https://github.com/Effect-TS/effect/commit/18270ddeaf4ad28723ca8a40aff04a5d4707b575) Thanks @lloydrichards! - MCP servers now support the 2024-11-05 and 2025-03-26 RPC revisions through version-specific protocol adapters. + +- [#7218](https://github.com/Effect-TS/effect/pull/7218) [`26db404`](https://github.com/Effect-TS/effect/commit/26db404a3284cfdbf4a3f351ccd05afc0de743b8) Thanks @tim-smart! - Run SQL `PersistedQueue` table creation through versioned migrations so future schema changes can be applied safely. + +- [#7210](https://github.com/Effect-TS/effect/pull/7210) [`2670398`](https://github.com/Effect-TS/effect/commit/26703982612e12954360382e4b1d177002699d1c) Thanks @tim-smart! - Preserve nanosecond precision when adjusting `TestClock` with large durations. + +- [#7205](https://github.com/Effect-TS/effect/pull/7205) [`3702bed`](https://github.com/Effect-TS/effect/commit/3702bedd8f6bcb3f603b87c640c521878d824eb3) Thanks @tim-smart! - Remove the `kubernetes-types` dependency by vendoring the Kubernetes Pod declarations used by the cluster helpers and exporting them from `effect/unstable/cluster/K8sTypes`. + +- [#7236](https://github.com/Effect-TS/effect/pull/7236) [`ccae60e`](https://github.com/Effect-TS/effect/commit/ccae60e5edb2bef553f4af52afb509dfd443cd03) Thanks @roninjin10! - Propagate a failed `BEGIN` or `SAVEPOINT` from `SqlClient.withTransaction` as a typed `SqlError`. + + `makeWithTransaction` wrapped the `begin` step together with the transaction body, so a + failed `BEGIN` took the rollback branch. No transaction was active at that point, the + `ROLLBACK` failed, and its `Effect.orDie` wrapper replaced the original typed error with a + defect (`cannot rollback - no transaction is active`). Callers could no longer classify the + failure as retryable. The path became reachable when the sqlite client started using + `BEGIN IMMEDIATE`, which acquires a write lock and can fail with `SQLITE_BUSY`. + + Commit and rollback now run only after `begin` or `savepoint` succeeds. A failed `begin` or + `savepoint` fails with its original `SqlError`, leaves the wrapped effect unexecuted, and + still closes the acquired connection scope. + +- [#7206](https://github.com/Effect-TS/effect/pull/7206) [`6ff5396`](https://github.com/Effect-TS/effect/commit/6ff53968138bbd7d4728ce8014e35eae8d6ca5d0) Thanks @tim-smart! - Bound cluster runner entity residency and storage reads. + + `ShardingConfig` gains two knobs: + + - `maxResidentEntities` (default `10_000`): the maximum number of entities + that can be resident on a runner at the same time. At the cap, the storage + read loop stops admitting messages for new entity addresses (they stay in + storage until a slot frees up) and volatile sends to new addresses fail with + `MailboxFull`. Persisted sends still succeed. `"unbounded"` restores the + previous behaviour and can only be set programmatically. + - `unprocessedMessageBatchSize` (default `1024`): the maximum number of + unprocessed messages read from storage in a single poll. + + `MessageStorage.unprocessedMessages` accepts an optional + `{ limit, addresses }` argument, and only claims the messages it actually + returns. The memory implementation now applies the same ten-minute claim + window as SQL, so bounded reads advance past in-flight requests; resetting an + address or shard makes its claimed messages immediately eligible again. + + The encoded driver contract replaces `Encoded.resetAddress` with the batched + `Encoded.resetAddresses` operation. `SqlMessageStorage.makeEncoded` constructs + the SQL encoded driver directly for custom storage composition. + + `ClusterWorkflowEngine` entities (workflows and the durable clock) now use a + fixed ten-second idle time, so completed and suspended executions release their + entity slots quickly. Their state is durable, so an evicted execution is + rebuilt from storage when its next message arrives. + +## 4.0.0-rc.108 + +### Patch Changes + +- [#6546](https://github.com/Effect-TS/effect/pull/6546) [`dfb173e`](https://github.com/Effect-TS/effect/commit/dfb173efffd20c4feded4efe409018dd55acdca8) Thanks @xianjianlf2! - Handle BigInt values safely and consistently across JSON diagnostics and logger formats. + +- [#7174](https://github.com/Effect-TS/effect/pull/7174) [`005e090`](https://github.com/Effect-TS/effect/commit/005e0902cace9f8960a4f43573665a3a9b53b6fa) Thanks @tim-smart! - Fix `Queue.await` failing with `Cause.Done` when registered before the queue ends. + +- [#7180](https://github.com/Effect-TS/effect/pull/7180) [`c82c532`](https://github.com/Effect-TS/effect/commit/c82c53228dc1c50cc99654ce6de7766b4de09e75) Thanks @gcanti! - Prioritize redacted representations in formatters and normalize text logger levels to uppercase. + +- [#7193](https://github.com/Effect-TS/effect/pull/7193) [`22b579f`](https://github.com/Effect-TS/effect/commit/22b579f6c582e6e2d951784791fea6f1802517ed) Thanks @kitlangton! - Fix `Deferred.await` dying with a `TypeError` when a waiter is interrupted after the `Deferred` has been completed. + +- [#7179](https://github.com/Effect-TS/effect/pull/7179) [`3e19539`](https://github.com/Effect-TS/effect/commit/3e19539205082b1006d84553045d1b03db9cc8a1) Thanks @tim-smart! - Fix `DurableDeferred.raceAll` so a completed deferred can wake an active workflow without changing success-biased race semantics + +- [#7189](https://github.com/Effect-TS/effect/pull/7189) [`08a3c74`](https://github.com/Effect-TS/effect/commit/08a3c74133206fc1cc728e0aa96d02e672fd80bd) Thanks @gcanti! - Fix `HttpApi` query decoding for array parameters with a single value. + +- [#6550](https://github.com/Effect-TS/effect/pull/6550) [`eb0bae0`](https://github.com/Effect-TS/effect/commit/eb0bae08d543d58754c9bb7a57e67c1e2bb3f55a) Thanks @xianjianlf2! - Return fresh OpenAPI specs from cached `OpenApi.fromApi` calls. + +- [#7188](https://github.com/Effect-TS/effect/pull/7188) [`97b544d`](https://github.com/Effect-TS/effect/commit/97b544d8b636587647b90691d669305c0eb4fc66) Thanks @gcanti! - Mark the internal `~sentinels` Schema annotation as `@internal` so release declaration stripping removes it together with `SchemaAST.Sentinel`. This keeps the published declarations self-consistent for consumers that type-check dependencies with `skipLibCheck: false`. + +- [#7158](https://github.com/Effect-TS/effect/pull/7158) [`4f6d131`](https://github.com/Effect-TS/effect/commit/4f6d131e85d74ab0ec0300e52e503a5f943fc576) Thanks @k3dom! - Improve Union candidate selection: a nested union member is dispatched by the sentinels common to all its members, and candidates whose sentinel the input contradicts are excluded. + +- [#7178](https://github.com/Effect-TS/effect/pull/7178) [`fad4b7c`](https://github.com/Effect-TS/effect/commit/fad4b7c5138b3f38c2427436da2e0685c1ca4e9b) Thanks @tim-smart! - Use Promise microtasks for synchronous Scheduler dispatch. + +- [#7181](https://github.com/Effect-TS/effect/pull/7181) [`accf447`](https://github.com/Effect-TS/effect/commit/accf4474513064e2a21d14b1937503261b4f34dc) Thanks @gcanti! - Move `SchemaError` into the `Schema` module and remove the standalone `SchemaError` module. + +- [#7195](https://github.com/Effect-TS/effect/pull/7195) [`31b27e4`](https://github.com/Effect-TS/effect/commit/31b27e49903c351588435f666c953aaac28f6120) Thanks @tim-smart! - Ensure discarded non-persisted cluster messages complete without waiting for the entity reply. + +- [#7191](https://github.com/Effect-TS/effect/pull/7191) [`8458951`](https://github.com/Effect-TS/effect/commit/84589518c3966c63d7f3679a5296d380eb1ba887) Thanks @Digifox03! - Fix `HttpRouter.Middleware.layer` to provide request error services for errors declared in `handles`, and expose global + middleware errors from `HttpRouter.toHttpEffect`. + ## 4.0.0-beta.107 ### Patch Changes @@ -2490,7 +2824,7 @@ - [#1725](https://github.com/Effect-TS/effect-smol/pull/1725) [`27fea0f`](https://github.com/Effect-TS/effect-smol/commit/27fea0f66910de5905f40fd63f8ddbb6f7ac5aba) Thanks @tim-smart! - Improve unstable HttpApi runtime failures for missing server middleware and missing group implementations. - HttpApiBuilder.applyMiddleware now resolves middleware services via Context.getUnsafe, so missing middleware fails with a clear "Service not found: " error instead of an opaque is not a function TypeError. - HttpApiBuilder.layer now reports missing groups with actionable context (group identifier, service key, suggested HttpApiBuilder.group(...) call, and available group keys). - - Added regression tests in packages/platform-node/test/HttpApi.test.ts covering: + - Added regression tests in packages/platform/node/test/HttpApi.test.ts covering: - addHttpApi + API-level middleware applied across merged groups - missing middleware service diagnostics - missing addHttpApi group layer diagnostics diff --git a/repos/effect/packages/effect/HTTPAPI.md b/repos/effect/packages/effect/HTTPAPI.md index fa6bc465dc..6567439ec1 100644 --- a/repos/effect/packages/effect/HTTPAPI.md +++ b/repos/effect/packages/effect/HTTPAPI.md @@ -61,7 +61,7 @@ const Api = HttpApi.make("MyApi").add( ) // Implementation -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Greetings", // The name of the group to handle (handlers) => @@ -72,14 +72,14 @@ const GroupLive = HttpApiBuilder.group( ) // Server -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) // Launch -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` After running the code, open a browser and navigate to http://localhost:3000. The server will respond with: @@ -97,9 +97,9 @@ Adding a documentation layer gives you an interactive page where you (and your A To include Scalar in your server setup, provide the `HttpApiScalar.layer` when configuring the server. ```ts -const ApiLive = HttpApiBuilder.layer(Api).pipe( +const ApiLayer = HttpApiBuilder.layer(Api).pipe( // Provide the Scalar layer so clients can access auto-generated docs - Layer.provide(GroupLive), + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) @@ -115,8 +115,8 @@ This URL will display the Scalar documentation, allowing you to explore the API' To include Swagger in your server setup, provide the `HttpApiSwagger.layer` when configuring the server. ```ts -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), // Provide the Swagger layer so clients can access auto-generated docs Layer.provide(HttpApiSwagger.layer(Api)), // "/docs" is the default path. // or Layer.provide(HttpApiScalar.layer(Api)), @@ -167,19 +167,19 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Greetings", (handlers) => handlers.handle("hello", () => Effect.succeed("Hello, World!")) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // Create a program that derives and uses the client const program = Effect.gen(function*() { @@ -356,7 +356,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -366,20 +366,22 @@ const GroupLive = HttpApiBuilder.group( )) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` ## POST Use `HttpApiEndpoint.post` to create an endpoint that accepts data. The `payload` option describes the shape of the request body, and `success` describes what the endpoint returns. +When a group has more than one endpoint, use `.handleAll` to register all the handlers in a single call, keyed by endpoint name. Single endpoints can still be registered one at a time with `.handle`. + **Example** (Defining a POST Endpoint with Payload and Success Schemas) ```ts @@ -417,35 +419,36 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => - handlers - .handle("getUsers", () => + handlers.handleAll({ + getUsers: () => Effect.succeed( [{ id: 1, name: "User 1" }, { id: 2, name: "User 2" }] - )) - .handle("getUser", (ctx) => { + ), + getUser: (ctx) => { const id = ctx.params.id return Effect.succeed({ id, name: `User ${id}` }) - }) - .handle("createUser", (ctx) => { + }, + createUser: (ctx) => { // ┌─── User // ▼ const user = ctx.payload return Effect.succeed(user) - }) + } + }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` ## DELETE @@ -493,37 +496,38 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => - handlers - .handle("getUsers", () => + handlers.handleAll({ + getUsers: () => Effect.succeed( [{ id: 1, name: "User 1" }, { id: 2, name: "User 2" }] - )) - .handle("getUser", (ctx) => { + ), + getUser: (ctx) => { const id = ctx.params.id return Effect.succeed({ id, name: `User ${id}` }) - }) - .handle("createUser", (ctx) => { + }, + createUser: (ctx) => { const user = ctx.payload return Effect.succeed(user) - }) - .handle("deleteUser", (ctx) => { + }, + deleteUser: (ctx) => { const id = ctx.params.id return Effect.log(`Deleting user ${id}`) - }) + } + }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` ## PATCH @@ -582,41 +586,42 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => - handlers - .handle("getUsers", () => + handlers.handleAll({ + getUsers: () => Effect.succeed( [{ id: 1, name: "User 1" }, { id: 2, name: "User 2" }] - )) - .handle("getUser", (ctx) => { + ), + getUser: (ctx) => { const id = ctx.params.id return Effect.succeed({ id, name: `User ${id}` }) - }) - .handle("createUser", (ctx) => { + }, + createUser: (ctx) => { const user = ctx.payload return Effect.succeed(user) - }) - .handle("deleteUser", (ctx) => { + }, + deleteUser: (ctx) => { const id = ctx.params.id return Effect.log(`Deleting user ${id}`) - }) - .handle("updateUser", (ctx) => { + }, + updateUser: (ctx) => { const id = ctx.params.id return Effect.succeed({ id, name: `User ${id}` }) - }) + } + }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` ## Parameters @@ -656,31 +661,32 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => - handlers - .handle("getUsers", () => + handlers.handleAll({ + getUsers: () => Effect.succeed( [{ id: 1, name: "User 1" }, { id: 2, name: "User 2" }] - )) - .handle("getUser", (ctx) => { + ), + getUser: (ctx) => { // ┌─── number // ▼ const id = ctx.params.id return Effect.succeed({ id, name: `User ${id}` }) - }) + } + }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` ## Catch-All Endpoints @@ -741,44 +747,45 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => - handlers - .handle("getUsers", () => + handlers.handleAll({ + getUsers: () => Effect.succeed( [{ id: 1, name: "User 1" }, { id: 2, name: "User 2" }] - )) - .handle("getUser", (ctx) => { + ), + getUser: (ctx) => { const id = ctx.params.id return Effect.succeed({ id, name: `User ${id}` }) - }) - .handle("createUser", (ctx) => { + }, + createUser: (ctx) => { const user = ctx.payload return Effect.succeed(user) - }) - .handle("deleteUser", (ctx) => { + }, + deleteUser: (ctx) => { const id = ctx.params.id return Effect.log(`Deleting user ${id}`) - }) - .handle("updateUser", (ctx) => { + }, + updateUser: (ctx) => { const id = ctx.params.id return Effect.succeed({ id, name: `User ${id}` }) - }) - .handle("catchAll", () => { + }, + catchAll: () => { return Effect.succeed("Not found") - }) + } + }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` > [!IMPORTANT] @@ -819,23 +826,24 @@ const Api = HttpApi.make("MyApi") // Prefix for the entire API .prefix("/apiPrefix") -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => - handlers - .handle("endpointA", () => Effect.succeed("Endpoint A")) - .handle("endpointB", () => Effect.succeed("Endpoint B")) + handlers.handleAll({ + endpointA: () => Effect.succeed("Endpoint A"), + endpointB: () => Effect.succeed("Endpoint B") + }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` You can test this endpoint using a GET request. For example: @@ -884,7 +892,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -898,14 +906,14 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` #### Defining an Array of Values for a Query Parameter @@ -939,7 +947,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -952,14 +960,14 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` You can test this endpoint by passing an array of values in the query string. For example: @@ -1013,7 +1021,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -1023,14 +1031,14 @@ const GroupLive = HttpApiBuilder.group( )) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` You can test the endpoint by sending the headers: @@ -1078,7 +1086,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -1092,14 +1100,14 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` You can test this endpoint by sending a multipart request with a file upload. For example: @@ -1150,7 +1158,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -1161,14 +1169,14 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` You can test this endpoint using a URL-encoded request body. For example: @@ -1203,7 +1211,7 @@ const Api = HttpApi.make("MyApi").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Greetings", (handlers) => @@ -1220,13 +1228,13 @@ const GroupLive = HttpApiBuilder.group( ) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` ## Validating Request Cookies @@ -1278,7 +1286,7 @@ const Api = HttpApi.make("api").add( .middleware(Auth) ) -const AuthLive = Layer.succeed( +const AuthLayer = Layer.succeed( Auth, { session: (effect, opts) => @@ -1296,7 +1304,7 @@ const AuthLive = Layer.succeed( } ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => @@ -1305,16 +1313,16 @@ const GroupLive = HttpApiBuilder.group( const user = yield* CurrentUser return { id: user.id } })) -).pipe(Layer.provide(AuthLive)) +).pipe(Layer.provide(AuthLayer)) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // Valid session: // curl "http://localhost:3000/me" --cookie "session=valid-session" @@ -1344,7 +1352,7 @@ const Api = HttpApi.make("api").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => @@ -1354,13 +1362,13 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // curl "http://localhost:3000/me" --cookie "lang=it" // "Language: it" @@ -1368,13 +1376,15 @@ Layer.launch(ApiLive).pipe(NodeRuntime.runMain) ## Streaming Requests -To receive large or continuous data from the client, define the payload as a `Uint8Array` and pipe it through `HttpApiSchema.asUint8Array()`. The handler receives the raw bytes, which you can decode as needed. +To receive large or continuous data from the client, register the endpoint with `.handleRaw`, which opts out of automatic payload decoding and exposes the raw `HttpServerRequest`. The request body is then available as a `Stream` of `Uint8Array` chunks through `request.stream`, so the handler can consume it incrementally instead of buffering it in memory. + +The payload schema still describes the endpoint in the generated documentation, but with `.handleRaw` the handler decides how the body is consumed. Note that the declared content type is no longer enforced at runtime: requests with a different `Content-Type` header are not rejected with `415`, so any such validation is up to the handler. **Example** (Handling Streaming Requests) ```ts import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer, Schema, Stream } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { createServer } from "node:http" @@ -1391,33 +1401,37 @@ const Api = HttpApi.make("myApi").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => - handlers.handle( - "acceptStream", - (ctx) => { - // Decode the incoming binary data into a string - return Effect.succeed(new TextDecoder().decode(ctx.payload)) - } - ) + // Use `handleRaw` to opt out of payload decoding and access the raw request + handlers.handleRaw("acceptStream", (ctx) => + // Consume the request body as a stream of Uint8Array chunks + ctx.request.stream.pipe( + // Fold over the chunks as they arrive, without buffering the body + Stream.runFold(() => 0, (total, chunk) => total + chunk.length), + Effect.map((total) => `received ${total} bytes`), + // `request.stream` fails with `HttpServerError`, which the endpoint + // does not declare, so treat it as a defect + Effect.orDie + )) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` You can test the streaming request using `curl` or any tool that supports sending binary data. For example: ```sh -echo "abc" | curl -X POST 'http://localhost:3000/stream' --data-binary @- -H "Content-Type: application/octet-stream" -# Output: abc +printf 'abc' | curl -X POST 'http://localhost:3000/stream' --data-binary @- -H "Content-Type: application/octet-stream" +# Output: "received 3 bytes" ``` # Response @@ -1458,7 +1472,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -1470,14 +1484,14 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` ## Changing the Response Encoding @@ -1516,7 +1530,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -1526,14 +1540,14 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` You can test this endpoint using a GET request. For example: @@ -1588,7 +1602,7 @@ const Api = HttpApi.make("MyApi").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -1600,13 +1614,13 @@ const GroupLive = HttpApiBuilder.group( }))) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // curl -v "http://localhost:3000/users" 2>&1 | grep -i "x-total-count" // < x-total-count: 1 @@ -1681,7 +1695,7 @@ const Api = HttpApi.make("MyApi").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -1695,13 +1709,13 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // curl -v "http://localhost:3000/user/1" 2>&1 | grep -i "x-user-id" // < x-user-id: 1 @@ -1732,7 +1746,7 @@ const Api = HttpApi.make("api").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => @@ -1745,13 +1759,13 @@ const GroupLive = HttpApiBuilder.group( })) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // curl -v "http://localhost:3000/hello" 2>&1 | grep -i "x-custom" // < x-custom: hello @@ -1778,7 +1792,7 @@ const Api = HttpApi.make("api").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => @@ -1795,13 +1809,13 @@ const GroupLive = HttpApiBuilder.group( })) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // curl -v "http://localhost:3000/hello" 2>&1 | grep -i "set-cookie" // < set-cookie: my-cookie=my-value; Path=/; HttpOnly; Secure @@ -1829,51 +1843,59 @@ const Api = HttpApi.make("MyApi").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => - handlers - .handle("newPage", () => Effect.succeed("You are on /new")) - .handle("oldPage", () => + handlers.handleAll({ + newPage: () => Effect.succeed("You are on /new"), + oldPage: () => Effect.succeed( HttpServerResponse.redirect("/new", { status: 302 }) - )) + ) + }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // curl "http://localhost:3000/old" -L ``` ## Streaming Responses -To stream data to the client over time, return an `HttpServerResponse.stream` from the handler. The stream emits chunks at whatever pace you choose. +To stream data to the client over time, declare the success schema with one of the streaming constructors: + +- `HttpApiSchema.StreamUint8Array()` streams raw bytes (default content type: `application/octet-stream`) +- `HttpApiSchema.StreamSse(options)` streams typed Server-Sent Events (default content type: `text/event-stream`) + +With a streaming success schema, the handler returns a `Stream` directly, and the derived client resolves to a typed `Stream` on the consuming side. The stream declaration is also rendered in the OpenAPI documentation. + +### Streaming Raw Bytes -**Example** (Implementing a Streaming Endpoint) +`HttpApiSchema.StreamUint8Array` declares a binary streaming response. The handler returns a `Stream` that emits chunks at whatever pace you choose. + +**Example** (Implementing a Binary Streaming Endpoint) ```ts import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { Effect, Layer, Schedule, Schema, Stream } from "effect" -import { HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { Effect, Layer, Schedule, Stream } from "effect" +import { HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { createServer } from "node:http" const Api = HttpApi.make("myApi").add( HttpApiGroup.make("group").add( HttpApiEndpoint.get("getStream", "/stream", { - success: Schema.String.pipe( - HttpApiSchema.asText({ - contentType: "application/octet-stream" - }) - ) + // Declare a streaming success response + // (default content type: application/octet-stream) + success: HttpApiSchema.StreamUint8Array() }) ) ) @@ -1884,23 +1906,24 @@ const stream = Stream.make("a", "b", "c").pipe( Stream.map((s) => new TextEncoder().encode(s)) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => handlers.handle( "getStream", - () => Effect.succeed(HttpServerResponse.stream(stream)) + // Return the stream directly from the handler + () => Effect.succeed(stream) ) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` You can test the streaming response using `curl` or any similar HTTP client that supports streaming: @@ -1911,6 +1934,103 @@ curl 'http://localhost:3000/stream' --no-buffer The response will stream data (`a`, `b`, `c`) with a 500ms interval between each item. +### Streaming Server-Sent Events + +`HttpApiSchema.StreamSse` declares a Server-Sent Events response. Pass a `data` schema to stream plain values: each stream element is JSON-encoded into the `data` field of an SSE event on the way out, and the derived client decodes it back to the value. + +**Example** (Implementing an SSE Endpoint) + +```ts +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { Effect, Layer, Schedule, Schema, Stream } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { createServer } from "node:http" + +const Message = Schema.Struct({ + text: Schema.String +}) + +const Api = HttpApi.make("myApi").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("getEvents", "/events", { + // Each stream element is JSON-encoded into the `data` field of an SSE + // event (default content type: text/event-stream) + success: HttpApiSchema.StreamSse({ data: Message }) + }) + ) +) + +const GroupLayer = HttpApiBuilder.group( + Api, + "group", + (handlers) => + handlers.handle("getEvents", () => + Effect.succeed( + Stream.make({ text: "one" }, { text: "two" }, { text: "three" }).pipe( + Stream.schedule(Schedule.spaced("500 millis")) + ) + )) +) + +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), + HttpRouter.serve, + Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) +) + +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) + +// curl 'http://localhost:3000/events' --no-buffer +// data: {"text":"one"} +// +// data: {"text":"two"} +// +// data: {"text":"three"} +``` + +To control the full event shape instead, pass an `events` schema whose Encoded side matches the SSE wire format: a required `data` string plus optional `id` and `event` fields. The handler then emits whole events. + +### Consuming Streams with the Derived Client + +For both constructors, calling the endpoint on the derived client resolves to a typed `Stream`: + +```ts +const events = yield * client.group.getEvents() +// events: Stream<{ readonly text: string }, ...> + +yield * Stream.runForEach(events, (message) => Console.log(message.text)) +``` + +### Typed Stream Failures + +`HttpApiSchema.StreamSse` accepts an `error` schema describing typed stream failures. Since the HTTP status is already sent when the stream fails, the failure cannot travel as a normal error response. Instead, the server encodes the full `Cause` into a reserved `effect/httpapi/stream/failure` event, and the derived client decodes that event and fails the stream with the original cause. + +**Example** (Declaring a Typed Stream Failure) + +```ts +class StreamError extends Schema.TaggedError()("StreamError", { + reason: Schema.String +}) {} + +const Api = HttpApi.make("myApi").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("getEvents", "/events", { + // The handler may return a Stream that fails with StreamError, + // and the client's stream fails with StreamError too + success: HttpApiSchema.StreamSse({ data: Message, error: StreamError }) + }) + ) +) +``` + +Things to know: + +- Set a custom status or content type with `HttpApiSchema.status(code)` and the `contentType` option, for example `HttpApiSchema.status(206)(HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }))`. +- Wrap a stream schema with `HttpApiSchema.WithHeaders` to declare typed response headers (see [Setting Response Headers](#setting-response-headers)). The handler returns `HttpApiSchema.withHeaders({ body, headers })` with the stream as `body`, and the client resolves to the same shape. +- An endpoint may declare at most one streaming success schema, `HEAD` endpoints cannot declare one, and streaming schemas are not supported in error responses. +- The `effect/httpapi/stream/failure` event name is reserved: `events` schemas may not declare it, and the client treats such an event as a stream failure only when its `data` decodes to a `Cause`. + # Error Handling ## Adding Custom Error Responses @@ -1969,35 +2089,36 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => - handlers - .handle("getUser", (ctx) => { + handlers.handleAll({ + getUser: (ctx) => { const id = ctx.params.id if (id === 1) { return Effect.fail(UserNotFound.make({ message: "User not found" })) } return Effect.succeed({ id, name: `User ${id}` }) - }) - .handle("deleteUser", (ctx) => { + }, + deleteUser: (ctx) => { const id = ctx.params.id if (id === 1) { return Effect.fail(UserNotFound.make({ message: "User not found" })) } return Effect.succeed(void 0) - }) + } + }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` You can test these endpoints. For example: @@ -2053,7 +2174,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -2067,14 +2188,14 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` | Name | Status | Description | @@ -2137,7 +2258,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -2151,14 +2272,14 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` ## Customizing Schema Error Responses @@ -2203,7 +2324,7 @@ class SchemaErrorHandler extends HttpApiMiddleware.Service() ) {} // Implement the middleware layer -const SchemaErrorHandlerLive = HttpApiMiddleware.layerSchemaErrorTransform( +const SchemaErrorHandlerLayer = HttpApiMiddleware.layerSchemaErrorTransform( SchemaErrorHandler, (schemaError) => Effect.fail( @@ -2231,21 +2352,21 @@ const Api = HttpApi.make("MyApi").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => handlers.handle("getUser", (ctx) => Effect.succeed({ id: ctx.query.id, name: `User ${ctx.query.id}` })) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), - Layer.provide(SchemaErrorHandlerLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), + Layer.provide(SchemaErrorHandlerLayer), Layer.provide(HttpApiScalar.layer(Api)), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // Test: // curl "http://localhost:3000/user?id=1" # 200 OK @@ -2307,7 +2428,7 @@ const Api = HttpApi.make("api").add( // Or apply the middleware to the entire group .middleware(Logger) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => @@ -2317,7 +2438,7 @@ const GroupLive = HttpApiBuilder.group( }) ) -const LoggerLive = Layer.effect( +const LoggerLayer = Layer.effect( Logger, Effect.gen(function*() { yield* Effect.log("creating Logger middleware") @@ -2331,15 +2452,15 @@ const LoggerLive = Layer.effect( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), - Layer.provide(LoggerLive), + Layer.provide(LoggerLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // Test this with this curl command: // curl "http://localhost:3000/user/1" @@ -2381,7 +2502,7 @@ const Api = HttpApi.make("api").add( ) ) -const LoadAuthLive = Layer.effect( +const LoadAuthLayer = Layer.effect( LoadAuth, Effect.succeed((effect) => Effect.provideService(effect, AuthInfo, { @@ -2390,7 +2511,7 @@ const LoadAuthLive = Layer.effect( ) ) -const RequireAuthLive = Layer.effect( +const RequireAuthLayer = Layer.effect( RequireAuth, Effect.succeed( Effect.fnUntraced(function*(effect) { @@ -2506,7 +2627,7 @@ class Authorization extends HttpApiMiddleware.Service +const UsersApiLayer = HttpApiBuilder.group(Api, "Users", (handlers) => handlers.handle("login", () => // Set the security cookie with a redacted value HttpApiBuilder.securitySetCookie(security, Redacted.make("keep me secret")))) @@ -2638,7 +2759,7 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => @@ -2653,8 +2774,8 @@ const GroupLive = HttpApiBuilder.group( }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), Layer.provide( Layer.succeed(UsersRepository, { @@ -2665,7 +2786,7 @@ const ApiLive = HttpApiBuilder.layer(Api).pipe( Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` # OpenAPI Documentation @@ -2724,41 +2845,42 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Users", (handlers) => - handlers - .handle("getUsers", () => + handlers.handleAll({ + getUsers: () => Effect.succeed( [{ id: 1, name: "User 1" }, { id: 2, name: "User 2" }] - )) - .handle("getUser", (ctx) => { + ), + getUser: (ctx) => { const id = ctx.params.id return Effect.succeed({ id, name: `User ${id}` }) - }) - .handle("createUser", (ctx) => { + }, + createUser: (ctx) => { const user = ctx.payload return Effect.succeed(user) - }) - .handle("deleteUser", (ctx) => { + }, + deleteUser: (ctx) => { const id = ctx.params.id return Effect.log(`Deleting user ${id}`) - }) - .handle("updateUser", (ctx) => { + }, + updateUser: (ctx) => { const id = ctx.params.id return Effect.succeed({ id, name: `User ${id}` }) - }) + } + }) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), // "/docs" is the default path. HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) ``` After running the server, open your browser and navigate to http://localhost:3000/docs. @@ -3284,19 +3406,19 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Greetings", (handlers) => handlers.handle("hello", () => Effect.succeed("Hello, World!")) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) // Create a program that derives and uses the client const program = Effect.gen(function*() { @@ -3343,19 +3465,19 @@ const Api = HttpApi.make("MyApi") ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "Greetings", (handlers) => handlers.handle("hello", () => Effect.succeed("Hello, World!")) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), HttpRouter.serve, Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) ) -Layer.launch(ApiLive).pipe(NodeRuntime.runMain) +Layer.launch(ApiLayer).pipe(NodeRuntime.runMain) const program = Effect.gen(function*() { const client = yield* HttpApiClient.make(Api, { @@ -3389,21 +3511,21 @@ const Api = HttpApi.make("myApi").add( ) ) -const GroupLive = HttpApiBuilder.group( +const GroupLayer = HttpApiBuilder.group( Api, "group", (handlers) => handlers.handle("get", () => Effect.succeed("Hello, world!")) ) -const ApiLive = HttpApiBuilder.layer(Api).pipe( - Layer.provide(GroupLive), +const ApiLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide(GroupLayer), Layer.provide(HttpApiScalar.layer(Api)), Layer.provide(HttpServer.layerServices) ) // Convert the API to a web handler const { dispose, handler } = HttpRouter.toWebHandler( - Layer.mergeAll(ApiLive) + Layer.mergeAll(ApiLayer) ) // Serving the handler using a custom HTTP server diff --git a/repos/effect/packages/effect/MCP.md b/repos/effect/packages/effect/MCP.md index 3668e6a9e9..66258490c0 100644 --- a/repos/effect/packages/effect/MCP.md +++ b/repos/effect/packages/effect/MCP.md @@ -80,9 +80,13 @@ The server exposes three main parts: The part layers are merged into one layer that has a MCP server implementation as dependency. `McpServer.layerStdio` is used to create a standard I/O–based MCP server identified by its name and version. Its ordered, non-empty `protocols` declaration names implemented protocol adapters rather -than arbitrary version strings. This release supports `McpProtocol.v2025_06_18`. Because of the -layer architecture the server implementation can be easily exchanged with an HTTP-based implementation -with `McpServer.layerHttp`. Finally, a logging layer is added with +than arbitrary version strings. This release supports `McpProtocol.v2024_11_05`, +`McpProtocol.v2025_03_26`, and `McpProtocol.v2025_06_18`. The `v2024_11_05` adapter implements that +revision's RPC schemas and stdio framing, including its batch policy. It does not implement the +historical two-endpoint HTTP+SSE transport. `McpServer.layerHttp` instead offers the 2024 RPC schema +through the same single-endpoint HTTP compatibility transport used by the 2025 adapters. Because of +the layer architecture the server implementation can be easily exchanged with this HTTP-based +implementation. Finally, a logging layer is added with `Logger.layer([Logger.consolePretty({ stderr: true })])`, ensuring logs are written to `stderr`. This is essential when using stdio, as any output to `stdout` would interfere with the protocol communication. diff --git a/repos/effect/packages/effect/README.md b/repos/effect/packages/effect/README.md index f98948465f..d702d36b63 100644 --- a/repos/effect/packages/effect/README.md +++ b/repos/effect/packages/effect/README.md @@ -1,43 +1,37 @@ -# `effect` Core Package +# effect -The `effect` package is the heart of the Effect framework, providing robust primitives for managing side effects, ensuring type safety, and supporting concurrency in your TypeScript applications. +Effect is a library for building robust, maintainable, type-safe, and production grade applications in TypeScript. -## Requirements +The `effect` package is the core of the framework. It provides primitives for managing side effects, errors, concurrency, resources, and structured data, alongside a rich standard library. -- **TypeScript 5.9 or Newer:** - Ensure you are using a compatible TypeScript version. +## Installation -- **Strict Type-Checking:** - The `strict` flag must be enabled in your `tsconfig.json`. For example: +```sh +npm install effect@rc +``` + +## Requirements + +- **TypeScript 5.9 or newer.** TypeScript 7 is recommended for the best performance and compatibility with [Effect's TypeScript tooling](https://github.com/Effect-TS/tsgo#installation). +- **Node.js 18 or newer** when running Effect on Node.js. +- **Strict type-checking:** the `strict` flag must be enabled in your `tsconfig.json`: ```json { "compilerOptions": { "strict": true - // ...other options } } ``` -## Installation - -Install the core package using your preferred package manager. For example, with npm: - -```bash -npm install effect -``` - ## Documentation -- **Website:** - For detailed information and usage examples, visit the [Effect website](https://www.effect.website/). - -- **API Reference:** - For a complete API reference of the core package `effect`, see the [Effect API documentation](https://effect-ts.github.io/effect/). +- [Effect website](https://effect.website) +- [API reference](https://effect.website/docs/v4/api/effect) -## Overview of Effect Modules +## Overview -The `effect` package provides a collection of modules designed for functional programming in TypeScript. Below is a brief overview of the core modules: +The `effect` package is a collection of modules. Some of the core ones: | Module | Description | | -------- | -------------------------------------------------------------------------------------------------------------------------- | @@ -49,3 +43,5 @@ The `effect` package provides a collection of modules designed for functional pr | Schedule | A module for defining retry and repeat policies with composable schedules. | | Scope | Manages the lifecycle of resources, ensuring proper acquisition and release. | | Schema | A powerful library for defining, validating, and transforming structured data with type-safe encoding and decoding. | + +In v4, functionality that previously lived in separate packages ships inside `effect` under the `effect/unstable/*` namespaces, including `http`, `httpapi`, `rpc`, `cluster`, `workflow`, `cli`, `ai`, `sql`, and `reactivity`. diff --git a/repos/effect/packages/effect/SCHEMA.md b/repos/effect/packages/effect/SCHEMA.md index 00095dc0d3..f52dbed391 100644 --- a/repos/effect/packages/effect/SCHEMA.md +++ b/repos/effect/packages/effect/SCHEMA.md @@ -2238,7 +2238,7 @@ console.log(String(Schema.decodeUnknownExit(URLSchema)(null))) ### Adding JSON support with `toCodecJson` -`Schema.toCodecJson` derives a codec that can convert your type **to and from JSON**. By default, declared schemas have no JSON representation — encoding produces `null`: +`Schema.toCodecJson` derives a codec that can convert your type **to and from JSON**. By default, declared schemas use `Schema.Json` as their encoded representation. This keeps codec derivation defined, but it does not invent a serialization for your custom type. For example, a `URL` instance is not itself a JSON value, so encoding fails: ```ts import { Schema } from "effect" @@ -2251,10 +2251,9 @@ const URLSchema = Schema.declare( // Derive a JSON codec from the schema const codec = Schema.toCodecJson(URLSchema) -// Encoding a URL produces null because Schema doesn't know -// how to serialize a URL to JSON yet +// Encoding fails because Schema doesn't know how to serialize a URL to JSON yet console.log(String(Schema.encodeUnknownExit(codec)(new URL("https://example.com")))) -// Success(null) +// Failure(Cause([Fail(SchemaError(Expected JSON value))])) ``` To fix this, provide a `toCodecJson` annotation. This annotation is a function that returns an `AST.Link`, a bridge that describes how to convert between your custom type and a JSON-friendly representation. @@ -2264,6 +2263,8 @@ You build a `Link` using `Schema.link()`, which takes two arguments: 1. **A JSON-side schema** — the shape of the JSON value (e.g. `Schema.String` for a URL string) 2. **A transformation** — how to convert back and forth between your type and the JSON value +The annotation only defines the link; the consuming parser chooses its execution and failure handling. Transformations may be asynchronous, may fail, and may use optional services, but cannot require services not declared by the input schema. + **Example** (Making `URL` JSON-serializable) ```ts @@ -4789,6 +4790,8 @@ A canonical codec describes how values that match a schema should be converted t 2. **AST transformation**: the codec builder walks the schema AST and produces a new schema that represents the serialized form (this traversal is handled by Effect). 3. **Recursive composition**: codecs apply through nested structures (objects, arrays, unions, and so on) without you having to wire everything manually. +Canonical codec derivation does not run transformations; the consuming parser chooses execution and failure handling. Annotation links may be asynchronous, may fail, and may use optional services, but cannot add required services absent from the derived codec type. `toCodecIso` declares none, while `toCodecJson` and `toCodecStringTree` preserve the input schema's requirements. + The next example shows why a custom class needs a codec when working with JSON. **Example** (A custom class that does not round-trip through JSON) @@ -4810,30 +4813,23 @@ class Point { const PointSchema = Schema.instanceOf(Point) ``` -Even if encoding produces something JSON-looking, decoding cannot rebuild a `Point` instance (including its prototype and methods) from plain JSON data. +Without an annotation, `Schema.toCodecJson` uses `Schema.Json` as the encoded representation. A `Point` instance is not a JSON value, and a plain object parsed from JSON is not a `Point` instance (including its prototype and methods), so neither direction can bridge the two representations. ```ts -// Encode a Point instance using the schema, then stringify it. -// This produces a plain JSON object, not a class instance. -const json = JSON.stringify(Schema.encodeUnknownSync(PointSchema)(new Point(1, 2))) +const codecJson = Schema.toCodecJson(PointSchema) -console.log(json) -// '{"x":1,"y":2}' +console.log(String(Schema.encodeUnknownExit(codecJson)(new Point(1, 2)))) +// Failure(Cause([Fail(SchemaError(Expected JSON value))])) -// Decode attempts to create a Point instance from parsed JSON. -// This fails because JSON.parse returns a plain object, not `new Point(...)`. -try { - Schema.decodeUnknownSync(PointSchema)(JSON.parse(json)) -} catch (error) { - console.error(String(error)) -} +console.log(String(Schema.decodeUnknownExit(codecJson)({ x: 1, y: 2 }))) +// Failure(Cause([Fail(SchemaError(Expected ))])) ``` -The same issue shows up when generating a JSON Schema document: since the schema represents a class instance and there is no JSON representation for it, the generator falls back to a placeholder. +The same issue shows up when generating a JSON Schema document. Since the opaque declaration has no structural JSON representation, the generator emits an unconstrained JSON Schema: ```ts console.log(Schema.toJsonSchemaDocument(PointSchema)) -// { dialect: 'draft-2020-12', schema: { type: 'null' }, definitions: {} } +// { dialect: 'draft-2020-12', schema: {}, definitions: {} } ``` #### Configuring the Codec @@ -4918,6 +4914,8 @@ When you call `Schema.toCodecJson(schema)`, the library: 3. **Applies transformations**: it replaces types that are not JSON-friendly with types that are. 4. **Composes recursively**: it builds codecs for nested schemas by combining the codecs of their parts. +When this traversal adds a transformation because a node has no direct JSON representation, its checks and annotations remain on the source node; they are not copied to the artificial JSON target. The codec still applies source checks after transforming values. + #### Custom Encodings `Schema.toCodecJson` respects **explicit encodings** you add to a schema. If you choose a custom representation, that choice takes priority over the default. @@ -5158,6 +5156,11 @@ Output: To generate a draft-07 JSON Schema, use `JsonSchema.toDocumentDraft07` to convert the draft-2020-12 JSON Schema. +The dialect converters preserve unknown and custom keywords as opaque values. When a known keyword cannot be +represented by the target dialect, or an opaque keyword would become active and change meaning in that dialect, +conversion throws instead of silently changing its meaning. For example, Draft-07 cannot represent dynamic +references, unevaluated constraints, or non-default `minContains` / `maxContains` cardinality. + **Example** (Tuple to draft-7 JSON Schema) ```ts @@ -5202,6 +5205,8 @@ Use `.annotate(...)` to attach standard JSON Schema annotations: - `readOnly` - `writeOnly` +`Schema.toJsonSchemaDocument` compiles the canonical JSON target. Checks and annotations left on the source side of an artificial transformation therefore do not appear in the generated document. Put metadata on an explicit JSON target when it must appear there. + **Example** (Adding basic annotations) ```ts @@ -5369,7 +5374,7 @@ console.log(JSON.stringify(document, null, 2)) #### Defining a JSON-safe representation for custom types -This example shows how `Schema.toCodecJson` and `Schema.toJsonSchema` can describe the same JSON shape for a custom type. +This example shows how `Schema.toCodecJson` and `Schema.toJsonSchemaDocument` can describe the same JSON shape for a custom type. `Headers` is not JSON-friendly by default. `JSON.stringify(new Headers({ a: "b" }))` produces `{}` because the header data is not stored in enumerable properties. By adding a `toCodecJson` annotation, you define a JSON-safe representation and use it for both serialization and JSON Schema generation. @@ -6031,7 +6036,7 @@ console.log(_s.replace("b", new B({ a: new A({ s: "a" }) }))) // B { a: A { s: 'b' } } ``` -Reading through the generated `Iso` encodes the schema value, while replacing through it decodes the new focus. Either direction can throw an `Error` with the generic message `"Schema validation failed"` and a `SchemaIssue.Issue` in its `cause`. Use `SchemaIssue.makeFormatterDefault()` to format that cause when human-readable details are needed. +Reading through the generated `Iso` encodes the schema value, while replacing through it decodes the new focus. `Schema.toIso` consumes `Schema.toCodecIso` synchronously, so failing, asynchronous, or service-dependent transformations can throw. Schema failures use `"Schema validation failed"` with a `SchemaIssue.Issue` in `cause`; format it with `SchemaIssue.makeFormatterDefault()`, or use an effectful parser with `Schema.toCodecIso` for asynchronous execution or explicit failure handling. ### Using the Differ Module for Type-Safe JSON Patches @@ -6112,7 +6117,7 @@ The idea is simple: if you have a `Schema` for a type `T`, you can serialize any This approach keeps patches independent from TypeScript types and uses the schema as the guardrail when turning JSON back into `T`. -Schema conversion failures from `diff` or `patch` throw an `Error` with the generic message `"Schema validation failed"` and a `SchemaIssue.Issue` in its `cause`. Format that cause explicitly with `SchemaIssue.makeFormatterDefault()`. Errors raised while applying an invalid JSON Patch operation are separate `JsonPatch` errors rather than schema validation failures. +`Schema.toDifferJsonPatch` consumes `Schema.toCodecJson` synchronously, so failing, asynchronous, or service-dependent transformations can throw. Schema failures use `"Schema validation failed"` with a `SchemaIssue.Issue` in `cause`, which `SchemaIssue.makeFormatterDefault()` can format; invalid patch operations instead produce `JsonPatch` errors. # Schema Representation @@ -6261,19 +6266,63 @@ const multiDocument = SchemaRepresentation.toRepresentations([ ]) ``` -Repeated structural nodes, identifiers, and recursive schemas are placed in `references`. Repeated `Suspend` and -`Declaration` nodes are reference candidates as well. For unions, enums, template literals, and string literals, the -converter uses an inexpensive size estimate and creates an anonymous reference only when it expects the reference to be -smaller than repeating the body. `toMultiDocument(document)` wraps a single document when a compiler requires multiple -roots. +By default, only candidates with a resolved identifier are placed in `references`. Anonymous non-recursive nodes remain +inline even when the same AST occurs more than once. Recursive schemas always require a reference; when no identifier is +available, the converter assigns a synthetic name such as `Objects_` or `Suspend_`. -An explicit `identifier` requests a reference name within a conversion. Reusing the same schema shares its reference. +The default policy uses an explicit `identifier` as the reference name. Reusing the same schema shares its reference. Context-only copies created through `SchemaAST.replaceContext` retain the original AST as their reference owner, including -across several successive context changes. Context still belongs to each occurrence and does not, by itself, make a node a -reference candidate. Independently constructed ASTs are not canonicalized merely because their other fields contain the -same references. When distinct schemas request the same name, the first schema keeps it and later schemas receive numeric -suffixes in encounter order, such as `Value_1` and `Value_2`. Internal `~identifier` annotations are fallback allocation -hints; their generated names use the `Encoded` suffix and follow the same collision rules. +across several successive context changes. Context still belongs to each occurrence and does not, by itself, create a new +candidate. Independently constructed ASTs are not canonicalized merely because they are structurally equal. When distinct +schemas request the same name, the first schema keeps it and later schemas receive numeric suffixes in encounter order, +such as `Value_1` and `Value_2`. Internal `~identifier` annotations are fallback allocation hints; their generated names +use the `Encoded` suffix and follow the same collision rules. + +`toMultiDocument(document)` wraps a single document when a compiler requires multiple roots. + +### Reference policies + +`Schema.toRepresentation`, `SchemaRepresentation.toRepresentation`, and `SchemaRepresentation.toRepresentations` accept +an optional `referencePolicy`. The policy runs once for every candidate, after occurrences across all roots have been +counted: + +```ts +interface ReferencePolicyInput { + readonly ast: SchemaAST.AST + readonly occurrences: number + readonly identifier: string | undefined +} +``` + +Return a reference name to move the candidate into `references`, or return `undefined` to leave it inline. This example +extracts anonymous candidates only when the same candidate occurs more than once, while still respecting identifiers: + +```ts +import { Schema, SchemaRepresentation } from "effect" + +const Item = Schema.Struct({ name: Schema.String }) + +const document = SchemaRepresentation.toRepresentations([Item.ast, Item.ast], { + referencePolicy: ({ ast, identifier, occurrences }) => identifier ?? (occurrences > 1 ? `${ast._tag}_` : undefined) +}) + +console.log(document.representations) +// [ +// { _tag: "Reference", $ref: "Objects_" }, +// { _tag: "Reference", $ref: "Objects_" } +// ] +``` + +Occurrences are counted by candidate identity, not structural equality. Two independently constructed but structurally +equal ASTs are separate candidates and each reports one occurrence. Recursive candidates cannot remain inline: if a policy +returns `undefined` for one, the converter assigns a synthetic reference name. If different candidates request the same +name, later candidates receive numeric suffixes. + +`Schema.toJsonSchemaDocument(schema, options)` forwards the policy after deriving the schema's canonical JSON codec, so +the policy receives canonical JSON-encoded ASTs. `OpenApi.fromApi(api, options)` applies the policy at the same canonical +JSON boundary when deciding which schemas become OpenAPI components. A `referencePolicy` passed to +`SchemaRepresentation.toJsonSchemaDocument` or `toJsonSchemaMultiDocument` cannot change an existing document's references; +pass it while creating the `Document` or `MultiDocument` instead. ## JSON persistence @@ -6373,9 +6422,10 @@ The same reviver can then be included in the `revivers` array passed to `fromRep ### Exporting JSON Schema For a runtime schema, prefer `Schema.toJsonSchemaDocument(schema)`. It first derives the schema's canonical JSON codec, -then compiles its encoded representation to JSON Schema Draft 2020-12. During this high-level conversion, declarations -are not extracted into anonymous references: their JSON Schema body is unconstrained, and leaving it inline preserves -empty-schema simplifications. Explicit and recursive references are unaffected. +then compiles its encoded representation to JSON Schema Draft 2020-12. The default reference policy extracts candidates +with resolved identifiers and leaves anonymous non-recursive candidates inline, including repeated declarations. Recursive +candidates still receive references, using a synthetic name when necessary. Pass `referencePolicy` in the options to use a +different allocation rule. At the lower level, `SchemaRepresentation.toJsonSchemaDocument(document)` compiles a live `Document`, and `toJsonSchemaMultiDocument` compiles a live `MultiDocument`. Check-level `toJsonSchema` callbacks contribute JSON Schema @@ -6399,15 +6449,27 @@ schema with revivers first. `SchemaRepresentation.fromJsonSchemaDocument` imports a JSON Schema Draft 2020-12 document as a runtime `Schema.Top`. It does not return a representation document. +Only direct local references to top-level definitions in the form `#/$defs/` are supported. Root +references, external references, and pointers below a definition throw an `Unsupported reference` error. A direct +reference to a missing definition throws an `Invalid reference` error. + `fromJsonSchemaMultiDocument` returns the ordered root schemas. It translates only definitions reachable from those roots. To pass the result to a representation compiler, call `toRepresentations` with the returned schemas' ASTs. -Import is best-effort: JSON Schema constructs are translated to Effect schemas where possible, but the result is not a -lossless reconstruction of an original Effect schema. The optional `onEnter` callback can normalize each JSON Schema node +Import translates a Draft 2020-12 subset. `$dynamicRef`, `contains`, `dependentRequired`, `dependentSchemas`, `not`, +active `if` / `then` / `else`, `unevaluatedItems`, and `unevaluatedProperties` throw an +`Unsupported JSON Schema keyword` error. Inactive conditional keywords and `minContains` / `maxContains` without +`contains` have no validation effect and are ignored. Unknown extension keywords are ignored and their semantics are not +enforced. Objects and arrays used as `const` values or `enum` members throw an +`Unsupported structured JSON Schema value` error. The optional `onEnter` callback can normalize each JSON Schema node before it is translated. -Regular expression constraints reached during best-effort translation are rejected by default because imported patterns -use the runtime's native regular expression engine and may block validation for an unbounded amount of time. Set +Intersections of overlapping unions are limited to disjoint root-type partitions and finite primitive `anyOf` literal +sets. Other union intersections, including cases that would duplicate a nested choice, throw an +`Unsupported intersection of overlapping unions` error. + +Regular expression constraints reached during translation are rejected by default because imported patterns use the +runtime's native regular expression engine and may block validation for an unbounded amount of time. Set `patterns: "apply"` only for trusted documents. Set `patterns: "ignore"` to skip reached pattern constraints explicitly; the resulting schema accepts values that the source document may reject. The policy includes `pattern`, the keys of `patternProperties`, and patterns nested in `propertyNames`. Ignoring `patternProperties` also skips its value constraints diff --git a/repos/effect/packages/effect/benchmark/Pool.ts b/repos/effect/packages/effect/benchmark/Pool.ts new file mode 100644 index 0000000000..094b50eb47 --- /dev/null +++ b/repos/effect/packages/effect/benchmark/Pool.ts @@ -0,0 +1,76 @@ +import { Effect, Exit, Pool, Scope } from "effect" +import { Bench } from "tinybench" + +const poolSize = 10 +let nextItem = 0 + +const acquire = Effect.sync(() => ++nextItem) +const getAll = (pool: Pool.Pool) => + Effect.all( + Array.from({ length: poolSize }, () => Pool.get(pool)), + { concurrency: "unbounded", discard: true } + ) + +const makeFixed = Effect.scoped( + Effect.flatMap( + Pool.make({ acquire, size: poolSize }), + getAll + ) +) + +const makeWithTTL = Effect.scoped( + Effect.flatMap( + Pool.makeWithTTL({ + acquire, + min: 0, + max: poolSize, + timeToLive: "1 minute" + }), + getAll + ) +) + +const poolScope = await Effect.runPromise(Scope.make()) +const fixedPool = await Effect.runPromise( + Pool.make({ acquire, size: poolSize }).pipe(Scope.provide(poolScope)) +) +const ttlPool = await Effect.runPromise( + Pool.makeWithTTL({ + acquire, + min: 0, + max: poolSize, + timeToLive: "1 minute" + }).pipe(Scope.provide(poolScope)) +) +const invalidationPool = await Effect.runPromise( + Pool.make({ acquire, size: 1 }).pipe(Scope.provide(poolScope)) +) + +await Effect.runPromise(Effect.scoped(Effect.all([getAll(fixedPool), getAll(ttlPool)], { discard: true }))) + +let invalidationItem = await Effect.runPromise(Effect.scoped(Pool.get(invalidationPool))) +const invalidateAndReplace = Effect.scoped( + Effect.gen(function*() { + yield* Pool.invalidate(invalidationPool, invalidationItem) + invalidationItem = yield* Pool.get(invalidationPool) + }) +) + +const bench = new Bench() + +const useItem = (item: number) => Effect.succeed(item) + +bench + .add("make fixed pool (10 items)", () => Effect.runPromise(makeFixed)) + .add("make TTL pool (10 items)", () => Effect.runPromise(makeWithTTL)) + .add("get and release (fixed pool)", () => Effect.runPromise(Effect.scoped(Pool.get(fixedPool)))) + .add("get and release (TTL pool)", () => Effect.runPromise(Effect.scoped(Pool.get(ttlPool)))) + .add("use (fixed pool)", () => Effect.runPromise(Pool.use(fixedPool, useItem))) + .add("use (TTL pool)", () => Effect.runPromise(Pool.use(ttlPool, useItem))) + .add("get and release (10 concurrent)", () => Effect.runPromise(Effect.scoped(getAll(fixedPool)))) + .add("invalidate and replace", () => Effect.runPromise(invalidateAndReplace)) + +await bench.run() +await Effect.runPromise(Scope.close(poolScope, Exit.void)) + +console.table(bench.table()) diff --git a/repos/effect/packages/effect/benchmark/http/tracer.ts b/repos/effect/packages/effect/benchmark/http/tracer.ts new file mode 100644 index 0000000000..ef9b4bbaf5 --- /dev/null +++ b/repos/effect/packages/effect/benchmark/http/tracer.ts @@ -0,0 +1,143 @@ +import { Effect } from "effect" +import * as Tracer from "effect/Tracer" +import * as Headers from "effect/unstable/http/Headers" +import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware" +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest" +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" +import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext" +import { Bench } from "tinybench" + +const bench = new Bench() + +const noopTracer = Tracer.make({ + span: (options) => + ({ + _tag: "Span", + spanId: "spanid0123456789", + traceId: "traceid0123456789traceid01234567", + sampled: true, + name: options.name, + parent: options.parent, + annotations: options.annotations, + links: options.links, + kind: options.kind, + status: { _tag: "Started", startTime: options.startTime }, + attributes: new Map(), + attribute() {}, + event() {}, + end() {}, + addLinks() {} + }) as Tracer.Span +}) + +const request = HttpServerRequest.fromWeb( + new Request("http://localhost:3000/some/path?foo=bar", { + headers: { + "user-agent": "bench/1.0", + "accept": "application/json", + "accept-encoding": "gzip, br", + "authorization": "Bearer secret", + "cookie": "session=abc", + "x-request-id": "12345678", + "host": "localhost:3000" + } + }) +) + +const requestWithTraceparent = HttpServerRequest.fromWeb( + new Request("http://localhost:3000/some/path?foo=bar", { + headers: { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "host": "localhost:3000" + } + }) +) + +const app = Effect.succeed(HttpServerResponse.text("ok")) + +const tracedApp = HttpMiddleware.tracer(app).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.provideService(Tracer.Tracer, noopTracer) +) + +const tracedAppNative = HttpMiddleware.tracer(app).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request) +) + +const tracedAppDisabled = HttpMiddleware.tracer(app).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.withTracerEnabled(false) +) + +const bareApp = app.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request) +) + +const withSpanApp = Effect.void.pipe( + Effect.withSpan("bench"), + Effect.provideService(Tracer.Tracer, noopTracer) +) + +const withSpanAppNoStack = Effect.void.pipe( + Effect.withSpan("bench", undefined, { captureStackTrace: false }), + Effect.provideService(Tracer.Tracer, noopTracer) +) + +const withSpanDisabled = Effect.void.pipe( + Effect.withSpan("bench"), + Effect.withTracerEnabled(false) +) + +bench + .add("bare app (no middleware)", async () => { + await Effect.runPromise(bareApp) + }) + .add("tracer middleware (noop tracer)", async () => { + await Effect.runPromise(tracedApp) + }) + .add("tracer middleware (NativeSpan)", async () => { + await Effect.runPromise(tracedAppNative) + }) + .add("tracer middleware (tracing disabled)", async () => { + await Effect.runPromise(tracedAppDisabled) + }) + .add("withSpan reused (noop tracer)", async () => { + await Effect.runPromise(withSpanApp) + }) + .add("withSpan constructed per run (noop tracer)", async () => { + await Effect.runPromise( + Effect.void.pipe( + Effect.withSpan("bench"), + Effect.provideService(Tracer.Tracer, noopTracer) + ) + ) + }) + .add("withSpan constructed per run, no stack capture", async () => { + await Effect.runPromise( + Effect.void.pipe( + Effect.withSpan("bench", undefined, { captureStackTrace: false }), + Effect.provideService(Tracer.Tracer, noopTracer) + ) + ) + }) + .add("withSpan reused, no stack capture (noop tracer)", async () => { + await Effect.runPromise(withSpanAppNoStack) + }) + .add("withSpan reused (tracing disabled)", async () => { + await Effect.runPromise(withSpanDisabled) + }) + .add("fromHeaders: no trace headers", () => { + HttpTraceContext.fromHeaders(request.headers) + }) + .add("fromHeaders: traceparent", () => { + HttpTraceContext.fromHeaders(requestWithTraceparent.headers) + }) + .add("Headers.redact (7 headers, default names)", () => { + Headers.redact(request.headers, ["authorization", "cookie", "set-cookie", "x-api-key"]) + }) + .add("Request.toURL", () => { + HttpServerRequest.toURL(request) + }) + +await bench.run() +console.table(bench.table()) diff --git a/repos/effect/packages/effect/benchmark/rpc/RpcSerialization.ts b/repos/effect/packages/effect/benchmark/rpc/RpcSerialization.ts new file mode 100644 index 0000000000..f5bc5e0470 --- /dev/null +++ b/repos/effect/packages/effect/benchmark/rpc/RpcSerialization.ts @@ -0,0 +1,299 @@ +import { Effect, Exit, Schema } from "effect" +import { Rpc, RpcSerialization } from "effect/unstable/rpc" +import assert from "node:assert/strict" +import { cpus } from "node:os" +import { Bench } from "tinybench" + +const User = Schema.Struct({ + id: Schema.String, + displayName: Schema.String, + email: Schema.String, + active: Schema.Boolean, + roles: Schema.Array(Schema.String) +}) + +const SearchUsers = Rpc.make("SearchUsers", { + payload: Schema.Struct({ + organizationId: Schema.String, + query: Schema.String, + page: Schema.Number, + pageSize: Schema.Number, + filters: Schema.Struct({ + active: Schema.optional(Schema.Boolean), + roles: Schema.Array(Schema.String) + }) + }), + success: Schema.Array(User), + error: Schema.Struct({ + code: Schema.String, + message: Schema.String + }) +}) + +const Event = Schema.Struct({ + sequence: Schema.Number, + timestamp: Schema.Date, + level: Schema.Literals(["info", "warning", "error"]), + message: Schema.String, + attributes: Schema.Record(Schema.String, Schema.String) +}) + +const searchPayload = { + organizationId: "org-effect", + query: "schema binary", + page: 3, + pageSize: 25, + filters: { + active: true, + roles: ["maintainer", "contributor"] + } +} + +const users = Array.from({ length: 12 }, (_, index) => ({ + id: `user-${index.toString().padStart(3, "0")}`, + displayName: `Benchmark User ${index}`, + email: `benchmark-${index}@example.com`, + active: index % 4 !== 0, + roles: index % 3 === 0 ? ["maintainer", "contributor"] : ["contributor"] +})) + +const makeEvent = (index: number) => ({ + sequence: index + 1, + timestamp: new Date(1_756_000_000_000 + index * 1_000), + level: index % 11 === 0 ? "warning" as const : "info" as const, + message: `Processed RPC event ${index + 1}`, + attributes: { + region: ["eu-west-1", "us-east-1", "ap-southeast-2"][index % 3]!, + worker: `worker-${index % 8}`, + attempt: String(index % 3 + 1) + } +}) + +const events = [ + makeEvent(0), + ...Array.from({ length: 31 }, (_, index) => makeEvent(index + 1)) +] satisfies Schema.NonEmptyArray["Type"] + +interface BenchmarkCase { + readonly name: string + readonly schema: Schema.Codec + readonly value: unknown + readonly envelope: (hole: unknown) => unknown + readonly hole: (envelope: unknown) => unknown +} + +const getHole = (key: PropertyKey) => (envelope: unknown): unknown => { + assert(typeof envelope === "object" && envelope !== null && key in envelope) + return (envelope as Record)[key] +} + +const cases: ReadonlyArray = [ + { + name: "request / nested search payload", + schema: SearchUsers.payloadSchema, + value: searchPayload, + envelope: (payload) => ({ + _tag: "Request", + id: 1, + tag: SearchUsers._tag, + payload, + headers: [ + ["authorization", "Bearer benchmark-token"], + ["x-request-id", "benchmark-request-0001"] + ], + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + sampled: true + }), + hole: getHole("payload") + }, + { + name: "exit / 12-user success", + schema: Rpc.exitSchema(SearchUsers), + value: Exit.succeed(users), + envelope: (exit) => ({ + _tag: "Exit", + requestId: 1, + exit + }), + hole: getHole("exit") + }, + { + name: "chunk / 32 events", + schema: Schema.NonEmptyArray(Event), + value: events, + envelope: (values) => ({ + _tag: "Chunk", + requestId: 1, + values + }), + hole: getHole("values") + } +] + +const schemaBinary = Effect.runSync( + RpcSerialization.RpcSerialization.pipe(Effect.provide(RpcSerialization.layerSchemaBinary())) +) + +const formats = [ + { name: "Msgpack", serialization: RpcSerialization.msgPack }, + { name: "SchemaBinary", serialization: schemaBinary } +] as const + +const warmupIterations = 500 +const iterations = 5_000 +// A serialization may carry state from one frame to the next, so the decode +// task walks a stream in order and never feeds the same frame twice. +const streamLength = warmupIterations + iterations + 1 +// Frames a connection sends before its serialization has settled. +const warmupFrames = 8 + +interface Prepared { + readonly caseName: string + readonly formatName: string + readonly firstFrameSize: number + readonly steadyFrameSize: number + readonly encode: () => unknown + readonly decode: () => unknown +} + +const toBytes = (encoded: Uint8Array | string | undefined): Uint8Array => { + assert(encoded instanceof Uint8Array) + return encoded.slice() +} + +const prepared = cases.flatMap((testCase): ReadonlyArray => + formats.map(({ name, serialization }) => { + const encodeHole = Schema.encodeUnknownSync(serialization.codecFor(testCase.schema)) + const decodeHole = Schema.decodeUnknownSync(serialization.codecFor(testCase.schema)) + // A binary serialization fills the hole with bytes; a JSON-shaped one fills + // it with the value itself. + const encodedHole = encodeHole(testCase.value) + const hole = encodedHole instanceof Uint8Array ? encodedHole.slice() : encodedHole + const encoder = serialization.makeUnsafe() + const encode = () => encoder.encode(testCase.envelope(encodeHole(testCase.value))) + const firstFrame = toBytes(encode()) + let steadyFrame = firstFrame + for (let i = 0; i < warmupFrames; i++) steadyFrame = toBytes(encode()) + + const streamWriter = serialization.makeUnsafe() + const stream = Array.from( + { length: streamLength }, + () => toBytes(streamWriter.encode(testCase.envelope(hole)) as Uint8Array) + ) + let decoder = serialization.makeUnsafe() + let index = 0 + const decode = () => { + if (index === stream.length) { + decoder = serialization.makeUnsafe() + index = 0 + } + const envelopes = decoder.decode(stream[index++]) + assert.strictEqual(envelopes.length, 1) + return decodeHole(testCase.hole(envelopes[0])) + } + + assert.deepStrictEqual(decode(), testCase.value) + + return { + caseName: testCase.name, + formatName: name, + firstFrameSize: firstFrame.length, + steadyFrameSize: steadyFrame.length, + encode, + decode + } + }) +) + +console.log(`${process.platform} ${process.arch}; ${cpus()[0]?.model ?? "unknown CPU"}; Node ${process.version}`) +console.log( + "End-to-end operations include the payload codec plus RPC envelope framing; codec construction is excluded." +) +console.log( + "Msgpack uses RpcSerialization.msgPack defaults, including records. SchemaBinary fingerprints envelopes only and shares one string dictionary across the frames of a connection." +) +console.log( + "First-frame sizes use a fresh serializer; steady sizes and throughput reuse one as on a long-lived connection, and decode walks a stream in frame order." +) +console.log( + `${warmupIterations.toLocaleString()} warmup operations and ${iterations.toLocaleString()} measured operations per case, format, and direction.` +) + +console.table(prepared.map((entry) => ({ + Case: entry.caseName, + Format: entry.formatName, + "First frame bytes": entry.firstFrameSize, + "Steady frame bytes": entry.steadyFrameSize +}))) + +const bench = new Bench({ + iterations, + time: 0, + warmupIterations, + warmupTime: 0, + timestampProvider: "hrtimeNow" +}) +const labels = new Map() +let sink: unknown + +for (const entry of prepared) { + for (const [direction, run] of [["encode", entry.encode], ["decode", entry.decode]] as const) { + const name = `${entry.caseName} / ${entry.formatName} / ${direction}` + labels.set(name, { + caseName: entry.caseName, + formatName: entry.formatName, + direction + }) + bench.add(name, () => { + sink = run() + }) + } +} + +await bench.run() +assert.notStrictEqual(sink, undefined) + +const msgpackThroughput = new Map() +for (const task of bench.tasks) { + const label = labels.get(task.name)! + if (label.formatName === "Msgpack" && task.result?.state === "completed") { + msgpackThroughput.set(`${label.caseName}/${label.direction}`, task.result.throughput.mean) + } +} + +console.table(bench.tasks.map((task) => { + const label = labels.get(task.name)! + const result = task.result + if (result?.state === "errored") { + return { + Case: label.caseName, + Format: label.formatName, + Direction: label.direction, + Error: result.error.message + } + } + if (result?.state !== "completed") { + return { + Case: label.caseName, + Format: label.formatName, + Direction: label.direction, + State: result?.state ?? "missing result" + } + } + const baseline = msgpackThroughput.get(`${label.caseName}/${label.direction}`)! + return { + Case: label.caseName, + Format: label.formatName, + Direction: label.direction, + "Throughput avg (ops/s)": Math.round(result.throughput.mean), + "vs Msgpack": `${(result.throughput.mean / baseline).toFixed(2)}x`, + "Latency med (us/op)": (result.latency.p50 * 1_000).toFixed(2), + "Latency RME": `${result.latency.rme.toFixed(2)}%`, + Samples: result.latency.samplesCount + } +})) diff --git a/repos/effect/packages/effect/benchmark/schema/SchemaBinary.md b/repos/effect/packages/effect/benchmark/schema/SchemaBinary.md new file mode 100644 index 0000000000..a337b718f7 --- /dev/null +++ b/repos/effect/packages/effect/benchmark/schema/SchemaBinary.md @@ -0,0 +1,155 @@ +# SchemaBinary benchmark + +Run from the repository root: + +```sh +nix develop -c pnpm --dir packages/effect exec node benchmark/schema/SchemaBinary.ts +``` + +To repeat the raw msgpackr cases without its native string extractor: + +```sh +nix develop -c env MSGPACKR_NATIVE_ACCELERATION_DISABLED=true pnpm --dir packages/effect exec node benchmark/schema/SchemaBinary.ts +``` + +These results are from one full run with fingerprint-mode row runs on Linux x86_64 (AMD EPYC 4344P) with Node 26.7.0. Codec and schema construction are excluded. One-shot tasks use 100 warmups and 1,000 measured samples; streaming tasks use 25 warmups and 250 measured samples. Throughput is machine-local and should only be compared within this run. + +Both modes pack each field id with a wire kind, so varint numbers, decimals, and booleans skip the per-field length prefix and booleans ride in the tag itself. Short decimals such as `12.5` encode as a varint mantissa plus a scale instead of an eight-byte f64, and index-signature record pairs pack the value kind into the key-length varint. An array of structs is written as a row run: each distinct set of present fields declares its shape once, and later rows reference that shape and any string already written in the same slot. Fingerprint mode omits field identifiers entirely: single frames use positional layouts and its row shapes are presence masks instead of field id lists. JSON, Msgpack, and NDJSON use the same `Schema.toCodecJson` representation. + +Protobuf uses `protobufjs` reflection types parsed once before timing. `Schema.Number` maps to proto3 `double`, records map to `map`, and top-level arrays and records use wrapper messages. Tuple samples and nested arrays use messages because protobuf does not support either shape directly. The timed decode paths include `toObject` and the case adapters, so every format produces its final application value. Descriptor construction and Protobuf encode adapters are excluded. + +Every format is timed through its public API, so the SchemaBinary and JSON / Msgpack numbers include the Schema pass that produces or validates the application value. Where the binary layer already validates a schema on its own, `toCodec` skips that pass in both directions rather than repeating the work: encoding runs the binary encoder directly, and decoding hands the value it just produced straight through. Any input the binary layer did not produce, `Schema.is` included, still runs the real check. + +The raw serializer section deliberately relaxes that rule. It compares the public SchemaBinary codec with raw msgpackr and JSON calls that do not validate the application value. `Effect Msgpack schema` remains in those tables as the public-API comparison. The benchmark prints whether msgpackr's native string extractor is active. + +A static `.proto` must pick one numeric type for `Schema.Number`, so integral values pay eight bytes where SchemaBinary picks a varint per value. Typing the known-integer fields as `uint32` would reduce the 200-row Protobuf payload from 24,480 to 20,600 bytes and the small record from 42 to 35, but would no longer cover the full `Schema.Number` domain. The reported sizes also reflect `protobufjs` 7.6.5 encoding default-valued scalars such as `verified: false`; an encoder that applies proto3 implicit presence would omit them. + +The streaming setup compares the closest public decode paths. SchemaBinary reuses one synchronous parser for each feed shape. Protobuf calls `decodeDelimited` across the batch and materializes every message with `toObject` plus the case adapter. Msgpack synchronously calls `unpackMultiple` on the batch, then validates each value. NDJSON runs `Ndjson.decodeSchema` through Effect Stream and Channel for every operation, including UTF-8 decoding, line splitting, `JSON.parse`, schema validation, and runtime scheduling. A batch is one Channel run over 32 lines, or 200 lines for the per-frame case. Single and fragmented measurements each run a complete Channel for one line, so their scheduling cost is not amortized. This makes batch the closest throughput comparison while preserving the cost of each public API. + +SchemaBinary and Protobuf use length-prefixed frames, NDJSON includes one newline byte per frame, and Msgpack concatenates self-delimiting values. Fragmented inputs split after the first byte. Stream compression is applied to the complete concatenated stream. + +The `200-row array payload` case uses `Schema.Array(LargeRow)`, so one value is an array containing 200 rows. A one-shot operation encodes or decodes that entire array. Its streaming batch contains 32 frames with the same 200-row array, or 6,400 row occurrences in total. The `200 single-row frames` case uses `LargeRow` directly and sends the 200 distinct rows as 200 frames. Streaming throughput is decoded values per second: arrays per second for the first case and rows per second for the second. Multiply the array rate by 200 to compare decoded row throughput. + +## Payload size + +Cells contain raw / gzip -6 / zstd bytes. + +| Case | SchemaBinary | Fingerprint | JSON | Msgpack | Protobuf | +| ---------------------- | -----------------: | -----------------: | ------------------: | ------------------: | ------------------: | +| small record | 47 / 70 / 56 | 30 / 50 / 39 | 89 / 100 / 92 | 69 / 88 / 78 | 42 / 51 / 44 | +| nested payload | 280 / 298 / 290 | 200 / 210 / 209 | 453 / 303 / 309 | 385 / 304 / 299 | 241 / 220 / 214 | +| collections | 1079 / 710 / 687 | 1065 / 692 / 669 | 1828 / 671 / 660 | 1462 / 788 / 805 | 2932 / 799 / 754 | +| index signatures / 128 | 1673 / 646 / 603 | 1680 / 652 / 612 | 2235 / 573 / 559 | 2203 / 676 / 629 | 2834 / 586 / 476 | +| index signatures / 512 | 7145 / 2278 / 2298 | 7152 / 2286 / 2310 | 9531 / 2266 / 2074 | 9287 / 2544 / 2304 | 11666 / 1978 / 1737 | +| 200-row array payload | 7771 / 2441 / 2224 | 7741 / 2398 / 2182 | 57529 / 3230 / 3008 | 51283 / 3516 / 3320 | 24480 / 3111 / 2839 | + +Streaming cells contain total raw / gzip -6 / zstd bytes for the complete stream. + +| Case | Frames | SchemaBinary | Fingerprint | Msgpack | Protobuf | NDJSON | +| ---------------------- | -----: | -------------------: | -------------------: | --------------------: | --------------------: | ---------------------: | +| small record | 32 | 1504 / 83 / 66 | 960 / 60 / 48 | 2240 / 111 / 87 | 1376 / 66 / 51 | 2880 / 123 / 98 | +| nested payload | 32 | 8960 / 380 / 295 | 6400 / 256 / 209 | 10880 / 369 / 306 | 7776 / 271 / 220 | 14528 / 399 / 315 | +| collections | 32 | 34528 / 968 / 729 | 34080 / 935 / 711 | 47424 / 1130 / 813 | 93888 / 2074 / 774 | 58528 / 1079 / 676 | +| index signatures / 128 | 32 | 53536 / 1007 / 615 | 53760 / 1017 / 624 | 71008 / 1252 / 649 | 90752 / 1503 / 492 | 71552 / 1073 / 540 | +| index signatures / 512 | 32 | 228640 / 4538 / 2320 | 228864 / 4557 / 2332 | 297696 / 5775 / 2037 | 373376 / 5956 / 1782 | 305024 / 5831 / 1829 | +| 200-row array payload | 32 | 248672 / 4598 / 2261 | 247712 / 4547 / 2220 | 623488 / 12335 / 2736 | 783456 / 12374 / 2909 | 1840960 / 87481 / 3226 | +| 200 single-row frames | 200 | 27040 / 3079 / 3173 | 21240 / 2876 / 2731 | 51520 / 2956 / 2888 | 24280 / 3104 / 2788 | 57528 / 3230 / 3019 | + +## One-shot throughput + +Average encode operations per second: + +| Case | Default | Fingerprint | JSON | Msgpack | Protobuf | +| ---------------------- | --------: | ----------: | ------: | ------: | --------: | +| small record | 1,319,592 | 1,507,529 | 436,487 | 704,398 | 2,000,847 | +| nested payload | 588,799 | 641,251 | 259,634 | 285,917 | 562,044 | +| collections | 146,285 | 153,447 | 22,636 | 23,006 | 75,809 | +| index signatures / 128 | 138,629 | 139,601 | 36,497 | 35,532 | 63,231 | +| index signatures / 512 | 36,813 | 36,699 | 6,999 | 6,358 | 15,699 | +| 200-row array payload | 19,622 | 19,671 | 7,182 | 4,192 | 12,040 | + +Average decode operations per second: + +| Case | Default | Fingerprint | JSON | Msgpack | Protobuf | +| ---------------------- | --------: | ----------: | ------: | ------: | --------: | +| small record | 1,560,001 | 1,613,600 | 398,999 | 733,207 | 2,281,446 | +| nested payload | 586,239 | 689,588 | 216,563 | 270,625 | 560,920 | +| collections | 113,511 | 116,816 | 21,138 | 22,045 | 77,912 | +| index signatures / 128 | 63,122 | 63,161 | 28,565 | 30,062 | 44,545 | +| index signatures / 512 | 16,412 | 16,338 | 5,432 | 4,593 | 6,029 | +| 200-row array payload | 23,071 | 23,094 | 5,624 | 4,809 | 15,663 | + +## Raw serializer adversarial cases + +These cases show where SchemaBinary loses. The shallow record makes fixed per-call costs visible. The clinical fixture is msgpackr's [`tests/example4.json`](https://github.com/kriszyp/msgpackr/blob/e3c852df383059b9ea8a8d3e5517d6e5527bf756/tests/example4.json), the input used by its own general benchmark. It has many nested, heterogeneous object shapes, which suit msgpackr's dynamic record cache. + +The clinical Schema is inferred once before timing. Objects at the same array path are merged, missing fields become optional, and mixed leaf types become unions. Schema inference and codec construction are excluded. The shared-structure Packr is primed once, matching msgpackr's steady-state benchmark setup. + +Raw / gzip -6 / zstd bytes: + +| Case | SchemaBinary | Fingerprint | Effect Msgpack schema | msgpackr shared | msgpackr plain | JSON raw | +| ------------------------- | -----------------: | -----------------: | --------------------: | -----------------: | -----------------: | -----------------: | +| shallow record | 47 / 70 / 56 | 30 / 50 / 39 | 69 / 88 / 78 | 24 / 42 / 33 | 69 / 88 / 78 | 89 / 100 / 92 | +| msgpackr clinical fixture | 4433 / 2282 / 2317 | 3513 / 1623 / 1672 | 6357 / 2364 / 2438 | 3821 / 1604 / 1681 | 6357 / 2364 / 2435 | 7569 / 2201 / 2288 | + +Average operations per second with msgpackr native acceleration enabled: + +| Case | Direction | SchemaBinary | Fingerprint | Effect Msgpack schema | msgpackr shared | msgpackr plain | JSON raw | +| ------------------------- | --------- | -----------: | ----------: | --------------------: | --------------: | -------------: | --------: | +| shallow record | encode | 1,704,527 | 1,842,472 | 798,150 | 2,463,086 | 2,806,880 | 2,928,575 | +| shallow record | decode | 2,039,262 | 2,202,957 | 891,250 | 6,906,994 | 4,324,485 | 2,280,053 | +| msgpackr clinical fixture | encode | 52,186 | 58,306 | 17,531 | 67,371 | 61,381 | 131,325 | +| msgpackr clinical fixture | decode | 48,462 | 58,404 | 16,928 | 183,899 | 53,859 | 56,427 | + +Clinical-fixture decode operations per second with native acceleration toggled: + +| Format | Enabled | Disabled | +| --------------------- | ------: | -------: | +| SchemaBinary | 48,462 | 48,476 | +| Fingerprint | 58,404 | 58,441 | +| Effect Msgpack schema | 16,928 | 15,124 | +| msgpackr shared | 183,899 | 118,799 | +| msgpackr plain | 53,859 | 40,537 | +| JSON raw | 56,427 | 57,388 | + +Shared-structure msgpackr still leads the clinical fixture: 1.29x on encode and 3.79x on decode against default SchemaBinary with native extraction enabled, and 2.45x on decode with it disabled. Fingerprint mode narrows that to 1.16x and 3.15x while staying 8% smaller than the shared-structure payload. + +Against everything that does not generate code, SchemaBinary is at or ahead of the field on the clinical fixture. Fingerprint decode beats plain msgpackr by 1.08x with native extraction enabled and 1.44x with it disabled, and beats raw `JSON.parse` by 1.03x. Fingerprint encode is within 5% of plain msgpackr, though `JSON.stringify` is 2.3x ahead of both. Against the schema-validating Effect Msgpack API, default SchemaBinary is 3.0x faster to encode and 2.9x faster to decode. + +The shallow record is where the remaining fixed cost shows. Both directions carry the parse pipeline around the codec, roughly a fifth of a shallow decode, which msgpackr does not pay because `unpack` is one function call. + +The rest of the shared-structure gap is code generation. msgpackr builds one reader per record structure with `new Function`, so a decoded object is an object literal: about 1 ns per property against 8 to 9 ns for the keyed store this codec has to use. That is worth roughly 2.3 us of the clinical fixture's 20 us decode. Removing the layout dispatch on top of it would leave around 13 us, still short of the 5.4 us shared-structure msgpackr reaches with its native string extractor. Closing that gap is a `new Function` decision, not a tuning one. + +## Streaming decode throughput + +Average decoded values per second for batched input: + +| Case | Default | Fingerprint | Msgpack | Protobuf | NDJSON | +| ---------------------- | --------: | ----------: | ------: | --------: | ------: | +| small record | 5,116,343 | 5,956,797 | 967,638 | 4,170,440 | 831,994 | +| nested payload | 821,034 | 1,042,459 | 284,246 | 586,806 | 312,906 | +| collections | 121,413 | 123,472 | 21,071 | 77,768 | 20,555 | +| index signatures / 128 | 65,080 | 65,325 | 28,163 | 38,953 | 28,743 | +| index signatures / 512 | 15,774 | 16,146 | 4,117 | 5,135 | 5,071 | +| 200-row array payload | 23,238 | 23,247 | 6,573 | 7,455 | 4,988 | +| 200 single-row frames | 2,257,102 | 2,682,446 | 849,319 | 1,692,708 | 950,570 | + +Average decoded values per second for single and first-byte-fragmented input: + +| Case | Default single | Default fragmented | Fingerprint single | Fingerprint fragmented | NDJSON single | NDJSON fragmented | +| ---------------------- | -------------: | -----------------: | -----------------: | ---------------------: | ------------: | ----------------: | +| small record | 3,020,534 | 2,168,195 | 3,695,255 | 2,720,058 | 131,636 | 133,208 | +| nested payload | 721,200 | 677,292 | 890,654 | 833,766 | 104,947 | 103,084 | +| collections | 119,996 | 118,256 | 121,800 | 119,050 | 18,668 | 18,652 | +| index signatures / 128 | 64,295 | 64,417 | 64,764 | 64,364 | 24,908 | 24,547 | +| index signatures / 512 | 16,424 | 16,396 | 16,581 | 16,488 | 5,195 | 5,098 | +| 200-row array payload | 23,468 | 23,205 | 23,418 | 23,052 | 5,251 | 5,218 | +| 200 single-row frames | 1,505,519 | 1,473,846 | 1,915,049 | 1,644,030 | 131,373 | 126,835 | + +## Analysis + +- Fingerprint mode is now the smallest SchemaBinary format for every case except the two index-signature maps, where the two modes are within 7 bytes: row runs apply in both modes, and fingerprint shapes are presence masks with no id list, so the `200-row array payload` dropped from 19,454 to 7,741 bytes. Its decode rate rose from 14,905 to 23,321 ops/s, matching the default mode. +- The default mode has the smallest raw payload of any non-fingerprint format in every case except the small record, where only Protobuf's one-byte field numbers beat its hashed five-byte field tags (42 vs 47 bytes). Fingerprint mode wins there too (30 bytes). +- Compression still changes the map ranking: Protobuf has the smallest zstd output for both index-signature cases, and JSON wins gzip at 128 keys. +- Protobuf keeps the small record in both directions, by about 1.5x. SchemaBinary leads the other five one-shot cases in both directions, from 1.05x on the nested payload up to 2.7x on index-signature decode, while returning schema-validated application values. Against Msgpack through the same public API it leads every case, by 1.9x to 6.4x. +- Single-frame and fragmented streaming rates for the `200 single-row frames` case carry 20% or worse RME at 250 samples, so only their batch column is worth comparing across runs. diff --git a/repos/effect/packages/effect/benchmark/schema/SchemaBinary.ts b/repos/effect/packages/effect/benchmark/schema/SchemaBinary.ts new file mode 100644 index 0000000000..1b1d931262 --- /dev/null +++ b/repos/effect/packages/effect/benchmark/schema/SchemaBinary.ts @@ -0,0 +1,930 @@ +import { Effect, Schema, Stream } from "effect" +import { Msgpack, Ndjson, SchemaBinary } from "effect/unstable/encoding" +import { isNativeAccelerationEnabled, pack, Packr, unpack, Unpackr } from "msgpackr" +import assert from "node:assert/strict" +import { Buffer } from "node:buffer" +import { readFileSync } from "node:fs" +import { gzipSync, zstdCompressSync } from "node:zlib" +import protobuf, { type Message, type Type } from "protobufjs" +import { Bench } from "tinybench" + +const streamBatchSize = 32 +const repeatedRecordStreamSize = 200 + +const SmallRecord = Schema.Struct({ + id: Schema.Number, + active: Schema.Boolean, + score: Schema.Number, + retryCount: Schema.Number, + region: Schema.String, + verified: Schema.Boolean +}) + +const LineItem = Schema.Struct({ + sku: Schema.String, + quantity: Schema.Number, + unitPrice: Schema.Number +}) + +const NestedPayload = Schema.Struct({ + orderId: Schema.String, + customer: Schema.Struct({ + id: Schema.String, + name: Schema.String, + email: Schema.String + }), + shipping: Schema.Struct({ + street: Schema.String, + city: Schema.String, + postalCode: Schema.String, + country: Schema.String + }), + lines: Schema.Array(LineItem), + metadata: Schema.Struct({ + source: Schema.String, + campaign: Schema.String, + priority: Schema.Boolean + }) +}) + +const Collections = Schema.Struct({ + tags: Schema.Array(Schema.String), + metrics: Schema.Record(Schema.String, Schema.Number), + samples: Schema.Array(Schema.Tuple([Schema.Number, Schema.Number, Schema.Boolean])), + buckets: Schema.Array(Schema.Array(Schema.Number)) +}) + +const LargeRow = Schema.Struct({ + transactionIdentifier: Schema.String, + customerIdentifier: Schema.String, + productDescription: Schema.String, + fulfillmentLocation: Schema.String, + quantityPurchased: Schema.Number, + unitPriceInCents: Schema.Number, + discountInBasisPoints: Schema.Number, + requiresManualReview: Schema.Boolean +}) + +const LargePayload = Schema.Array(LargeRow) + +const largeRows = Array.from({ length: repeatedRecordStreamSize }, (_, index) => ({ + transactionIdentifier: `transaction-${index.toString().padStart(4, "0")}`, + customerIdentifier: `customer-${index % 37}`, + productDescription: `Product ${index % 19} with a repeated descriptive field value`, + fulfillmentLocation: ["London", "New York", "Singapore", "Sydney"][index % 4]!, + quantityPurchased: index % 9 + 1, + unitPriceInCents: 500 + index % 73 * 25, + discountInBasisPoints: index % 5 * 125, + requiresManualReview: index % 17 === 0 +})) + +const metrics = (count: number) => + Object.fromEntries(Array.from({ length: count }, (_, index) => [`metric-${index}`, index * 1.25])) + +type InferredNode = + | { readonly _tag: "never" } + | { readonly _tag: "null" | "string" | "number" | "boolean" } + | { readonly _tag: "array"; readonly element: InferredNode } + | { + readonly _tag: "object" + readonly count: number + readonly fields: ReadonlyMap + } + | { readonly _tag: "union"; readonly members: ReadonlyMap } + +const inferredNever: InferredNode = { _tag: "never" } + +const inferredMembers = (node: InferredNode): ReadonlyMap => + node._tag === "union" ? node.members : new Map([[node._tag, node]]) + +const mergeInferred = (left: InferredNode, right: InferredNode): InferredNode => { + if (left._tag === "never") return right + if (right._tag === "never") return left + if (left._tag === right._tag) { + if (left._tag === "array" && right._tag === "array") { + return { _tag: "array", element: mergeInferred(left.element, right.element) } + } + if (left._tag === "object" && right._tag === "object") { + const fields = new Map(left.fields) + for (const [key, field] of right.fields) { + const previous = fields.get(key) + fields.set( + key, + previous === undefined + ? field + : { seen: previous.seen + field.seen, node: mergeInferred(previous.node, field.node) } + ) + } + return { _tag: "object", count: left.count + right.count, fields } + } + if (left._tag === "union" && right._tag === "union") { + const members = new Map(left.members) + for (const [tag, member] of right.members) { + members.set(tag, members.has(tag) ? mergeInferred(members.get(tag)!, member) : member) + } + return { _tag: "union", members } + } + return left + } + const members = new Map(inferredMembers(left)) + for (const [tag, member] of inferredMembers(right)) { + members.set(tag, members.has(tag) ? mergeInferred(members.get(tag)!, member) : member) + } + return { _tag: "union", members } +} + +const inferNode = (value: unknown): InferredNode => { + if (value === null) return { _tag: "null" } + if (typeof value === "string") return { _tag: "string" } + if (typeof value === "number") return { _tag: "number" } + if (typeof value === "boolean") return { _tag: "boolean" } + if (Array.isArray(value)) { + return { _tag: "array", element: value.reduce((node, item) => mergeInferred(node, inferNode(item)), inferredNever) } + } + if (typeof value === "object") { + return { + _tag: "object", + count: 1, + fields: new Map(Object.entries(value).map(([key, field]) => [key, { seen: 1, node: inferNode(field) }])) + } + } + throw new Error(`Cannot infer a benchmark schema for ${typeof value}`) +} + +const inferredSchema = (node: InferredNode): Schema.ConstraintCodec => { + switch (node._tag) { + case "never": + return Schema.Never + case "null": + return Schema.Null + case "string": + return Schema.String + case "number": + return Schema.Number + case "boolean": + return Schema.Boolean + case "array": + return Schema.Array(inferredSchema(node.element)) + case "union": + return Schema.Union(Array.from(node.members.values(), inferredSchema)) + case "object": + return Schema.Struct(Object.fromEntries(Array.from(node.fields, ([key, field]) => [ + key, + field.seen === node.count + ? inferredSchema(field.node) + : Schema.optionalKey(inferredSchema(field.node)) + ]))) + } +} + +// From msgpackr's benchmark corpus at e3c852d: https://github.com/kriszyp/msgpackr/blob/e3c852df383059b9ea8a8d3e5517d6e5527bf756/tests/example4.json +const msgpackrClinicalValue: unknown = JSON.parse( + readFileSync(new URL("./fixtures/msgpackr-example4.json", import.meta.url), "utf8") +) +const MsgpackrClinical = inferredSchema(inferNode(msgpackrClinicalValue)) + +const protobufRoot = protobuf.parse(` + syntax = "proto3"; + + message SmallRecord { + double id = 1; + bool active = 2; + double score = 3; + double retryCount = 4; + string region = 5; + bool verified = 6; + } + + message LineItem { + string sku = 1; + double quantity = 2; + double unitPrice = 3; + } + + message Customer { + string id = 1; + string name = 2; + string email = 3; + } + + message Shipping { + string street = 1; + string city = 2; + string postalCode = 3; + string country = 4; + } + + message Metadata { + string source = 1; + string campaign = 2; + bool priority = 3; + } + + message NestedPayload { + string orderId = 1; + Customer customer = 2; + Shipping shipping = 3; + repeated LineItem lines = 4; + Metadata metadata = 5; + } + + message Sample { + double first = 1; + double second = 2; + bool third = 3; + } + + message Bucket { + repeated double values = 1; + } + + message Collections { + repeated string tags = 1; + map metrics = 2; + repeated Sample samples = 3; + repeated Bucket buckets = 4; + } + + message NumberMap { + map values = 1; + } + + message LargeRow { + string transactionIdentifier = 1; + string customerIdentifier = 2; + string productDescription = 3; + string fulfillmentLocation = 4; + double quantityPurchased = 5; + double unitPriceInCents = 6; + double discountInBasisPoints = 7; + bool requiresManualReview = 8; + } + + message LargePayload { + repeated LargeRow values = 1; + } +`).root + +interface ProtobufFixture { + readonly type: Type + readonly encodeInput: (value: unknown) => object + readonly decodeOutput: (message: Message) => unknown +} + +const protobufObject = (type: Type, message: Message): T => + type.toObject(message, { arrays: true, defaults: true, objects: true }) as T + +const directProtobufFixture = (name: string): ProtobufFixture => { + const type = protobufRoot.lookupType(name) + return { + type, + encodeInput: (value) => value as object, + decodeOutput: (message) => protobufObject(type, message) + } +} + +const collectionsProtobufType = protobufRoot.lookupType("Collections") +const numberMapProtobufType = protobufRoot.lookupType("NumberMap") +const largePayloadProtobufType = protobufRoot.lookupType("LargePayload") + +const collectionsProtobufFixture: ProtobufFixture = { + type: collectionsProtobufType, + encodeInput: (value) => { + const collections = value as (typeof Collections)["Type"] + return { + tags: collections.tags, + metrics: collections.metrics, + samples: collections.samples.map(([first, second, third]) => ({ first, second, third })), + buckets: collections.buckets.map((values) => ({ values })) + } + }, + decodeOutput: (message) => { + const collections = protobufObject<{ + readonly tags: ReadonlyArray + readonly metrics: Readonly> + readonly samples: ReadonlyArray<{ readonly first: number; readonly second: number; readonly third: boolean }> + readonly buckets: ReadonlyArray<{ readonly values: ReadonlyArray }> + }>(collectionsProtobufType, message) + return { + tags: collections.tags, + metrics: collections.metrics, + samples: collections.samples.map((sample) => [sample.first, sample.second, sample.third]), + buckets: collections.buckets.map((bucket) => bucket.values) + } + } +} + +const numberMapProtobufFixture: ProtobufFixture = { + type: numberMapProtobufType, + encodeInput: (value) => ({ values: value }), + decodeOutput: (message) => + protobufObject<{ readonly values: Readonly> }>(numberMapProtobufType, message).values +} + +const largePayloadProtobufFixture: ProtobufFixture = { + type: largePayloadProtobufType, + encodeInput: (value) => ({ values: value }), + decodeOutput: (message) => + protobufObject<{ readonly values: ReadonlyArray<(typeof LargeRow)["Type"]> }>(largePayloadProtobufType, message) + .values +} + +const largeRowProtobufFixture = directProtobufFixture("LargeRow") + +const cases = [ + { + name: "small record", + schema: SmallRecord, + value: { + id: 42, + active: true, + score: 98.5, + retryCount: 2, + region: "eu-west-1", + verified: false + }, + protobuf: directProtobufFixture("SmallRecord") + }, + { + name: "nested payload", + schema: NestedPayload, + value: { + orderId: "order-2026-000184", + customer: { + id: "customer-91", + name: "Ada Lovelace", + email: "ada@example.com" + }, + shipping: { + street: "12 Analytical Engine Way", + city: "London", + postalCode: "SW1A 1AA", + country: "GB" + }, + lines: [ + { sku: "widget-blue", quantity: 2, unitPrice: 12.5 }, + { sku: "adapter-pro", quantity: 1, unitPrice: 48 }, + { sku: "cable-2m", quantity: 3, unitPrice: 8.25 } + ], + metadata: { + source: "partner-api", + campaign: "summer-2026", + priority: true + } + }, + protobuf: directProtobufFixture("NestedPayload") + }, + { + name: "collections", + schema: Collections, + value: { + tags: Array.from({ length: 24 }, (_, index) => `tag-${index}`), + metrics: Object.fromEntries(Array.from({ length: 24 }, (_, index) => [`metric-${index}`, index * 1.25])), + samples: Array.from({ length: 48 }, (_, index) => [index, index / 10, index % 3 === 0] as const), + buckets: Array.from({ length: 8 }, (_, bucket) => Array.from({ length: 16 }, (_, index) => bucket * 100 + index)) + }, + protobuf: collectionsProtobufFixture + }, + { + name: "index signatures / 128 keys", + schema: Schema.Record(Schema.String, Schema.Number), + value: metrics(128), + protobuf: numberMapProtobufFixture + }, + { + name: "index signatures / 512 keys", + schema: Schema.Record(Schema.String, Schema.Number), + value: metrics(512), + protobuf: numberMapProtobufFixture + }, + { + name: "200-row array payload", + schema: LargePayload, + value: largeRows, + protobuf: largePayloadProtobufFixture + } +] as const + +const rawCases = [ + { + name: "small record / raw serializers", + schema: SmallRecord, + value: cases[0].value + }, + { + name: "msgpackr clinical fixture / raw serializers", + schema: MsgpackrClinical, + value: msgpackrClinicalValue + } +] as const + +interface Format { + readonly name: string + readonly encodedSize: number + readonly gzipSize: number + readonly zstdSize: number + readonly encode: () => unknown + readonly decode: () => unknown +} + +interface StreamFormat { + readonly name: string + readonly framesPerOp: number + readonly decode: () => ReadonlyArray | Promise> +} + +interface StreamSize { + readonly name: string + readonly frames: number + readonly encodedSize: number + readonly gzipSize: number + readonly zstdSize: number +} + +const textEncoder = new TextEncoder() + +const concatFrames = (frames: ReadonlyArray): Uint8Array => { + const out = new Uint8Array(frames.reduce((length, frame) => length + frame.length, 0)) + let offset = 0 + for (const frame of frames) { + out.set(frame, offset) + offset += frame.length + } + return out +} + +const sizes = (encoded: Uint8Array): Pick => ({ + encodedSize: encoded.length, + gzipSize: gzipSync(encoded).length, + zstdSize: zstdCompressSync(encoded).length +}) + +const prepare = >( + schema: S, + value: S["Type"], + protobufFixture: ProtobufFixture +): { + readonly formats: ReadonlyArray +} => { + const jsonSchema = Schema.toCodecJson(schema) + const binaryCodec = SchemaBinary.toCodec(schema) + const fingerprintCodec = SchemaBinary.toCodec(schema, { fingerprint: true }) + const jsonCodec = Schema.fromJsonString(jsonSchema) + const msgpackCodec = Msgpack.schema(jsonSchema) + + const binaryEncode = Schema.encodeUnknownSync(binaryCodec) + const binaryDecode = Schema.decodeUnknownSync(binaryCodec) + const fingerprintEncode = Schema.encodeUnknownSync(fingerprintCodec) + const fingerprintDecode = Schema.decodeUnknownSync(fingerprintCodec) + const jsonEncode = Schema.encodeUnknownSync(jsonCodec) + const jsonDecode = Schema.decodeUnknownSync(jsonCodec) + const msgpackEncode = Schema.encodeUnknownSync(msgpackCodec) + const msgpackDecode = Schema.decodeUnknownSync(msgpackCodec) + const protobufValue = protobufFixture.encodeInput(value) + + const binary = binaryEncode(value) + const fingerprint = fingerprintEncode(value).slice() + const json = jsonEncode(value) + const jsonBytes = textEncoder.encode(json) + const msgpack = msgpackEncode(value) + const protobufBytes = protobufFixture.type.encode(protobufValue).finish() + const protobufDecode = () => protobufFixture.decodeOutput(protobufFixture.type.decode(protobufBytes)) + + assert.deepStrictEqual(binaryDecode(binary), value) + assert.deepStrictEqual(fingerprintDecode(fingerprint), value) + assert.deepStrictEqual(jsonDecode(json), value) + assert.deepStrictEqual(msgpackDecode(msgpack), value) + assert.deepStrictEqual(protobufDecode(), value) + + return { + formats: [ + { + name: "SchemaBinary", + ...sizes(binary), + encode: () => binaryEncode(value), + decode: () => binaryDecode(binary) + }, + { + name: "SchemaBinary fingerprint", + ...sizes(fingerprint), + encode: () => fingerprintEncode(value), + decode: () => fingerprintDecode(fingerprint) + }, + { + name: "JSON", + ...sizes(jsonBytes), + encode: () => jsonEncode(value), + decode: () => jsonDecode(json) + }, + { + name: "Msgpack", + ...sizes(msgpack), + encode: () => msgpackEncode(value), + decode: () => msgpackDecode(msgpack) + }, + { + name: "Protobuf", + ...sizes(protobufBytes), + encode: () => protobufFixture.type.encode(protobufValue).finish(), + decode: protobufDecode + } + ] + } +} + +const prepareRaw = >( + schema: S, + value: S["Type"] +): { readonly formats: ReadonlyArray } => { + const binaryCodec = SchemaBinary.toCodec(schema) + const fingerprintCodec = SchemaBinary.toCodec(schema, { fingerprint: true }) + const msgpackCodec = Msgpack.schema(Schema.toCodecJson(schema)) + const binaryEncode = Schema.encodeUnknownSync(binaryCodec) + const binaryDecode = Schema.decodeUnknownSync(binaryCodec) + const fingerprintEncode = Schema.encodeUnknownSync(fingerprintCodec) + const fingerprintDecode = Schema.decodeUnknownSync(fingerprintCodec) + const msgpackEncode = Schema.encodeUnknownSync(msgpackCodec) + const msgpackDecode = Schema.decodeUnknownSync(msgpackCodec) + const sharedPackr = new Packr({ structures: [] }) + + sharedPackr.pack(value) + const binary = binaryEncode(value).slice() + const fingerprint = fingerprintEncode(value).slice() + const effectMsgpack = msgpackEncode(value) + const sharedMsgpack = sharedPackr.pack(value).slice() + const plainMsgpack = pack(value).slice() + const json = Buffer.from(JSON.stringify(value)) + + assert.deepStrictEqual(binaryDecode(binary), value) + assert.deepStrictEqual(fingerprintDecode(fingerprint), value) + assert.deepStrictEqual(msgpackDecode(effectMsgpack), value) + assert.deepStrictEqual(sharedPackr.unpack(sharedMsgpack), value) + assert.deepStrictEqual(unpack(plainMsgpack), value) + assert.deepStrictEqual(JSON.parse(json.toString()), value) + + return { + formats: [ + { + name: "SchemaBinary", + ...sizes(binary), + encode: () => binaryEncode(value), + decode: () => binaryDecode(binary) + }, + { + name: "SchemaBinary fingerprint", + ...sizes(fingerprint), + encode: () => fingerprintEncode(value), + decode: () => fingerprintDecode(fingerprint) + }, + { + name: "Effect Msgpack schema", + ...sizes(effectMsgpack), + encode: () => msgpackEncode(value), + decode: () => msgpackDecode(effectMsgpack) + }, + { + name: "msgpackr raw / shared structures", + ...sizes(sharedMsgpack), + encode: () => sharedPackr.pack(value), + decode: () => sharedPackr.unpack(sharedMsgpack) + }, + { + name: "msgpackr raw / plain", + ...sizes(plainMsgpack), + encode: () => pack(value), + decode: () => unpack(plainMsgpack) + }, + { + name: "JSON raw", + ...sizes(json), + encode: () => Buffer.from(JSON.stringify(value)), + decode: () => JSON.parse(json.toString()) + } + ] + } +} + +const prepared = cases.map((testCase) => ({ + name: testCase.name, + ...prepare(testCase.schema, testCase.value, testCase.protobuf) +})) + +const preparedRaw = rawCases.map((testCase) => ({ + name: testCase.name, + ...prepareRaw(testCase.schema, testCase.value) +})) + +const prepareStream = async >( + schema: S, + values: ReadonlyArray, + protobufFixture: ProtobufFixture +): Promise<{ readonly formats: ReadonlyArray; readonly sizes: ReadonlyArray }> => { + const binaryCodec = SchemaBinary.toCodec(schema) + const binaryEncode = Schema.encodeUnknownSync(binaryCodec) + const binaryFrames = values.map((value) => binaryEncode(value).slice()) + const binaryStream = concatFrames(binaryFrames) + const binaryFragments = binaryFrames.map((frame) => { + return [frame.subarray(0, 1), frame.subarray(1)] as const + }) + + const fingerprintEncode = Schema.encodeUnknownSync(SchemaBinary.toCodec(schema, { fingerprint: true })) + const fingerprintFrames = values.map((value) => fingerprintEncode(value).slice()) + const fingerprintStream = concatFrames(fingerprintFrames) + const fingerprintFragments = fingerprintFrames.map((frame) => { + return [frame.subarray(0, 1), frame.subarray(1)] as const + }) + + const jsonSchema = Schema.toCodecJson(schema) + const encodeMsgpackValue = Schema.encodeUnknownSync(jsonSchema) + const decodeMsgpackValue = Schema.decodeUnknownSync(jsonSchema) + const msgpackPackr = new Packr() + const msgpackStream = concatFrames(values.map((value) => msgpackPackr.pack(encodeMsgpackValue(value)).slice())) + const msgpackUnpackr = new Unpackr() + + const protobufFrames = values.map((value) => + protobufFixture.type.encodeDelimited(protobufFixture.encodeInput(value)).finish() + ) + const protobufStream = concatFrames(protobufFrames) + const decodeProtobufStream = () => { + const reader = protobuf.Reader.create(protobufStream) + const decoded: Array = [] + while (reader.pos < reader.len) { + decoded.push(protobufFixture.decodeOutput(protobufFixture.type.decodeDelimited(reader))) + } + return decoded + } + + const ndjsonFrames = values.map((value) => textEncoder.encode(`${JSON.stringify(encodeMsgpackValue(value))}\n`)) + const ndjsonStream = concatFrames(ndjsonFrames) + const ndjsonFragments = ndjsonFrames.map((frame) => { + return [frame.subarray(0, 1), frame.subarray(1)] as const + }) + const ndjsonDecoder = Ndjson.decodeSchema(jsonSchema)() + const runNdjson = (chunks: ReadonlyArray) => + Effect.runPromise( + Stream.fromIterable(chunks).pipe( + Stream.pipeThroughChannel(ndjsonDecoder), + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)) + ) + ) + let ndjsonSingleIndex = 0 + let ndjsonFragmentedIndex = 0 + const decodeNdjsonSingle = () => runNdjson([ndjsonFrames[ndjsonSingleIndex++ % ndjsonFrames.length]!]) + const decodeNdjsonBatch = () => runNdjson([ndjsonStream]) + const decodeNdjsonFragmented = () => runNdjson(ndjsonFragments[ndjsonFragmentedIndex++ % ndjsonFragments.length]!) + + const feedShapes = (options?: { readonly fingerprint: true }) => { + const frames = options === undefined ? binaryFrames : fingerprintFrames + const fragments = options === undefined ? binaryFragments : fingerprintFragments + const stream = options === undefined ? binaryStream : fingerprintStream + const singleParser = SchemaBinary.parser(schema, options) + const fragmentedParser = SchemaBinary.parser(schema, options) + const batchParser = SchemaBinary.parser(schema, options) + let singleIndex = 0 + let fragmentedIndex = 0 + return { + single: () => singleParser.feedSync(frames[singleIndex++ % frames.length]), + fragmented: () => { + const pair = fragments[fragmentedIndex++ % fragments.length] + const first = fragmentedParser.feedSync(pair[0]) + const second = fragmentedParser.feedSync(pair[1]) + return first.length === 0 ? second : [...first, ...second] + }, + batch: () => batchParser.feedSync(stream) + } + } + + const defaultFeeds = feedShapes() + const fingerprintFeeds = feedShapes({ fingerprint: true }) + const decodeSingle = defaultFeeds.single + const decodeFragmented = defaultFeeds.fragmented + const decodeBatch = defaultFeeds.batch + const decodeMsgpackStream = () => + msgpackUnpackr.unpackMultiple(msgpackStream).map((value) => decodeMsgpackValue(value)) + + assert.deepStrictEqual(decodeSingle(), [values[0]]) + assert.deepStrictEqual(decodeFragmented(), [values[0]]) + assert.deepStrictEqual(decodeBatch(), values) + assert.deepStrictEqual(fingerprintFeeds.single(), [values[0]]) + assert.deepStrictEqual(fingerprintFeeds.fragmented(), [values[0]]) + assert.deepStrictEqual(fingerprintFeeds.batch(), values) + assert.deepStrictEqual(decodeMsgpackStream(), values) + assert.deepStrictEqual(decodeProtobufStream(), values) + assert.deepStrictEqual(await decodeNdjsonSingle(), [values[0]]) + assert.deepStrictEqual(await decodeNdjsonFragmented(), [values[0]]) + assert.deepStrictEqual(await decodeNdjsonBatch(), values) + + return { + formats: [ + { name: "SchemaBinary parser / single frame", framesPerOp: 1, decode: decodeSingle }, + { name: "SchemaBinary parser / batch", framesPerOp: values.length, decode: decodeBatch }, + { name: "SchemaBinary parser / fragmented", framesPerOp: 1, decode: decodeFragmented }, + { name: "SchemaBinary fingerprint / single frame", framesPerOp: 1, decode: fingerprintFeeds.single }, + { name: "SchemaBinary fingerprint / batch", framesPerOp: values.length, decode: fingerprintFeeds.batch }, + { name: "SchemaBinary fingerprint / fragmented", framesPerOp: 1, decode: fingerprintFeeds.fragmented }, + { name: "Msgpack unpackMultiple / batch", framesPerOp: values.length, decode: decodeMsgpackStream }, + { name: "Protobuf decodeDelimited / batch", framesPerOp: values.length, decode: decodeProtobufStream }, + { name: "NDJSON Channel / single frame", framesPerOp: 1, decode: decodeNdjsonSingle }, + { name: "NDJSON Channel / batch", framesPerOp: values.length, decode: decodeNdjsonBatch }, + { name: "NDJSON Channel / fragmented", framesPerOp: 1, decode: decodeNdjsonFragmented } + ], + sizes: [ + { name: "SchemaBinary", frames: values.length, ...sizes(binaryStream) }, + { name: "SchemaBinary fingerprint", frames: values.length, ...sizes(fingerprintStream) }, + { name: "Msgpack", frames: values.length, ...sizes(msgpackStream) }, + { name: "Protobuf", frames: values.length, ...sizes(protobufStream) }, + { name: "NDJSON", frames: values.length, ...sizes(ndjsonStream) } + ] + } +} + +const preparedStreams = await Promise.all([ + ...cases.map((testCase) => ({ + name: testCase.name, + prepared: prepareStream( + testCase.schema, + Array.from({ length: streamBatchSize }, () => testCase.value), + testCase.protobuf + ) + })), + { name: "200 single-row frames", prepared: prepareStream(LargeRow, largeRows, largeRowProtobufFixture) } +].map(async ({ name, prepared }) => ({ name, ...await prepared }))) + +console.log(`Node ${process.version}; codec and schema construction excluded from timings.`) +console.log("JSON and Msgpack use the same Schema.toCodecJson representation; JSON sizes are UTF-8 bytes.") +console.log(`msgpackr native acceleration enabled: ${isNativeAccelerationEnabled}.`) +console.log( + "Cases suffixed with / raw serializers compare SchemaBinary's public codec with unvalidated raw serializers." +) +console.log("Protobuf uses prebuilt protobufjs descriptors; descriptor construction and encode adapters are excluded.") +console.log( + "Compare formats within a case and direction in the same run; absolute rates vary with the machine and runtime." +) + +console.table(prepared.flatMap((testCase) => + testCase.formats.map((format) => ({ + Case: testCase.name, + Format: format.name, + "Raw bytes": format.encodedSize, + "gzip -6 bytes": format.gzipSize, + "zstd bytes": format.zstdSize + })) +)) + +console.table(preparedRaw.flatMap((testCase) => + testCase.formats.map((format) => ({ + Case: testCase.name, + Format: format.name, + "Raw bytes": format.encodedSize, + "gzip -6 bytes": format.gzipSize, + "zstd bytes": format.zstdSize + })) +)) + +console.log( + "SchemaBinary streaming reuses one parser per feed shape; NDJSON runs its Channel per operation. Fragmented frames split after the first byte." +) +console.table(preparedStreams.flatMap((testCase) => + testCase.sizes.map((format) => ({ + Case: testCase.name, + Format: format.name, + Frames: format.frames, + "Raw bytes": format.encodedSize, + "Bytes / frame": format.encodedSize / format.frames, + "gzip -6 bytes": format.gzipSize, + "zstd bytes": format.zstdSize + })) +)) + +const bench = new Bench({ + iterations: 1_000, + time: 0, + warmupIterations: 100, + warmupTime: 0, + timestampProvider: "hrtimeNow" +}) +const tasks = new Map() +const sinkSentinel = Symbol("benchmark did not run") +let sink: unknown = sinkSentinel + +for (const testCase of prepared) { + for (const format of testCase.formats) { + for (const [direction, run] of [["encode", format.encode], ["decode", format.decode]] as const) { + const name = `${testCase.name} / ${format.name} / ${direction}` + tasks.set(name, { caseName: testCase.name, formatName: format.name, direction }) + bench.add(name, () => { + sink = run() + }) + } + } +} + +for (const testCase of preparedRaw) { + for (const format of testCase.formats) { + for (const [direction, run] of [["encode", format.encode], ["decode", format.decode]] as const) { + const name = `${testCase.name} / ${format.name} / ${direction}` + tasks.set(name, { caseName: testCase.name, formatName: format.name, direction }) + bench.add(name, () => { + sink = run() + }) + } + } +} + +await bench.run() + +if (sink === sinkSentinel) { + throw new Error("Benchmark did not run") +} + +console.table(bench.tasks.map((task) => { + const labels = tasks.get(task.name)! + const result = task.result + if (result?.state === "errored") { + return { + Case: labels.caseName, + Format: labels.formatName, + Direction: labels.direction, + Error: result.error.message + } + } + if (result?.state !== "completed") { + return { + Case: labels.caseName, + Format: labels.formatName, + Direction: labels.direction, + State: result?.state ?? "missing result" + } + } + return { + Case: labels.caseName, + Format: labels.formatName, + Direction: labels.direction, + "Throughput avg (ops/s)": Math.round(result.throughput.mean), + "Latency med (us/op)": (result.latency.p50 * 1_000).toFixed(2), + "Latency RME": `${result.latency.rme.toFixed(2)}%`, + Samples: result.latency.samplesCount + } +})) + +const streamBench = new Bench({ + iterations: 250, + time: 0, + warmupIterations: 25, + warmupTime: 0, + timestampProvider: "hrtimeNow" +}) +const streamTasks = new Map< + string, + { readonly caseName: string; readonly formatName: string; readonly framesPerOp: number } +>() + +for (const testCase of preparedStreams) { + for (const format of testCase.formats) { + const name = `${testCase.name} / ${format.name} / stream decode` + streamTasks.set(name, { + caseName: testCase.name, + formatName: format.name, + framesPerOp: format.framesPerOp + }) + streamBench.add(name, () => { + const decoded = format.decode() + if (decoded instanceof Promise) { + return decoded.then((value) => { + sink = value + }) + } + sink = decoded + }) + } +} + +await streamBench.run() + +console.table(streamBench.tasks.map((task) => { + const labels = streamTasks.get(task.name)! + const result = task.result + if (result?.state === "errored") { + return { + Case: labels.caseName, + Format: labels.formatName, + Error: result.error.message + } + } + if (result?.state !== "completed") { + return { + Case: labels.caseName, + Format: labels.formatName, + State: result?.state ?? "missing result" + } + } + return { + Case: labels.caseName, + Format: labels.formatName, + "Throughput avg (values/s)": Math.round(result.throughput.mean * labels.framesPerOp), + "Latency med (us/value)": (result.latency.p50 * 1_000 / labels.framesPerOp).toFixed(2), + "Latency RME": `${result.latency.rme.toFixed(2)}%`, + Samples: result.latency.samplesCount + } +})) diff --git a/repos/effect/packages/effect/benchmark/schema/SchemaError.ts b/repos/effect/packages/effect/benchmark/schema/SchemaError.ts new file mode 100644 index 0000000000..ba2b450c74 --- /dev/null +++ b/repos/effect/packages/effect/benchmark/schema/SchemaError.ts @@ -0,0 +1,46 @@ +import { Schema, SchemaIssue } from "effect" +import { Bench } from "tinybench" + +const batchSize = 1_000 +const bench = new Bench({ + iterations: 1_000, + time: 0, + warmupIterations: 100, + warmupTime: 0, + timestampProvider: "hrtimeNow" +}) +const issue = new SchemaIssue.InvalidValue({ message: "Expected string" }) +let sink: Schema.SchemaError | undefined + +bench.add("SchemaError construction", () => { + let error = new Schema.SchemaError(issue) + for (let index = 1; index < batchSize; index++) { + error = new Schema.SchemaError(issue) + } + sink = error +}) + +await bench.run() + +if (sink === undefined) { + throw new Error("Benchmark did not run") +} + +console.table(bench.table((task) => { + const result = task.result + if (result?.state !== "completed") { + return { + "Task name": task.name, + State: result?.state ?? "missing result" + } + } + const latencyToNs = (value: number) => value * 1_000_000 / batchSize + return { + "Task name": task.name, + "Latency avg (ns/op)": latencyToNs(result.latency.mean).toFixed(2), + "Latency med (ns/op)": latencyToNs(result.latency.p50).toFixed(2), + "Latency RME": `${result.latency.rme.toFixed(2)}%`, + "Throughput avg (ops/s)": Math.round(result.throughput.mean * batchSize), + Samples: result.latency.samplesCount + } +})) diff --git a/repos/effect/packages/effect/benchmark/schema/fixtures/msgpackr-example4.json b/repos/effect/packages/effect/benchmark/schema/fixtures/msgpackr-example4.json new file mode 100644 index 0000000000..585c88e55d --- /dev/null +++ b/repos/effect/packages/effect/benchmark/schema/fixtures/msgpackr-example4.json @@ -0,0 +1,284 @@ +{ + "metadata": { + "Designs": ["Randomized Controlled Trial"], + "Types": [], + "BriefSummary": "To determine the efficacy, long-term safety, and tolerability of alirocumab 300 mg every 4\n weeks (Q4W), in comparison with placebo, as well as its potential as a starting regimen. The\n dose regimen of 75 mg every 2 weeks (Q2W), as used in other studies, was added as a\n calibrator.", + "Abstract": "To determine the efficacy, long-term safety, and tolerability of alirocumab 300 mg every 4\n weeks (Q4W), in comparison with placebo, as well as its potential as a starting regimen. The\n dose regimen of 75 mg every 2 weeks (Q2W), as used in other studies, was added as a\n calibrator.", + "Acronym": null, + "ArticleId": "Qy3gwKWSoaWRmbmFEQA", + "Authors": null, + "CochraneID": null, + "Confidential": false, + "CorporateAuthor": null, + "Country": "Bulgaria, Canada, Hungary, Israel, Norway, Slovakia, United Kingdom, United States", + "CustomData": null, + "DatabaseType": "ClinicalTrials.gov", + "DOI": null, + "EmbaseAccessionNumber": null, + "Emtree": null, + "ErrataText": null, + "FullTextURL": null, + "Institution": null, + "ISSN": null, + "Issue": null, + "JournalTitle": null, + "MedlineID": null, + "MeSH": "Hypercholesterolemia|Antibodies, Monoclonal", + "Pages": null, + "ParentChildStatus": null, + "ParentID": null, + "PublicationDate": "March 21, 2017", + "PublicationYear": 2017, + "PubType": null, + "ReferenceStudy": null, + "SecondarySourceID": null, + "Source": "Regeneron Pharmaceuticals", + "SourceReferenceId": "NCT01926782", + "TaStudyDesign": "Randomized", + "Title": "A Randomized, Double-Blind, Placebo-Controlled Study to Evaluate the Efficacy and Safety of an Every Four Weeks Treatment Regimen of Alirocumab in Patients With Primary Hypercholesterolemia", + "TrialOutcome": null, + "Volume": null, + "Id": 179246831, + "Created": "2020-04-10T14:48:20.4384957Z", + "VersionNo": 2, + "ExtractData": null, + "Digitized": true, + "IsRapidExtract": false, + "IsUploaded": false + }, + "design": "Randomized Controlled Trial", + "conditions": [{ "label": "Cholesterol Total Increased", "id": "SUE_c" }], + "phase": 3, + "name": "NCT01926782", + "trialIds": ["NCT01926782"], + "acronyms": [], + "outcomeCount": 156, + "id": 179246831, + "groups": [ + { + "Id": "4r", + "RefId": "B5|O2~Alirocumab 75 mg Q2W/Up 150 mg Q2W Without Concomitant Statin", + "OriginalName": "Alirocumab 75 mg Q2W/Up 150 mg Q2W Without Concomitant Statin", + "N": 37, + "age": 59.3, + "ageSD": 11.3, + "male": 37.83783783783784, + "Interventions": [{ "termIds": [["SUBYEL", "SUB_Oc"], ["SUNUVb"]] }], + "analyzeAs": "Alirocumab", + "analyzableScore": 1.0717734625362931, + "matchingScore": 0 + }, + { + "Id": "zB", + "RefId": "B6|O3~Alirocumab 300 mg Q4W/Up 150 mg Q2W Without Concomitant Statin", + "OriginalName": "Alirocumab 300 mg Q4W/Up 150 mg Q2W Without Concomitant Statin", + "N": 146, + "age": 59.2, + "ageSD": 10.8, + "male": 45.205479452054796, + "Interventions": [{ "termIds": [["SUBYEL", "SUB_Oc"]] }], + "analyzeAs": "Statins", + "analyzableScore": 1.0717734625362931, + "matchingScore": 0 + }, + { + "Id": "3!", + "RefId": "B4|O1~Placebo Q2W Without Concomitant Statin", + "OriginalName": "Placebo Q2W Without Concomitant Statin", + "N": 73, + "age": 59.4, + "ageSD": 10.2, + "male": 54.794520547945204, + "Interventions": [{ "termIds": [["SUGeLS"], ["SUBYEL", "SUB_Oc"]] }], + "analyzeAs": "Control", + "analyzableScore": 1.2020833333333334, + "matchingScore": 0 + }, + { + "Id": "tv", + "RefId": "E3", + "OriginalName": "Alirocumab 300 mg Q4W/Up 150 mg Q2W", + "Interventions": [{ "termIds": [["SUCO54", "SUNUVb"]] }] + }, + { + "Id": "jt", + "RefId": "B3|O3~Alirocumab 300 mg Q4W/Up 150 mg Q2W With Concomitant Statin", + "OriginalName": "Alirocumab 300 mg Q4W/Up 150 mg Q2W With Concomitant Statin", + "N": 312, + "age": 61.6, + "ageSD": 10, + "male": 60.8974358974359, + "Interventions": [{ "termIds": [["SUBYEL", "SUB_Oc"]] }] + }, + { + "Id": "5!", + "RefId": "E2", + "OriginalName": "Alirocumab 75 mg Q2W/Up 150 mg Q2W", + "Interventions": [{ "termIds": [["SUNUVb"]] }] + }, + { + "Id": "4E", + "RefId": "B2|O2~Alirocumab 75 mg Q2W/Up 150 mg Q2W With Concomitant Statin", + "OriginalName": "Alirocumab 75 mg Q2W/Up 150 mg Q2W With Concomitant Statin", + "N": 78, + "age": 60.7, + "ageSD": 9.1, + "male": 65.38461538461539, + "Interventions": [{ "termIds": [["SUBYEL", "SUB_Oc"], ["SUNUVb"]] }] + }, + { + "Id": "i4", + "Interventions": [ + { + "Id": "Ya", + "Name": 178613599, + "Treatments": [{ "Id": "((", "Phase": "k)" }], + "Type": "Drug", + "termIds": [["SUGeLS"], ["SUNUVb"]], + "terms": [["Placebo"], ["Alirocumab"]] + }, + { + "Id": "o)", + "Name": 2159990, + "Treatments": [{ "Id": "1$", "Phase": "k)" }], + "Type": "Drug", + "termIds": [["SUBYEL"]], + "terms": [["Statins"]] + } + ], + "RefId": "E1|Placebo Q2W", + "OriginalName": "Placebo Q2W" + }, + { + "Id": "Ls", + "RefId": "B1|O1~Placebo Q2W With Concomitant Statin", + "OriginalName": "Placebo Q2W With Concomitant Statin", + "N": 157, + "age": 61.6, + "ageSD": 9.7, + "male": 64.3312101910828, + "Interventions": [{ "termIds": [["SUGeLS"], ["SUBYEL", "SUB_Oc"]] }] + } + ], + "hasDocData": true, + "hasRapidExtract": false, + "N": 803, + "queryScore": 1.4868329805051381, + "matchingScore": 7.960635921410255, + "score": 22.084654254966498, + "outcomes": [ + { + "id": "179246387", + "type": "Change", + "unit": "%", + "termIds": [["SUF0R", "SUBskP"]], + "quantifiers": [], + "name": "Calculated LDL-C in Not Receiving Concomitant Statin Therapy - On-Treatment Analysis", + "cells": [ + { "number": -0.4, "unit": "%", "group": "3!", "varType": "se", "N": 70, "se": 2, "sd": 16.73 }, + { "number": -54.6, "unit": "%", "group": "4r", "varType": "se", "N": 37, "se": 2.8, "sd": 17.03 }, + { "number": -59.4, "unit": "%", "group": "zB", "varType": "se", "N": 141, "se": 1.4, "sd": 16.62 } + ], + "time": { + "Id": 67122072, + "Low": { "Value": "Baseline" }, + "High": { "Number": 24, "Unit": "wk" }, + "Type": "Total", + "days": 168, + "description": "24wk" + }, + "score": 2.08, + "matchingTerm": "SUF0R", + "suggestedPositive": false, + "sourceUnit": "%" + }, + { + "id": "179246389", + "type": "Change", + "unit": "%", + "termIds": [["SUF0R", "SUBskP"]], + "quantifiers": [], + "name": "Calculated LDL-C in Receiving Concomitant Statin Therapy - On-Treatment Analysis", + "cells": [ + { "number": -0.3, "unit": "%", "group": "Ls", "varType": "se", "N": 151, "se": 2.1, "sd": 25.81 }, + { "number": -55.1, "unit": "%", "group": "4E", "varType": "se", "N": 75, "se": 3, "sd": 25.98 }, + { "number": -62.3, "unit": "%", "group": "jt", "varType": "se", "N": 302, "se": 1.5, "sd": 26.07 } + ], + "time": { + "Id": 67122072, + "Low": { "Value": "Baseline" }, + "High": { "Number": 24, "Unit": "wk" }, + "Type": "Total", + "days": 168, + "description": "24wk" + }, + "score": 2.08, + "matchingTerm": "SUF0R", + "suggestedPositive": false, + "sourceUnit": "%" + }, + { + "id": "179246393", + "type": "Change", + "unit": "%", + "termIds": [["SUF0R", "SUBskP"]], + "quantifiers": [], + "name": "Calculated LDL-C in Not Receiving Concomitant Statin Therapy - On-Treatment Analysis", + "cells": [ + { "number": -0.5, "unit": "%", "group": "3!", "varType": "se", "N": 70, "se": 2, "sd": 16.73 }, + { "number": -53.9, "unit": "%", "group": "4r", "varType": "se", "N": 37, "se": 2.7, "sd": 16.42 }, + { "number": -60, "unit": "%", "group": "zB", "varType": "se", "N": 141, "se": 1.4, "sd": 16.62 } + ], + "time": { + "Id": 67122069, + "Low": { "Value": "Baseline" }, + "High": { "Number": 12, "Unit": "wk" }, + "Type": "Total", + "days": 84, + "description": "12wk" + }, + "score": 2.08, + "matchingTerm": "SUF0R", + "suggestedPositive": false, + "sourceUnit": "%" + }, + { + "id": "179246394", + "type": "Change", + "unit": "%", + "termIds": [["SUF0R", "SUBskP"]], + "quantifiers": [], + "name": "Calculated LDL-C in Receiving Concomitant Statin Therapy - On-Treatment Analysis", + "cells": [ + { "number": 1.4, "unit": "%", "group": "Ls", "varType": "se", "N": 151, "se": 1.9, "sd": 23.35 }, + { "number": -47.3, "unit": "%", "group": "4E", "varType": "se", "N": 75, "se": 2.8, "sd": 24.25 }, + { "number": -58, "unit": "%", "group": "jt", "varType": "se", "N": 302, "se": 1.4, "sd": 24.33 } + ], + "time": { + "Id": 67122069, + "Low": { "Value": "Baseline" }, + "High": { "Number": 12, "Unit": "wk" }, + "Type": "Total", + "days": 84, + "description": "12wk" + }, + "score": 2.08, + "matchingTerm": "SUF0R", + "suggestedPositive": false, + "sourceUnit": "%" + } + ], + "characteristics": [ + { + "id": "179246354", + "type": "Binary", + "isCharacteristic": true, + "termIds": [["SUE_c", "SUCbN", "SUyJj"]], + "quantifiers": [], + "name": "Patients not having adequate control of their hypercholesterolemia based on their individual level of CVD risk", + "cells": [], + "number": 100 + } + ], + "outcomesScore": 18.97947630112307 +} diff --git a/repos/effect/packages/effect/package.json b/repos/effect/packages/effect/package.json index b4e32f4507..21bcb291ed 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-beta.107", + "version": "4.0.0-rc.112", "license": "MIT", "description": "The missing standard library for TypeScript, for writing production-grade software.", "homepage": "https://effect.website", @@ -105,19 +105,17 @@ "check": "tsc -b tsconfig.json" }, "devDependencies": { - "@types/node": "^26.1.2", + "@types/node": "^26.2.0", "ajv": "^8.20.0", "ajv-draft-04": "^1.0.0", "ast-types": "^0.14.2", - "immer": "^11.1.11", - "tinybench": "^6.0.2", + "immer": "^11.1.18", + "protobufjs": "^7.6.5", + "tinybench": "^6.1.3", "valibot": "^1.4.2" }, "dependencies": { - "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^2.0.4", - "uuid": "^14.0.1" + "msgpackr": "^2.0.5" } } diff --git a/repos/effect/packages/effect/runtimeperf/config.json b/repos/effect/packages/effect/runtimeperf/config.json index 5118ac2f41..92d07ddf0f 100644 --- a/repos/effect/packages/effect/runtimeperf/config.json +++ b/repos/effect/packages/effect/runtimeperf/config.json @@ -160,6 +160,34 @@ ], "path": "invalid", "size": 100 + }, + { + "name": "multi-sentinel-100-valid-first-effect", + "export": "effectMultiSentinel100ValidFirst", + "scenario": "multi-sentinel-100-valid-first", + "implementation": "effect", + "family": "union", + "astTags": [ + "Union", + "Objects", + "Literal" + ], + "path": "valid", + "size": 100 + }, + { + "name": "multi-sentinel-100-invalid-variant-effect", + "export": "effectMultiSentinel100InvalidVariant", + "scenario": "multi-sentinel-100-invalid-variant", + "implementation": "effect", + "family": "union", + "astTags": [ + "Union", + "Objects", + "Literal" + ], + "path": "invalid", + "size": 100 } ] }, diff --git a/repos/effect/packages/effect/runtimeperf/suites/schema/fixtures/comparison.ts b/repos/effect/packages/effect/runtimeperf/suites/schema/fixtures/comparison.ts index 44f7d64da8..252635985c 100644 --- a/repos/effect/packages/effect/runtimeperf/suites/schema/fixtures/comparison.ts +++ b/repos/effect/packages/effect/runtimeperf/suites/schema/fixtures/comparison.ts @@ -69,3 +69,23 @@ const effectTagged100 = Schema.Union( export const effectTagged100ValidLast = effectCase(effectTagged100, taggedInput, true) export const effectTagged100InvalidSelected = effectCase(effectTagged100, taggedInvalidSelected, false) export const effectTagged100InvalidTag = effectCase(effectTagged100, taggedInvalidTag, false) + +const makeEffectMultiSentinelMember = (index) => + Schema.Struct({ + kind: Schema.Literal("shared"), + variant: Schema.Literal(`variant${index}`), + value: Schema.String + }) + +const effectMultiSentinel100 = Schema.Union( + Array.from({ length: 100 }, (_, index) => makeEffectMultiSentinelMember(index)) +) +const multiSentinelValidFirst = { kind: "shared", variant: "variant0", value: "value" } +const multiSentinelInvalidVariant = { kind: "shared", variant: "missing", value: "value" } + +export const effectMultiSentinel100ValidFirst = effectCase(effectMultiSentinel100, multiSentinelValidFirst, true) +export const effectMultiSentinel100InvalidVariant = effectCase( + effectMultiSentinel100, + multiSentinelInvalidVariant, + false +) diff --git a/repos/effect/packages/effect/src/Array.ts b/repos/effect/packages/effect/src/Array.ts index c35cf612a9..9e594cb808 100644 --- a/repos/effect/packages/effect/src/Array.ts +++ b/repos/effect/packages/effect/src/Array.ts @@ -3032,6 +3032,13 @@ export const group: (self: NonEmptyReadonlyArray) => NonEmptyArray(self: NonEmptyReadonlyArray) => NonEmptyArray( f: (a: A) => K - ): (self: Iterable) => Record, NonEmptyArray> + ): (self: Iterable) => Record.ReadonlyRecord.GroupByResult> ( self: Iterable, f: (a: A) => K - ): Record, NonEmptyArray> + ): Record.ReadonlyRecord.GroupByResult> } = dual(2, ( self: Iterable, f: (a: A) => K -): Record, NonEmptyArray> => { +): Record.ReadonlyRecord.GroupByResult> => { const out: Record> = {} for (const a of self) { const k = f(a) diff --git a/repos/effect/packages/effect/src/Channel.ts b/repos/effect/packages/effect/src/Channel.ts index a9d09d3c1b..cbd1247629 100644 --- a/repos/effect/packages/effect/src/Channel.ts +++ b/repos/effect/packages/effect/src/Channel.ts @@ -20,7 +20,7 @@ import * as Fiber from "./Fiber.ts" import type * as Filter from "./Filter.ts" import type { LazyArg } from "./Function.ts" import { constant, constTrue, constVoid, dual, identity as identity_ } from "./Function.ts" -import { ClockRef, endSpan } from "./internal/effect.ts" +import { ClockRef, endSpan, scopeFinalizerCountUnsafe } from "./internal/effect.ts" import { addSpanStackTrace } from "./internal/tracer.ts" import * as Iterable from "./Iterable.ts" import * as Latch from "./Latch.ts" @@ -2171,7 +2171,7 @@ const mapEffectConcurrent = < Effect.Effect>, OutErr | EX | Cause.Done >(concurrencyN - 2) - yield* Scope.addFinalizer(forkedScope, Queue.shutdown(queue)) + yield* Scope.addFinalizer(forkedScope, Queue.shutdown(effects)) yield* Queue.take(effects).pipe( Effect.flatten, @@ -2500,7 +2500,7 @@ const flatMapSequential = < const catchHalt = Pull.catchDone((_) => { childPull = undefined // we can reuse the scope if the only finalizer is the "fork" one - if (childScope!.state._tag === "Open" && childScope!.state.finalizers.size === 1) { + if (childScope!.state._tag === "Open" && scopeFinalizerCountUnsafe(childScope!) === 1) { return makePull } const close = Scope.close(childScope!, Exit.void) diff --git a/repos/effect/packages/effect/src/Context.ts b/repos/effect/packages/effect/src/Context.ts index 631e2d1d7c..3db89c975c 100644 --- a/repos/effect/packages/effect/src/Context.ts +++ b/repos/effect/packages/effect/src/Context.ts @@ -584,13 +584,13 @@ const Proto: Omit< ContextImpl, "cacheRoot" | "base" | "overlay" | "depth" | "_flat" | "baseHits" > = { + get mapUnsafe() { + return flatten(this as any as ContextImpl) + }, ...PipeInspectableProto, [TypeId]: { _Services: (_: never) => _ }, - get mapUnsafe() { - return flatten(this as any as ContextImpl) - }, toJSON(this: Context) { return { _id: "Context", @@ -776,24 +776,36 @@ export const add: { self: Context, key: Key, service: Types.NoInfer +): Context => addUnsafe(self, key.key, service)) + +/** + * Adds a service by key to a given `Context` using a string key. + * + * @category combining + * @since 4.0.0 + */ +export const addUnsafe = ( + self: Context, + key: string, + service: Types.NoInfer ): Context => { const impl = self as ContextImpl - const cacheRoot = cacheKeys.has(key.key) ? undefined : impl.cacheRoot + const cacheRoot = cacheKeys.has(key) ? undefined : impl.cacheRoot if (impl.depth >= MaxDepth) { // Rebase the overlay chain into a flat map, keeping the cacheRoot so a // rebase on an ordinary key does not invalidate fiber caches const map = new Map(impl.mapUnsafe) - map.set(key.key, service) + map.set(key, service) return makeImpl(cacheRoot, map, undefined, 0) } return makeImpl( cacheRoot, impl.base, - { key: key.key, value: service, parent: impl.overlay }, + { key, value: service, parent: impl.overlay }, impl.depth + 1 ) -}) +} /** * Adds or removes a service depending on an `Option`. diff --git a/repos/effect/packages/effect/src/Crypto.ts b/repos/effect/packages/effect/src/Crypto.ts index b817a3e8c8..9dfc905902 100644 --- a/repos/effect/packages/effect/src/Crypto.ts +++ b/repos/effect/packages/effect/src/Crypto.ts @@ -11,6 +11,7 @@ */ import * as Context from "./Context.ts" import * as Effect from "./Effect.ts" +import * as Uuid from "./internal/uuid.ts" import * as PlatformError from "./PlatformError.ts" const TypeId = "~effect/platform/Crypto" @@ -270,9 +271,9 @@ export const make = ( } return buffer }), - randomUUIDv4: Effect.sync(() => formatUUIDv4(randomBytesUnsafe(16))), + randomUUIDv4: Effect.sync(() => Uuid.v4String(randomBytesUnsafe(16))), randomUUIDv7: Effect.clockWith((clock) => - Effect.succeed(formatUUIDv7(clock.currentTimeMillisUnsafe(), randomBytesUnsafe(16))) + Effect.succeed(Uuid.v7String(clock.currentTimeMillisUnsafe(), randomBytesUnsafe(16))) ) }) } @@ -285,41 +286,3 @@ const validateSize = (method: string, size: number): Effect.Effect byte.toString(16).padStart(2, "0") - -const formatUUID = (bytes: Uint8Array): string => { - const segments = [ - bytes.subarray(0, 4), - bytes.subarray(4, 6), - bytes.subarray(6, 8), - bytes.subarray(8, 10), - bytes.subarray(10, 16) - ] - - return segments.map((segment) => Array.from(segment, hex).join("")).join("-") -} - -const formatUUIDv4 = (bytes: Uint8Array): string => { - bytes[6] = (bytes[6] & 0x0f) | 0x40 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - - return formatUUID(bytes) -} - -const maxUUIDv7Timestamp = 2 ** 48 - 1 - -const formatUUIDv7 = (timestampMillis: number, bytes: Uint8Array): string => { - const timestamp = Math.min(Math.max(0, Math.trunc(timestampMillis)), maxUUIDv7Timestamp) - - bytes[0] = Math.floor(timestamp / 2 ** 40) - bytes[1] = Math.floor(timestamp / 2 ** 32) & 0xff - bytes[2] = Math.floor(timestamp / 2 ** 24) & 0xff - bytes[3] = Math.floor(timestamp / 2 ** 16) & 0xff - bytes[4] = Math.floor(timestamp / 2 ** 8) & 0xff - bytes[5] = timestamp & 0xff - bytes[6] = (bytes[6] & 0x0f) | 0x70 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - - return formatUUID(bytes) -} diff --git a/repos/effect/packages/effect/src/Deferred.ts b/repos/effect/packages/effect/src/Deferred.ts index 048fe64332..afe2ab3a97 100644 --- a/repos/effect/packages/effect/src/Deferred.ts +++ b/repos/effect/packages/effect/src/Deferred.ts @@ -176,8 +176,12 @@ const _await = (self: Deferred): Effect => self.resumes ??= [] self.resumes.push(resume) return internalEffect.sync(() => { - const index = self.resumes!.indexOf(resume) - self.resumes!.splice(index, 1) + // Completion resumes all waiters and clears `resumes`, so a cleanup + // running after completion has nothing to unregister. + const resumes = self.resumes + if (resumes === undefined) return + const index = resumes.indexOf(resume) + if (index >= 0) resumes.splice(index, 1) }) }) @@ -853,10 +857,14 @@ export const doneUnsafe = (self: Deferred, effect: Effect): bo if (self.effect) return false self.effect = effect if (self.resumes) { - for (let i = 0; i < self.resumes.length; i++) { - self.resumes[i](effect) - } + // Clear `resumes` before resuming: a waiter resumed with an interrupt + // cause dies synchronously inside `resume`, and its await cleanup would + // otherwise splice this array mid-iteration and skip the next waiter. + const resumes = self.resumes self.resumes = undefined + for (let i = 0; i < resumes.length; i++) { + resumes[i](effect) + } } return true } diff --git a/repos/effect/packages/effect/src/Effect.ts b/repos/effect/packages/effect/src/Effect.ts index 6cb027671e..3afd6618b1 100644 --- a/repos/effect/packages/effect/src/Effect.ts +++ b/repos/effect/packages/effect/src/Effect.ts @@ -777,10 +777,12 @@ export const findFirstFilter: { * @since 2.0.0 */ export const forEach: { - , const Discard extends boolean = false>( - f: (a: Arr.ReadonlyArray.Infer, i: number) => Effect, + = Iterable, const Discard extends boolean = false>( + f: (a: A, i: number) => Effect, options?: { readonly concurrency?: Concurrency | undefined; readonly discard?: Discard | undefined } | undefined - ): (self: S) => Effect : void, E, R> + ): ( + self: [S] extends [never] ? Iterable : S + ) => Effect : void, E, R> , const Discard extends boolean = false>( self: S, f: (a: Arr.ReadonlyArray.Infer, i: number) => Effect, @@ -788,6 +790,34 @@ export const forEach: { ): Effect : void, E, R> } = internal.forEach +/** + * Returns the first element of the iterable produced by an effect, or fails + * with `NoSuchElementError` if the iterable is empty. + * + * **When to use** + * + * Use when an effect produces a collection that must contain at least one + * element and absence should be represented in the typed error channel. + * + * **Example** (Getting the first element) + * + * ```ts import.meta.vitest + * import { Effect, Option } from "effect" + * + * const first = await Effect.runPromise(Effect.head(Effect.succeed([1, 2, 3]))) + * first // => 1 + * + * const empty = Effect.head(Effect.succeed([] as Array)).pipe(Effect.catchNoSuchElement) + * await Effect.runPromise(empty) // => Option.none() + * ``` + * + * @category getters + * @since 2.0.0 + */ +export const head: ( + self: Effect, E, R> +) => Effect = internal.head + /** * Executes a body effect repeatedly while a condition holds true. * @@ -1813,10 +1843,19 @@ export const fromResult: (result: Result.Result) => Effect = i * @category converting * @since 4.0.0 */ -export const fromOption: | LazyArg, E = Cause.NoSuchElementError>( - arg: Arg, - ...rest: [Arg] extends [Option] ? [onNone?: LazyArg] : [] -) => [Arg] extends [Option] ? Effect +export const fromOption: < + Arg extends Option | LazyArg, + Rest extends [] | [onNone: LazyArg | undefined] = [] +>( + arg: Arg & (Rest extends [] ? unknown : Option), + ...rest: Rest +) => [Arg] extends [Option] ? Effect< + A, + Rest extends [LazyArg] ? E + : Rest extends [undefined] ? Cause.NoSuchElementError + : Rest extends [LazyArg | undefined] ? E | Cause.NoSuchElementError + : Cause.NoSuchElementError + > : [Arg] extends [LazyArg] ? (option: Option) => Effect : never = internal.fromOption @@ -5813,7 +5852,7 @@ export const contextWith: ( * * const Database = Context.Service("Database") * - * const DatabaseLive = Layer.succeed(Database)({ + * const DatabaseLayer = Layer.succeed(Database)({ * query: Effect.fn("Database.query")((sql: string) => Effect.succeed(`Result for: ${sql}`)) * }) * @@ -5822,7 +5861,7 @@ export const contextWith: ( * return yield* db.query("SELECT * FROM users") * }) * - * const provided = Effect.provide(program, DatabaseLive) + * const provided = Effect.provide(program, DatabaseLayer) * * await Effect.runPromise(provided) // => "Result for: SELECT * FROM users" * ``` @@ -6891,7 +6930,8 @@ export const onErrorFilter: { * **Details** * * This low-level operator preserves the source effect's result unless the - * finalizer fails. Prefer `onExit` for normal cleanup logic. + * finalizer fails. If both the source effect and the finalizer fail, the two + * causes are merged. Prefer `onExit` for normal cleanup logic. * * @see {@link onExit} for ordinary exit-aware cleanup whose finalizer always returns an effect * @@ -6908,6 +6948,8 @@ export const onExitPrimitive: ( * Ensures that a cleanup function runs whether this effect succeeds, fails, or * is interrupted. * + * If both the effect and the cleanup function fail, the two causes are merged. + * * **Example** (Observing every exit) * * ```ts import.meta.vitest @@ -14520,11 +14562,12 @@ export const tx = ( effect: Effect ): Effect> => withFiber((fiber) => { - if (fiber.context.mapUnsafe.has(Transaction.key)) { + let state = Context.getOrUndefined(fiber.context, Transaction) + if (state) { return effect as Effect> } // Create transaction state only at the outermost boundary - const state: Transaction["Service"] = { journal: new Map(), retry: false } + state = { journal: new Map(), retry: false } let result: Exit.Exit | undefined return uninterruptibleMask((restore) => flatMap( diff --git a/repos/effect/packages/effect/src/Encoding.ts b/repos/effect/packages/effect/src/Encoding.ts index 918f05e550..5e6a77844d 100644 --- a/repos/effect/packages/effect/src/Encoding.ts +++ b/repos/effect/packages/effect/src/Encoding.ts @@ -405,6 +405,32 @@ export const decodeBase64UrlString = (str: string) => Result.map(decodeBase64Url export const encodeHex: (input: Uint8Array | string) => string = (input) => typeof input === "string" ? hexEncodeUint8Array(encoder.encode(input)) : hexEncodeUint8Array(input) +/** + * Generates a random lowercase hexadecimal string, optimized for lengths that + * are multiples of 8. + * + * `length` is not validated. The function generates `length >>> 3` random + * 8-character words, so non-negative lengths below `2 ** 32` are rounded down + * to a multiple of 8 and other values follow JavaScript's unsigned 32-bit + * coercion rules. + * + * This function uses `Math.random()` and is not cryptographically secure. For + * security-sensitive values, use the `Crypto.Crypto` service's `randomBytes` + * method and encode the result with {@link encodeHex}. + * + * @category encoding + * @since 4.0.0 + */ +export const randomHex = (length: number): string => { + let result = "" + for (let i = length >>> 3; i > 0; i--) { + const word = (Math.random() * 0x100000000) >>> 0 + result += byteToHex[word >>> 24] + byteToHex[(word >>> 16) & 0xff] + byteToHex[(word >>> 8) & 0xff] + + byteToHex[word & 0xff] + } + return result +} + /** * Decodes a hexadecimal string into bytes safely. * @@ -743,12 +769,16 @@ const base64UrlEncodeUint8Array = (data: Uint8Array) => // Hex internals -const hexEncodeUint8Array = (bytes: Uint8Array) => { +const byteToHex: Array = [] +for (let i = 0; i < 256; i++) { + byteToHex.push(i.toString(16).padStart(2, "0")) +} + +const hexEncodeUint8Array = (bytes: Uint8Array): string => { let result = "" - for (let i = 0; i < bytes.length; ++i) { - result += bytesToHex[bytes[i]] + for (let i = 0; i < bytes.length; i++) { + result += byteToHex[bytes[i]] } - return result } @@ -767,262 +797,3 @@ const fromHexChar = (byte: number) => { throw new TypeError("Invalid input") } - -const bytesToHex = [ - "00", - "01", - "02", - "03", - "04", - "05", - "06", - "07", - "08", - "09", - "0a", - "0b", - "0c", - "0d", - "0e", - "0f", - "10", - "11", - "12", - "13", - "14", - "15", - "16", - "17", - "18", - "19", - "1a", - "1b", - "1c", - "1d", - "1e", - "1f", - "20", - "21", - "22", - "23", - "24", - "25", - "26", - "27", - "28", - "29", - "2a", - "2b", - "2c", - "2d", - "2e", - "2f", - "30", - "31", - "32", - "33", - "34", - "35", - "36", - "37", - "38", - "39", - "3a", - "3b", - "3c", - "3d", - "3e", - "3f", - "40", - "41", - "42", - "43", - "44", - "45", - "46", - "47", - "48", - "49", - "4a", - "4b", - "4c", - "4d", - "4e", - "4f", - "50", - "51", - "52", - "53", - "54", - "55", - "56", - "57", - "58", - "59", - "5a", - "5b", - "5c", - "5d", - "5e", - "5f", - "60", - "61", - "62", - "63", - "64", - "65", - "66", - "67", - "68", - "69", - "6a", - "6b", - "6c", - "6d", - "6e", - "6f", - "70", - "71", - "72", - "73", - "74", - "75", - "76", - "77", - "78", - "79", - "7a", - "7b", - "7c", - "7d", - "7e", - "7f", - "80", - "81", - "82", - "83", - "84", - "85", - "86", - "87", - "88", - "89", - "8a", - "8b", - "8c", - "8d", - "8e", - "8f", - "90", - "91", - "92", - "93", - "94", - "95", - "96", - "97", - "98", - "99", - "9a", - "9b", - "9c", - "9d", - "9e", - "9f", - "a0", - "a1", - "a2", - "a3", - "a4", - "a5", - "a6", - "a7", - "a8", - "a9", - "aa", - "ab", - "ac", - "ad", - "ae", - "af", - "b0", - "b1", - "b2", - "b3", - "b4", - "b5", - "b6", - "b7", - "b8", - "b9", - "ba", - "bb", - "bc", - "bd", - "be", - "bf", - "c0", - "c1", - "c2", - "c3", - "c4", - "c5", - "c6", - "c7", - "c8", - "c9", - "ca", - "cb", - "cc", - "cd", - "ce", - "cf", - "d0", - "d1", - "d2", - "d3", - "d4", - "d5", - "d6", - "d7", - "d8", - "d9", - "da", - "db", - "dc", - "dd", - "de", - "df", - "e0", - "e1", - "e2", - "e3", - "e4", - "e5", - "e6", - "e7", - "e8", - "e9", - "ea", - "eb", - "ec", - "ed", - "ee", - "ef", - "f0", - "f1", - "f2", - "f3", - "f4", - "f5", - "f6", - "f7", - "f8", - "f9", - "fa", - "fb", - "fc", - "fd", - "fe", - "ff" -] diff --git a/repos/effect/packages/effect/src/ErrorReporter.ts b/repos/effect/packages/effect/src/ErrorReporter.ts index 5864295478..220c141e0b 100644 --- a/repos/effect/packages/effect/src/ErrorReporter.ts +++ b/repos/effect/packages/effect/src/ErrorReporter.ts @@ -204,7 +204,7 @@ export const CurrentErrorReporters: Context.Reference * }) * * // Replace all existing reporters - * const ReporterLive = ErrorReporter.layer([ + * const ReporterLayer = ErrorReporter.layer([ * firstReporter, * secondReporter * ]) @@ -217,7 +217,7 @@ export const CurrentErrorReporters: Context.Reference * * const program = Effect.fail("boom").pipe( * Effect.withErrorReporting, - * Effect.provide(ReporterLive), + * Effect.provide(ReporterLayer), * Effect.exit * ) * diff --git a/repos/effect/packages/effect/src/Formatter.ts b/repos/effect/packages/effect/src/Formatter.ts index 843b5b88bc..db202ef930 100644 --- a/repos/effect/packages/effect/src/Formatter.ts +++ b/repos/effect/packages/effect/src/Formatter.ts @@ -127,32 +127,6 @@ export function format(input: unknown, options?: { } function recur(v: unknown, d = 0): string { - if (Array.isArray(v)) { - if (ancestors.has(v)) return CIRCULAR - ancestors.add(v) - const output = !gap || v.length <= 1 - ? `[${v.map((x) => recur(x, d)).join(",")}]` - : `[\n${ind(d + 1)}${v.map((x) => recur(x, d + 1)).join(",\n" + ind(d + 1))}\n${ind(d)}]` - ancestors.delete(v) - return output - } - - if (v instanceof Date) return formatDate(v) - - if ( - !options?.ignoreToString && - Predicate.hasProperty(v, "toString") && - typeof v["toString"] === "function" && - v["toString"] !== Object.prototype.toString && - v["toString"] !== Array.prototype.toString - ) { - const s = safeToString(v) - if (v instanceof Error && v.cause) { - return `${s} (cause: ${recur(v.cause, d)})` - } - return s - } - if (typeof v === "string") return JSON.stringify(v) if ( @@ -170,7 +144,22 @@ export function format(input: unknown, options?: { let output: string if (symbolRedactable in v) { - output = format(getRedacted(v as any)) + output = recur(getRedacted(v as any), d) + } else if (Array.isArray(v)) { + output = !gap || v.length <= 1 + ? `[${v.map((x) => recur(x, d)).join(",")}]` + : `[\n${ind(d + 1)}${v.map((x) => recur(x, d + 1)).join(",\n" + ind(d + 1))}\n${ind(d)}]` + } else if (v instanceof Date) { + output = formatDate(v) + } else if ( + !options?.ignoreToString && + Predicate.hasProperty(v, "toString") && + typeof v["toString"] === "function" && + v["toString"] !== Object.prototype.toString && + v["toString"] !== Array.prototype.toString + ) { + const s = safeToString(v) + output = v instanceof Error && v.cause ? `${s} (cause: ${recur(v.cause, d)})` : s } else if (Symbol.iterator in v) { output = `${v.constructor.name}(${recur(Array.from(v as any), d)})` } else { @@ -250,9 +239,9 @@ function safeToString(input: any): string { * Uses `JSON.stringify` internally with a replacer that tracks the current * object ancestry. Circular references are replaced with `undefined`, which * omits them from object output. `Redactable` values are automatically redacted - * before serialization. Values not supported by JSON otherwise follow standard - * `JSON.stringify` behavior. The `space` parameter controls indentation and - * defaults to `0`. + * before serialization. `BigInt` values are stringified with an `n` suffix. + * Values not supported by JSON otherwise follow standard `JSON.stringify` + * behavior. The `space` parameter controls indentation and defaults to `0`. * * **Gotchas** * @@ -298,8 +287,14 @@ export function formatJson(input: unknown, options?: { const ancestors: Array = [] return JSON.stringify( input, - function(this: unknown, _key: string, value: unknown) { - const redacted = redact(value) + function(this: object, key: string, value: unknown) { + const original = Object.getOwnPropertyDescriptor(this, key)?.value + const redacted = Predicate.hasProperty(original, symbolRedactable) + ? redact(original) + : redact(value) + if (typeof redacted === "bigint") { + return format(redacted) + } if (typeof redacted !== "object" || redacted === null) { return redacted } diff --git a/repos/effect/packages/effect/src/Graph.ts b/repos/effect/packages/effect/src/Graph.ts index f564b512ba..4a099e3056 100644 --- a/repos/effect/packages/effect/src/Graph.ts +++ b/repos/effect/packages/effect/src/Graph.ts @@ -14,15 +14,15 @@ import * as Equal from "./Equal.ts" import { dual } from "./Function.ts" import * as Hash from "./Hash.ts" import type { Inspectable } from "./Inspectable.ts" -import { NodeInspectSymbol } from "./Inspectable.ts" +import * as internal from "./internal/graph.ts" +import * as csr from "./internal/graphCsr.ts" import * as MutableHashMap from "./MutableHashMap.ts" import * as Option from "./Option.ts" import type { Pipeable } from "./Pipeable.ts" -import { pipeArguments } from "./Pipeable.ts" import { hasProperty } from "./Predicate.ts" -import type { Covariant, Invariant, Mutable } from "./Types.ts" +import type { Covariant, Invariant } from "./Types.ts" -const TypeId = "~effect/collections/Graph" +const TypeId = internal.TypeId /** * Node index for node identification using plain numbers. @@ -42,7 +42,6 @@ const TypeId = "~effect/collections/Graph" * are not reused. * * @see {@link EdgeIndex} for edge identifiers instead of node identifiers - * @see {@link addNode} for creating node identifiers * * @category models * @since 3.18.0 @@ -63,9 +62,6 @@ export type NodeIndex = number * identifiers are not reused. * * @see {@link NodeIndex} for node identifiers instead of edge identifiers - * @see {@link Edge} for the edge value addressed by this identifier - * @see {@link addEdge} for creating edge identifiers - * @see {@link getEdge} for reading edges by identifier * * @category models * @since 3.18.0 @@ -80,18 +76,14 @@ export type EdgeIndex = number * Use as the graph edge value that carries source node, target node, and stored * edge data together. * - * @see {@link getEdge} for reading a single edge by identifier - * @see {@link addEdge} for adding edges to a graph - * @see {@link edges} for iterating graph edges - * * @category models * @since 3.18.0 */ -export class Edge extends Data.Class<{ +export interface Edge { readonly source: NodeIndex readonly target: NodeIndex readonly data: E -}> {} +} /** * Graph type for distinguishing directed and undirected graphs. @@ -101,14 +93,60 @@ export class Edge extends Data.Class<{ * Use when writing graph-polymorphic types or helpers that need to preserve * whether a graph is directed or undirected. * - * @see {@link Graph} for immutable graphs parameterized by kind - * @see {@link MutableGraph} for mutable graphs parameterized by kind - * * @category models * @since 3.18.0 */ export type Kind = "directed" | "undirected" +/** + * A node and its stable index in a graph snapshot. + * + * @category models + * @since 4.0.0 + */ +export interface IndexedNode { + readonly index: NodeIndex + readonly data: N +} + +/** + * An edge and its stable index in a graph snapshot. + * + * @category models + * @since 4.0.0 + */ +export interface IndexedEdge extends Edge { + readonly index: EdgeIndex +} + +/** + * Active indexed structure used to reconstruct an immutable graph. + * + * **When to use** + * + * Use when serializing or importing graph structure while preserving active + * node and edge identifiers. + * + * **Details** + * + * Node and edge indexes must be non-negative safe integers in strictly + * increasing order. Every edge endpoint must reference an indexed node. + * + * **Gotchas** + * + * A snapshot records only active identifiers, not allocator history. After + * reconstruction, new identifiers continue after the greatest active index. + * + * @see {@link fromSnapshot} for reconstructing a graph + * @category models + * @since 4.0.0 + */ +export interface Snapshot { + readonly type: T + readonly nodes: ReadonlyArray> + readonly edges: ReadonlyArray> +} + /** * Common public protocol for graph values. * @@ -118,7 +156,7 @@ export type Kind = "directed" | "undirected" * internal; use module functions such as `nodes`, `edges`, `getNode`, and * `getEdge` to inspect graph contents. * - * @category models + * @category protocols * @since 3.18.0 */ export interface Proto extends Iterable, Equal.Equal, Pipeable, Inspectable { @@ -133,9 +171,12 @@ export interface Proto extends Iterable, * Use as the immutable graph model for code that queries, traverses, * transforms, or analyzes graph structure without mutating it. * + * **Gotchas** + * + * After a graph is hashed, its transitively contained node and edge payloads + * used by hashing must remain immutable, as with other Effect values. + * * @see {@link MutableGraph} for the mutable counterpart used inside mutation scopes - * @see {@link DirectedGraph} for a `Graph` fixed to directed edges - * @see {@link UndirectedGraph} for a `Graph` fixed to undirected edges * * @category models * @since 3.18.0 @@ -155,7 +196,7 @@ export declare namespace Graph { /** * Type-level variance marker for immutable graphs. * - * @category models + * @category utility types * @since 4.0.0 */ export interface Variance { @@ -172,10 +213,14 @@ export declare namespace Graph { * Use when adding, removing, or updating nodes and edges inside a graph * mutation scope. * + * **Gotchas** + * + * A callback invoked by another graph operation may query the same mutable + * graph, but cannot mutate or finalize it. Mutation is allowed in callbacks + * passed to graph constructors and `mutate`, where mutation is the purpose. + * * @see {@link Graph} for the immutable graph interface * @see {@link mutate} for scoped mutation of an immutable graph - * @see {@link beginMutation} for opening a mutable graph manually - * @see {@link endMutation} for returning to an immutable graph * * @category models * @since 3.18.0 @@ -198,7 +243,7 @@ export declare namespace MutableGraph { /** * Type-level variance marker for scoped mutable graphs. * - * @category models + * @category utility types * @since 4.0.0 */ export interface Variance { @@ -208,34 +253,11 @@ export declare namespace MutableGraph { } /** @internal */ -interface GraphImpl - extends Iterable, Equal.Equal, Pipeable, Inspectable -{ - readonly [TypeId]: unknown - readonly type: T - mutable: boolean - nodes: Map - edges: Map> - adjacency: Map> - reverseAdjacency: Map> - nextNodeIndex: NodeIndex - nextEdgeIndex: EdgeIndex - acyclic: Option.Option -} - -/** @internal */ -const graphImpl = ( - graph: Graph | MutableGraph -): GraphImpl => graph as unknown as GraphImpl - -/** @internal */ -const cloneAdjacency = (adjacency: Map>): Map> => { - const cloned = new Map>() - for (const [nodeIndex, edges] of adjacency) { - cloned.set(nodeIndex, [...edges]) - } - return cloned -} +const copyEdge = (edge: Edge): Edge => ({ + source: edge.source, + target: edge.target, + data: edge.data +}) /** * Immutable graph type for source-to-target relationships. @@ -251,9 +273,7 @@ const cloneAdjacency = (adjacency: Map>): Map = Graph * * @see {@link undirected} for constructing undirected graphs * @see {@link DirectedGraph} for graphs whose edges have source-to-target direction - * @see {@link MutableUndirectedGraph} for the mutable undirected graph type * * @category models * @since 3.18.0 @@ -290,7 +309,6 @@ export type UndirectedGraph = Graph * whose edges have source-to-target direction. * * @see {@link MutableGraph} for the generic mutable graph type - * @see {@link DirectedGraph} for the immutable directed graph type * @see {@link MutableUndirectedGraph} for mutable graphs without edge direction * * @category models @@ -307,7 +325,6 @@ export type MutableDirectedGraph = MutableGraph * whose edges connect both endpoints without direction. * * @see {@link MutableDirectedGraph} for mutable graphs with directed edges - * @see {@link UndirectedGraph} for the immutable undirected graph type * @see {@link MutableGraph} for the generic mutable graph type * * @category models @@ -315,103 +332,6 @@ export type MutableDirectedGraph = MutableGraph */ export type MutableUndirectedGraph = MutableGraph -// ============================================================================= -// Proto Objects -// ============================================================================= - -/** @internal */ -const edgeEquals = (type: Kind, self: Edge, that: Edge): boolean => - (type === "directed" - ? self.source === that.source && self.target === that.target - : (self.source === that.source && self.target === that.target) || - (self.source === that.target && self.target === that.source)) && - Equal.equals(self.data, that.data) - -/** @internal */ -const edgeHash = (type: Kind, edge: Edge): number => - type === "directed" - ? Hash.hash(edge) - : Hash.optimize(Hash.hash(edge.data) ^ (Hash.hash(edge.source) + Hash.hash(edge.target))) - -/** @internal */ -const ProtoGraph = { - [TypeId]: { - _N: (_: never) => _, - _E: (_: never) => _ - }, - [Symbol.iterator](this: GraphImpl) { - return this.nodes[Symbol.iterator]() - }, - [NodeInspectSymbol](this: GraphImpl) { - return this.toJSON() - }, - [Equal.symbol](this: GraphImpl, that: Equal.Equal): boolean { - if (isGraph(that)) { - const thatImpl = graphImpl(that) - if ( - this.nodes.size !== thatImpl.nodes.size || - this.edges.size !== thatImpl.edges.size || - this.nextNodeIndex !== thatImpl.nextNodeIndex || - this.nextEdgeIndex !== thatImpl.nextEdgeIndex || - this.type !== thatImpl.type - ) { - return false - } - // Compare nodes - for (const [nodeIndex, nodeData] of this.nodes) { - if (!thatImpl.nodes.has(nodeIndex)) { - return false - } - const otherNodeData = thatImpl.nodes.get(nodeIndex)! - if (!Equal.equals(nodeData, otherNodeData)) { - return false - } - } - // Compare edges - for (const [edgeIndex, edgeData] of this.edges) { - if (!thatImpl.edges.has(edgeIndex)) { - return false - } - const otherEdge = thatImpl.edges.get(edgeIndex)! - if (!edgeEquals(this.type, edgeData, otherEdge)) { - return false - } - } - return true - } - return false - }, - [Hash.symbol](this: GraphImpl): number { - let hash = Hash.string("Graph") - hash = hash ^ Hash.string(this.type) - hash = hash ^ Hash.number(this.nodes.size) - hash = hash ^ Hash.number(this.edges.size) - hash = hash ^ Hash.number(this.nextNodeIndex) - hash = hash ^ Hash.number(this.nextEdgeIndex) - for (const [nodeIndex, nodeData] of this.nodes) { - hash = hash ^ (Hash.hash(nodeIndex) + Hash.hash(nodeData)) - } - for (const [edgeIndex, edgeData] of this.edges) { - hash = hash ^ (Hash.hash(edgeIndex) + edgeHash(this.type, edgeData)) - } - return hash - }, - toJSON(this: GraphImpl) { - return { - _id: "Graph", - nodeCount: this.nodes.size, - edgeCount: this.edges.size, - type: this.type - } - }, - toString(this: GraphImpl) { - return `Graph(${this.type}, ${this.nodes.size}, ${this.edges.size})` - }, - pipe() { - return pipeArguments(this, arguments) - } -} - // ============================================================================= // Errors // ============================================================================= @@ -438,6 +358,15 @@ export class GraphError extends Data.TaggedError("GraphError")<{ /** @internal */ const missingNode = (node: number) => new GraphError({ message: `Node ${node} does not exist` }) +/** @internal */ +const traversalRadius = (radius: number | undefined, defaultRadius: number): number => { + const value = radius ?? defaultRadius + if (value !== Infinity && (!Number.isInteger(value) || value < 0)) { + throw new GraphError({ message: "Traversal radius must be a non-negative integer or Infinity" }) + } + return value +} + /** @internal */ function assertMutable( graph: Graph | MutableGraph @@ -447,6 +376,24 @@ function assertMutable( } } +/** @internal */ +const getMutableImplForMutation = ( + graph: MutableGraph +): internal.GraphImpl => { + assertMutable(graph) + if (internal.isTransforming(graph)) { + throw new GraphError({ message: "Cannot mutate graph during a transformation" }) + } + csr.invalidate(graph) + return internal.toImpl(graph) +} + +/** @internal */ +const withMutationGuard = ( + graph: Graph | MutableGraph, + evaluate: () => A +): A => graph.mutable ? internal.withTransformation(graph, evaluate) : evaluate() + // ============================================================================= // Constructors // ============================================================================= @@ -472,6 +419,146 @@ export const isGraph = | MutableGraph ): u is Graph | MutableGraph => hasProperty(u, TypeId) +/** + * Reconstructs an immutable graph from its indexed active structure. + * + * **When to use** + * + * Use when importing a snapshot or other externally indexed graph structure. + * Prefer `directed` or `undirected` when creating a new graph without existing + * identifiers. + * + * **Gotchas** + * + * The node and edge arrays must be ordered by strictly increasing, + * non-negative safe integer indexes, and every edge endpoint must reference a + * node in the snapshot. Invalid snapshots throw a `GraphError`. Historical + * removed identifiers after the greatest active index are not retained. + * + * **Example** (Preserving graph indexes) + * + * ```ts import.meta.vitest + * import { Graph } from "effect" + * + * const graph = Graph.fromSnapshot({ + * type: "directed", + * nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }], + * edges: [{ index: 3, source: 2, target: 5, data: 1 }] + * }) + * + * Graph.toSnapshot(graph).edges[0].index // => 3 + * ``` + * + * @see {@link toSnapshot} for capturing a graph snapshot + * @category constructors + * @since 4.0.0 + */ +export const fromSnapshot = (snapshot: Snapshot): Graph => { + if (snapshot.type !== "directed" && snapshot.type !== "undirected") { + throw new GraphError({ message: "Snapshot type must be directed or undirected" }) + } + + let previous = -1 + const nodeIndexes = new Set() + for (let i = 0; i < snapshot.nodes.length; i++) { + const node = snapshot.nodes[i] + if (node === undefined || node === null) { + throw new GraphError({ message: `Node at position ${i} must be defined` }) + } + const index = node.index + if (!Number.isSafeInteger(index) || index < 0) { + throw new GraphError({ message: `Node index at position ${i} must be a non-negative safe integer` }) + } + if (index <= previous) { + throw new GraphError({ message: "Node indexes must be strictly increasing" }) + } + previous = index + nodeIndexes.add(index) + } + + previous = -1 + for (let i = 0; i < snapshot.edges.length; i++) { + const edge = snapshot.edges[i] + if (edge === undefined || edge === null) { + throw new GraphError({ message: `Edge at position ${i} must be defined` }) + } + if (!Number.isSafeInteger(edge.index) || edge.index < 0) { + throw new GraphError({ message: `Edge index at position ${i} must be a non-negative safe integer` }) + } + if (edge.index <= previous) { + throw new GraphError({ message: "Edge indexes must be strictly increasing" }) + } + previous = edge.index + if (!Number.isSafeInteger(edge.source) || edge.source < 0) { + throw new GraphError({ message: `Edge source at position ${i} must be a non-negative safe integer` }) + } + if (!nodeIndexes.has(edge.source)) { + throw new GraphError({ message: `Edge source ${edge.source} does not reference a node` }) + } + if (!Number.isSafeInteger(edge.target) || edge.target < 0) { + throw new GraphError({ message: `Edge target at position ${i} must be a non-negative safe integer` }) + } + if (!nodeIndexes.has(edge.target)) { + throw new GraphError({ message: `Edge target ${edge.target} does not reference a node` }) + } + } + + return internal.hydrate(snapshot) +} + +/** + * Returns the active indexed structure of a graph. + * + * **When to use** + * + * Use when serializing a graph or passing its active structure across a + * boundary where node and edge identifiers must be preserved. + * + * **Details** + * + * Nodes and edges are returned in graph order with their current indexes. + * Undirected edges retain their stored endpoint orientation, and each returned + * node and edge record is newly allocated. The operation runs in `O(V + E)`. + * + * **Gotchas** + * + * Node and edge payloads are not cloned. The snapshot also omits allocator + * history for identifiers that are no longer active. + * + * **Example** (Round-tripping a graph snapshot) + * + * ```ts import.meta.vitest + * import { Equal, Graph } from "effect" + * + * const graph = Graph.fromSnapshot({ + * type: "undirected", + * nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }], + * edges: [{ index: 3, source: 5, target: 2, data: "A-B" }] + * }) + * + * Equal.equals(Graph.fromSnapshot(Graph.toSnapshot(graph)), graph) // => true + * ``` + * + * @see {@link fromSnapshot} for reconstructing an immutable graph + * @category converting + * @since 4.0.0 + */ +export const toSnapshot = ( + graph: Graph | MutableGraph +): Snapshot => { + const impl = internal.toImpl(graph) + return { + type: graph.type, + nodes: Array.from(impl.nodes, ([index, data]) => ({ index, data })), + edges: Array.from(impl.edges, ([index, edge]) => ({ + index, + source: edge.source, + target: edge.target, + data: edge.data + })) + } +} + /** * Creates a graph constructor for the specified graph kind. * @@ -500,22 +587,14 @@ export const isGraph = (type: T) => (mutate?: (mutable: MutableGraph) => undefined): Graph => { - const graph: Mutable> = Object.create(ProtoGraph) - graph.type = type - graph.nodes = new Map() - graph.edges = new Map() - graph.adjacency = new Map() - graph.reverseAdjacency = new Map() - graph.nextNodeIndex = 0 - graph.nextEdgeIndex = 0 - graph.acyclic = Option.some(true) - + if (type !== "directed" && type !== "undirected") { + throw new GraphError({ message: "Graph type must be directed or undirected" }) + } if (mutate === undefined) { - graph.mutable = false - return graph as unknown as Graph + return internal.make(type, false) as unknown as Graph } - graph.mutable = true + const graph = internal.make(type, true) const mutable = Equal.byReferenceUnsafe(graph as unknown as MutableGraph) return mutateScoped(mutable, mutate) } @@ -523,6 +602,16 @@ export const make = /** * Creates a directed graph, optionally with initial mutations. * + * **When to use** + * + * Use when relationships have a source-to-target direction, such as + * dependencies, workflows, or routing links. + * + * **Gotchas** + * + * The mutable callback handle is finalized when the callback returns and must + * not be retained for later mutation. + * * **Example** (Creating a directed graph) * * ```ts import.meta.vitest @@ -549,6 +638,16 @@ export const directed: ( /** * Creates an undirected graph, optionally with initial mutations. * + * **When to use** + * + * Use when relationships connect both endpoints symmetrically, such as social + * connections or physical links. + * + * **Gotchas** + * + * The mutable callback handle is finalized when the callback returns and must + * not be retained for later mutation. + * * **Example** (Creating an undirected graph) * * ```ts import.meta.vitest @@ -577,7 +676,18 @@ export const undirected: ( // ============================================================================= /** - * Creates a mutable scope for safe graph mutations by copying the data structure. + * Creates a mutable copy of an immutable graph for a manual mutation scope. + * + * **When to use** + * + * Use when a mutation scope must span code that cannot be expressed as one + * `mutate` callback. + * + * **Gotchas** + * + * The graph structure is copied, but node and edge payload objects remain + * shared by reference. Always finish the scope with `endMutation`; prefer + * `mutate` when a callback is sufficient. * * **Example** (Beginning a mutation scope) * @@ -590,37 +700,30 @@ export const undirected: ( * Array.of(Graph.nodeCount(mutable), Graph.nodeCount(graph)) // => [0, 0] * ``` * + * @see {@link endMutation} for finalizing the mutable graph + * @see {@link mutate} for automatically scoped mutation * @category mutations * @since 3.18.0 */ export const beginMutation = ( graph: Graph ): MutableGraph => { - const source = graphImpl(graph) - const adjacency = cloneAdjacency(source.adjacency) - const reverseAdjacency = cloneAdjacency(source.reverseAdjacency) - - const mutable: Mutable> = Object.create(ProtoGraph) - mutable.type = source.type - mutable.nodes = new Map(source.nodes) - mutable.edges = new Map(source.edges) - mutable.adjacency = adjacency - mutable.reverseAdjacency = reverseAdjacency - mutable.nextNodeIndex = source.nextNodeIndex - mutable.nextEdgeIndex = source.nextEdgeIndex - mutable.acyclic = source.acyclic - mutable.mutable = true - + const source = internal.toImpl(graph) + const mutable = internal.clone(source, true) return Equal.byReferenceUnsafe(mutable as unknown as MutableGraph) } /** * Converts a mutable graph back to an immutable graph, ending the mutation scope. * - * **Details** + * **When to use** + * + * Use to finish a mutation scope opened with `beginMutation`. + * + * **Gotchas** * - * Finalizes the mutable handle. Later public mutation operations on that handle - * fail with a `GraphError`. + * Finalization is terminal. Later public mutation operations on the same + * mutable handle fail with a `GraphError`. * * **Example** (Ending a mutation scope) * @@ -633,6 +736,8 @@ export const beginMutation = ( * Graph.nodeCount(Graph.endMutation(mutable)) // => 0 * ``` * + * @see {@link beginMutation} for opening a manual mutation scope + * @see {@link mutate} for automatically scoped mutation * @category mutations * @since 3.18.0 */ @@ -640,18 +745,12 @@ export const endMutation = ( mutable: MutableGraph ): Graph => { assertMutable(mutable) - const source = graphImpl(mutable) - - const graph: Mutable> = Object.create(ProtoGraph) - graph.type = source.type - graph.nodes = new Map(source.nodes) - graph.edges = new Map(source.edges) - graph.adjacency = cloneAdjacency(source.adjacency) - graph.reverseAdjacency = cloneAdjacency(source.reverseAdjacency) - graph.nextNodeIndex = source.nextNodeIndex - graph.nextEdgeIndex = source.nextEdgeIndex - graph.acyclic = source.acyclic - graph.mutable = false + if (internal.isTransforming(mutable)) { + throw new GraphError({ message: "Cannot mutate graph during a transformation" }) + } + const source = internal.toImpl(mutable) + csr.invalidate(mutable) + const graph = internal.finalize(source) source.mutable = false return graph as unknown as Graph @@ -662,17 +761,35 @@ const mutateScoped = ( mutable: MutableGraph, f: (mutable: MutableGraph) => undefined ): Graph => { - let graph: Graph try { f(mutable) - } finally { - graph = endMutation(mutable) + } catch (error) { + if (mutable.mutable) { + endMutation(mutable) + } + throw error } - return graph + return endMutation(mutable) } /** - * Performs scoped mutations on a graph, automatically managing the mutation lifecycle. + * Returns an immutable graph after applying scoped mutations to a structural copy. + * + * **When to use** + * + * Use for the usual immutable update workflow when several node or edge + * mutations should be applied together. + * + * **Details** + * + * The original graph remains structurally unchanged. The mutable callback + * handle is finalized whether the callback returns or throws. + * + * **Gotchas** + * + * Payload objects are shared unless the callback replaces them. A callback + * failure is rethrown after the mutable handle is finalized, and the handle + * must not escape for later mutation. * * **Example** (Applying scoped mutations) * @@ -690,6 +807,8 @@ const mutateScoped = ( * Graph.edgeCount(newGraph) // => 1 * ``` * + * @see {@link beginMutation} for opening a manual mutation scope + * @see {@link endMutation} for finalizing a manual mutation scope * @category mutations * @since 3.18.0 */ @@ -764,10 +883,16 @@ class EdgeIdentity implements Equal.Equal { /** * Configures node and edge identity for graph set operations. * + * **When to use** + * + * Use when logical graph membership should be based on a stable key rather + * than the complete node or edge payload. + * * **Details** * * Both functions default to using the complete node or edge data. Edge identity * also includes the identities of its endpoint nodes and the graph kind. + * Projected identities use Effect equality and hashing semantics. * * **Gotchas** * @@ -775,7 +900,7 @@ class EdgeIdentity implements Equal.Equal { * with the same endpoint identities and projected edge identity are treated as * the same member by graph set operations. * - * @category models + * @category configuration * @since 4.0.0 */ export interface IdentityOptions { @@ -788,7 +913,7 @@ const buildNodeMaps = ( graph: Graph, identity: (node: N) => I ): NodeMaps => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) const byIdentity = MutableHashMap.empty() const byIndex = new Map() @@ -810,7 +935,7 @@ const buildEdgeMap = ( nodeMaps: NodeMaps, identity: (edge: E) => EI ): MutableHashMap.MutableHashMap, E> => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) const edges = MutableHashMap.empty, E>() for (const edge of impl.edges.values()) { const sourceIdentity = nodeIdentityAt(nodeMaps, edge.source) @@ -860,6 +985,11 @@ const assertSameKind = (self: Graph, that: Graph): /** * Composes two graphs, merging nodes by identity. * + * **When to use** + * + * Use when combining graphs that describe overlapping logical entities and + * should merge those entities by payload or a projected identity. + * * **Details** * * Nodes and edges present in both graphs use data from `that`. The result has @@ -874,7 +1004,7 @@ const assertSameKind = (self: Graph, that: Graph): * Nodes with equal identities in one input graph are coalesced. The last node * supplies the data, and redirected edges can collapse or become self-loops. * Parallel edges with equal identities are also coalesced, with the last edge - * supplying the data. + * supplying the data. The result allocates new node and edge identifiers. * * **Example** (Combining graphs) * @@ -901,6 +1031,7 @@ const assertSameKind = (self: Graph, that: Graph): * Graph.edgeCount(result) // => 2 * ``` * + * @see {@link sum} for combining graphs without merging equal nodes * @category set operations * @since 4.0.0 */ @@ -953,6 +1084,11 @@ export const compose: { /** * Returns the intersection of two graphs, matching nodes by identity. * + * **When to use** + * + * Use when extracting nodes and edges that represent the same logical + * structure in both graphs. + * * **Details** * * Node data comes from `self`, and edge data comes from `that`. The result has @@ -966,7 +1102,8 @@ export const compose: { * * Nodes with equal identities in one input graph are coalesced. The last node * supplies the data, and redirected edges can collapse or become self-loops. - * The result contains at most one edge for each shared edge identity. + * The result contains at most one edge for each shared edge identity and + * allocates new node and edge identifiers. * * **Example** (Finding shared structure) * @@ -991,6 +1128,7 @@ export const compose: { * Graph.edgeCount(result) // => 1 * ``` * + * @see {@link compose} for identity-based graph union * @category set operations * @since 4.0.0 */ @@ -1010,7 +1148,7 @@ export const intersection: { options?: IdentityOptions ): Graph => { assertSameKind(self, that) - const thatImpl = graphImpl(that) + const thatImpl = internal.toImpl(that) const getNodeIdentity = options?.nodeIdentity ?? ((node: N) => node as unknown as NI) const getEdgeIdentity = options?.edgeIdentity ?? ((edge: E) => edge as unknown as EI) const selfMaps = buildNodeMaps(self, getNodeIdentity) @@ -1048,6 +1186,11 @@ export const intersection: { /** * Returns `self` without edges also present in `that`. * + * **When to use** + * + * Use when retaining all logical nodes from one graph while removing edge + * relationships also represented by another graph. + * * **Details** * * All nodes from `self` are preserved. Edges are matched by endpoint and edge @@ -1062,7 +1205,7 @@ export const intersection: { * Nodes with equal identities in one input graph are coalesced. The last node * supplies the data, and redirected edges can collapse or become self-loops. * If `that` contains an edge identity, every parallel edge with that identity - * is removed from `self`. + * is removed from `self`. The result allocates new node and edge identifiers. * * **Example** (Removing shared edges) * @@ -1089,6 +1232,7 @@ export const intersection: { * Graph.edgeCount(result) // => 1 * ``` * + * @see {@link symmetricDifference} for retaining edges unique to either graph * @category set operations * @since 4.0.0 */ @@ -1108,7 +1252,7 @@ export const difference: { options?: IdentityOptions ): Graph => { assertSameKind(self, that) - const selfImpl = graphImpl(self) + const selfImpl = internal.toImpl(self) const getNodeIdentity = options?.nodeIdentity ?? ((node: N) => node as unknown as NI) const getEdgeIdentity = options?.edgeIdentity ?? ((edge: E) => edge as unknown as EI) const selfMaps = buildNodeMaps(self, getNodeIdentity) @@ -1132,6 +1276,10 @@ export const difference: { /** * Returns edges present in exactly one of two graphs. * + * **When to use** + * + * Use when comparing graphs and retaining relationships unique to either one. + * * **Details** * * Keeps nodes from both graphs. Overlapping nodes use data from `that`. The @@ -1148,7 +1296,7 @@ export const difference: { * Nodes with equal identities in one input graph are coalesced. The last node * supplies the data, and redirected edges can collapse or become self-loops. * Parallel edges with equal identities are coalesced before the graphs are - * compared. + * compared. The result allocates new node and edge identifiers. * * **Example** (Finding differing edges) * @@ -1177,6 +1325,7 @@ export const difference: { * Graph.edgeCount(result) // => 2 * ``` * + * @see {@link difference} for removing only the edges found in another graph * @category set operations * @since 4.0.0 */ @@ -1232,13 +1381,24 @@ export const symmetricDifference: { /** * Returns the complement over the existing node set. * + * **When to use** + * + * Use when materializing every relationship that is currently absent between + * distinct nodes. + * * **Details** * - * Adds every missing edge between distinct nodes. The `createEdge` function - * receives the source and target node data for each added edge. The result has - * the same graph kind as `self`. + * Directed graphs add each missing ordered pair. Undirected graphs add each + * missing unordered pair once. The `createEdge` function receives the source + * and target node data for each added edge. The result has the same graph kind + * as `self`. + * + * `G' = {V, ((V x V) without self-pairs) \ E}` + * + * **Gotchas** * - * `G' = {V, (V x V) \ E}` + * Self-loops are never created. If any edge already connects a pair, parallel + * complement edges are not added. The result allocates new identifiers. * * **Example** (Finding missing relationships) * @@ -1256,6 +1416,7 @@ export const symmetricDifference: { * Graph.edgeCount(result) // => 1 * ``` * + * @see {@link hasEdge} for testing one relationship * @category set operations * @since 4.0.0 */ @@ -1271,31 +1432,29 @@ export const complement: { self: Graph, createEdge: (source: N, target: N) => E ): Graph => { - const selfImpl = graphImpl(self) - const nodeEntries = Array.from(selfImpl.nodes) + const cache = csr.get(self) + const outgoing = csr.getOutgoing(cache) + const neighborMarks = new Uint32Array(cache.nodeIds.length) return make(self.type)((mutable) => { - const newIndexMap = new Map() + const newIndices = new Uint32Array(cache.nodeIds.length) - for (const [oldIndex, data] of nodeEntries) { - newIndexMap.set(oldIndex, addNode(mutable, data)) + for (let i = 0; i < cache.nodeIds.length; i++) { + newIndices[i] = addNode(mutable, cache.nodeData[i] as N) } - for (let i = 0; i < nodeEntries.length; i++) { - const [sourceOldIndex, sourceData] = nodeEntries[i] + for (let i = 0; i < cache.nodeIds.length; i++) { + const generation = i + 1 + for (let edge = outgoing.rowOffsets[i]; edge < outgoing.rowOffsets[i + 1]; edge++) { + neighborMarks[outgoing.columnIndices[edge]] = generation + } const start = self.type === "undirected" ? i + 1 : 0 - for (let j = start; j < nodeEntries.length; j++) { - const [targetOldIndex, targetData] = nodeEntries[j] - if (sourceOldIndex === targetOldIndex || hasEdge(self, sourceOldIndex, targetOldIndex)) { + for (let j = start; j < cache.nodeIds.length; j++) { + if (i === j || neighborMarks[j] === generation) { continue } - - const sourceIndex = newIndexMap.get(sourceOldIndex) - const targetIndex = newIndexMap.get(targetOldIndex) - if (sourceIndex !== undefined && targetIndex !== undefined) { - addEdge(mutable, sourceIndex, targetIndex, createEdge(sourceData, targetData)) - } + addEdge(mutable, newIndices[i], newIndices[j], createEdge(cache.nodeData[i] as N, cache.nodeData[j] as N)) } } }) @@ -1307,10 +1466,11 @@ export const complement: { * **Details** * * `radius` limits the edge distance from the center node and defaults to `1`. + * It accepts non-negative integers and `Infinity`. * `direction` controls how directed edges are traversed and defaults to * `"outgoing"`. * - * @category models + * @category configuration * @since 4.0.0 */ export interface NeighborhoodConfig { @@ -1321,13 +1481,24 @@ export interface NeighborhoodConfig { /** * Returns the induced subgraph containing nodes within a radius of a node. * + * **When to use** + * + * Use when extracting a local reachable region around one node. + * * **Details** * - * The `radius` option is the maximum edge distance from `nodeIndex` and - * defaults to `1`. The `direction` option controls directed graph traversal and - * defaults to `"outgoing"`. The result has the same graph kind as `self` and - * keeps all original edges whose endpoints are both reached. `"undirected"` - * ignores edge direction while finding reachable nodes. + * The `radius` option is the maximum edge distance from `nodeIndex`, accepts + * non-negative integers and `Infinity`, and defaults to `1`. Invalid radii + * throw a `GraphError`. The `direction` option controls directed graph + * traversal and defaults to `"outgoing"`. The result has the same graph kind + * as `self` and keeps all original edges whose endpoints are both reached. + * `"undirected"` ignores edge direction while finding reachable nodes. + * + * **Gotchas** + * + * Traversal chooses the nodes, then all original edges between reached nodes + * are retained. The result is not merely a traversal tree, and it allocates new + * node and edge identifiers. * * **Example** (Getting a local neighborhood) * @@ -1347,6 +1518,7 @@ export interface NeighborhoodConfig { * Graph.nodeCount(result) // => 2 * ``` * + * @see {@link inducedSubgraph} for selecting nodes while preserving identifiers * @category set operations * @since 4.0.0 */ @@ -1365,8 +1537,8 @@ export const neighborhood: { nodeIndex: NodeIndex, options?: NeighborhoodConfig ): Graph => { - const selfImpl = graphImpl(self) - const radius = options?.radius ?? 1 + const selfImpl = internal.toImpl(self) + const radius = traversalRadius(options?.radius, 1) const direction = options?.direction ?? "outgoing" const reached = new Set() @@ -1394,32 +1566,100 @@ export const neighborhood: { }) /** - * Returns the disjoint union of two graphs. + * Returns the subgraph induced by a collection of node indices. + * + * **When to use** + * + * Use when selecting an exact node set while preserving its active node and + * edge identifiers. * * **Details** * - * Copies all nodes and edges from both graphs without merging equal node data. - * The result has the same graph kind as `self`. Throws a `GraphError` when the - * graph kinds do not match. + * Node and edge indices are preserved. Duplicate input indices are ignored, + * output ordering follows the original graph, and every edge whose endpoints + * are both selected is retained. * - * `G1 + G2 = {disjoint V1 + V2, disjoint E1 + E2}` + * **Gotchas** + * + * Throws a `GraphError` when a selected node does not exist. + * + * @see {@link neighborhood} for selecting nodes by traversal distance * * @category set operations * @since 4.0.0 */ -export const sum: { - (that: Graph): (self: Graph>) => Graph - (self: Graph, that: Graph>): Graph -} = dual(2, (self: Graph, that: Graph): Graph => { - assertSameKind(self, that) - return make(self.type)((mutable) => { - const copyInto = (graph: Graph) => { - const impl = graphImpl(graph) - const indexMap = new Map() +export const inducedSubgraph: { + (nodeIndices: Iterable): (self: Graph) => Graph + ( + self: Graph, + nodeIndices: Iterable + ): Graph +} = dual(2, ( + self: Graph, + nodeIndices: Iterable +): Graph => { + const impl = internal.toImpl(self) + const selected = new Set() + for (const nodeIndex of nodeIndices) { + if (!impl.nodes.has(nodeIndex)) { + throw missingNode(nodeIndex) + } + selected.add(nodeIndex) + } - for (const [oldIndex, data] of impl.nodes) { - indexMap.set(oldIndex, addNode(mutable, data)) - } + const nodes: Array> = [] + for (const [index, data] of impl.nodes) { + if (selected.has(index)) { + nodes.push({ index, data }) + } + } + const edges: Array> = [] + for (const [index, edge] of impl.edges) { + if (selected.has(edge.source) && selected.has(edge.target)) { + edges.push({ index, source: edge.source, target: edge.target, data: edge.data }) + } + } + return fromSnapshot({ type: self.type, nodes, edges }) +}) + +/** + * Returns the disjoint union of two graphs. + * + * **When to use** + * + * Use when combining graphs while keeping every node distinct, even when node + * payloads are equal. + * + * **Details** + * + * Copies all nodes and edges from both graphs without merging equal node data. + * The result has the same graph kind as `self`. Throws a `GraphError` when the + * graph kinds do not match. + * + * `G1 + G2 = {disjoint V1 + V2, disjoint E1 + E2}` + * + * **Gotchas** + * + * All node and edge identifiers are newly allocated. + * + * @see {@link compose} for merging overlapping logical nodes by identity + * + * @category set operations + * @since 4.0.0 + */ +export const sum: { + (that: Graph): (self: Graph>) => Graph + (self: Graph, that: Graph>): Graph +} = dual(2, (self: Graph, that: Graph): Graph => { + assertSameKind(self, that) + return make(self.type)((mutable) => { + const copyInto = (graph: Graph) => { + const impl = internal.toImpl(graph) + const indexMap = new Map() + + for (const [oldIndex, data] of impl.nodes) { + indexMap.set(oldIndex, addNode(mutable, data)) + } for (const edge of impl.edges.values()) { const sourceIndex = indexMap.get(edge.source) @@ -1470,7 +1710,6 @@ export const sum: { * * @see {@link mutate} for obtaining a mutable graph from an immutable graph * @see {@link addEdge} for connecting existing nodes - * @see {@link removeNode} for removing nodes from a mutable graph * * @category mutations * @since 3.18.0 @@ -1479,10 +1718,11 @@ export const addNode = ( mutable: MutableGraph, data: N ): NodeIndex => { - assertMutable(mutable) - const impl = graphImpl(mutable) - + const impl = getMutableImplForMutation(mutable) const nodeIndex = impl.nextNodeIndex + if (!Number.isSafeInteger(nodeIndex)) { + throw new GraphError({ message: "Graph has exhausted safe node indexes" }) + } // Add node data impl.nodes.set(nodeIndex, data) @@ -1527,7 +1767,7 @@ export const getNode: { graph: Graph | MutableGraph, nodeIndex: NodeIndex ): Option.Option => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) return impl.nodes.has(nodeIndex) ? Option.some(impl.nodes.get(nodeIndex)!) : Option.none() }) @@ -1547,7 +1787,7 @@ export const getNode: { * Graph.hasNode(graph, 999) // => false * ``` * - * @category getters + * @category predicates * @since 3.18.0 */ export const hasNode: { @@ -1556,7 +1796,7 @@ export const hasNode: { } = dual(2, ( graph: Graph | MutableGraph, nodeIndex: NodeIndex -): boolean => graphImpl(graph).nodes.has(nodeIndex)) +): boolean => internal.toImpl(graph).nodes.has(nodeIndex)) /** * Returns the number of nodes in the graph. @@ -1583,7 +1823,7 @@ export const hasNode: { */ export const nodeCount = ( graph: Graph | MutableGraph -): number => graphImpl(graph).nodes.size +): number => internal.toImpl(graph).nodes.size /** * Finds the first node that matches the given predicate. @@ -1618,13 +1858,15 @@ export const findNode: { graph: Graph | MutableGraph, predicate: (data: N) => boolean ): Option.Option => { - const impl = graphImpl(graph) - for (const [index, data] of impl.nodes) { - if (predicate(data)) { - return Option.some(index) + const impl = internal.toImpl(graph) + return withMutationGuard(graph, () => { + for (const [index, data] of impl.nodes) { + if (predicate(data)) { + return Option.some(index) + } } - } - return Option.none() + return Option.none() + }) }) /** @@ -1660,14 +1902,16 @@ export const findNodes: { graph: Graph | MutableGraph, predicate: (data: N) => boolean ): Array => { - const impl = graphImpl(graph) - const results: Array = [] - for (const [index, data] of impl.nodes) { - if (predicate(data)) { - results.push(index) + const impl = internal.toImpl(graph) + return withMutationGuard(graph, () => { + const results: Array = [] + for (const [index, data] of impl.nodes) { + if (predicate(data)) { + results.push(index) + } } - } - return results + return results + }) }) /** @@ -1705,13 +1949,15 @@ export const findEdge: { graph: Graph | MutableGraph, predicate: (data: E, source: NodeIndex, target: NodeIndex) => boolean ): Option.Option => { - const impl = graphImpl(graph) - for (const [edgeIndex, edgeData] of impl.edges) { - if (predicate(edgeData.data, edgeData.source, edgeData.target)) { - return Option.some(edgeIndex) + const impl = internal.toImpl(graph) + return withMutationGuard(graph, () => { + for (const [edgeIndex, edgeData] of impl.edges) { + if (predicate(edgeData.data, edgeData.source, edgeData.target)) { + return Option.some(edgeIndex) + } } - } - return Option.none() + return Option.none() + }) }) /** @@ -1750,19 +1996,31 @@ export const findEdges: { graph: Graph | MutableGraph, predicate: (data: E, source: NodeIndex, target: NodeIndex) => boolean ): Array => { - const impl = graphImpl(graph) - const results: Array = [] - for (const [edgeIndex, edgeData] of impl.edges) { - if (predicate(edgeData.data, edgeData.source, edgeData.target)) { - results.push(edgeIndex) + const impl = internal.toImpl(graph) + return withMutationGuard(graph, () => { + const results: Array = [] + for (const [edgeIndex, edgeData] of impl.edges) { + if (predicate(edgeData.data, edgeData.source, edgeData.target)) { + results.push(edgeIndex) + } } - } - return results + return results + }) }) /** * Updates a single node's data by applying a transformation function. * + * **When to use** + * + * Use when replacing one node payload while preserving its identifier and + * incident edges. + * + * **Gotchas** + * + * A missing node index is ignored. The transformation may query the graph, but + * cannot mutate or finalize the same graph while it runs. + * * **Example** (Updating node data) * * ```ts import.meta.vitest @@ -1785,21 +2043,31 @@ export const updateNode = ( index: NodeIndex, f: (data: N) => N ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - - if (!impl.nodes.has(index)) { - return - } + const impl = getMutableImplForMutation(mutable) + internal.withTransformation(mutable, () => { + if (!impl.nodes.has(index)) { + return + } - const currentData = impl.nodes.get(index)! - const newData = f(currentData) - impl.nodes.set(index, newData) + const currentData = impl.nodes.get(index)! + const newData = f(currentData) + impl.nodes.set(index, newData) + }) } /** * Updates a single edge's data by applying a transformation function. * + * **When to use** + * + * Use when replacing one edge payload while preserving its identifier and + * endpoints. + * + * **Gotchas** + * + * A missing edge index is ignored. The transformation may query the graph, but + * cannot mutate or finalize the same graph while it runs. + * * **Example** (Updating edge data) * * ```ts import.meta.vitest @@ -1815,7 +2083,7 @@ export const updateNode = ( * Option.map(Graph.getEdge(result, 0), (edge) => edge.data) // => Option.some(20) * ``` * - * @category mutations + * @category transforming * @since 3.18.0 */ export const updateEdge = ( @@ -1823,26 +2091,40 @@ export const updateEdge = ( edgeIndex: EdgeIndex, f: (data: E) => E ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - - if (!impl.edges.has(edgeIndex)) { - return - } + const impl = getMutableImplForMutation(mutable) + internal.withTransformation(mutable, () => { + if (!impl.edges.has(edgeIndex)) { + return + } - const currentEdge = impl.edges.get(edgeIndex)! - const newData = f(currentEdge.data) - impl.edges.set(edgeIndex, new Edge({ ...currentEdge, data: newData })) + const currentEdge = impl.edges.get(edgeIndex)! + const newData = f(currentEdge.data) + impl.edges.set(edgeIndex, { + source: currentEdge.source, + target: currentEdge.target, + data: newData + }) + }) } /** * Transforms every node's data in a mutable graph in place using the provided * mapping function. * + * **When to use** + * + * Use when updating every node payload without changing graph structure. + * * **Details** * * Node indices and edges are preserved; only the stored node data is replaced. * + * **Gotchas** + * + * This function mutates in place, returns `void`, and cannot change the node + * payload type. The mapping function may query the graph, but cannot mutate or + * finalize the same graph while it runs. + * * **Example** (Mapping node data) * * ```ts import.meta.vitest @@ -1858,25 +2140,41 @@ export const updateEdge = ( * Graph.getNode(graph, 0) // => Option.some("NODE A") * ``` * - * @category transforming + * @see {@link updateNode} for updating one node + * @see {@link filterMapNodes} for mapping while removing nodes + * @category mapping * @since 3.18.0 */ export const mapNodes = ( mutable: MutableGraph, f: (data: N) => N ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - - // Transform existing node data in place - for (const [index, data] of impl.nodes) { - const newData = f(data) - impl.nodes.set(index, newData) - } + const impl = getMutableImplForMutation(mutable) + internal.withTransformation(mutable, () => { + // Transform existing node data in place + for (const [index, data] of impl.nodes) { + const newData = f(data) + impl.nodes.set(index, newData) + } + }) } /** - * Transforms all edge data in a mutable graph using the provided mapping function. + * Transforms every edge payload in a mutable graph in place. + * + * **When to use** + * + * Use when updating every edge payload without changing graph structure. + * + * **Details** + * + * Edge identifiers and endpoints are preserved. + * + * **Gotchas** + * + * This function mutates in place, returns `void`, and cannot change the edge + * payload type. The mapping function may query the graph, but cannot mutate or + * finalize the same graph while it runs. * * **Example** (Mapping edge data) * @@ -1895,57 +2193,45 @@ export const mapNodes = ( * Option.map(Graph.getEdge(graph, 0), (edge) => edge.data) // => Option.some(20) * ``` * - * @category transforming + * @see {@link updateEdge} for updating one edge + * @see {@link filterMapEdges} for mapping while removing edges + * @category mapping * @since 3.18.0 */ export const mapEdges = ( mutable: MutableGraph, f: (data: E) => E ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - - // Transform existing edge data in place - for (const [index, edgeData] of impl.edges) { - const newData = f(edgeData.data) - impl.edges.set( - index, - new Edge({ - ...edgeData, + const impl = getMutableImplForMutation(mutable) + internal.withTransformation(mutable, () => { + // Transform existing edge data in place + for (const [index, edgeData] of impl.edges) { + const newData = f(edgeData.data) + impl.edges.set(index, { + source: edgeData.source, + target: edgeData.target, data: newData }) - ) - } -} - -/** - * @internal - */ -const rebuildAdjacency = ( - mutable: GraphImpl -): void => { - mutable.adjacency.clear() - mutable.reverseAdjacency.clear() - - for (const nodeIndex of mutable.nodes.keys()) { - mutable.adjacency.set(nodeIndex, []) - mutable.reverseAdjacency.set(nodeIndex, []) - } - - for (const [edgeIndex, edgeData] of mutable.edges) { - mutable.adjacency.get(edgeData.source)!.push(edgeIndex) - mutable.reverseAdjacency.get(edgeData.target)!.push(edgeIndex) - - if (mutable.type === "undirected") { - mutable.adjacency.get(edgeData.target)!.push(edgeIndex) - mutable.reverseAdjacency.get(edgeData.source)!.push(edgeIndex) } - } + }) } /** * Swaps source and target nodes for every edge in a mutable graph. * + * **When to use** + * + * Use when reversing every relationship in a directed graph, such as creating + * a dependency transpose. + * + * **Details** + * + * Edge identifiers and payloads are preserved. + * + * **Gotchas** + * + * This operation is a no-op for undirected graphs. + * * **Example** (Reversing edge directions) * * ```ts import.meta.vitest @@ -1969,26 +2255,23 @@ const rebuildAdjacency = ( export const reverse = ( mutable: MutableGraph ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - + const impl = getMutableImplForMutation(mutable) if (impl.type === "undirected") { return } // Reverse all edges by swapping source and target for (const [index, edgeData] of impl.edges) { - impl.edges.set( - index, - new Edge({ - source: edgeData.target, - target: edgeData.source, - data: edgeData.data - }) - ) + impl.edges.set(index, { + source: edgeData.target, + target: edgeData.source, + data: edgeData.data + }) } - rebuildAdjacency(impl) + const adjacency = impl.adjacency + impl.adjacency = impl.reverseAdjacency + impl.reverseAdjacency = adjacency // Invalidate cycle flag since edge directions changed impl.acyclic = Option.none() @@ -1998,6 +2281,11 @@ export const reverse = ( * Filters and optionally transforms nodes in a mutable graph using a predicate function. * Nodes that return Option.none are removed along with all their connected edges. * + * **Gotchas** + * + * The function may query the graph, but cannot mutate or finalize the same + * graph while it runs. Retained payloads must remain the same node type. + * * **Example** (Filtering and mapping nodes) * * ```ts import.meta.vitest @@ -2021,40 +2309,42 @@ export const reverse = ( * Graph.nodeCount(graph) // => 2 * ``` * - * @category transforming + * @category filtering * @since 3.18.0 */ export const filterMapNodes = ( mutable: MutableGraph, f: (data: N) => Option.Option ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - - const nodesToRemove: Array = [] - - // First pass: identify nodes to remove and transform data for nodes to keep - for (const [index, data] of impl.nodes) { - const result = f(data) - if (Option.isSome(result)) { - // Transform node data - impl.nodes.set(index, result.value) - } else { - // Mark for removal - nodesToRemove.push(index) + const impl = getMutableImplForMutation(mutable) + const remove: Array = [] + internal.withTransformation(mutable, () => { + // First pass: identify nodes to remove and transform data for nodes to keep + for (const [index, data] of impl.nodes) { + const result = f(data) + if (Option.isSome(result)) { + // Transform node data + impl.nodes.set(index, result.value) + } else { + // Mark for removal + remove.push(index) + } } - } + }) // Second pass: remove filtered out nodes and their edges - for (const nodeIndex of nodesToRemove) { - removeNode(mutable, nodeIndex) - } + removeNodes(mutable, remove) } /** * Filters and optionally transforms edges in a mutable graph using a predicate function. * Edges that return Option.none are removed from the graph. * + * **Gotchas** + * + * The function may query the graph, but cannot mutate or finalize the same + * graph while it runs. Retained payloads must remain the same edge type. + * * **Example** (Filtering and mapping edges) * * ```ts import.meta.vitest @@ -2078,45 +2368,47 @@ export const filterMapNodes = ( * Graph.edgeCount(graph) // => 2 * ``` * - * @category transforming + * @category filtering * @since 3.18.0 */ export const filterMapEdges = ( mutable: MutableGraph, f: (data: E) => Option.Option ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - - const edgesToRemove: Array = [] - - // First pass: identify edges to remove and transform data for edges to keep - for (const [index, edgeData] of impl.edges) { - const result = f(edgeData.data) - if (Option.isSome(result)) { - // Transform edge data - impl.edges.set( - index, - new Edge({ - ...edgeData, + const impl = getMutableImplForMutation(mutable) + const remove: Array = [] + internal.withTransformation(mutable, () => { + // First pass: identify edges to remove and transform data for edges to keep + for (const [index, edgeData] of impl.edges) { + const result = f(edgeData.data) + if (Option.isSome(result)) { + // Transform edge data + impl.edges.set(index, { + source: edgeData.source, + target: edgeData.target, data: result.value }) - ) - } else { - // Mark for removal - edgesToRemove.push(index) + } else { + // Mark for removal + remove.push(index) + } } - } + }) // Second pass: remove filtered out edges - for (const edgeIndex of edgesToRemove) { - removeEdge(mutable, edgeIndex) - } + removeEdges(mutable, remove) } /** * Filters nodes by removing those that don't match the predicate. * This function modifies the mutable graph in place. + * Removed nodes also remove all incident edges; retained node identifiers are + * preserved. + * + * **Gotchas** + * + * The predicate may query the graph, but cannot mutate or finalize the same + * graph while it runs. * * **Example** (Filtering nodes) * @@ -2136,34 +2428,38 @@ export const filterMapEdges = ( * Graph.nodeCount(graph) // => 2 * ``` * - * @category transforming + * @category filtering * @since 3.18.0 */ export const filterNodes = ( mutable: MutableGraph, predicate: (data: N) => boolean ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - - const nodesToRemove: Array = [] - - // Identify nodes to remove - for (const [index, data] of impl.nodes) { - if (!predicate(data)) { - nodesToRemove.push(index) + const impl = getMutableImplForMutation(mutable) + const remove: Array = [] + + internal.withTransformation(mutable, () => { + // Identify nodes to remove + for (const [index, data] of impl.nodes) { + if (!predicate(data)) { + remove.push(index) + } } - } + }) // Remove filtered out nodes (this also removes connected edges) - for (const nodeIndex of nodesToRemove) { - removeNode(mutable, nodeIndex) - } + removeNodes(mutable, remove) } /** * Filters edges by removing those that don't match the predicate. * This function modifies the mutable graph in place. + * Nodes are retained even when removing edges leaves them isolated. + * + * **Gotchas** + * + * The predicate may query the graph, but cannot mutate or finalize the same + * graph while it runs. * * **Example** (Filtering edges) * @@ -2186,29 +2482,27 @@ export const filterNodes = ( * Graph.edgeCount(graph) // => 2 * ``` * - * @category transforming + * @category filtering * @since 3.18.0 */ export const filterEdges = ( mutable: MutableGraph, predicate: (data: E) => boolean ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - - const edgesToRemove: Array = [] - - // Identify edges to remove - for (const [index, edgeData] of impl.edges) { - if (!predicate(edgeData.data)) { - edgesToRemove.push(index) + const impl = getMutableImplForMutation(mutable) + const remove: Array = [] + + internal.withTransformation(mutable, () => { + // Identify edges to remove + for (const [index, edgeData] of impl.edges) { + if (!predicate(edgeData.data)) { + remove.push(index) + } } - } + }) // Remove filtered out edges - for (const edgeIndex of edgesToRemove) { - removeEdge(mutable, edgeIndex) - } + removeEdges(mutable, remove) } // ============================================================================= @@ -2217,20 +2511,20 @@ export const filterEdges = ( /** @internal */ const invalidateCycleFlagOnRemoval = ( - mutable: GraphImpl + mutable: internal.GraphImpl ): void => { // Only invalidate if the graph had cycles (removing edges/nodes cannot introduce cycles in acyclic graphs). - if (mutable.acyclic._tag === "Some" && mutable.acyclic.value === false) { + if (Option.isSome(mutable.acyclic) && mutable.acyclic.value === false) { mutable.acyclic = Option.none() } } /** @internal */ const invalidateCycleFlagOnAddition = ( - mutable: GraphImpl + mutable: internal.GraphImpl ): void => { // Only invalidate if the graph was acyclic (adding edges cannot remove cycles from cyclic graphs). - if (mutable.acyclic._tag === "Some" && mutable.acyclic.value === true) { + if (Option.isSome(mutable.acyclic) && mutable.acyclic.value === true) { mutable.acyclic = Option.none() } } @@ -2249,9 +2543,9 @@ const invalidateCycleFlagOnAddition = ( * * **Details** * - * Creates an `Edge` with the source, target, and data at the next edge index, - * updates adjacency indexes, and increments the graph's next edge index. - * Undirected graphs register the same edge for both endpoints. + * Self-loops and parallel edges are allowed. Undirected graphs retain the + * supplied source and target orientation in the stored `Edge`, while traversal + * and neighbor queries treat the connection as bidirectional. * * **Gotchas** * @@ -2272,8 +2566,6 @@ const invalidateCycleFlagOnAddition = ( * * @see {@link mutate} for obtaining a mutable graph from an immutable graph * @see {@link addNode} for creating node indexes before connecting them - * @see {@link getEdge} for reading the returned edge - * @see {@link removeEdge} for removing an edge from a mutable graph * * @category mutations * @since 3.18.0 @@ -2284,8 +2576,7 @@ export const addEdge = ( target: NodeIndex, data: E ): EdgeIndex => { - assertMutable(mutable) - const impl = graphImpl(mutable) + const impl = getMutableImplForMutation(mutable) // Validate that both nodes exist if (!impl.nodes.has(source)) { @@ -2296,9 +2587,12 @@ export const addEdge = ( } const edgeIndex = impl.nextEdgeIndex + if (!Number.isSafeInteger(edgeIndex)) { + throw new GraphError({ message: "Graph has exhausted safe edge indexes" }) + } // Create edge data - const edgeData = new Edge({ source, target, data }) + const edgeData: Edge = { source, target, data } impl.edges.set(edgeIndex, edgeData) // Update adjacency lists @@ -2338,6 +2632,10 @@ export const addEdge = ( /** * Removes a node and all its incident edges from a mutable graph. * + * **Gotchas** + * + * A missing node index is ignored. + * * **Example** (Removing a node) * * ```ts import.meta.vitest @@ -2360,37 +2658,92 @@ export const addEdge = ( export const removeNode = ( mutable: MutableGraph, nodeIndex: NodeIndex +): void => { + const impl = getMutableImplForMutation(mutable) + if (removeNodeInternal(impl, nodeIndex)) { + invalidateCycleFlagOnRemoval(impl) + } +} + +/** + * Removes multiple nodes and all their incident edges from a mutable graph. + * + * **When to use** + * + * Use when deleting a collection of nodes in one mutation pass. + * + * **Details** + * + * The input is collected before mutation, so it may be backed by an iterator + * over the same graph. + * + * **Gotchas** + * + * Missing and duplicate node indices are ignored. Removing a node also removes + * all of its incident edges. + * + * @see {@link removeNode} for removing one node + * + * @category mutations + * @since 4.0.0 + */ +export const removeNodes = ( + mutable: MutableGraph, + nodeIndices: Iterable ): void => { assertMutable(mutable) - const impl = graphImpl(mutable) + if (internal.isTransforming(mutable)) { + throw new GraphError({ message: "Cannot mutate graph during a transformation" }) + } + const indices = internal.withTransformation(mutable, () => Array.from(nodeIndices)) + const impl = getMutableImplForMutation(mutable) - // Check if node exists - if (!impl.nodes.has(nodeIndex)) { - return // Node doesn't exist, nothing to remove + let removed = false + for (const nodeIndex of indices) { + if (removeNodeInternal(impl, nodeIndex)) { + removed = true + } } - // Collect all incident edges for removal - const edgesToRemove: Array = [] + if (removed) { + invalidateCycleFlagOnRemoval(impl) + } +} - // Get outgoing edges - const outgoingEdges = impl.adjacency.get(nodeIndex) - if (outgoingEdges !== undefined) { - for (const edge of outgoingEdges) { - edgesToRemove.push(edge) - } +/** @internal */ +const removeNodeInternal = ( + impl: internal.GraphImpl, + nodeIndex: NodeIndex +): boolean => { + // Check if node exists + if (!impl.nodes.has(nodeIndex)) { + return false // Node doesn't exist, nothing to remove } - // Get incoming edges - const incomingEdges = impl.reverseAdjacency.get(nodeIndex) - if (incomingEdges !== undefined) { - for (const edge of incomingEdges) { - edgesToRemove.push(edge) - } + const edgesToRemove = new Set(impl.adjacency.get(nodeIndex)!) + for (const edgeIndex of impl.reverseAdjacency.get(nodeIndex)!) { + edgesToRemove.add(edgeIndex) } - // Remove all incident edges for (const edgeIndex of edgesToRemove) { - removeEdgeInternal(impl, edgeIndex) + const edge = impl.edges.get(edgeIndex)! + if (edge.source !== nodeIndex) { + const adjacency = impl.adjacency.get(edge.source)! + adjacency.splice(adjacency.indexOf(edgeIndex), 1) + if (impl.type === "undirected") { + const reverseAdjacency = impl.reverseAdjacency.get(edge.source)! + reverseAdjacency.splice(reverseAdjacency.indexOf(edgeIndex), 1) + } + } + if (edge.target !== nodeIndex) { + const reverseAdjacency = impl.reverseAdjacency.get(edge.target)! + reverseAdjacency.splice(reverseAdjacency.indexOf(edgeIndex), 1) + if (impl.type === "undirected") { + const adjacency = impl.adjacency.get(edge.target)! + adjacency.splice(adjacency.indexOf(edgeIndex), 1) + } + } + impl.edges.delete(edgeIndex) } // Remove the node itself @@ -2398,14 +2751,16 @@ export const removeNode = ( impl.adjacency.delete(nodeIndex) impl.reverseAdjacency.delete(nodeIndex) - // Only invalidate cycle flag if the graph wasn't already known to be acyclic - // Removing nodes cannot introduce cycles in an acyclic graph - invalidateCycleFlagOnRemoval(impl) + return true } /** * Removes an edge from a mutable graph. * + * **Gotchas** + * + * A missing edge index is ignored. + * * **Example** (Removing an edge) * * ```ts import.meta.vitest @@ -2429,26 +2784,66 @@ export const removeEdge = ( mutable: MutableGraph, edgeIndex: EdgeIndex ): void => { - assertMutable(mutable) - const impl = graphImpl(mutable) - - const wasRemoved = removeEdgeInternal(impl, edgeIndex) - + const impl = getMutableImplForMutation(mutable) // Only invalidate cycle flag if an edge was actually removed // and only if the graph wasn't already known to be acyclic - if (wasRemoved) { + if (removeEdgeInternal(impl, edgeIndex)) { invalidateCycleFlagOnRemoval(impl) } } -/** @internal */ -const removeEdgeInternal = ( - mutable: GraphImpl, - edgeIndex: EdgeIndex -): boolean => { - // Get edge data - const edge = mutable.edges.get(edgeIndex) - if (edge === undefined) { +/** + * Removes multiple edges from a mutable graph. + * + * **When to use** + * + * Use when deleting a collection of edges in one mutation pass. + * + * **Details** + * + * The input is collected before mutation, so it may be backed by an iterator + * over the same graph. + * + * **Gotchas** + * + * Missing and duplicate edge indices are ignored. Nodes are never removed. + * + * @see {@link removeEdge} for removing one edge + * + * @category mutations + * @since 4.0.0 + */ +export const removeEdges = ( + mutable: MutableGraph, + edgeIndices: Iterable +): void => { + assertMutable(mutable) + if (internal.isTransforming(mutable)) { + throw new GraphError({ message: "Cannot mutate graph during a transformation" }) + } + const indices = internal.withTransformation(mutable, () => Array.from(edgeIndices)) + const impl = getMutableImplForMutation(mutable) + + let removed = false + for (const edgeIndex of indices) { + if (removeEdgeInternal(impl, edgeIndex)) { + removed = true + } + } + + if (removed) { + invalidateCycleFlagOnRemoval(impl) + } +} + +/** @internal */ +const removeEdgeInternal = ( + mutable: internal.GraphImpl, + edgeIndex: EdgeIndex +): boolean => { + // Get edge data + const edge = mutable.edges.get(edgeIndex) + if (edge === undefined) { return false // Edge doesn't exist, no mutation occurred } @@ -2514,7 +2909,7 @@ const removeEdgeInternal = ( * Graph.addEdge(mutable, nodeA, nodeB, 42) * }) * - * Graph.getEdge(graph, 0) // => Option.some(new Graph.Edge({ source: 0, target: 1, data: 42 })) + * Graph.getEdge(graph, 0) // => Option.some({ source: 0, target: 1, data: 42 }) * ``` * * @category getters @@ -2531,11 +2926,23 @@ export const getEdge: { } = dual(2, ( graph: Graph | MutableGraph, edgeIndex: EdgeIndex -): Option.Option> => Option.fromUndefinedOr(graphImpl(graph).edges.get(edgeIndex))) +): Option.Option> => { + const edge = internal.toImpl(graph).edges.get(edgeIndex) + return edge === undefined ? Option.none() : Option.some(copyEdge(edge)) +}) /** * Checks whether an edge exists between two nodes in the graph. * + * **Details** + * + * Directed graphs test only `source` to `target`; undirected graphs accept + * either stored orientation. Parallel edges still produce one boolean result. + * + * **Gotchas** + * + * Returns `false` when either node does not exist. + * * **Example** (Checking edge existence) * * ```ts import.meta.vitest @@ -2552,7 +2959,8 @@ export const getEdge: { * Graph.hasEdge(graph, 0, 2) // => false * ``` * - * @category getters + * @see {@link edgesBetween} for all matching edge identifiers + * @category predicates * @since 3.18.0 */ export const hasEdge: { @@ -2570,7 +2978,7 @@ export const hasEdge: { source: NodeIndex, target: NodeIndex ): boolean => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) const adjacencyList = impl.adjacency.get(source) if (adjacencyList === undefined) { return false @@ -2618,14 +3026,292 @@ export const hasEdge: { */ export const edgeCount = ( graph: Graph | MutableGraph -): number => graphImpl(graph).edges.size +): number => internal.toImpl(graph).edges.size + +/** + * Returns the indices of all edges incident to a node. + * + * Each edge is returned once in graph edge order, including self-loops. + * Throws a `GraphError` when the node does not exist. + * + * @category getters + * @since 4.0.0 + */ +export const incidentEdges: { + ( + nodeIndex: NodeIndex + ): (graph: Graph | MutableGraph) => Array + ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex + ): Array +} = dual(2, ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex +): Array => { + const impl = internal.toImpl(graph) + if (!impl.nodes.has(nodeIndex)) { + throw missingNode(nodeIndex) + } + const outgoing = impl.adjacency.get(nodeIndex)! + if (graph.type === "undirected") { + const result: Array = [] + let previous = -1 + for (const edgeIndex of outgoing) { + if (edgeIndex !== previous) { + result.push(edgeIndex) + previous = edgeIndex + } + } + return result + } + + const incoming = impl.reverseAdjacency.get(nodeIndex)! + const result: Array = [] + let outgoingPosition = 0 + let incomingPosition = 0 + while (outgoingPosition < outgoing.length && incomingPosition < incoming.length) { + const outgoingEdge = outgoing[outgoingPosition] + const incomingEdge = incoming[incomingPosition] + if (outgoingEdge < incomingEdge) { + result.push(outgoingEdge) + outgoingPosition++ + } else if (incomingEdge < outgoingEdge) { + result.push(incomingEdge) + incomingPosition++ + } else { + result.push(outgoingEdge) + outgoingPosition++ + incomingPosition++ + } + } + while (outgoingPosition < outgoing.length) result.push(outgoing[outgoingPosition++]) + while (incomingPosition < incoming.length) result.push(incoming[incomingPosition++]) + return result +}) + +/** + * Returns the indices of outgoing edges for a node in a directed graph. + * + * Parallel edges and self-loops are returned separately in adjacency order. + * Throws a `GraphError` for an undirected graph or missing node. + * + * @category getters + * @since 4.0.0 + */ +export const outgoingEdges: { + (nodeIndex: NodeIndex): ( + graph: Graph | MutableGraph + ) => Array + ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex + ): Array +} = dual(2, ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex +): Array => { + if (graph.type === "undirected") { + throw new GraphError({ message: "Cannot get outgoing edges of undirected graph" }) + } + const impl = internal.toImpl(graph) + if (!impl.nodes.has(nodeIndex)) { + throw missingNode(nodeIndex) + } + return Array.from(impl.adjacency.get(nodeIndex)!) +}) + +/** + * Returns the indices of incoming edges for a node in a directed graph. + * + * Parallel edges and self-loops are returned separately in reverse-adjacency + * order. Throws a `GraphError` for an undirected graph or missing node. + * + * @category getters + * @since 4.0.0 + */ +export const incomingEdges: { + (nodeIndex: NodeIndex): ( + graph: Graph | MutableGraph + ) => Array + ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex + ): Array +} = dual(2, ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex +): Array => { + if (graph.type === "undirected") { + throw new GraphError({ message: "Cannot get incoming edges of undirected graph" }) + } + const impl = internal.toImpl(graph) + if (!impl.nodes.has(nodeIndex)) { + throw missingNode(nodeIndex) + } + return Array.from(impl.reverseAdjacency.get(nodeIndex)!) +}) + +/** + * Returns all edge indices connecting the supplied nodes. + * + * Directed graphs only include edges from `source` to `target`; undirected + * graphs include either stored orientation. Parallel edges are retained. + * Throws a `GraphError` when either node does not exist. + * + * @category getters + * @since 4.0.0 + */ +export const edgesBetween: { + (source: NodeIndex, target: NodeIndex): ( + graph: Graph | MutableGraph + ) => Array + ( + graph: Graph | MutableGraph, + source: NodeIndex, + target: NodeIndex + ): Array +} = dual(3, ( + graph: Graph | MutableGraph, + source: NodeIndex, + target: NodeIndex +): Array => { + const impl = internal.toImpl(graph) + if (!impl.nodes.has(source)) { + throw missingNode(source) + } + if (!impl.nodes.has(target)) { + throw missingNode(target) + } + const result: Array = [] + let previous = -1 + for (const edgeIndex of impl.adjacency.get(source)!) { + if (edgeIndex === previous) { + continue + } + previous = edgeIndex + const edge = impl.edges.get(edgeIndex)! + const neighbor = graph.type === "undirected" && edge.target === source ? edge.source : edge.target + if (neighbor === target) { + result.push(edgeIndex) + } + } + return result +}) + +/** + * Returns the degree of a node in an undirected graph. + * + * Parallel edges count separately and a self-loop contributes two. Throws a + * `GraphError` for a directed graph or missing node. + * + * @category getters + * @since 4.0.0 + */ +export const degree: { + (nodeIndex: NodeIndex): ( + graph: Graph | MutableGraph + ) => number + (graph: Graph | MutableGraph, nodeIndex: NodeIndex): number +} = dual(2, ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex +): number => { + if (graph.type === "directed") { + throw new GraphError({ message: "Cannot get degree of directed graph" }) + } + const impl = internal.toImpl(graph) + if (!impl.nodes.has(nodeIndex)) { + throw missingNode(nodeIndex) + } + return impl.adjacency.get(nodeIndex)!.length +}) + +/** + * Returns the out-degree of a node in a directed graph. + * + * Parallel edges count separately and a self-loop contributes one. Throws a + * `GraphError` for an undirected graph or missing node. + * + * @category getters + * @since 4.0.0 + */ +export const outDegree: { + (nodeIndex: NodeIndex): ( + graph: Graph | MutableGraph + ) => number + (graph: Graph | MutableGraph, nodeIndex: NodeIndex): number +} = dual(2, ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex +): number => { + if (graph.type === "undirected") { + throw new GraphError({ message: "Cannot get outgoing edges of undirected graph" }) + } + const impl = internal.toImpl(graph) + if (!impl.nodes.has(nodeIndex)) { + throw missingNode(nodeIndex) + } + return impl.adjacency.get(nodeIndex)!.length +}) + +/** + * Returns the in-degree of a node in a directed graph. + * + * Parallel edges count separately and a self-loop contributes one. Throws a + * `GraphError` for an undirected graph or missing node. + * + * @category getters + * @since 4.0.0 + */ +export const inDegree: { + (nodeIndex: NodeIndex): ( + graph: Graph | MutableGraph + ) => number + (graph: Graph | MutableGraph, nodeIndex: NodeIndex): number +} = dual(2, ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex +): number => { + if (graph.type === "undirected") { + throw new GraphError({ message: "Cannot get incoming edges of undirected graph" }) + } + const impl = internal.toImpl(graph) + if (!impl.nodes.has(nodeIndex)) { + throw missingNode(nodeIndex) + } + return impl.reverseAdjacency.get(nodeIndex)!.length +}) const getDirectedNeighbors = ( graph: Graph | MutableGraph, nodeIndex: NodeIndex, direction: Direction ): Array => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) + + if (!graph.mutable) { + const cache = csr.peek(graph) + if (cache !== undefined) { + const node = csr.getNodeIndex(cache, nodeIndex) + if (node === undefined) { + return [] + } + + const adjacency = direction === "incoming" + ? csr.getIncoming(cache) + : csr.getOutgoing(cache) + + const start = adjacency.rowOffsets[node] + const result = new Array(adjacency.rowOffsets[node + 1] - start) + for (let i = 0; i < result.length; i++) { + result[i] = cache.nodeIds[adjacency.columnIndices[start + i]] + } + + return result + } + } + const adjacencyMap = direction === "incoming" ? impl.reverseAdjacency : impl.adjacency @@ -2646,6 +3332,12 @@ const getDirectedNeighbors = ( return result } +const getUniqueDirectedNeighbors = ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex, + direction: Direction +): Array => Array.from(new Set(getDirectedNeighbors(graph, nodeIndex, direction))) + /** * Returns the neighboring node indices for a node. * @@ -2653,6 +3345,13 @@ const getDirectedNeighbors = ( * * For directed graphs, neighbors are the targets of outgoing edges. For * undirected graphs, neighbors are the other endpoints of incident edges. + * Each neighbor appears once in first edge occurrence order, including the + * queried node when it has a self-loop. + * + * **Gotchas** + * + * Returns an empty array when the node does not exist. For directed graphs, + * use `predecessors` when incoming neighbors are required. * * **Example** (Getting outgoing neighbors) * @@ -2691,7 +3390,7 @@ export const neighbors: { return getUndirectedNeighbors(graph as any, nodeIndex) } - return getDirectedNeighbors(graph as Graph | MutableGraph, nodeIndex, "outgoing") + return getUniqueDirectedNeighbors(graph as any, nodeIndex, "outgoing") }) /** @@ -2702,9 +3401,13 @@ export const neighbors: { * Use when you need the nodes reached by following outgoing edges from a node in * a directed graph. * + * Each node appears once in first outgoing edge occurrence order. A self-loop + * contributes the queried node once. + * * **Gotchas** * - * Throws a `GraphError` when used with an undirected graph. + * Throws a `GraphError` when used with an undirected graph. A missing node + * returns an empty array. * * @see {@link predecessors} for incoming neighbors in a directed graph * @see {@link neighbors} for generic neighbor lookup across graph kinds @@ -2727,7 +3430,7 @@ export const successors: { if (graph.type === "undirected") { throw new GraphError({ message: "Cannot get successors of undirected graph" }) } - return getDirectedNeighbors(graph as Graph | MutableGraph, nodeIndex, "outgoing") + return getUniqueDirectedNeighbors(graph as any, nodeIndex, "outgoing") }) /** @@ -2738,9 +3441,13 @@ export const successors: { * Use when you need the nodes that reach a node by following incoming edges in a * directed graph. * + * Each node appears once in first incoming edge occurrence order. A self-loop + * contributes the queried node once. + * * **Gotchas** * - * Throws a `GraphError` when used with an undirected graph. + * Throws a `GraphError` when used with an undirected graph. A missing node + * returns an empty array. * * @see {@link successors} for outgoing neighbors in a directed graph * @see {@link neighbors} for generic neighbor lookup across graph kinds @@ -2763,7 +3470,7 @@ export const predecessors: { if (graph.type === "undirected") { throw new GraphError({ message: "Cannot get predecessors of undirected graph" }) } - return getDirectedNeighbors(graph as Graph | MutableGraph, nodeIndex, "incoming") + return getUniqueDirectedNeighbors(graph as any, nodeIndex, "incoming") }) /** @@ -2773,6 +3480,8 @@ export const predecessors: { * * Use when maintaining existing code that already passes an explicit traversal * direction. New code should prefer `successors` or `predecessors`. + * Results contain each node once in first edge occurrence order, and a self-loop + * contributes the queried node once. * * **Gotchas** * @@ -2824,7 +3533,7 @@ export const neighborsDirected: { if (graph.type === "undirected") { throw new GraphError({ message: "Cannot get directed neighbors of undirected graph" }) } - return getDirectedNeighbors(graph as Graph | MutableGraph, nodeIndex, direction) + return getUniqueDirectedNeighbors(graph as any, nodeIndex, direction) }) // ============================================================================= @@ -2834,6 +3543,10 @@ export const neighborsDirected: { /** * Configuration options for GraphViz DOT format generation from graphs. * + * **When to use** + * + * Use when customizing labels or the graph name produced by `toGraphViz`. + * * **Details** * * These options customize node labels, edge labels, and graph naming in DOT @@ -2859,7 +3572,8 @@ export const neighborsDirected: { * Array.of(basicOptions.nodeLabel?.("A"), namedOptions.graphName) // => ["Node: A", "MyDependencyGraph"] * ``` * - * @category options + * @see {@link toGraphViz} for generating DOT output + * @category configuration * @since 3.18.0 */ export interface GraphVizOptions { @@ -2888,6 +3602,11 @@ const escapeGraphVizString = (value: string): string => /** * Exports a graph to GraphViz DOT format for visualization. * + * **When to use** + * + * Use when sending graph structure to GraphViz-compatible visualization or + * documentation tools. + * * **Example** (Exporting GraphViz DOT) * * ```ts import.meta.vitest @@ -2905,6 +3624,7 @@ const escapeGraphVizString = (value: string): string => * Graph.toGraphViz(graph).split("\n") // => ['digraph "G" {', ' "0" [label="Node A"];', ' "1" [label="Node B"];', ' "2" [label="Node C"];', ' "0" -> "1" [label="1"];', ' "1" -> "2" [label="2"];', ' "2" -> "0" [label="3"];', "}"] * ``` * + * @see {@link toMermaid} for Mermaid diagram output * @category converting * @since 3.18.0 */ @@ -2920,7 +3640,7 @@ export const toGraphViz: { graph: Graph | MutableGraph, options?: GraphVizOptions ): string => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) const { edgeLabel = (data: E) => String(data), graphName = "G", @@ -2932,23 +3652,25 @@ export const toGraphViz: { const edgeOperator = isDirected ? "->" : "--" const graphId = `"${escapeGraphVizString(graphName)}"` - const lines: Array = [] - lines.push(`${graphType} ${graphId} {`) + return withMutationGuard(graph, () => { + const lines: Array = [] + lines.push(`${graphType} ${graphId} {`) - // Add nodes - for (const [nodeIndex, nodeData] of impl.nodes) { - const label = escapeGraphVizString(nodeLabel(nodeData)) - lines.push(` "${nodeIndex}" [label="${label}"];`) - } + // Add nodes + for (const [nodeIndex, nodeData] of impl.nodes) { + const label = escapeGraphVizString(nodeLabel(nodeData)) + lines.push(` "${nodeIndex}" [label="${label}"];`) + } - // Add edges - for (const [, edgeData] of impl.edges) { - const label = escapeGraphVizString(edgeLabel(edgeData.data)) - lines.push(` "${edgeData.source}" ${edgeOperator} "${edgeData.target}" [label="${label}"];`) - } + // Add edges + for (const [, edgeData] of impl.edges) { + const label = escapeGraphVizString(edgeLabel(edgeData.data)) + lines.push(` "${edgeData.source}" ${edgeOperator} "${edgeData.target}" [label="${label}"];`) + } - lines.push("}") - return lines.join("\n") + lines.push("}") + return lines.join("\n") + }) }) // ============================================================================= @@ -3085,19 +3807,18 @@ export type MermaidDiagramType = | "flowchart" // For directed graphs | "graph" // For undirected graphs -/** - * Configuration options for Mermaid diagram generation, following GraphViz pattern. - * - * @category models - * @since 4.0.0 - */ /** * Configuration options for Mermaid diagram generation from graphs. * + * **When to use** + * + * Use when customizing labels, layout, node shapes, or syntax emitted by + * `toMermaid`. + * * **Details** * * These options customize node labels, edge labels, diagram type, layout - * direction, node shapes, and graph naming in Mermaid format. + * direction and node shapes in Mermaid format. * * **Example** (Configuring Mermaid output) * @@ -3121,7 +3842,8 @@ export type MermaidDiagramType = * Array.of(basicOptions.nodeLabel?.("A"), advancedOptions.nodeShape?.("start")) // => ["Node: A", "circle"] * ``` * - * @category options + * @see {@link toMermaid} for generating Mermaid output + * @category configuration * @since 3.18.0 */ export interface MermaidOptions { @@ -3177,7 +3899,7 @@ const escapeMermaidLabel = (label: string): string => { .replace(/\)/g, "#41;") .replace(/\|/g, "#124;") .replace(/\\/g, "#92;") - .replace(/\n/g, "
") + .replace(/\r\n|\r|\n/g, "
") } /** @@ -3213,150 +3935,32 @@ const formatMermaidNode = ( /** * Exports a graph to Mermaid diagram format for visualization. * - * **Details** - * - * Mermaid is a popular diagram-as-code tool that generates flowcharts and other - * visualizations from text-based definitions. This function converts Effect Graph - * structures to valid Mermaid syntax for use in documentation, web applications, - * and visualization tools. - * - * **Example** (Exporting a directed Mermaid diagram) + * **When to use** * - * ```ts import.meta.vitest - * import { Graph } from "effect" + * Use when embedding graph diagrams in Markdown, documentation sites, or other + * Mermaid-compatible tools. * - * // Basic directed graph export - * const graph = Graph.directed((mutable) => { - * const app = Graph.addNode(mutable, "App") - * const db = Graph.addNode(mutable, "Database") - * const cache = Graph.addNode(mutable, "Cache") - * Graph.addEdge(mutable, app, db, 1) - * Graph.addEdge(mutable, app, cache, 2) - * }) + * **Details** * - * Graph.toMermaid(graph).split("\n") // => ["flowchart TD", ' 0["App"]', ' 1["Database"]', ' 2["Cache"]', ' 0 -->|"1"| 1', ' 0 -->|"2"| 2'] - * ``` + * Directed graphs default to `flowchart` with arrow edges, while undirected + * graphs default to `graph` with line edges. Labels and node shapes can be + * customized with `MermaidOptions`. * - * **Example** (Exporting an undirected Mermaid diagram) + * **Example** (Exporting a Mermaid diagram) * * ```ts import.meta.vitest * import { Graph } from "effect" * - * // Undirected graph with custom labels and direction - * const socialGraph = Graph.undirected<{ name: string }, string>((mutable) => { - * const alice = Graph.addNode(mutable, { name: "Alice" }) - * const bob = Graph.addNode(mutable, { name: "Bob" }) - * const charlie = Graph.addNode(mutable, { name: "Charlie" }) - * Graph.addEdge(mutable, alice, bob, "friends") - * Graph.addEdge(mutable, bob, charlie, "colleagues") - * }) - * - * const mermaid = Graph.toMermaid(socialGraph, { - * nodeLabel: (person) => person.name, - * edgeLabel: (relationship) => relationship, - * direction: "LR" + * const graph = Graph.directed((mutable) => { + * const app = Graph.addNode(mutable, "App") + * const database = Graph.addNode(mutable, "Database") + * Graph.addEdge(mutable, app, database, "queries") * }) - * mermaid.split("\n") // => ["graph LR", ' 0["Alice"]', ' 1["Bob"]', ' 2["Charlie"]', ' 0 ---|"friends"| 1', ' 1 ---|"colleagues"| 2'] - * ``` - * - * **Example** (Customizing Mermaid node shapes) - * - * ```ts import.meta.vitest - * import { Graph } from "effect" * - * // Advanced styling with node shapes for flowchart - * const workflow = Graph.directed<{ type: string; name: string }, string>( - * (mutable) => { - * const start = Graph.addNode(mutable, { type: "start", name: "Begin" }) - * const process = Graph.addNode(mutable, { - * type: "process", - * name: "Process Data" - * }) - * const decision = Graph.addNode(mutable, { - * type: "decision", - * name: "Valid?" - * }) - * const end = Graph.addNode(mutable, { type: "end", name: "Complete" }) - * Graph.addEdge(mutable, start, process, "") - * Graph.addEdge(mutable, process, decision, "") - * Graph.addEdge(mutable, decision, end, "yes") - * } - * ) - * - * const mermaid = Graph.toMermaid(workflow, { - * nodeLabel: (node) => node.name, - * nodeShape: (node) => { - * switch (node.type) { - * case "start": - * return "stadium" - * case "process": - * return "rectangle" - * case "decision": - * return "diamond" - * case "end": - * return "stadium" - * default: - * return "rectangle" - * } - * } - * }) - * mermaid.split("\n") // => ["flowchart TD", ' 0(["Begin"])', ' 1["Process Data"]', ' 2{"Valid?"}', ' 3(["Complete"])', " 0 --> 1", " 1 --> 2", ' 2 -->|"yes"| 3'] - * ``` - * - * **Example** (Visualizing dependency graphs) - * - * ```ts import.meta.vitest - * import { Graph } from "effect" - * - * // Real-world example: Software dependency graph - * interface Dependency { - * name: string - * version: string - * type: "library" | "framework" | "tool" - * } - * - * const dependencyGraph = Graph.directed((mutable) => { - * const app = Graph.addNode(mutable, { - * name: "MyApp", - * version: "1.0.0", - * type: "library" - * } satisfies Dependency) - * const react = Graph.addNode(mutable, { - * name: "React", - * version: "18.0.0", - * type: "framework" - * } satisfies Dependency) - * const lodash = Graph.addNode(mutable, { - * name: "Lodash", - * version: "4.17.0", - * type: "library" - * } satisfies Dependency) - * const webpack = Graph.addNode(mutable, { - * name: "Webpack", - * version: "5.0.0", - * type: "tool" - * } satisfies Dependency) - * - * Graph.addEdge(mutable, app, react, "depends on") - * Graph.addEdge(mutable, app, lodash, "depends on") - * Graph.addEdge(mutable, app, webpack, "builds with") - * }) - * - * const dependencyDiagram = Graph.toMermaid(dependencyGraph, { - * nodeLabel: (dep) => `${dep.name}\\nv${dep.version}`, - * edgeLabel: (edge) => edge, - * nodeShape: (dep) => - * dep.type === "framework" ? - * "hexagon" : - * dep.type === "tool" - * ? "diamond" - * : "rectangle", - * direction: "TB" - * }) - * - * dependencyDiagram.split("\n") // => ["flowchart TB", ' 0["MyApp#92;nv1.0.0"]', ' 1{{"React#92;nv18.0.0"}}', ' 2["Lodash#92;nv4.17.0"]', ' 3{"Webpack#92;nv5.0.0"}', ' 0 -->|"depends on"| 1', ' 0 -->|"depends on"| 2', ' 0 -->|"builds with"| 3'] + * Graph.toMermaid(graph).split("\n") // => ["flowchart TD", ' 0["App"]', ' 1["Database"]', ' 0 -->|"queries"| 1'] * ``` * + * @see {@link toGraphViz} for GraphViz DOT output * @category converting * @since 3.18.0 */ @@ -3372,7 +3976,7 @@ export const toMermaid: { graph: Graph | MutableGraph, options?: MermaidOptions ): string => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) // Extract and validate options with defaults const { diagramType, @@ -3386,34 +3990,36 @@ export const toMermaid: { const finalDiagramType = diagramType ?? (graph.type === "directed" ? "flowchart" : "graph") - // Generate diagram header - const lines: Array = [] - lines.push(`${finalDiagramType} ${direction}`) - - // Add nodes - for (const [nodeIndex, nodeData] of impl.nodes) { - const nodeId = String(nodeIndex) - const label = escapeMermaidLabel(nodeLabel(nodeData)) - const shape = nodeShape(nodeData) - const formattedNode = formatMermaidNode(nodeId, label, shape) - lines.push(` ${formattedNode}`) - } + return withMutationGuard(graph, () => { + // Generate diagram header + const lines: Array = [] + lines.push(`${finalDiagramType} ${direction}`) + + // Add nodes + for (const [nodeIndex, nodeData] of impl.nodes) { + const nodeId = String(nodeIndex) + const label = escapeMermaidLabel(nodeLabel(nodeData)) + const shape = nodeShape(nodeData) + const formattedNode = formatMermaidNode(nodeId, label, shape) + lines.push(` ${formattedNode}`) + } - // Add edges - const edgeOperator = finalDiagramType === "flowchart" ? "-->" : "---" - for (const [, edgeData] of impl.edges) { - const sourceId = String(edgeData.source) - const targetId = String(edgeData.target) - const label = escapeMermaidLabel(edgeLabel(edgeData.data)) + // Add edges + const edgeOperator = finalDiagramType === "flowchart" ? "-->" : "---" + for (const [, edgeData] of impl.edges) { + const sourceId = String(edgeData.source) + const targetId = String(edgeData.target) + const label = escapeMermaidLabel(edgeLabel(edgeData.data)) - if (label) { - lines.push(` ${sourceId} ${edgeOperator}|"${label}"| ${targetId}`) - } else { - lines.push(` ${sourceId} ${edgeOperator} ${targetId}`) + if (label) { + lines.push(` ${sourceId} ${edgeOperator}|"${label}"| ${targetId}`) + } else { + lines.push(` ${sourceId} ${edgeOperator} ${targetId}`) + } } - } - return lines.join("\n") + return lines.join("\n") + }) }) // ============================================================================= @@ -3469,15 +4075,111 @@ export type TraversalDirection = Direction | "undirected" // Graph Structure Analysis Algorithms // ============================================================================= +/** + * A cycle witness containing a closed node path and its traversed edges. + * + * **Details** + * + * `path` repeats its first node at the end, so `edges.length` is always + * `path.length - 1`. + * + * @category models + * @since 4.0.0 + */ +export interface CycleResult { + readonly path: Array + readonly edges: Array +} + +/** + * Returns one cycle in a graph, if present. + * + * **When to use** + * + * Use when you need the nodes and edges of a concrete cycle for diagnostics or + * reporting. + * + * **Details** + * + * Directed cycles respect edge orientation. A self-loop is represented as a + * one-edge cycle, and two parallel undirected edges form a two-edge cycle. + * + * @see {@link isAcyclic} when only a boolean cycle check is needed + * @category algorithms + * @since 4.0.0 + */ +export const findCycle = ( + graph: Graph | MutableGraph +): Option.Option => { + const impl = internal.toImpl(graph) + const colors = new Map() + const parentNodes = new Map() + const parentEdges = new Map() + + const makeCycle = (ancestor: NodeIndex, current: NodeIndex, closingEdge: EdgeIndex): CycleResult => { + const path = [current] + const edges: Array = [] + let cursor = current + while (cursor !== ancestor) { + edges.push(parentEdges.get(cursor)!) + cursor = parentNodes.get(cursor)! + path.push(cursor) + } + path.reverse() + edges.reverse() + path.push(ancestor) + edges.push(closingEdge) + return { path, edges } + } + + for (const start of impl.nodes.keys()) { + if ((colors.get(start) ?? 0) !== 0) { + continue + } + colors.set(start, 1) + const stack: Array<{ readonly node: NodeIndex; position: number }> = [{ node: start, position: 0 }] + while (stack.length > 0) { + const frame = stack[stack.length - 1] + const adjacency = impl.adjacency.get(frame.node)! + if (frame.position >= adjacency.length) { + colors.set(frame.node, 2) + stack.pop() + continue + } + + const edgeIndex = adjacency[frame.position++] + if (graph.type === "undirected" && parentEdges.get(frame.node) === edgeIndex) { + continue + } + const edge = impl.edges.get(edgeIndex)! + const neighbor = getTraversableNeighbor(graph, frame.node, edge) + const color = colors.get(neighbor) ?? 0 + if (color === 1) { + return Option.some(makeCycle(neighbor, frame.node, edgeIndex)) + } + if (color === 0) { + colors.set(neighbor, 1) + parentNodes.set(neighbor, frame.node) + parentEdges.set(neighbor, edgeIndex) + stack.push({ node: neighbor, position: 0 }) + } + } + } + return Option.none() +} + /** * Checks whether the graph is acyclic (contains no cycles). * + * **When to use** + * + * Use when validating that a graph contains no cycle and a cycle witness is + * not needed. + * * **Details** * - * Uses depth-first search to detect back edges, which indicate cycles. - * For directed graphs, any back edge creates a cycle. For undirected graphs, - * a back edge that doesn't use the same edge used to enter the current node - * creates a cycle. + * Directed cycles respect edge orientation. Self-loops are cycles, and two + * parallel edges form a cycle in an undirected graph. * * **Example** (Checking cycles) * @@ -3504,128 +4206,94 @@ export type TraversalDirection = Direction | "undirected" * Graph.isAcyclic(cyclic) // => false * ``` * + * @see {@link findCycle} for retrieving one cycle witness + * @see {@link topo} for ordering a directed acyclic graph * @category algorithms * @since 3.18.0 */ export const isAcyclic = ( graph: Graph | MutableGraph ): boolean => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) // Use existing cycle flag if available if (Option.isSome(impl.acyclic)) { return impl.acyclic.value } + const cache = csr.get(graph) + const outgoing = csr.getOutgoingWithEdges(cache) if (graph.type === "undirected") { - const visited = new Set() + // Each undirected edge occurs in both endpoint rows; ignore only the edge used to enter the node. + const visited = new Uint8Array(cache.nodeIds.length) + const stack: Array = [] + const parentEdges: Array = [] - for (const startNode of impl.nodes.keys()) { - if (visited.has(startNode)) { + for (let start = 0; start < cache.nodeIds.length; start++) { + if (visited[start] !== 0) { continue } - - visited.add(startNode) - const stack: Array<{ node: NodeIndex; incoming: EdgeIndex | null }> = [{ node: startNode, incoming: null }] + visited[start] = 1 + stack.push(start) + parentEdges.push(-1) while (stack.length > 0) { - const { node, incoming } = stack.pop()! - const adjacencyList = impl.adjacency.get(node) - if (adjacencyList === undefined) { - continue - } - - for (const edgeIndex of adjacencyList) { - if (edgeIndex === incoming) { - continue - } - const edge = impl.edges.get(edgeIndex) - if (edge === undefined) { + const node = stack.pop()! + const parentEdge = parentEdges.pop()! + for (let i = outgoing.rowOffsets[node]; i < outgoing.rowOffsets[node + 1]; i++) { + const edge = outgoing.edgeIndices[i] + if (edge === parentEdge) { continue } - const neighbor = getTraversableNeighbor(graph, node, edge) - if (!visited.has(neighbor)) { - visited.add(neighbor) - stack.push({ node: neighbor, incoming: edgeIndex }) - } else { + const neighbor = outgoing.columnIndices[i] + if (visited[neighbor] !== 0) { impl.acyclic = Option.some(false) return false } + visited[neighbor] = 1 + stack.push(neighbor) + parentEdges.push(edge) } } } - - impl.acyclic = Option.some(true) - return true - } - - // Stack-safe DFS cycle detection using iterative approach - const visited = new Set() - const recursionStack = new Set() - - // Stack entry: [node, neighbors, neighborIndex, isFirstVisit] - type DfsStackEntry = [NodeIndex, Array, number, boolean] - - // Get all nodes to handle disconnected components - for (const startNode of impl.nodes.keys()) { - if (visited.has(startNode)) { - continue // Already processed this component - } - - // Iterative DFS with explicit stack - const stack: Array = [[startNode, [], 0, true]] - - while (stack.length > 0) { - const [node, neighbors, neighborIndex, isFirstVisit] = stack[stack.length - 1] - - // First visit to this node - if (isFirstVisit) { - if (recursionStack.has(node)) { - // Back edge found - cycle detected - impl.acyclic = Option.some(false) - return false - } - - if (visited.has(node)) { - stack.pop() - continue - } - - visited.add(node) - recursionStack.add(node) - - // Get neighbors for this node - const nodeNeighbors = getDirectedNeighbors( - graph as Graph | MutableGraph, - node, - "outgoing" - ) - stack[stack.length - 1] = [node, nodeNeighbors, 0, false] + } else { + // Colors encode unseen, active, and finished nodes; row positions make the recursive DFS stack explicit. + const colors = new Uint8Array(cache.nodeIds.length) + const stack: Array = [] + const positions: Array = [] + + for (let start = 0; start < cache.nodeIds.length; start++) { + if (colors[start] !== 0) { continue } + colors[start] = 1 + stack.push(start) + positions.push(outgoing.rowOffsets[start]) - // Process next neighbor - if (neighborIndex < neighbors.length) { - const neighbor = neighbors[neighborIndex] - stack[stack.length - 1] = [node, neighbors, neighborIndex + 1, false] - - if (recursionStack.has(neighbor)) { - // Back edge found - cycle detected - impl.acyclic = Option.some(false) - return false - } - - if (!visited.has(neighbor)) { - stack.push([neighbor, [], 0, true]) + while (stack.length > 0) { + const frame = stack.length - 1 + const node = stack[frame] + const position = positions[frame] + if (position < outgoing.rowOffsets[node + 1]) { + positions[frame] = position + 1 + const neighbor = outgoing.columnIndices[position] + if (colors[neighbor] === 1) { + impl.acyclic = Option.some(false) + return false + } + if (colors[neighbor] === 0) { + colors[neighbor] = 1 + stack.push(neighbor) + positions.push(outgoing.rowOffsets[neighbor]) + } + } else { + colors[node] = 2 + stack.pop() + positions.pop() } - } else { - // Done with this node - backtrack - recursionStack.delete(node) - stack.pop() } } } - // Cache the result impl.acyclic = Option.some(true) return true } @@ -3633,11 +4301,15 @@ export const isAcyclic = ( /** * Checks whether an undirected graph is bipartite. * + * **When to use** + * + * Use when validating that nodes can be divided into two groups with every + * edge crossing between the groups. + * * **Details** * * A bipartite graph is one whose vertices can be divided into two disjoint sets - * such that no two vertices within the same set are adjacent. Uses BFS coloring - * to determine bipartiteness. + * such that no two vertices within the same set are adjacent. * * **Example** (Checking bipartite graphs) * @@ -3668,56 +4340,271 @@ export const isAcyclic = ( * Graph.isBipartite(triangle) // => false * ``` * + * @see {@link maximumBipartiteMatching} for matching nodes after validation * @category algorithms * @since 3.18.0 */ export const isBipartite = ( graph: Graph | MutableGraph ): boolean => { - const impl = graphImpl(graph) - const coloring = new Map() - const discovered = new Set() - let isBipartiteGraph = true - - // Get all nodes to handle disconnected components - for (const startNode of impl.nodes.keys()) { - if (!discovered.has(startNode)) { - // Start BFS coloring from this component - const queue: Array = [startNode] - coloring.set(startNode, 0) // Color start node with 0 - discovered.add(startNode) - - while (queue.length > 0 && isBipartiteGraph) { - const current = queue.shift()! - const currentColor = coloring.get(current)! - const neighborColor: 0 | 1 = currentColor === 0 ? 1 : 0 - - // Get all neighbors for undirected graph - const nodeNeighbors = getUndirectedNeighbors(graph, current) - for (const neighbor of nodeNeighbors) { - if (!discovered.has(neighbor)) { - // Color unvisited neighbor with opposite color - coloring.set(neighbor, neighborColor) - discovered.add(neighbor) - queue.push(neighbor) - } else { - // Check if neighbor has the same color (conflict) - if (coloring.get(neighbor) === currentColor) { - isBipartiteGraph = false - break - } - } + if ((graph as Graph | MutableGraph).type === "directed") { + throw new GraphError({ message: "Cannot determine bipartite status of directed graph" }) + } + const cache = csr.get(graph) + const outgoing = csr.getOutgoing(cache) + // -1 is uncolored; compact indices let coloring and the queue stay in typed arrays. + const colors = new Int8Array(cache.nodeIds.length) + const queue = new Uint32Array(cache.nodeIds.length) + colors.fill(-1) + let head = 0 + let tail = 0 + + for (let start = 0; start < cache.nodeIds.length; start++) { + if (colors[start] !== -1) { + continue + } + colors[start] = 0 + queue[tail++] = start + + while (head < tail) { + const current = queue[head++] + const neighborColor = colors[current] === 0 ? 1 : 0 + for (let i = outgoing.rowOffsets[current]; i < outgoing.rowOffsets[current + 1]; i++) { + const neighbor = outgoing.columnIndices[i] + if (colors[neighbor] === -1) { + colors[neighbor] = neighborColor + queue[tail++] = neighbor + } else if (colors[neighbor] === colors[current]) { + return false } } + } + } - // Early exit if not bipartite - if (!isBipartiteGraph) { - break + return true +} + +/** + * A pair of matched nodes and the edge that realizes the match. + * + * **Details** + * + * `left` and `right` refer to the bipartition derived by + * `maximumBipartiteMatching`, not to the stored edge orientation. + * + * @category models + * @since 4.0.0 + */ +export interface BipartiteMatch { + readonly left: NodeIndex + readonly right: NodeIndex + readonly edge: EdgeIndex +} + +/** @internal */ +const bipartiteColors = ( + graph: Graph | MutableGraph +): { readonly cache: csr.Csr; readonly colors: Int8Array } => { + if ((graph as Graph | MutableGraph).type === "directed") { + throw new GraphError({ message: "Cannot find bipartite matching of directed graph" }) + } + const cache = csr.get(graph) + const outgoing = csr.getOutgoing(cache) + const colors = new Int8Array(cache.nodeIds.length) + const queue = new Uint32Array(cache.nodeIds.length) + colors.fill(-1) + + for (let start = 0; start < cache.nodeIds.length; start++) { + if (colors[start] !== -1) { + continue + } + let head = 0 + let tail = 1 + colors[start] = 0 + queue[0] = start + while (head < tail) { + const node = queue[head++] + const color = colors[node] === 0 ? 1 : 0 + for (let i = outgoing.rowOffsets[node]; i < outgoing.rowOffsets[node + 1]; i++) { + const neighbor = outgoing.columnIndices[i] + if (colors[neighbor] === -1) { + colors[neighbor] = color + queue[tail++] = neighbor + } else if (colors[neighbor] === colors[node]) { + throw new GraphError({ message: "Cannot find bipartite matching of non-bipartite graph" }) + } + } + } + } + return { cache, colors } +} + +/** + * Returns a maximum-cardinality matching of an undirected bipartite graph. + * + * **When to use** + * + * Use when assigning as many disjoint pairs as possible between the two sides + * of a bipartite graph, such as workers to jobs or users to resources. + * + * **Details** + * + * The bipartition is derived internally. Self-loops and odd cycles throw a + * `GraphError`. Isolated nodes are allowed. Parallel edges do not change the + * matching cardinality, and the first edge in graph order between each matched + * pair is reported. Results follow left-partition graph order. Hopcroft-Karp + * runs in `O(E * sqrt(V))` time. + * + * **Gotchas** + * + * The graph must be undirected and bipartite. The derived left and right sides + * are not based on stored edge orientation. + * + * **Example** (Matching a bipartite graph) + * + * ```ts import.meta.vitest + * import { Graph } from "effect" + * + * const graph = Graph.undirected((mutable) => { + * for (const node of ["A", "B", "X", "Y"]) Graph.addNode(mutable, node) + * Graph.addEdge(mutable, 0, 2, "A-X") + * Graph.addEdge(mutable, 0, 3, "A-Y") + * Graph.addEdge(mutable, 1, 2, "B-X") + * }) + * + * Graph.maximumBipartiteMatching(graph) // => [{ left: 0, right: 3, edge: 1 }, { left: 1, right: 2, edge: 2 }] + * ``` + * + * @see {@link isBipartite} for validating the graph without computing a matching + * @category algorithms + * @since 4.0.0 + */ +export const maximumBipartiteMatching = ( + graph: Graph | MutableGraph +): Array => { + const { cache, colors } = bipartiteColors(graph) + const endpoints = csr.getEdgeEndpoints(cache) + const edgeIds = csr.getEdgeIds(cache) + const adjacency: Array> = Array.from({ + length: cache.nodeIds.length + }, () => []) + const seen = Array.from({ length: cache.nodeIds.length }, () => new Set()) + + for (let edge = 0; edge < edgeIds.length; edge++) { + const source = endpoints.sources[edge] + const target = endpoints.targets[edge] + const left = colors[source] === 0 ? source : target + const right = colors[source] === 0 ? target : source + if (!seen[left].has(right)) { + seen[left].add(right) + adjacency[left].push({ right, edge }) + } + } + + const unmatched = -1 + const infinity = 0x7fffffff + const matchLeft = new Int32Array(cache.nodeIds.length) + const matchRight = new Int32Array(cache.nodeIds.length) + const matchEdge = new Int32Array(cache.nodeIds.length) + const distance = new Int32Array(cache.nodeIds.length) + const queue = new Uint32Array(cache.nodeIds.length) + matchLeft.fill(unmatched) + matchRight.fill(unmatched) + matchEdge.fill(unmatched) + let shortestDistance = infinity + + const hasLayer = (): boolean => { + let head = 0 + let tail = 0 + shortestDistance = infinity + for (let left = 0; left < colors.length; left++) { + if (colors[left] !== 0) { + continue + } + if (matchLeft[left] === unmatched) { + distance[left] = 0 + queue[tail++] = left + } else { + distance[left] = infinity + } + } + while (head < tail) { + const left = queue[head++] + if (distance[left] >= shortestDistance) { + continue + } + for (const arc of adjacency[left]) { + const next = matchRight[arc.right] + if (next === unmatched) { + shortestDistance = distance[left] + 1 + } else if (distance[next] === infinity) { + distance[next] = distance[left] + 1 + queue[tail++] = next + } + } + } + return shortestDistance !== infinity + } + + const augment = (start: number): boolean => { + const stack: Array<{ + readonly left: number + position: number + readonly viaRight: number + readonly viaEdge: number + }> = [{ left: start, position: 0, viaRight: unmatched, viaEdge: unmatched }] + while (stack.length > 0) { + const frame = stack[stack.length - 1] + const arcs = adjacency[frame.left] + if (frame.position >= arcs.length) { + distance[frame.left] = infinity + stack.pop() + continue + } + const arc = arcs[frame.position++] + const next = matchRight[arc.right] + if (next === unmatched && distance[frame.left] + 1 === shortestDistance) { + matchLeft[frame.left] = arc.right + matchRight[arc.right] = frame.left + matchEdge[frame.left] = arc.edge + for (let i = stack.length - 1; i > 0; i--) { + const child = stack[i] + const parent = stack[i - 1] + matchLeft[parent.left] = child.viaRight + matchRight[child.viaRight] = parent.left + matchEdge[parent.left] = child.viaEdge + } + return true + } + if (next === unmatched) { + continue + } + if (distance[next] === distance[frame.left] + 1) { + stack.push({ left: next, position: 0, viaRight: arc.right, viaEdge: arc.edge }) + } + } + return false + } + + while (hasLayer()) { + for (let left = 0; left < colors.length; left++) { + if (colors[left] === 0 && matchLeft[left] === unmatched) { + augment(left) } } } - return isBipartiteGraph + const matches: Array = [] + for (let left = 0; left < colors.length; left++) { + if (matchLeft[left] !== unmatched) { + matches.push({ + left: cache.nodeIds[left], + right: cache.nodeIds[matchLeft[left]], + edge: edgeIds[matchEdge[left]] + }) + } + } + return matches } /** @@ -3728,7 +4615,7 @@ const getUndirectedNeighbors = ( graph: Graph | MutableGraph, nodeIndex: NodeIndex ): Array => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) const neighbors = new Set() // Check edges where this node is the source @@ -3747,25 +4634,6 @@ const getUndirectedNeighbors = ( return Array.from(neighbors) } -const getTraversalNeighbors = ( - graph: Graph | MutableGraph, - nodeIndex: NodeIndex, - direction: TraversalDirection -): Array => { - if (graph.type === "undirected") { - return getUndirectedNeighbors(graph as any, nodeIndex) - } - const directed = graph as Graph | MutableGraph - if (direction !== "undirected") { - return getDirectedNeighbors(directed, nodeIndex, direction) - } - const neighbors = new Set(getDirectedNeighbors(directed, nodeIndex, "outgoing")) - for (const neighbor of getDirectedNeighbors(directed, nodeIndex, "incoming")) { - neighbors.add(neighbor) - } - return Array.from(neighbors) -} - const getTraversableNeighbor = ( graph: Graph | MutableGraph, current: NodeIndex, @@ -3773,89 +4641,995 @@ const getTraversableNeighbor = ( ): NodeIndex => graph.type === "undirected" && edge.target === current ? edge.source : edge.target /** - * Finds connected components in an undirected graph. - * Each component is represented as an array of node indices. + * Configuration for unweighted reachability queries. * - * **Example** (Finding connected components) + * **When to use** * - * ```ts import.meta.vitest - * import { Graph } from "effect" + * Use when controlling whether reachability follows outgoing edges, incoming + * edges, or either direction. * - * const graph = Graph.undirected((mutable) => { - * const a = Graph.addNode(mutable, "A") - * const b = Graph.addNode(mutable, "B") - * const c = Graph.addNode(mutable, "C") - * const d = Graph.addNode(mutable, "D") - * Graph.addEdge(mutable, a, b, "edge") // Component 1: A-B - * Graph.addEdge(mutable, c, d, "edge") // Component 2: C-D - * }) + * **Details** * - * Graph.connectedComponents(graph) // => [[0, 1], [2, 3]] - * ``` + * `direction` defaults to `"outgoing"` and is ignored for undirected graphs. * - * @category algorithms - * @since 3.18.0 + * @category configuration + * @since 4.0.0 */ -export const connectedComponents = ( - graph: Graph | MutableGraph -): Array> => { - const impl = graphImpl(graph) - const visited = new Set() - const components: Array> = [] - for (const startNode of impl.nodes.keys()) { - if (!visited.has(startNode)) { - // DFS to find all nodes in this component - const component: Array = [] - const stack: Array = [startNode] +export interface ReachabilityConfig { + readonly direction?: TraversalDirection +} - while (stack.length > 0) { - const current = stack.pop()! - if (!visited.has(current)) { - visited.add(current) - component.push(current) - - // Add all unvisited neighbors to stack - const nodeNeighbors = getUndirectedNeighbors(graph, current) - for (const neighbor of nodeNeighbors) { - if (!visited.has(neighbor)) { - stack.push(neighbor) - } - } +const getUnweightedDistances = ( + graph: Graph | MutableGraph, + source: NodeIndex, + direction: TraversalDirection, + target?: NodeIndex +): Map => { + const impl = internal.toImpl(graph) + if (!impl.nodes.has(source)) { + throw missingNode(source) + } + if (target !== undefined && !impl.nodes.has(target)) { + throw missingNode(target) + } + + const cache = csr.get(graph) + const sourceNode = csr.getNodeIndex(cache, source)! + const targetNode = target === undefined ? undefined : csr.getNodeIndex(cache, target)! + const adjacencies = csr.getAdjacencies(cache, graph.type === "undirected" ? "outgoing" : direction) + const compactDistances = new Int32Array(cache.nodeIds.length) + compactDistances.fill(-1) + compactDistances[sourceNode] = 0 + const queue = new Uint32Array(cache.nodeIds.length) + let head = 0 + let tail = 0 + queue[tail++] = sourceNode + + while (head < tail) { + const current = queue[head++] + if (current === targetNode) { + break + } + const visit = (adjacency: csr.Adjacency) => { + for (let i = adjacency.rowOffsets[current]; i < adjacency.rowOffsets[current + 1]; i++) { + const neighbor = adjacency.columnIndices[i] + if (compactDistances[neighbor] === -1) { + compactDistances[neighbor] = compactDistances[current] + 1 + queue[tail++] = neighbor } } - - components.push(component) + } + visit(adjacencies.primary) + if (adjacencies.secondary !== undefined) { + visit(adjacencies.secondary) } } - return components + const result = new Map() + for (let i = 0; i < cache.nodeIds.length; i++) { + if (compactDistances[i] !== -1) { + result.set(cache.nodeIds[i], compactDistances[i]) + } + } + return result } /** - * Finds strongly connected components in a directed graph using Kosaraju's algorithm. - * Each SCC is represented as an array of node indices. + * Returns minimum unweighted distances from a source to every reachable node. * - * **Gotchas** + * **When to use** * - * Throws a `GraphError` when used with an undirected graph. + * Use when every edge represents one step and you need hop counts from one + * source. * - * **Example** (Finding strongly connected components) + * **Details** * - * ```ts import.meta.vitest - * import { Graph } from "effect" + * Directed traversal is outgoing by default and can be changed with + * `direction`. * - * const graph = Graph.directed((mutable) => { - * const a = Graph.addNode(mutable, "A") - * const b = Graph.addNode(mutable, "B") - * const c = Graph.addNode(mutable, "C") - * Graph.addEdge(mutable, a, b, "A->B") - * Graph.addEdge(mutable, b, c, "B->C") - * Graph.addEdge(mutable, c, a, "C->A") // Creates SCC: A-B-C + * **Gotchas** + * + * Throws a `GraphError` when the source does not exist. + * + * @see {@link hasPath} when only a reachability boolean is needed + * @see {@link bfs} for lazy traversal in increasing hop distance + * @see {@link dijkstra} for weighted shortest paths + * + * @category algorithms + * @since 4.0.0 + */ +export const unweightedDistances: { + (source: NodeIndex, options?: ReachabilityConfig): ( + graph: Graph | MutableGraph + ) => Map + ( + graph: Graph | MutableGraph, + source: NodeIndex, + options?: ReachabilityConfig + ): Map +} = dual((args) => isGraph(args[0]), ( + graph: Graph | MutableGraph, + source: NodeIndex, + options?: ReachabilityConfig +): Map => getUnweightedDistances(graph, source, options?.direction ?? "outgoing")) + +/** + * Tests whether a target is reachable from a source. + * + * **When to use** + * + * Use when you only need a reachability boolean rather than distances or a + * reconstructed path. + * + * **Details** + * + * Directed traversal is outgoing by default and can be changed with + * `direction`. A node is reachable from itself. + * + * **Gotchas** + * + * Throws a `GraphError` when either endpoint does not exist. + * + * @see {@link unweightedDistances} for hop distances to all reachable nodes + * @see {@link dijkstra} for a minimum-cost path + * + * @category predicates + * @since 4.0.0 + */ +export const hasPath: { + (source: NodeIndex, target: NodeIndex, options?: ReachabilityConfig): ( + graph: Graph | MutableGraph + ) => boolean + ( + graph: Graph | MutableGraph, + source: NodeIndex, + target: NodeIndex, + options?: ReachabilityConfig + ): boolean +} = dual((args) => isGraph(args[0]), ( + graph: Graph | MutableGraph, + source: NodeIndex, + target: NodeIndex, + options?: ReachabilityConfig +): boolean => { + const impl = internal.toImpl(graph) + if (!impl.nodes.has(source)) { + throw missingNode(source) + } + if (!impl.nodes.has(target)) { + throw missingNode(target) + } + if (source === target) { + return true + } + + const cache = csr.get(graph) + const sourceNode = csr.getNodeIndex(cache, source)! + const targetNode = csr.getNodeIndex(cache, target)! + const adjacencies = csr.getAdjacencies( + cache, + graph.type === "undirected" ? "outgoing" : options?.direction ?? "outgoing" + ) + const visited = new Uint8Array(cache.nodeIds.length) + const queue = new Uint32Array(cache.nodeIds.length) + let head = 0 + let tail = 1 + visited[sourceNode] = 1 + queue[0] = sourceNode + + while (head < tail) { + const current = queue[head++] + const primary = adjacencies.primary + for (let i = primary.rowOffsets[current]; i < primary.rowOffsets[current + 1]; i++) { + const neighbor = primary.columnIndices[i] + if (neighbor === targetNode) { + return true + } + if (visited[neighbor] === 0) { + visited[neighbor] = 1 + queue[tail++] = neighbor + } + } + const secondary = adjacencies.secondary + if (secondary !== undefined) { + for (let i = secondary.rowOffsets[current]; i < secondary.rowOffsets[current + 1]; i++) { + const neighbor = secondary.columnIndices[i] + if (neighbor === targetNode) { + return true + } + if (visited[neighbor] === 0) { + visited[neighbor] = 1 + queue[tail++] = neighbor + } + } + } + } + return false +}) + +/** + * Returns the connected components of an undirected graph. + * + * **When to use** + * + * Use when partitioning an undirected graph into groups connected by paths. + * + * **Details** + * + * Each component is represented as an array of node indices. Isolated nodes + * form singleton components. + * + * **Gotchas** + * + * Throws a `GraphError` when used with a directed graph. + * + * **Example** (Finding connected components) + * + * ```ts import.meta.vitest + * import { Graph } from "effect" + * + * const graph = Graph.undirected((mutable) => { + * const a = Graph.addNode(mutable, "A") + * const b = Graph.addNode(mutable, "B") + * const c = Graph.addNode(mutable, "C") + * const d = Graph.addNode(mutable, "D") + * Graph.addEdge(mutable, a, b, "edge") // Component 1: A-B + * Graph.addEdge(mutable, c, d, "edge") // Component 2: C-D + * }) + * + * Graph.connectedComponents(graph) // => [[0, 1], [2, 3]] + * ``` + * + * @see {@link isConnected} when only a boolean connectivity check is needed + * @see {@link weaklyConnectedComponents} for directed graphs with orientation ignored + * @see {@link stronglyConnectedComponents} for mutual directed reachability + * @category algorithms + * @since 3.18.0 + */ +export const connectedComponents = ( + graph: Graph | MutableGraph +): Array> => { + if ((graph as Graph | MutableGraph).type === "directed") { + throw new GraphError({ message: "Cannot find connected components of directed graph" }) + } + const cache = csr.get(graph) + const outgoing = csr.getOutgoing(cache) + const visited = new Uint8Array(cache.nodeIds.length) + const neighborMarks = new Uint32Array(cache.nodeIds.length) + const components: Array> = [] + let neighborGeneration = 0 + + for (let start = 0; start < cache.nodeIds.length; start++) { + if (visited[start] !== 0) { + continue + } + const component: Array = [] + const stack: Array = [start] + + while (stack.length > 0) { + const current = stack.pop()! + if (visited[current] !== 0) { + continue + } + visited[current] = 1 + component.push(cache.nodeIds[current]) + + // Generation marks deduplicate parallel-edge neighbors without clearing a full-sized array per node. + neighborGeneration++ + for (let i = outgoing.rowOffsets[current]; i < outgoing.rowOffsets[current + 1]; i++) { + const neighbor = outgoing.columnIndices[i] + if (neighborMarks[neighbor] !== neighborGeneration) { + neighborMarks[neighbor] = neighborGeneration + if (visited[neighbor] === 0) { + stack.push(neighbor) + } + } + } + } + + components.push(component) + } + + return components +} + +/** @internal */ +interface LowLinkResult { + readonly bridges: Array + readonly articulationPoints: Array + readonly biconnectedComponents: Array> +} + +/** @internal */ +const analyzeLowLinks = ( + graph: Graph | MutableGraph +): LowLinkResult => { + if ((graph as Graph | MutableGraph).type === "directed") { + throw new GraphError({ message: "Cannot analyze undirected connectivity of directed graph" }) + } + const cache = csr.get(graph) + const outgoing = csr.getOutgoingWithEdges(cache) + const edgeIds = csr.getEdgeIds(cache) + const endpoints = csr.getEdgeEndpoints(cache) + const discovered = new Int32Array(cache.nodeIds.length) + const low = new Int32Array(cache.nodeIds.length) + const parentNode = new Int32Array(cache.nodeIds.length) + const parentEdge = new Int32Array(cache.nodeIds.length) + const childCount = new Uint32Array(cache.nodeIds.length) + const bridgeMarks = new Uint8Array(edgeIds.length) + const articulationMarks = new Uint8Array(cache.nodeIds.length) + const edgeStack: Array = [] + const components: Array> = [] + const loopNodes = new Set() + discovered.fill(-1) + parentNode.fill(-1) + parentEdge.fill(-1) + let time = 0 + + const popComponent = (stopEdge: number): void => { + const nodes = new Set() + while (edgeStack.length > 0) { + const edge = edgeStack.pop()! + nodes.add(endpoints.sources[edge]) + nodes.add(endpoints.targets[edge]) + if (edge === stopEdge) { + break + } + } + if (nodes.size > 0) { + components.push(Array.from(nodes).sort((a, b) => a - b)) + } + } + + for (let start = 0; start < cache.nodeIds.length; start++) { + if (discovered[start] !== -1) { + continue + } + discovered[start] = low[start] = time++ + const stack: Array<{ readonly node: number; position: number }> = [{ + node: start, + position: outgoing.rowOffsets[start] + }] + + while (stack.length > 0) { + const frame = stack[stack.length - 1] + const end = outgoing.rowOffsets[frame.node + 1] + if (frame.position < end) { + const position = frame.position++ + const edge = outgoing.edgeIndices[position] + const neighbor = outgoing.columnIndices[position] + if (neighbor === frame.node) { + loopNodes.add(frame.node) + continue + } + if (edge === parentEdge[frame.node]) { + continue + } + if (discovered[neighbor] === -1) { + childCount[frame.node]++ + parentNode[neighbor] = frame.node + parentEdge[neighbor] = edge + discovered[neighbor] = low[neighbor] = time++ + edgeStack.push(edge) + stack.push({ node: neighbor, position: outgoing.rowOffsets[neighbor] }) + } else if (discovered[neighbor] < discovered[frame.node]) { + low[frame.node] = Math.min(low[frame.node], discovered[neighbor]) + edgeStack.push(edge) + } + continue + } + + stack.pop() + const parent = parentNode[frame.node] + if (parent === -1) { + if (childCount[frame.node] > 1) { + articulationMarks[frame.node] = 1 + } + } else { + low[parent] = Math.min(low[parent], low[frame.node]) + if (low[frame.node] > discovered[parent]) { + bridgeMarks[parentEdge[frame.node]] = 1 + } + if (low[frame.node] >= discovered[parent]) { + if (parentNode[parent] !== -1) { + articulationMarks[parent] = 1 + } + popComponent(parentEdge[frame.node]) + } + } + } + } + + for (const node of loopNodes) { + components.push([node]) + } + components.sort((left, right) => { + const length = Math.min(left.length, right.length) + for (let i = 0; i < length; i++) { + if (left[i] !== right[i]) { + return left[i] - right[i] + } + } + return left.length - right.length + }) + + const resultBridges: Array = [] + for (let edge = 0; edge < edgeIds.length; edge++) { + if (bridgeMarks[edge] !== 0) { + resultBridges.push(edgeIds[edge]) + } + } + const resultArticulationPoints: Array = [] + for (let node = 0; node < cache.nodeIds.length; node++) { + if (articulationMarks[node] !== 0) { + resultArticulationPoints.push(cache.nodeIds[node]) + } + } + return { + bridges: resultBridges, + articulationPoints: resultArticulationPoints, + biconnectedComponents: components.map((component) => component.map((node) => cache.nodeIds[node])) + } +} + +/** + * Returns the edges whose removal increases the number of connected components. + * + * **When to use** + * + * Use when locating single-edge failure points in an undirected network. + * + * **Details** + * + * Parent edges are tracked by edge index, so a parallel edge prevents either + * edge from being a bridge. Self-loops are never bridges. Results follow graph + * edge order. The iterative low-link traversal is stack-safe and runs in + * `O(V + E)` time. + * + * **Gotchas** + * + * Throws a `GraphError` when used with a directed graph. + * + * **Example** (Finding bridge edges) + * + * ```ts import.meta.vitest + * import { Graph } from "effect" + * + * const graph = Graph.undirected((mutable) => { + * for (let i = 0; i < 3; i++) Graph.addNode(mutable, undefined) + * Graph.addEdge(mutable, 0, 1, undefined) + * Graph.addEdge(mutable, 1, 2, undefined) + * }) + * + * Graph.bridges(graph) // => [0, 1] + * ``` + * + * @see {@link articulationPoints} for single-node failure points + * @see {@link biconnectedComponents} for maximal regions without an articulation split + * @category algorithms + * @since 4.0.0 + */ +export const bridges = ( + graph: Graph | MutableGraph +): Array => analyzeLowLinks(graph).bridges + +/** + * Returns the nodes whose removal increases the number of connected components. + * + * **When to use** + * + * Use when locating single-node failure points in an undirected network. + * + * **Details** + * + * Disconnected components, parallel edges, and self-loops are handled by an + * iterative, stack-safe low-link traversal in `O(V + E)` time. Results follow + * graph node order. + * + * **Gotchas** + * + * Throws a `GraphError` when used with a directed graph. + * + * **Example** (Finding articulation points) + * + * ```ts import.meta.vitest + * import { Graph } from "effect" + * + * const graph = Graph.undirected((mutable) => { + * for (let i = 0; i < 3; i++) Graph.addNode(mutable, undefined) + * Graph.addEdge(mutable, 0, 1, undefined) + * Graph.addEdge(mutable, 1, 2, undefined) + * }) + * + * Graph.articulationPoints(graph) // => [1] + * ``` + * + * @see {@link bridges} for single-edge failure points + * @see {@link biconnectedComponents} for the regions joined at articulation points + * @category algorithms + * @since 4.0.0 + */ +export const articulationPoints = ( + graph: Graph | MutableGraph +): Array => analyzeLowLinks(graph).articulationPoints + +/** + * Returns the maximal biconnected node components of an undirected graph. + * + * **When to use** + * + * Use when decomposing an undirected graph into maximal regions that remain + * connected after removing any one node from the region. + * + * **Details** + * + * Articulation points can occur in more than one component. Isolated vertices + * are excluded, while a vertex with a self-loop forms a singleton component. + * Nodes within components and the components themselves follow graph order. + * Parallel edges are treated independently. The iterative low-link traversal + * is stack-safe and runs in `O(V + E)` time. + * + * **Gotchas** + * + * Throws a `GraphError` when used with a directed graph. + * + * **Example** (Finding biconnected components) + * + * ```ts import.meta.vitest + * import { Graph } from "effect" + * + * const graph = Graph.undirected((mutable) => { + * for (let i = 0; i < 5; i++) Graph.addNode(mutable, undefined) + * Graph.addEdge(mutable, 0, 1, undefined) + * Graph.addEdge(mutable, 1, 2, undefined) + * Graph.addEdge(mutable, 2, 0, undefined) + * Graph.addEdge(mutable, 2, 3, undefined) + * Graph.addEdge(mutable, 3, 4, undefined) + * Graph.addEdge(mutable, 4, 2, undefined) + * }) + * + * Graph.biconnectedComponents(graph) // => [[0, 1, 2], [2, 3, 4]] + * ``` + * + * @see {@link articulationPoints} for the nodes shared between components + * @see {@link bridges} for edges whose removal disconnects the graph + * @category algorithms + * @since 4.0.0 + */ +export const biconnectedComponents = ( + graph: Graph | MutableGraph +): Array> => analyzeLowLinks(graph).biconnectedComponents + +/** + * Configuration for source-to-target flow algorithms. + * + * **When to use** + * + * Use when defining endpoints and edge capacities for `maximumFlow` or + * `minimumCut`. + * + * **Details** + * + * `capacity` receives stored edge data and must return a finite, + * non-negative number. + * + * **Gotchas** + * + * The source and target must be distinct existing nodes in a directed graph. + * + * @category configuration + * @since 4.0.0 + */ +export interface MaximumFlowConfig { + readonly source: NodeIndex + readonly target: NodeIndex + readonly capacity: (edge: E) => number +} + +/** + * Maximum flow value, per-edge flows, and a corresponding minimum cut. + * + * **Details** + * + * `flows` contains every original edge, including zero-flow edges. `cut` + * contains the crossing edge identifiers of the corresponding minimum cut. + * + * @category models + * @since 4.0.0 + */ +export interface MaximumFlowResult { + readonly value: number + readonly flows: Map + readonly cut: Array +} + +/** + * Minimum cut value, crossing edges, and residual-reachability partitions. + * + * **Details** + * + * `source` contains nodes residual-reachable from the configured source and + * `target` contains the remaining nodes. + * + * @category models + * @since 4.0.0 + */ +export interface MinimumCutResult { + readonly value: number + readonly edges: Array + readonly source: Array + readonly target: Array +} + +/** @internal */ +interface FlowSolution extends MaximumFlowResult { + readonly sourceSide: Uint8Array + readonly nodeIds: Array +} + +/** @internal */ +interface ResidualArc { + readonly from: number + readonly to: number + readonly capacity: number + readonly edge: number + flow: number +} + +/** @internal */ +const solveMaximumFlow = ( + graph: Graph | MutableGraph, + config: MaximumFlowConfig +): FlowSolution => { + if ((graph as Graph | MutableGraph).type === "undirected") { + throw new GraphError({ message: "Cannot compute flow of undirected graph" }) + } + const cache = csr.get(graph) + const source = csr.getNodeIndex(cache, config.source) + if (source === undefined) { + throw missingNode(config.source) + } + const target = csr.getNodeIndex(cache, config.target) + if (target === undefined) { + throw missingNode(config.target) + } + if (source === target) { + throw new GraphError({ message: "Flow source and target must be different nodes" }) + } + + const edges = csr.getEdges(cache) as Array> + const edgeIds = csr.getEdgeIds(cache) + const endpoints = csr.getEdgeEndpoints(cache) + const capacities = new Float64Array(edges.length) + const arcs: Array = [] + const adjacency: Array> = Array.from({ length: cache.nodeIds.length }, () => []) + const forwardArc = new Int32Array(edges.length) + forwardArc.fill(-1) + + withMutationGuard(graph, () => { + for (let edge = 0; edge < edges.length; edge++) { + const capacity = config.capacity(edges[edge].data) + if (!Number.isFinite(capacity) || capacity < 0) { + throw new GraphError({ message: `Edge ${edgeIds[edge]} capacity must be a finite non-negative number` }) + } + capacities[edge] = capacity + const from = endpoints.sources[edge] + const to = endpoints.targets[edge] + if (from === to) { + continue + } + const index = arcs.length + forwardArc[edge] = index + adjacency[from].push(index) + arcs.push({ from, to, capacity, edge, flow: 0 }) + adjacency[to].push(index + 1) + arcs.push({ from: to, to: from, capacity: 0, edge: -1, flow: 0 }) + } + }) + + const parentArc = new Int32Array(cache.nodeIds.length) + const visited = new Uint8Array(cache.nodeIds.length) + const queue = new Uint32Array(cache.nodeIds.length) + let value = 0 + while (true) { + parentArc.fill(-1) + visited.fill(0) + let head = 0 + let tail = 1 + queue[0] = source + visited[source] = 1 + while (head < tail && visited[target] === 0) { + const node = queue[head++] + for (const arcIndex of adjacency[node]) { + const arc = arcs[arcIndex] + if (arc.capacity - arc.flow > 0 && visited[arc.to] === 0) { + visited[arc.to] = 1 + parentArc[arc.to] = arcIndex + queue[tail++] = arc.to + if (arc.to === target) { + break + } + } + } + } + if (visited[target] === 0) { + break + } + + let amount = Infinity + for (let node = target; node !== source;) { + const arc = arcs[parentArc[node]] + amount = Math.min(amount, arc.capacity - arc.flow) + node = arc.from + } + if (!Number.isFinite(value + amount)) { + throw new GraphError({ message: "Maximum flow exceeds the finite number range" }) + } + for (let node = target; node !== source;) { + const arcIndex = parentArc[node] + const arc = arcs[arcIndex] + arc.flow += amount + arcs[arcIndex ^ 1].flow -= amount + node = arc.from + } + value += amount + } + + const flows = new Map() + for (let edge = 0; edge < edgeIds.length; edge++) { + const arcIndex = forwardArc[edge] + flows.set(edgeIds[edge], arcIndex === -1 ? 0 : arcs[arcIndex].flow) + } + + visited.fill(0) + let head = 0 + let tail = 1 + queue[0] = source + visited[source] = 1 + while (head < tail) { + const node = queue[head++] + for (const arcIndex of adjacency[node]) { + const arc = arcs[arcIndex] + if (arc.capacity - arc.flow > 0 && visited[arc.to] === 0) { + visited[arc.to] = 1 + queue[tail++] = arc.to + } + } + } + + const cut: Array = [] + for (let edge = 0; edge < edgeIds.length; edge++) { + if ( + endpoints.sources[edge] !== endpoints.targets[edge] && + visited[endpoints.sources[edge]] !== 0 && + visited[endpoints.targets[edge]] === 0 + ) { + cut.push(edgeIds[edge]) + } + } + return { value, flows, cut, sourceSide: visited, nodeIds: cache.nodeIds } +} + +/** + * Returns a maximum flow and corresponding minimum cut for a directed graph. + * + * **When to use** + * + * Use when computing the greatest transferable capacity from one node to + * another and per-edge flow values are required. + * + * **Details** + * + * Parallel edges retain independent capacities, self-loops carry no + * source-to-target flow, and the flow map includes every original edge in graph + * order, including zero-flow edges. Edmonds-Karp runs in `O(V * E^2)` time. + * + * **Gotchas** + * + * The graph must be directed. Capacities must be finite and non-negative. + * Missing or equal endpoints, invalid capacities, and a total flow outside the + * finite number range throw a `GraphError`. Self-loops always carry zero flow. + * + * **Example** (Computing maximum flow) + * + * ```ts import.meta.vitest + * import { Graph } from "effect" + * + * const graph = Graph.directed((mutable) => { + * for (const node of ["source", "a", "target"]) Graph.addNode(mutable, node) + * Graph.addEdge(mutable, 0, 1, 3) + * Graph.addEdge(mutable, 1, 2, 2) + * Graph.addEdge(mutable, 0, 2, 1) + * }) + * + * Graph.maximumFlow(graph, { source: 0, target: 2, capacity: (edge) => edge }).value // => 3 + * ``` + * + * @see {@link minimumCut} for the residual-reachability partition + * @category algorithms + * @since 4.0.0 + */ +export const maximumFlow: { + (config: MaximumFlowConfig): ( + graph: Graph | MutableGraph + ) => MaximumFlowResult + ( + graph: Graph | MutableGraph, + config: MaximumFlowConfig + ): MaximumFlowResult +} = dual(2, ( + graph: Graph | MutableGraph, + config: MaximumFlowConfig +): MaximumFlowResult => { + const { cut, flows, value } = solveMaximumFlow(graph, config) + return { value, flows, cut } +}) + +/** + * Returns a minimum cut and its node partitions for a directed graph. + * + * **When to use** + * + * Use when identifying the minimum-capacity edges that separate a source from + * a target, together with the resulting node partitions. + * + * **Details** + * + * The source partition contains nodes reachable from the source in the final + * residual network; the target partition contains its complement. Both follow + * graph node order. Cut edges follow graph edge order, and their total capacity + * equals the returned maximum-flow value. Validation, parallel-edge, + * self-loop, and `O(V * E^2)` complexity behavior match `maximumFlow`. + * + * **Gotchas** + * + * The graph must be directed. Invalid capacities, missing endpoints, or equal + * source and target nodes throw a `GraphError`. + * + * **Example** (Partitioning a minimum cut) + * + * ```ts import.meta.vitest + * import { Graph } from "effect" + * + * const graph = Graph.directed((mutable) => { + * for (const node of ["source", "a", "target"]) Graph.addNode(mutable, node) + * Graph.addEdge(mutable, 0, 1, 2) + * Graph.addEdge(mutable, 1, 2, 1) + * }) + * + * Graph.minimumCut(graph, { source: 0, target: 2, capacity: (edge) => edge }).source // => [0, 1] + * ``` + * + * @see {@link maximumFlow} for per-edge flow values + * @category algorithms + * @since 4.0.0 + */ +export const minimumCut: { + (config: MaximumFlowConfig): ( + graph: Graph | MutableGraph + ) => MinimumCutResult + ( + graph: Graph | MutableGraph, + config: MaximumFlowConfig + ): MinimumCutResult +} = dual(2, ( + graph: Graph | MutableGraph, + config: MaximumFlowConfig +): MinimumCutResult => { + const solution = solveMaximumFlow(graph, config) + const source: Array = [] + const target: Array = [] + for (let node = 0; node < solution.nodeIds.length; node++) { + ;(solution.sourceSide[node] === 0 ? target : source).push(solution.nodeIds[node]) + } + return { value: solution.value, edges: solution.cut, source, target } +}) + +/** + * Finds weakly connected components in a directed graph. + * + * **When to use** + * + * Use when grouping directed nodes by connectivity while ignoring edge + * orientation. + * + * **Details** + * + * Edge direction is ignored while partitioning nodes. Isolated nodes form + * singleton components. + * + * **Gotchas** + * + * Throws a `GraphError` when used with an undirected graph. + * + * @see {@link isWeaklyConnected} when only a boolean check is needed + * @see {@link stronglyConnectedComponents} for mutual directed reachability + * + * @category algorithms + * @since 4.0.0 + */ +export const weaklyConnectedComponents = ( + graph: Graph | MutableGraph +): Array> => { + if ((graph as Graph | MutableGraph).type === "undirected") { + throw new GraphError({ message: "Cannot find weakly connected components of undirected graph" }) + } + + const cache = csr.get(graph) + const { primary, secondary } = csr.getAdjacencies(cache, "undirected") + const nodeCount = cache.nodeIds.length + const visited = new Uint8Array(nodeCount) + const stack = new Uint32Array(primary.columnIndices.length + secondary!.columnIndices.length + 1) + const components: Array> = [] + for (let start = 0; start < nodeCount; start++) { + if (visited[start] !== 0) { + continue + } + const component: Array = [] + let stackSize = 1 + stack[0] = start + while (stackSize > 0) { + const current = stack[--stackSize] + if (visited[current] !== 0) { + continue + } + visited[current] = 1 + component.push(cache.nodeIds[current]) + + for (let i = primary.rowOffsets[current]; i < primary.rowOffsets[current + 1]; i++) { + const neighbor = primary.columnIndices[i] + if (visited[neighbor] === 0) { + stack[stackSize++] = neighbor + } + } + for (let i = secondary!.rowOffsets[current]; i < secondary!.rowOffsets[current + 1]; i++) { + const neighbor = secondary!.columnIndices[i] + if (visited[neighbor] === 0) { + stack[stackSize++] = neighbor + } + } + } + components.push(component) + } + return components +} + +/** + * Returns the strongly connected components of a directed graph. + * + * **When to use** + * + * Use when grouping nodes so every node in a component can reach every other + * node in that component. + * + * **Details** + * + * Each component is represented as an array of node indices and is computed + * with Kosaraju's algorithm. + * + * **Gotchas** + * + * Throws a `GraphError` when used with an undirected graph. + * + * **Example** (Finding strongly connected components) + * + * ```ts import.meta.vitest + * import { Graph } from "effect" + * + * const graph = Graph.directed((mutable) => { + * const a = Graph.addNode(mutable, "A") + * const b = Graph.addNode(mutable, "B") + * const c = Graph.addNode(mutable, "C") + * Graph.addEdge(mutable, a, b, "A->B") + * Graph.addEdge(mutable, b, c, "B->C") + * Graph.addEdge(mutable, c, a, "C->A") // Creates SCC: A-B-C * }) * * Graph.stronglyConnectedComponents(graph) // => [[0, 2, 1]] * ``` * + * @see {@link isStronglyConnected} when only a boolean check is needed + * @see {@link weaklyConnectedComponents} when edge orientation should be ignored * @category algorithms * @since 3.18.0 */ @@ -3866,95 +5640,418 @@ export const stronglyConnectedComponents = ( throw new GraphError({ message: "Cannot find strongly connected components of undirected graph" }) } - const impl = graphImpl(graph) - const visited = new Set() - const finishOrder: Array = [] - // Iterate directly over node keys + const cache = csr.get(graph) + const outgoing = csr.getOutgoing(cache) + const incoming = csr.getIncoming(cache) + const visited = new Uint8Array(cache.nodeIds.length) + const finishOrder: Array = [] + const stack: Array = [] + const positions: Array = [] + + // First pass records finish order on the original graph using an explicit stack. + for (let start = 0; start < cache.nodeIds.length; start++) { + if (visited[start] !== 0) { + continue + } + visited[start] = 1 + stack.push(start) + positions.push(outgoing.rowOffsets[start]) - // Step 1: Stack-safe DFS on original graph to get finish times - // Stack entry: [node, neighbors, neighborIndex, isFirstVisit] - type DfsStackEntry = [NodeIndex, Array, number, boolean] + while (stack.length > 0) { + const frame = stack.length - 1 + const node = stack[frame] + const position = positions[frame] + if (position < outgoing.rowOffsets[node + 1]) { + positions[frame] = position + 1 + const neighbor = outgoing.columnIndices[position] + if (visited[neighbor] === 0) { + visited[neighbor] = 1 + stack.push(neighbor) + positions.push(outgoing.rowOffsets[neighbor]) + } + } else { + finishOrder.push(node) + stack.pop() + positions.pop() + } + } + } - for (const startNode of impl.nodes.keys()) { - if (visited.has(startNode)) { + visited.fill(0) + const components: Array> = [] + // Reversing finish order and traversing the transpose yields one SCC per search. + for (let i = finishOrder.length - 1; i >= 0; i--) { + const start = finishOrder[i] + if (visited[start] !== 0) { continue } - - const stack: Array = [[startNode, [], 0, true]] + const component: Array = [] + stack.push(start) while (stack.length > 0) { - const [node, nodeNeighbors, neighborIndex, isFirstVisit] = stack[stack.length - 1] + const node = stack.pop()! + if (visited[node] !== 0) { + continue + } + visited[node] = 1 + component.push(cache.nodeIds[node]) - if (isFirstVisit) { - if (visited.has(node)) { - stack.pop() - continue + for (let j = incoming.rowOffsets[node]; j < incoming.rowOffsets[node + 1]; j++) { + const predecessor = incoming.columnIndices[j] + if (visited[predecessor] === 0) { + stack.push(predecessor) } - - visited.add(node) - const nodeNeighborsList = getDirectedNeighbors(graph, node, "outgoing") - stack[stack.length - 1] = [node, nodeNeighborsList, 0, false] - continue } + } + + components.push(component) + } - // Process next neighbor - if (neighborIndex < nodeNeighbors.length) { - const neighbor = nodeNeighbors[neighborIndex] - stack[stack.length - 1] = [node, nodeNeighbors, neighborIndex + 1, false] + return components +} - if (!visited.has(neighbor)) { - stack.push([neighbor, [], 0, true]) +/** @internal */ +const csrReachesAll = ( + nodeCount: number, + primary: csr.Adjacency, + secondary?: csr.Adjacency +): boolean => { + if (nodeCount === 0) { + return true + } + const visited = new Uint8Array(nodeCount) + const queue = new Uint32Array(nodeCount) + let head = 0 + let tail = 1 + visited[0] = 1 + queue[0] = 0 + + while (head < tail) { + const current = queue[head++] + for (let i = primary.rowOffsets[current]; i < primary.rowOffsets[current + 1]; i++) { + const neighbor = primary.columnIndices[i] + if (visited[neighbor] === 0) { + visited[neighbor] = 1 + queue[tail++] = neighbor + } + } + if (secondary !== undefined) { + for (let i = secondary.rowOffsets[current]; i < secondary.rowOffsets[current + 1]; i++) { + const neighbor = secondary.columnIndices[i] + if (visited[neighbor] === 0) { + visited[neighbor] = 1 + queue[tail++] = neighbor } - } else { - // Done with this node - add to finish order (post-order) - finishOrder.push(node) - stack.pop() } } } + return tail === nodeCount +} + +/** + * Tests whether an undirected graph has at most one connected component. + * + * **When to use** + * + * Use when checking undirected connectivity without allocating the component + * partition. + * + * **Gotchas** + * + * The empty graph is considered connected. Throws a `GraphError` when used + * with a directed graph. + * + * @see {@link connectedComponents} for the component partition + * + * @category predicates + * @since 4.0.0 + */ +export const isConnected = ( + graph: Graph | MutableGraph +): boolean => { + if ((graph as Graph | MutableGraph).type === "directed") { + throw new GraphError({ message: "Cannot find connected components of directed graph" }) + } + const cache = csr.get(graph) + return csrReachesAll(cache.nodeIds.length, csr.getOutgoing(cache)) +} - // Step 2: Stack-safe DFS on transpose graph in reverse finish order - visited.clear() - const sccs: Array> = [] +/** + * Tests whether a directed graph has at most one weakly connected component. + * + * **When to use** + * + * Use when checking whether a directed graph is connected after ignoring edge + * orientation. + * + * **Gotchas** + * + * The empty graph is considered weakly connected. Throws a `GraphError` when + * used with an undirected graph. + * + * @see {@link isStronglyConnected} when edge orientation must be respected + * + * @category predicates + * @since 4.0.0 + */ +export const isWeaklyConnected = ( + graph: Graph | MutableGraph +): boolean => { + if ((graph as Graph | MutableGraph).type === "undirected") { + throw new GraphError({ message: "Cannot find weakly connected components of undirected graph" }) + } + const cache = csr.get(graph) + const { primary, secondary } = csr.getAdjacencies(cache, "undirected") + return csrReachesAll(cache.nodeIds.length, primary, secondary) +} - for (let i = finishOrder.length - 1; i >= 0; i--) { - const startNode = finishOrder[i] - if (visited.has(startNode)) { +/** + * Tests whether a directed graph has at most one strongly connected component. + * + * **When to use** + * + * Use when checking that every node in a directed graph can reach every other + * node. + * + * **Gotchas** + * + * The empty graph is considered strongly connected. Throws a `GraphError` when + * used with an undirected graph. + * + * @see {@link isWeaklyConnected} when edge orientation should be ignored + * + * @category predicates + * @since 4.0.0 + */ +export const isStronglyConnected = ( + graph: Graph | MutableGraph +): boolean => { + if ((graph as Graph | MutableGraph).type === "undirected") { + throw new GraphError({ message: "Cannot find strongly connected components of undirected graph" }) + } + const cache = csr.get(graph) + return csrReachesAll(cache.nodeIds.length, csr.getOutgoing(cache)) && + csrReachesAll(cache.nodeIds.length, csr.getIncoming(cache)) +} + +/** + * Tests whether a non-empty undirected graph is a tree. + * + * **When to use** + * + * Use when validating that an undirected graph is connected and has no cycle. + * + * **Gotchas** + * + * The empty graph is not a tree. Parallel edges and self-loops prevent a graph + * from being a tree. Throws a `GraphError` when used with a directed graph. + * + * @category predicates + * @since 4.0.0 + */ +export const isTree = ( + graph: Graph | MutableGraph +): boolean => { + if ((graph as Graph | MutableGraph).type === "directed") { + throw new GraphError({ message: "Cannot determine tree status of directed graph" }) + } + const nodes = nodeCount(graph) + return nodes > 0 && edgeCount(graph) === nodes - 1 && isConnected(graph) +} + +/** + * Returns a minimum spanning forest of an undirected graph using Kruskal's + * algorithm. + * + * **When to use** + * + * Use when selecting a minimum-cost acyclic connector for every connected + * component of an undirected graph. + * + * **Details** + * + * All node indices and selected edge indices are preserved. Negative finite + * weights are allowed, `Infinity` marks an unavailable edge, and equal weights + * are resolved by original edge order. Disconnected inputs produce a forest, + * and isolated nodes remain present. + * + * **Gotchas** + * + * Throws a `GraphError` for a directed graph or when a weight is `NaN` or + * `-Infinity`. Edges weighted `Infinity` are omitted. + * + * @category algorithms + * @since 4.0.0 + */ +export const minimumSpanningForest: { + (cost: (edgeData: E) => number): ( + graph: Graph | MutableGraph + ) => Graph + ( + graph: Graph | MutableGraph, + cost: (edgeData: E) => number + ): Graph +} = dual(2, ( + graph: Graph | MutableGraph, + cost: (edgeData: E) => number +): Graph => { + if ((graph as Graph | MutableGraph).type === "directed") { + throw new GraphError({ message: "Cannot find minimum spanning forest of directed graph" }) + } + const impl = internal.toImpl(graph) + const nodes: Array> = [] + const compactByNode = new Map() + for (const [index, data] of impl.nodes) { + compactByNode.set(index, nodes.length) + nodes.push({ index, data }) + } + const weightedEdges: Array<{ readonly index: EdgeIndex; readonly weight: number; readonly order: number }> = [] + let order = 0 + withMutationGuard(graph, () => { + for (const [index, edge] of impl.edges) { + const weight = cost(edge.data) + if (Number.isNaN(weight) || weight === -Infinity) { + throw new GraphError({ message: "Minimum spanning forest does not support NaN or -Infinity edge weights" }) + } + if (weight !== Infinity) { + weightedEdges.push({ index, weight, order }) + } + order++ + } + }) + weightedEdges.sort((self, that) => self.weight - that.weight || self.order - that.order) + + const parents = new Uint32Array(nodes.length) + const ranks = new Uint8Array(nodes.length) + for (let i = 0; i < parents.length; i++) { + parents[i] = i + } + const find = (node: number): number => { + let root = node + while (parents[root] !== root) { + root = parents[root] + } + while (parents[node] !== node) { + const parent = parents[node] + parents[node] = root + node = parent + } + return root + } + const selected = new Set() + for (const weighted of weightedEdges) { + const edge = impl.edges.get(weighted.index)! + let sourceRoot = find(compactByNode.get(edge.source)!) + let targetRoot = find(compactByNode.get(edge.target)!) + if (sourceRoot === targetRoot) { continue } + selected.add(weighted.index) + if (ranks[sourceRoot] < ranks[targetRoot]) { + const swap = sourceRoot + sourceRoot = targetRoot + targetRoot = swap + } + parents[targetRoot] = sourceRoot + if (ranks[sourceRoot] === ranks[targetRoot]) { + ranks[sourceRoot]++ + } + } - const scc: Array = [] - const stack: Array = [startNode] + const edges: Array> = [] + for (const [index, edge] of impl.edges) { + if (selected.has(index)) { + edges.push({ index, source: edge.source, target: edge.target, data: edge.data }) + } + } + return fromSnapshot({ type: "undirected", nodes, edges }) +}) - while (stack.length > 0) { - const node = stack.pop()! +/** + * Returns the transitive reduction of a directed acyclic graph. + * + * **When to use** + * + * Use when simplifying a dependency DAG while preserving which nodes can + * reach which other nodes. + * + * **Details** + * + * The result preserves reachability with the fewest structural source-target + * pairs. Node and retained edge indices are preserved. + * + * **Gotchas** + * + * This operation is structural and ignores edge costs. Parallel edges are + * coalesced by retaining the first edge for each required pair. Throws a + * `GraphError` for an undirected graph or cyclic input. + * + * @category algorithms + * @since 4.0.0 + */ +export const transitiveReduction = ( + graph: Graph | MutableGraph +): Graph => { + if ((graph as Graph | MutableGraph).type === "undirected") { + throw new GraphError({ message: "Cannot transitively reduce undirected graph" }) + } + if (!isAcyclic(graph)) { + throw new GraphError({ message: "Cannot transitively reduce cyclic graph" }) + } - if (visited.has(node)) { - continue - } + const impl = internal.toImpl(graph) + const nodes: Array> = [] + for (const [index, data] of impl.nodes) { + nodes.push({ index, data }) + } + const firstEdges = new Map>() + for (const [edgeIndex, edge] of impl.edges) { + let targets = firstEdges.get(edge.source) + if (targets === undefined) { + targets = new Map() + firstEdges.set(edge.source, targets) + } + if (!targets.has(edge.target)) { + targets.set(edge.target, edgeIndex) + } + } - visited.add(node) - scc.push(node) - - // Use reverse adjacency (transpose graph) - const reverseAdjacency = impl.reverseAdjacency.get(node) - if (reverseAdjacency !== undefined) { - for (const edgeIndex of reverseAdjacency) { - const edge = impl.edges.get(edgeIndex) - if (edge !== undefined) { - const predecessor = edge.source - if (!visited.has(predecessor)) { - stack.push(predecessor) - } + const retained = new Set() + for (const [source, targets] of firstEdges) { + for (const [target, edgeIndex] of targets) { + const visited = new Set([source]) + const queue = [source] + let reachable = false + for (let head = 0; head < queue.length && !reachable; head++) { + const current = queue[head] + for (const candidateIndex of impl.adjacency.get(current)!) { + const candidate = impl.edges.get(candidateIndex)! + if (current === source && candidate.target === target) { + continue + } + if (candidate.target === target) { + reachable = true + break + } + if (!visited.has(candidate.target)) { + visited.add(candidate.target) + queue.push(candidate.target) } } } + if (!reachable) { + retained.add(edgeIndex) + } } - - sccs.push(scc) } - return sccs + const edges: Array> = [] + for (const [index, edge] of impl.edges) { + if (retained.has(index)) { + edges.push({ index, source: edge.source, target: edge.target, data: edge.data }) + } + } + return fromSnapshot({ type: "directed", nodes, edges }) } // ============================================================================= @@ -3967,78 +6064,154 @@ export const stronglyConnectedComponents = ( * **When to use** * * Use to read the successful source-to-target shortest path returned by - * path-finding algorithms, including the ordered node indices, total distance, - * and traversed edge data. + * path-finding algorithms, including the ordered node and edge indices, total + * distance, and traversed edge data. * * **Details** * - * Contains the node-index path, the total numeric distance, and the edge data - * encountered along the path. + * Contains the node-index path, the traversed edge indices, the total numeric + * distance, and the edge data encountered along the path. * * **Gotchas** * * `costs` contains original edge data, not the numeric output of the cost * function unless the edge data is numeric. * - * @see {@link dijkstra} for shortest paths with non-negative edge costs - * @see {@link astar} for heuristic shortest-path search - * @see {@link bellmanFord} for shortest paths that may include negative edge weights - * @see {@link AllPairsResult} for the all-pairs shortest-path result shape - * * @category models * @since 3.18.0 */ export interface PathResult { readonly path: Array + readonly edges: Array readonly distance: number readonly costs: Array } -interface MinHeapEntry { - readonly node: NodeIndex - readonly priority: number - readonly sequence: number +interface DenseMinHeap { + nodes: Uint32Array + priorities: Float64Array + sequences: Float64Array + positions: Int32Array | undefined + size: number + poppedNode: number + poppedPriority: number } -const minHeapLessThan = (self: MinHeapEntry, that: MinHeapEntry): boolean => - self.priority < that.priority || (self.priority === that.priority && self.sequence < that.sequence) +const denseMinHeapMake = (capacity: number, indexed = false): DenseMinHeap => { + const positions = indexed ? new Int32Array(capacity) : undefined + positions?.fill(-1) + return { + nodes: new Uint32Array(Math.max(4, capacity)), + priorities: new Float64Array(Math.max(4, capacity)), + sequences: new Float64Array(Math.max(4, capacity)), + positions, + size: 0, + poppedNode: 0, + poppedPriority: 0 + } +} + +const denseMinHeapPush = ( + heap: DenseMinHeap, + node: number, + priority: number, + sequence: number +): void => { + let index = heap.positions?.[node] ?? -1 + if (index === -1) { + if (heap.size === heap.nodes.length) { + const capacity = heap.size * 2 + const nodes = new Uint32Array(capacity) + const priorities = new Float64Array(capacity) + const sequences = new Float64Array(capacity) + nodes.set(heap.nodes) + priorities.set(heap.priorities) + sequences.set(heap.sequences) + heap.nodes = nodes + heap.priorities = priorities + heap.sequences = sequences + } + index = heap.size++ + } -const minHeapPush = (heap: Array, entry: MinHeapEntry): void => { - let index = heap.length - heap.push(entry) while (index > 0) { const parent = (index - 1) >>> 1 - if (!minHeapLessThan(entry, heap[parent])) { + if ( + priority > heap.priorities[parent] || + (priority === heap.priorities[parent] && sequence >= heap.sequences[parent]) + ) { break } - heap[index] = heap[parent] + heap.nodes[index] = heap.nodes[parent] + heap.priorities[index] = heap.priorities[parent] + heap.sequences[index] = heap.sequences[parent] + if (heap.positions !== undefined) { + heap.positions[heap.nodes[index]] = index + } index = parent } - heap[index] = entry + heap.nodes[index] = node + heap.priorities[index] = priority + heap.sequences[index] = sequence + if (heap.positions !== undefined) { + heap.positions[node] = index + } } -const minHeapPop = (heap: Array): MinHeapEntry | undefined => { - const first = heap[0] - const last = heap.pop() - if (last === undefined || heap.length === 0) { - return first +const denseMinHeapPop = (heap: DenseMinHeap): boolean => { + if (heap.size === 0) { + return false + } + + heap.poppedNode = heap.nodes[0] + heap.poppedPriority = heap.priorities[0] + if (heap.positions !== undefined) { + heap.positions[heap.poppedNode] = -1 } + const last = --heap.size + if (last === 0) { + return true + } + + const node = heap.nodes[last] + const priority = heap.priorities[last] + const sequence = heap.sequences[last] let index = 0 while (true) { const left = index * 2 + 1 - if (left >= heap.length) { + if (left >= last) { break } const right = left + 1 - const child = right < heap.length && minHeapLessThan(heap[right], heap[left]) ? right : left - if (!minHeapLessThan(heap[child], last)) { + let child = left + if ( + right < last && + (heap.priorities[right] < heap.priorities[left] || + (heap.priorities[right] === heap.priorities[left] && heap.sequences[right] < heap.sequences[left])) + ) { + child = right + } + if ( + heap.priorities[child] > priority || + (heap.priorities[child] === priority && heap.sequences[child] >= sequence) + ) { break } - heap[index] = heap[child] + heap.nodes[index] = heap.nodes[child] + heap.priorities[index] = heap.priorities[child] + heap.sequences[index] = heap.sequences[child] + if (heap.positions !== undefined) { + heap.positions[heap.nodes[index]] = index + } index = child } - heap[index] = last - return first + heap.nodes[index] = node + heap.priorities[index] = priority + heap.sequences[index] = sequence + if (heap.positions !== undefined) { + heap.positions[node] = index + } + return true } /** @@ -4060,11 +6233,7 @@ const minHeapPop = (heap: Array): MinHeapEntry | undefined => { * `dijkstra` throws a `GraphError` when either endpoint does not exist or when * the cost function returns a negative weight or `NaN`. * - * @see {@link dijkstra} for the algorithm that consumes this configuration - * @see {@link AstarConfig} for heuristic shortest-path search - * @see {@link BellmanFordConfig} for shortest paths that may include negative edge weights - * - * @category models + * @category configuration * @since 3.18.0 */ export interface DijkstraConfig { @@ -4077,12 +6246,21 @@ export interface DijkstraConfig { * Finds the shortest path from the configured source node to the target node * using Dijkstra's algorithm. * + * **When to use** + * + * Use when you need one source-to-target shortest path and every edge cost is + * non-negative. + * * **Details** * * Edge costs must be non-negative and not `NaN`. `Infinity` is allowed and * behaves like an impassable edge. Returns `Option.none()` when the target is - * not reachable, and throws a `GraphError` when either endpoint is missing or an - * edge cost is negative or `NaN`. + * not reachable. + * + * **Gotchas** + * + * Throws a `GraphError` when either endpoint is missing or an edge cost is + * negative or `NaN`, or when a path distance exceeds the finite number range. * * **Example** (Finding shortest paths with Dijkstra) * @@ -4107,6 +6285,9 @@ export interface DijkstraConfig { * Option.map(result, ({ distance, path }) => [distance, path] as const) // => Option.some([7, [0, 1, 2]]) * ``` * + * @see {@link astar} when a useful heuristic can guide the search + * @see {@link bellmanFord} when edge costs may be negative + * @see {@link floydWarshall} when shortest paths are needed for all pairs * @category algorithms * @since 3.18.0 */ @@ -4122,7 +6303,7 @@ export const dijkstra: { graph: Graph | MutableGraph, config: DijkstraConfig ): Option.Option> => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) // Validate that source and target nodes exist if (!impl.nodes.has(config.source)) { throw missingNode(config.source) @@ -4131,113 +6312,98 @@ export const dijkstra: { throw missingNode(config.target) } - const edgeWeights = new Map() - for (const [edgeIndex, edgeData] of impl.edges) { - const weight = config.cost(edgeData.data) - if (Number.isNaN(weight) || weight < 0) { - throw new GraphError({ message: "Dijkstra's algorithm requires non-negative edge weights" }) + const cache = csr.get(graph) + const cachedEdges = csr.getEdges(cache) + const cachedEdgeIds = csr.getEdgeIds(cache) + const outgoing = csr.getOutgoingWithEdges(cache) + const source = csr.getNodeIndex(cache, config.source)! + const target = csr.getNodeIndex(cache, config.target)! + const edgeWeights = new Float64Array(cachedEdges.length) + withMutationGuard(graph, () => { + for (let i = 0; i < cachedEdges.length; i++) { + const weight = config.cost(cachedEdges[i].data) + if (Number.isNaN(weight) || weight < 0) { + throw new GraphError({ message: "Dijkstra's algorithm requires non-negative edge weights" }) + } + edgeWeights[i] = weight } - edgeWeights.set(edgeIndex, weight) - } + }) // Early return if source equals target if (config.source === config.target) { return Option.some({ path: [config.source], + edges: [], distance: 0, costs: [] }) } - // Distance tracking and priority queue simulation - const distances = new Map() - const previous = new Map() - const visited = new Set() - - // Initialize distances - // Iterate directly over node keys - for (const node of impl.nodes.keys()) { - distances.set(node, node === config.source ? 0 : Infinity) - previous.set(node, null) - } - - const priorityQueue: Array = [] + const distances = new Float64Array(cache.nodeIds.length) + distances.fill(Infinity) + distances[source] = 0 + // Predecessor node and edge arrays reconstruct both the public node path and its edge data. + const previousNode = new Int32Array(cache.nodeIds.length) + const previousEdge = new Int32Array(cache.nodeIds.length) + previousNode.fill(-1) + previousEdge.fill(-1) + const visited = new Uint8Array(cache.nodeIds.length) + const priorityQueue = denseMinHeapMake(cache.nodeIds.length, true) let sequence = 0 - minHeapPush(priorityQueue, { node: config.source, priority: 0, sequence: sequence++ }) - - while (priorityQueue.length > 0) { - const current = minHeapPop(priorityQueue)! - const currentNode = current.node + denseMinHeapPush(priorityQueue, source, 0, sequence++) - // Skip if already visited (can happen with duplicate entries) - if (visited.has(currentNode)) { + while (priorityQueue.size > 0) { + denseMinHeapPop(priorityQueue) + const currentNode = priorityQueue.poppedNode + const currentDistance = priorityQueue.poppedPriority + if (visited[currentNode] !== 0) { continue } - - visited.add(currentNode) - - // Early termination if we reached the target - if (currentNode === config.target) { + visited[currentNode] = 1 + if (currentNode === target) { break } - // Get current distance - const currentDistance = distances.get(currentNode)! - - // Examine all outgoing edges - const adjacencyList = impl.adjacency.get(currentNode) - if (adjacencyList !== undefined) { - for (const edgeIndex of adjacencyList) { - const edge = impl.edges.get(edgeIndex) - if (edge !== undefined) { - const neighbor = getTraversableNeighbor(graph, currentNode, edge) - const cost = edgeWeights.get(edgeIndex)! - - const newDistance = currentDistance + cost - const neighborDistance = distances.get(neighbor)! - - // Relaxation step - if (newDistance < neighborDistance) { - distances.set(neighbor, newDistance) - previous.set(neighbor, { node: currentNode, edgeData: edge.data }) - - // Add to priority queue if not visited - if (!visited.has(neighbor)) { - minHeapPush(priorityQueue, { node: neighbor, priority: newDistance, sequence: sequence++ }) - } - } + for (let i: number = outgoing.rowOffsets[currentNode]; i < outgoing.rowOffsets[currentNode + 1]; i++) { + const neighbor = outgoing.columnIndices[i] + const edge = outgoing.edgeIndices[i] + const nextDistance = currentDistance + edgeWeights[edge] + if (edgeWeights[edge] !== Infinity && !Number.isFinite(nextDistance)) { + throw new GraphError({ message: "Dijkstra distance calculation exceeded the finite number range" }) + } + if (nextDistance < distances[neighbor]) { + distances[neighbor] = nextDistance + previousNode[neighbor] = currentNode + previousEdge[neighbor] = edge + if (visited[neighbor] === 0) { + denseMinHeapPush(priorityQueue, neighbor, nextDistance, sequence++) } } } } - // Check if target is reachable - const distance = distances.get(config.target)! - if (distance === Infinity) { - return Option.none() // No path exists + if (distances[target] === Infinity) { + return Option.none() } - // Reconstruct path const path: Array = [] + const edges: Array = [] const costs: Array = [] - let currentNode: NodeIndex | null = config.target - - while (currentNode !== null) { - path.unshift(currentNode) - const prev: { node: NodeIndex; edgeData: E } | null = previous.get(currentNode)! - if (prev !== null) { - costs.unshift(prev.edgeData) - currentNode = prev.node - } else { - currentNode = null + let current = target + while (current !== -1) { + path.push(cache.nodeIds[current]) + const edge = previousEdge[current] + if (edge !== -1) { + edges.push(cachedEdgeIds[edge]) + costs.push(cachedEdges[edge].data) } + current = previousNode[current] } + path.reverse() + edges.reverse() + costs.reverse() - return Option.some({ - path, - distance, - costs - }) + return Option.some({ path, edges, distance: distances[target], costs }) }) /** @@ -4246,16 +6412,14 @@ export const dijkstra: { * **When to use** * * Use when storing or passing around the complete output of `floydWarshall` so - * callers can look up shortest distances, node paths, and edge data for any - * source and target node pair. + * callers can look up shortest distances, node and edge paths, and edge data + * for any source and target node pair. * * **Details** * - * Contains distance, node-path, and edge-data maps keyed by source and target - * node indices. - * - * @see {@link floydWarshall} for computing an all-pairs shortest path result - * @see {@link PathResult} for the single source-to-target result shape used by path-finding algorithms + * Contains distance, node-path, edge-index-path, and edge-data maps keyed by + * source and target node indices. Unreachable pairs have distance `Infinity`, + * path `null`, and empty edge and cost arrays. * * @category models * @since 3.18.0 @@ -4263,6 +6427,7 @@ export const dijkstra: { export interface AllPairsResult { readonly distances: Map> readonly paths: Map | null>> + readonly edges: Map>> readonly costs: Map>> } @@ -4270,12 +6435,22 @@ export interface AllPairsResult { * Finds shortest paths between all pairs of nodes using the Floyd-Warshall * algorithm. * + * **When to use** + * + * Use when many or all node pairs will be queried and cubic computation plus + * quadratic result storage is acceptable. + * * **Details** * * Computes distances, reconstructed node paths, and edge-data paths for every * source and target pair in O(V^3) time. Negative edge weights are allowed, and - * `Infinity` behaves like an impassable edge. A `GraphError` is thrown if any - * edge weight is `NaN` or `-Infinity`, or if any negative cycle is detected. + * `Infinity` behaves like an impassable edge. + * + * **Gotchas** + * + * A `GraphError` is thrown if any edge weight is `NaN` or `-Infinity`, or if + * finite arithmetic overflows or underflows, or if any negative cycle is + * detected. * * **Example** (Finding all-pairs shortest paths) * @@ -4296,6 +6471,8 @@ export interface AllPairsResult { * shortest // => { distance: 5, path: [0, 1, 2] } * ``` * + * @see {@link dijkstra} for one query with non-negative edge costs + * @see {@link bellmanFord} for one query that may include negative edge costs * @category algorithms * @since 3.18.0 */ @@ -4311,125 +6488,132 @@ export const floydWarshall: { graph: Graph | MutableGraph, cost: (edgeData: E) => number ): AllPairsResult => { - const impl = graphImpl(graph) - // Get all nodes for Floyd-Warshall algorithm (needs array for nested iteration) - const allNodes = Array.from(impl.nodes.keys()) - - // Initialize distance matrix - const distances = new Map>() - const next = new Map>() - const edgeMatrix = new Map>() - - // Initialize with infinity for all pairs - for (const i of allNodes) { - distances.set(i, new Map()) - next.set(i, new Map()) - edgeMatrix.set(i, new Map()) - - for (const j of allNodes) { - distances.get(i)!.set(j, i === j ? 0 : Infinity) - } + const cache = csr.get(graph) + const edges = csr.getEdges(cache) + const edgeIds = csr.getEdgeIds(cache) + const edgeCache = csr.getEdgeEndpoints(cache) + const size = cache.nodeIds.length + // Flat matrices keep the O(N^2) working set contiguous and avoid nested map lookups in the O(N^3) loop. + const distancesMatrix = new Float64Array(size * size) + const nextMatrix = new Int32Array(size * size) + const edgeMatrix = new Int32Array(size * size) + distancesMatrix.fill(Infinity) + nextMatrix.fill(-1) + edgeMatrix.fill(-1) + for (let i = 0; i < size; i++) { + distancesMatrix[i * size + i] = 0 } - // Set edge weights - for (const [, edgeData] of impl.edges) { - const weight = cost(edgeData.data) - if (Number.isNaN(weight) || weight === -Infinity) { - throw new GraphError({ message: "Floyd-Warshall algorithm does not support NaN or -Infinity edge weights" }) - } - const i = edgeData.source - const j = edgeData.target - - // Use minimum weight if multiple edges exist - const currentWeight = distances.get(i)!.get(j)! - if (weight < currentWeight) { - distances.get(i)!.set(j, weight) - next.get(i)!.set(j, j) - edgeMatrix.get(i)!.set(j, edgeData.data) - } - - if (graph.type === "undirected") { - const reverseWeight = distances.get(j)!.get(i)! - if (weight < reverseWeight) { - distances.get(j)!.set(i, weight) - next.get(j)!.set(i, i) - edgeMatrix.get(j)!.set(i, edgeData.data) + withMutationGuard(graph, () => { + for (let edge = 0; edge < edges.length; edge++) { + const weight = cost(edges[edge].data) + if (Number.isNaN(weight) || weight === -Infinity) { + throw new GraphError({ message: "Floyd-Warshall algorithm does not support NaN or -Infinity edge weights" }) + } + const source = edgeCache.sources[edge] + const target = edgeCache.targets[edge] + const position = source * size + target + if (weight < distancesMatrix[position]) { + distancesMatrix[position] = weight + nextMatrix[position] = target + edgeMatrix[position] = edge + } + if (graph.type === "undirected") { + const reverse = target * size + source + if (weight < distancesMatrix[reverse]) { + distancesMatrix[reverse] = weight + nextMatrix[reverse] = source + edgeMatrix[reverse] = edge + } } } - } - - // Floyd-Warshall main loop - for (const k of allNodes) { - for (const i of allNodes) { - for (const j of allNodes) { - const distIK = distances.get(i)!.get(k)! - const distKJ = distances.get(k)!.get(j)! - const distIJ = distances.get(i)!.get(j)! + }) - if (distIK !== Infinity && distKJ !== Infinity && distIK + distKJ < distIJ) { - const nextIK = next.get(i)!.get(k) - if (nextIK !== undefined) { - distances.get(i)!.set(j, distIK + distKJ) - next.get(i)!.set(j, nextIK) - } + for (let k = 0; k < size; k++) { + const kRow = k * size + for (let i = 0; i < size; i++) { + const iRow = i * size + const distanceIK = distancesMatrix[iRow + k] + if (distanceIK === Infinity) { + continue + } + const nextIK = nextMatrix[iRow + k] + for (let j = 0; j < size; j++) { + const distanceKJ = distancesMatrix[kRow + j] + if (distanceKJ === Infinity) { + continue + } + const candidate = distanceIK + distanceKJ + if (!Number.isFinite(candidate)) { + throw new GraphError({ message: "Floyd-Warshall distance calculation exceeded the finite number range" }) + } + if (candidate < distancesMatrix[iRow + j] && nextIK !== -1) { + distancesMatrix[iRow + j] = candidate + nextMatrix[iRow + j] = nextIK } } } } - // Check for negative cycles - for (const i of allNodes) { - if (distances.get(i)!.get(i)! < 0) { - throw new GraphError({ message: `Negative cycle detected involving node ${i}` }) + for (let i = 0; i < size; i++) { + if (distancesMatrix[i * size + i] < 0) { + throw new GraphError({ message: `Negative cycle detected involving node ${cache.nodeIds[i]}` }) } } - // Build result paths and edge weights + const distances = new Map>() const paths = new Map | null>>() + const edgePaths = new Map>>() const costs = new Map>>() - - for (const i of allNodes) { - paths.set(i, new Map()) - costs.set(i, new Map()) - - for (const j of allNodes) { + for (let i = 0; i < size; i++) { + const source = cache.nodeIds[i] + const distanceRow = new Map() + const pathRow = new Map | null>() + const edgePathRow = new Map>() + const costRow = new Map>() + distances.set(source, distanceRow) + paths.set(source, pathRow) + edgePaths.set(source, edgePathRow) + costs.set(source, costRow) + + for (let j = 0; j < size; j++) { + const target = cache.nodeIds[j] + const distance = distancesMatrix[i * size + j] + distanceRow.set(target, distance) if (i === j) { - paths.get(i)!.set(j, [i]) - costs.get(i)!.set(j, []) - } else if (distances.get(i)!.get(j)! === Infinity) { - paths.get(i)!.set(j, null) - costs.get(i)!.set(j, []) + pathRow.set(target, [source]) + edgePathRow.set(target, []) + costRow.set(target, []) + } else if (distance === Infinity) { + pathRow.set(target, null) + edgePathRow.set(target, []) + costRow.set(target, []) } else { - // Reconstruct path iteratively - const path: Array = [] - const weights: Array = [] + const path = [source] + const pathEdges: Array = [] + const pathCosts: Array = [] let current = i - - path.push(current) while (current !== j) { - const nextNode = next.get(current)!.get(j) - if (nextNode === undefined) break - - const edgeRow = edgeMatrix.get(current)! - if (edgeRow.has(nextNode)) { - weights.push(edgeRow.get(nextNode) as E) + const next = nextMatrix[current * size + j] + if (next === -1) { + break } - - current = nextNode - path.push(current) + const edge = edgeMatrix[current * size + next] + if (edge !== -1) { + pathEdges.push(edgeIds[edge]) + pathCosts.push(edges[edge].data) + } + current = next + path.push(cache.nodeIds[current]) } - - paths.get(i)!.set(j, path) - costs.get(i)!.set(j, weights) + pathRow.set(target, path) + edgePathRow.set(target, pathEdges) + costRow.set(target, pathCosts) } } } - return { - distances, - paths, - costs - } + return { distances, paths, edges: edgePaths, costs } }) /** @@ -4446,11 +6630,12 @@ export const floydWarshall: { * edge data to non-negative weights, and a heuristic that estimates the * remaining cost from a node to the target. * - * @see {@link astar} for the algorithm that consumes this configuration - * @see {@link DijkstraConfig} for shortest paths without a heuristic - * @see {@link BellmanFordConfig} for shortest paths that may include negative edge weights + * **Gotchas** + * + * Heuristic values must be finite and the heuristic must be consistent for A* + * to guarantee a shortest path. * - * @category models + * @category configuration * @since 3.18.0 */ export interface AstarConfig { @@ -4464,15 +6649,24 @@ export interface AstarConfig { * Finds the shortest path from the configured source node to the target node * using the A* pathfinding algorithm. * + * **When to use** + * + * Use when a meaningful heuristic can reduce point-to-point search compared + * with Dijkstra's algorithm. + * * **Details** * * The edge-cost function must return non-negative weights and not `NaN`. - * `Infinity` is allowed and behaves like an impassable edge. The heuristic - * should be consistent to preserve shortest-path guarantees. Returns - * `Option.none()` when the target is not reachable, and throws a `GraphError` - * when either endpoint is missing or an edge cost is negative or `NaN`. + * `Infinity` is allowed and behaves like an impassable edge. Returns + * `Option.none()` when the target is not reachable. + * + * **Gotchas** + * + * The heuristic must be consistent for the shortest-path guarantee and must + * return finite values. Missing endpoints, invalid edge costs, or non-finite + * heuristic values or arithmetic results throw a `GraphError`. * - * **Example** (Finding shortest paths with A-star) + * **Example** (Finding shortest paths with A*) * * ```ts import.meta.vitest * import { Graph, Option } from "effect" @@ -4501,6 +6695,8 @@ export interface AstarConfig { * Option.map(result, ({ distance, path }) => [distance, path] as const) // => Option.some([2, [0, 1, 2]]) * ``` * + * @see {@link dijkstra} when no useful heuristic is available + * @see {@link bellmanFord} when edge costs may be negative * @category algorithms * @since 3.18.0 */ @@ -4516,7 +6712,7 @@ export const astar: { graph: Graph | MutableGraph, config: AstarConfig ): Option.Option> => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) // Validate that source and target nodes exist if (!impl.nodes.has(config.source)) { throw missingNode(config.source) @@ -4525,141 +6721,115 @@ export const astar: { throw missingNode(config.target) } - const edgeWeights = new Map() - for (const [edgeIndex, edgeData] of impl.edges) { - const weight = config.cost(edgeData.data) - if (Number.isNaN(weight) || weight < 0) { - throw new GraphError({ message: "A* algorithm requires non-negative edge weights" }) + const cache = csr.get(graph) + const cachedEdges = csr.getEdges(cache) + const cachedEdgeIds = csr.getEdgeIds(cache) + const outgoing = csr.getOutgoingWithEdges(cache) + const source = csr.getNodeIndex(cache, config.source)! + const target = csr.getNodeIndex(cache, config.target)! + const sourceNodeData = cache.nodeData[source] as N + const targetNodeData = cache.nodeData[target] as N + const edgeWeights = new Float64Array(cachedEdges.length) + withMutationGuard(graph, () => { + for (let i = 0; i < cachedEdges.length; i++) { + const weight = config.cost(cachedEdges[i].data) + if (Number.isNaN(weight) || weight < 0) { + throw new GraphError({ message: "A* algorithm requires non-negative edge weights" }) + } + edgeWeights[i] = weight } - edgeWeights.set(edgeIndex, weight) - } + }) // Early return if source equals target if (config.source === config.target) { + if (!Number.isFinite(withMutationGuard(graph, () => config.heuristic(sourceNodeData, targetNodeData)))) { + throw new GraphError({ message: "A* algorithm requires finite heuristic values" }) + } return Option.some({ path: [config.source], + edges: [], distance: 0, costs: [] }) } - // Get target node data for heuristic calculations - const targetNodeData = getNode(graph, config.target) - if (Option.isNone(targetNodeData)) { - throw new GraphError({ message: `Missing node data for target node ${config.target}` }) - } - - // Distance tracking (g-score) and f-score (g + h) - const gScore = new Map() - const fScore = new Map() - const previous = new Map() - const visited = new Set() - - // Initialize scores - // Iterate directly over node keys - for (const node of impl.nodes.keys()) { - gScore.set(node, node === config.source ? 0 : Infinity) - fScore.set(node, Infinity) - previous.set(node, null) - } - - // Calculate initial f-score for source - const sourceNodeData = getNode(graph, config.source) - if (Option.isSome(sourceNodeData)) { - const h = config.heuristic(sourceNodeData.value, targetNodeData.value) - fScore.set(config.source, h) + const getHeuristic = (nodeData: N): number => { + const value = withMutationGuard(graph, () => config.heuristic(nodeData, targetNodeData)) + if (!Number.isFinite(value)) { + throw new GraphError({ message: "A* algorithm requires finite heuristic values" }) + } + return value } - const openSet: Array = [] + const scores = new Float64Array(cache.nodeIds.length) + scores.fill(Infinity) + scores[source] = 0 + // Predecessor node and edge arrays preserve path reconstruction while the hot loop uses compact indices. + const previousNode = new Int32Array(cache.nodeIds.length) + const previousEdge = new Int32Array(cache.nodeIds.length) + previousNode.fill(-1) + previousEdge.fill(-1) + const visited = new Uint8Array(cache.nodeIds.length) + const openSet = denseMinHeapMake(cache.nodeIds.length) let sequence = 0 - minHeapPush(openSet, { - node: config.source, - priority: fScore.get(config.source)!, - sequence: sequence++ - }) - - while (openSet.length > 0) { - const current = minHeapPop(openSet)! - const currentNode = current.node + denseMinHeapPush(openSet, source, getHeuristic(sourceNodeData), sequence++) - // Skip if already visited - if (visited.has(currentNode)) { + while (openSet.size > 0) { + denseMinHeapPop(openSet) + const current = openSet.poppedNode + if (visited[current] !== 0) { continue } - - visited.add(currentNode) - - // Early termination if we reached the target - if (currentNode === config.target) { + visited[current] = 1 + if (current === target) { break } - // Get current g-score - const currentGScore = gScore.get(currentNode)! - - // Examine all outgoing edges - const adjacencyList = impl.adjacency.get(currentNode) - if (adjacencyList !== undefined) { - for (const edgeIndex of adjacencyList) { - const edge = impl.edges.get(edgeIndex) - if (edge !== undefined) { - const neighbor = getTraversableNeighbor(graph, currentNode, edge) - const weight = edgeWeights.get(edgeIndex)! - - const tentativeGScore = currentGScore + weight - const neighborGScore = gScore.get(neighbor)! - - // If this path to neighbor is better than any previous one - if (tentativeGScore < neighborGScore) { - // Update g-score and previous - gScore.set(neighbor, tentativeGScore) - previous.set(neighbor, { node: currentNode, edgeData: edge.data }) - - // Calculate f-score using heuristic - const neighborNodeData = getNode(graph, neighbor) - if (Option.isSome(neighborNodeData)) { - const h = config.heuristic(neighborNodeData.value, targetNodeData.value) - const f = tentativeGScore + h - fScore.set(neighbor, f) - - // Add to open set if not visited - if (!visited.has(neighbor)) { - minHeapPush(openSet, { node: neighbor, priority: f, sequence: sequence++ }) - } - } - } + const currentScore = scores[current] + for (let i: number = outgoing.rowOffsets[current]; i < outgoing.rowOffsets[current + 1]; i++) { + const neighbor = outgoing.columnIndices[i] + if (visited[neighbor] !== 0) { + continue + } + const edge = outgoing.edgeIndices[i] + const tentativeScore = currentScore + edgeWeights[edge] + if (edgeWeights[edge] !== Infinity && !Number.isFinite(tentativeScore)) { + throw new GraphError({ message: "A* distance calculation exceeded the finite number range" }) + } + if (tentativeScore < scores[neighbor]) { + scores[neighbor] = tentativeScore + previousNode[neighbor] = current + previousEdge[neighbor] = edge + const priority = tentativeScore + getHeuristic(cache.nodeData[neighbor] as N) + if (!Number.isFinite(priority)) { + throw new GraphError({ message: "A* priority calculation exceeded the finite number range" }) } + denseMinHeapPush(openSet, neighbor, priority, sequence++) } } } - // Check if target is reachable - const distance = gScore.get(config.target)! - if (distance === Infinity) { - return Option.none() // No path exists + if (scores[target] === Infinity) { + return Option.none() } - // Reconstruct path const path: Array = [] + const edges: Array = [] const costs: Array = [] - let currentNode: NodeIndex | null = config.target - - while (currentNode !== null) { - path.unshift(currentNode) - const prev: { node: NodeIndex; edgeData: E } | null = previous.get(currentNode) ?? null - if (prev !== null) { - costs.unshift(prev.edgeData) - currentNode = prev.node - } else { - currentNode = null + let current = target + while (current !== -1) { + path.push(cache.nodeIds[current]) + const edge = previousEdge[current] + if (edge !== -1) { + edges.push(cachedEdgeIds[edge]) + costs.push(cachedEdges[edge].data) } + current = previousNode[current] } - - return Option.some({ - path, - distance, - costs - }) + path.reverse() + edges.reverse() + costs.reverse() + return Option.some({ path, edges, distance: scores[target], costs }) }) /** @@ -4675,11 +6845,7 @@ export const astar: { * Specifies the source and target node indices, plus a cost function that maps * each edge's data to a numeric weight. * - * @see {@link bellmanFord} for the algorithm that consumes this configuration - * @see {@link DijkstraConfig} for non-negative edge costs - * @see {@link AstarConfig} for heuristic shortest-path search - * - * @category models + * @category configuration * @since 3.18.0 */ export interface BellmanFordConfig { @@ -4692,12 +6858,22 @@ export interface BellmanFordConfig { * Finds the shortest path from the configured source node to the target node * using the Bellman-Ford algorithm. * + * **When to use** + * + * Use when one source-to-target shortest-path query may traverse negative-cost + * edges. + * * **Details** * * Negative edge weights are allowed, and `Infinity` behaves like an impassable - * edge. Returns `Option.none()` when the target is unreachable or when a - * negative cycle affects the path to the target. Throws a `GraphError` when - * either endpoint is missing or an edge weight is `NaN` or `-Infinity`. + * edge. Returns `Option.none()` when the target is unreachable. A reachable + * negative cycle only causes failure when it can affect the target. + * + * **Gotchas** + * + * Missing endpoints, unsupported weights, finite-range overflow, or a relevant + * negative cycle throw a `GraphError`. In an undirected graph, any reachable + * negative edge forms a negative cycle because it can be traversed both ways. * * **Example** (Finding shortest paths with Bellman-Ford) * @@ -4722,6 +6898,7 @@ export interface BellmanFordConfig { * Option.map(result, ({ distance, path }) => [distance, path] as const) // => Option.some([2, [0, 1, 2]]) * ``` * + * @see {@link dijkstra} for non-negative edge costs * @category algorithms * @since 3.18.0 */ @@ -4737,7 +6914,7 @@ export const bellmanFord: { graph: Graph | MutableGraph, config: BellmanFordConfig ): Option.Option> => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) // Validate that source and target nodes exist if (!impl.nodes.has(config.source)) { throw missingNode(config.source) @@ -4746,116 +6923,489 @@ export const bellmanFord: { throw missingNode(config.target) } - // Initialize distances and predecessors - const distances = new Map() - const previous = new Map() + const cache = csr.get(graph) + const edges = csr.getEdges(cache) + const edgeIds = csr.getEdgeIds(cache) + const edgeCache = csr.getEdgeEndpoints(cache) + const outgoing = csr.getOutgoing(cache) + const source = csr.getNodeIndex(cache, config.source)! + const target = csr.getNodeIndex(cache, config.target)! + const weights = new Float64Array(edges.length) + withMutationGuard(graph, () => { + for (let i = 0; i < edges.length; i++) { + const weight = config.cost(edges[i].data) + if (Number.isNaN(weight) || weight === -Infinity) { + throw new GraphError({ message: "Bellman-Ford algorithm does not support NaN or -Infinity edge weights" }) + } + weights[i] = weight + } + }) + + const addWeight = (distance: number, weight: number): number => { + if (distance === Infinity || weight === Infinity) { + return Infinity + } + const candidate = distance + weight + if (!Number.isFinite(candidate)) { + throw new GraphError({ message: "Bellman-Ford distance calculation exceeded the finite number range" }) + } + return candidate + } + + const distances = new Float64Array(cache.nodeIds.length) + const previousNode = new Int32Array(cache.nodeIds.length) + const previousEdge = new Int32Array(cache.nodeIds.length) + distances.fill(Infinity) + previousNode.fill(-1) + previousEdge.fill(-1) + distances[source] = 0 + + for (let iteration = 0; iteration < cache.nodeIds.length - 1; iteration++) { + let hasUpdate = false + for (let edge = 0; edge < edges.length; edge++) { + const edgeSource = edgeCache.sources[edge] + const edgeTarget = edgeCache.targets[edge] + const weight = weights[edge] + const sourceDistance = distances[edgeSource] + const candidate = addWeight(sourceDistance, weight) + if (candidate < distances[edgeTarget]) { + distances[edgeTarget] = candidate + previousNode[edgeTarget] = edgeSource + previousEdge[edgeTarget] = edge + hasUpdate = true + } + if (graph.type === "undirected" && edgeSource !== edgeTarget) { + const targetDistance = distances[edgeTarget] + const reverseCandidate = addWeight(targetDistance, weight) + if (reverseCandidate < distances[edgeSource]) { + distances[edgeSource] = reverseCandidate + previousNode[edgeSource] = edgeTarget + previousEdge[edgeSource] = edge + hasUpdate = true + } + } + } + if (!hasUpdate) { + break + } + } + + // A relaxable edge after N-1 passes marks a reachable negative cycle; propagate to see if it reaches the target. + const affected = new Uint8Array(cache.nodeIds.length) + const queue = new Uint32Array(cache.nodeIds.length) + let head = 0 + let tail = 0 + const markAffected = (node: number) => { + if (affected[node] === 0) { + affected[node] = 1 + queue[tail++] = node + } + } + for (let edge = 0; edge < edges.length; edge++) { + const edgeSource = edgeCache.sources[edge] + const edgeTarget = edgeCache.targets[edge] + const weight = weights[edge] + if (addWeight(distances[edgeSource], weight) < distances[edgeTarget]) { + markAffected(edgeTarget) + } + if ( + graph.type === "undirected" && + edgeSource !== edgeTarget && + addWeight(distances[edgeTarget], weight) < distances[edgeSource] + ) { + markAffected(edgeSource) + } + } + if (tail > 0) { + while (head < tail) { + const node = queue[head++] + for (let i = outgoing.rowOffsets[node]; i < outgoing.rowOffsets[node + 1]; i++) { + markAffected(outgoing.columnIndices[i]) + } + } + } + if (affected[target] !== 0) { + throw new GraphError({ message: `Negative cycle affects path to node ${config.target}` }) + } + if (distances[target] === Infinity) { + return Option.none() + } + + const path: Array = [] + const pathEdges: Array = [] + const costs: Array = [] + let current = target + let remaining = cache.nodeIds.length + while (current !== -1) { + if (remaining-- === 0) { + throw new GraphError({ message: `Negative cycle affects path to node ${config.target}` }) + } + path.push(cache.nodeIds[current]) + const edge = previousEdge[current] + if (edge !== -1) { + pathEdges.push(edgeIds[edge]) + costs.push(edges[edge].data) + } + current = previousNode[current] + } + path.reverse() + pathEdges.reverse() + costs.reverse() + return Option.some({ path, edges: pathEdges, distance: distances[target], costs }) +}) + +/** + * A repeatable lazy iterable of edge-aware graph paths. + * + * **When to use** + * + * Use as the lazy result of graph path-enumeration functions. + * + * **Details** + * + * Each fresh iterator repeats the path enumeration. + * + * @category models + * @since 4.0.0 + */ +export interface PathWalker extends Iterable> {} + +/** + * Configuration for lazy simple-path enumeration. + * + * **When to use** + * + * Use when bounding enumeration of loop-free routes between two nodes. + * + * **Details** + * + * `limit` bounds the number of yielded paths and defaults to `Infinity`. + * + * **Gotchas** + * + * `limit` must be a non-negative integer or `Infinity`. + * + * @category configuration + * @since 4.0.0 + */ +export interface SimplePathsConfig { + readonly source: NodeIndex + readonly target: NodeIndex + readonly limit?: number +} + +/** + * Configuration for enumerating all tied shortest paths. + * + * **When to use** + * + * Use when bounding enumeration of every route tied for minimum total cost. + * + * **Details** + * + * Edge costs must be non-negative. `limit` bounds the number of yielded paths + * and defaults to `Infinity`. + * + * **Gotchas** + * + * Invalid costs and limits throw a `GraphError` when evaluated. + * + * @category configuration + * @since 4.0.0 + */ +export interface AllShortestPathsConfig extends DijkstraConfig { + readonly limit?: number +} + +const pathEnumerationLimit = (limit: number | undefined): number => { + const value = limit ?? Infinity + if (value !== Infinity && (!Number.isInteger(value) || value < 0)) { + throw new GraphError({ message: "Path enumeration limit must be a non-negative integer or Infinity" }) + } + return value +} + +const pathWalker = (iterator: () => Iterator>): PathWalker => ({ + [Symbol.iterator]: iterator +}) + +/** + * Lazily enumerates simple source-to-target paths in depth-first edge order. + * + * **When to use** + * + * Use when you need possible loop-free routes rather than only an optimal + * route. + * + * **Details** + * + * Nodes are never repeated within a path, so enumeration is finite even for + * cyclic graphs. Path distance is the number of traversed edges. + * + * **Gotchas** + * + * The number of simple paths can be exponential. Missing endpoints or an + * invalid `limit` throw a `GraphError`. Mutable graphs are snapshotted when + * iteration begins. + * + * @see {@link allShortestPaths} for enumerating only minimum-cost routes + * + * @category algorithms + * @since 4.0.0 + */ +export const simplePaths: { + (config: SimplePathsConfig): ( + graph: Graph | MutableGraph + ) => PathWalker + ( + graph: Graph | MutableGraph, + config: SimplePathsConfig + ): PathWalker +} = dual(2, ( + graph: Graph | MutableGraph, + config: SimplePathsConfig +): PathWalker => { + const impl = internal.toImpl(graph) + if (!impl.nodes.has(config.source)) { + throw missingNode(config.source) + } + if (!impl.nodes.has(config.target)) { + throw missingNode(config.target) + } + const limit = pathEnumerationLimit(config.limit) + + return pathWalker(function*() { + const cache = csr.get(graph) + const source = csr.getNodeIndex(cache, config.source) + if (source === undefined) { + throw missingNode(config.source) + } + const target = csr.getNodeIndex(cache, config.target) + if (target === undefined) { + throw missingNode(config.target) + } + if (limit === 0) { + return + } + const outgoing = csr.getOutgoingWithEdges(cache) + const edgeIds = csr.getEdgeIds(cache) + const graphEdges = csr.getEdges(cache) + const path = [config.source] + const pathEdges: Array = [] + const costs: Array = [] + const visited = new Uint8Array(cache.nodeIds.length) + visited[source] = 1 + const stack: Array<{ readonly node: number; position: number }> = [{ + node: source, + position: outgoing.rowOffsets[source] + }] + let emitted = 0 + + const backtrack = () => { + const frame = stack.pop()! + if (stack.length > 0) { + visited[frame.node] = 0 + path.pop() + pathEdges.pop() + costs.pop() + } + } + + while (stack.length > 0 && emitted < limit) { + const frame = stack[stack.length - 1] + if (frame.node === target) { + emitted++ + yield { + path: Array.from(path), + edges: Array.from(pathEdges), + distance: pathEdges.length, + costs: Array.from(costs) + } + backtrack() + continue + } + if (frame.position >= outgoing.rowOffsets[frame.node + 1]) { + backtrack() + continue + } + const position = frame.position++ + const neighbor = outgoing.columnIndices[position] + if (visited[neighbor] !== 0) { + continue + } + const edge = outgoing.edgeIndices[position] + visited[neighbor] = 1 + path.push(cache.nodeIds[neighbor]) + pathEdges.push(edgeIds[edge]) + costs.push(graphEdges[edge].data) + stack.push({ node: neighbor, position: outgoing.rowOffsets[neighbor] }) + } + }) +}) - // Iterate directly over node keys - for (const node of impl.nodes.keys()) { - distances.set(node, node === config.source ? 0 : Infinity) - previous.set(node, null) +/** + * Lazily enumerates all simple paths tied for minimum total cost. + * + * **When to use** + * + * Use when every distinct route tied for the minimum total cost is required. + * + * **Details** + * + * Parallel edges produce distinct paths. Edge costs must be non-negative; + * `Infinity` behaves as unavailable. + * + * **Gotchas** + * + * The number of tied paths can still be large. Missing endpoints, invalid + * costs, arithmetic overflow, or an invalid `limit` throw a `GraphError`. + * Mutable graphs are snapshotted when iteration begins. + * + * @see {@link dijkstra} when one shortest path is sufficient + * @see {@link simplePaths} for routes regardless of cost + * + * @category algorithms + * @since 4.0.0 + */ +export const allShortestPaths: { + (config: AllShortestPathsConfig): ( + graph: Graph | MutableGraph + ) => PathWalker + ( + graph: Graph | MutableGraph, + config: AllShortestPathsConfig + ): PathWalker +} = dual(2, ( + graph: Graph | MutableGraph, + config: AllShortestPathsConfig +): PathWalker => { + const impl = internal.toImpl(graph) + if (!impl.nodes.has(config.source)) { + throw missingNode(config.source) + } + if (!impl.nodes.has(config.target)) { + throw missingNode(config.target) } + const limit = pathEnumerationLimit(config.limit) - // Collect all edges for relaxation - const edges: Array<{ source: NodeIndex; target: NodeIndex; weight: number; edgeData: E }> = [] - for (const [, edgeData] of impl.edges) { - const weight = config.cost(edgeData.data) - if (Number.isNaN(weight) || weight === -Infinity) { - throw new GraphError({ message: "Bellman-Ford algorithm does not support NaN or -Infinity edge weights" }) + return pathWalker(function*() { + const cache = csr.get(graph) + const source = csr.getNodeIndex(cache, config.source) + if (source === undefined) { + throw missingNode(config.source) } - edges.push({ - source: edgeData.source, - target: edgeData.target, - weight, - edgeData: edgeData.data + const target = csr.getNodeIndex(cache, config.target) + if (target === undefined) { + throw missingNode(config.target) + } + const graphEdges = csr.getEdges(cache) + const edgeIds = csr.getEdgeIds(cache) + const outgoing = csr.getOutgoingWithEdges(cache) + const weights = new Float64Array(graphEdges.length) + withMutationGuard(graph, () => { + for (let edge = 0; edge < graphEdges.length; edge++) { + const weight = config.cost(graphEdges[edge].data) + if (Number.isNaN(weight) || weight < 0) { + throw new GraphError({ message: "All shortest paths requires non-negative edge weights" }) + } + weights[edge] = weight + } }) - if (graph.type === "undirected" && edgeData.source !== edgeData.target) { - edges.push({ - source: edgeData.target, - target: edgeData.source, - weight, - edgeData: edgeData.data - }) + if (limit === 0) { + return } - } - - // Relax edges up to V-1 times - const nodeCount = impl.nodes.size - for (let i = 0; i < nodeCount - 1; i++) { - let hasUpdate = false - - for (const edge of edges) { - const sourceDistance = distances.get(edge.source)! - const targetDistance = distances.get(edge.target)! - // Relaxation step - if (sourceDistance !== Infinity && sourceDistance + edge.weight < targetDistance) { - distances.set(edge.target, sourceDistance + edge.weight) - previous.set(edge.target, { node: edge.source, edgeData: edge.edgeData }) - hasUpdate = true + const distances = new Float64Array(cache.nodeIds.length) + distances.fill(Infinity) + distances[source] = 0 + const previous: Array | undefined> = new Array( + cache.nodeIds.length + ) + const queue = denseMinHeapMake(cache.nodeIds.length) + let sequence = 0 + denseMinHeapPush(queue, source, 0, sequence++) + while (queue.size > 0) { + denseMinHeapPop(queue) + const currentNode = queue.poppedNode + const currentDistance = queue.poppedPriority + if (currentDistance !== distances[currentNode]) { + continue + } + for (let i = outgoing.rowOffsets[currentNode]; i < outgoing.rowOffsets[currentNode + 1]; i++) { + const edge = outgoing.edgeIndices[i] + const neighbor = outgoing.columnIndices[i] + const nextDistance = currentDistance + weights[edge] + if (weights[edge] !== Infinity && !Number.isFinite(nextDistance)) { + throw new GraphError({ message: "All shortest paths distance calculation exceeded the finite number range" }) + } + const known = distances[neighbor] + const predecessor = { node: currentNode, edge } + if (nextDistance < known) { + distances[neighbor] = nextDistance + previous[neighbor] = [predecessor] + denseMinHeapPush(queue, neighbor, nextDistance, sequence++) + } else if (nextDistance === known && nextDistance !== Infinity) { + const predecessors = previous[neighbor] + if (predecessors === undefined) { + previous[neighbor] = [predecessor] + } else { + predecessors.push(predecessor) + } + } } } - // Early termination if no updates - if (!hasUpdate) { - break + const distance = distances[target] + if (distance === Infinity) { + return + } + if (source === target) { + yield { path: [config.source], edges: [], distance: 0, costs: [] } + return + } + const reversePath = [target] + const reverseEdges: Array = [] + const visited = new Uint8Array(cache.nodeIds.length) + visited[target] = 1 + const stack: Array<{ readonly node: number; position: number }> = [{ node: target, position: 0 }] + let emitted = 0 + + const backtrack = () => { + const frame = stack.pop()! + if (stack.length > 0) { + visited[frame.node] = 0 + reversePath.pop() + reverseEdges.pop() + } } - } - - // Check for negative cycles - for (const edge of edges) { - const sourceDistance = distances.get(edge.source)! - const targetDistance = distances.get(edge.target)! - - if (sourceDistance !== Infinity && sourceDistance + edge.weight < targetDistance) { - // Negative cycle detected - check if it affects the path to target - const affectedNodes = new Set() - const queue = [edge.target] - - while (queue.length > 0) { - const node = queue.shift()! - if (affectedNodes.has(node)) continue - affectedNodes.add(node) - // Add all nodes reachable from this node - for (const neighbor of getTraversalNeighbors(graph, node, "outgoing")) { - queue.push(neighbor) + while (stack.length > 0 && emitted < limit) { + const frame = stack[stack.length - 1] + if (frame.node === source) { + emitted++ + yield { + path: reversePath.map((node) => cache.nodeIds[node]).reverse(), + edges: reverseEdges.map((edge) => edgeIds[edge]).reverse(), + distance, + costs: reverseEdges.map((edge) => graphEdges[edge].data as E).reverse() } + backtrack() + continue } - - // If target is affected by a negative cycle, no shortest path exists. - if (affectedNodes.has(config.target)) { - return Option.none() + const predecessors = previous[frame.node] ?? [] + if (frame.position >= predecessors.length) { + backtrack() + continue } + const predecessor = predecessors[frame.position++] + if (visited[predecessor.node] !== 0) { + continue + } + visited[predecessor.node] = 1 + reversePath.push(predecessor.node) + reverseEdges.push(predecessor.edge) + stack.push({ node: predecessor.node, position: 0 }) } - } - - // Check if target is reachable - const distance = distances.get(config.target)! - if (distance === Infinity) { - return Option.none() // No path exists - } - - // Reconstruct path - const path: Array = [] - const costs: Array = [] - let currentNode: NodeIndex | null = config.target - - while (currentNode !== null) { - path.unshift(currentNode) - const prev: { node: NodeIndex; edgeData: E } | null = previous.get(currentNode)! - if (prev !== null) { - costs.unshift(prev.edgeData) - currentNode = prev.node - } else { - currentNode = null - } - } - - return Option.some({ - path, - distance, - costs }) }) @@ -4901,13 +7451,12 @@ export class Walker implements Iterable<[T, N]> { readonly [Symbol.iterator]: () => Iterator<[T, N]> /** - * Visits each element and maps it to a value using the provided function. + * Lazily maps each walker entry with the provided function. * * **Details** * - * Takes a function that receives the index and data, - * and returns an iterable of the mapped values. Skips elements that - * no longer exist in the graph. + * The function receives the index and data and runs as the returned iterable + * is consumed. * * **Example** (Visiting walker elements) * @@ -4934,36 +7483,6 @@ export class Walker implements Iterable<[T, N]> { readonly visit: (f: (index: T, data: N) => U) => Iterable constructor( - /** - * Visits each element and maps it to a value using the provided function. - * - * Takes a function that receives the index and data, - * and returns an iterable of the mapped values. Skips elements that - * no longer exist in the graph. - * - * **Example** (Visiting walker elements) - * - * ```ts import.meta.vitest - * import { Graph } from "effect" - * - * const graph = Graph.directed((mutable) => { - * const a = Graph.addNode(mutable, "A") - * const b = Graph.addNode(mutable, "B") - * Graph.addEdge(mutable, a, b, 1) - * }) - * - * const dfs = Graph.dfs(graph, { start: [0] }) - * - * // Map to just the node data - * Array.from(dfs.visit((index, data) => data)) // => ["A", "B"] - * - * // Map to custom objects - * Array.from(dfs.visit((index, data) => ({ id: index, name: data }))) // => [{ id: 0, name: "A" }, { id: 1, name: "B" }] - * ``` - * - * @category iterators - * @since 4.0.0 - */ visit: (f: (index: T, data: N) => U) => Iterable ) { this.visit = visit @@ -4971,6 +7490,44 @@ export class Walker implements Iterable<[T, N]> { } } +const makeCsrNodeWalker = ( + graph: Graph | MutableGraph, + makeIterator: (cache: csr.Csr, f: (index: NodeIndex, data: N) => U) => Iterator +): Walker => { + return new Walker((f) => ({ + // Capture CSR at iterator creation so invalidation cannot change an in-flight mutable traversal. + [Symbol.iterator]: () => + makeIterator(csr.get(graph), (index, data) => withMutationGuard(graph, () => f(index, data))) + })) +} + +const traversalStarts = ( + graph: Graph | MutableGraph, + start: ReadonlyArray | undefined +): Array => { + if (start === undefined) { + return [] + } + for (const nodeIndex of start) { + if (!hasNode(graph, nodeIndex)) { + throw missingNode(nodeIndex) + } + } + return Array.from(start) +} + +const traversalStartPositions = (cache: csr.Csr, start: ReadonlyArray): Array => { + const positions = new Array(start.length) + for (let i = 0; i < start.length; i++) { + const position = csr.getNodeIndex(cache, start[i]) + if (position === undefined) { + throw missingNode(start[i]) + } + positions[i] = position + } + return positions +} + /** * Type alias for node iteration using Walker. * NodeWalker is represented as Walker. @@ -5088,20 +7645,20 @@ export const entries = (walker: Walker): Iterable<[T, N]> => * **Details** * * `start` supplies the node indices where traversal begins. If it is omitted, - * the iterator is empty. `direction` chooses whether traversal follows - * outgoing edges, incoming edges, or ignores edge direction. `radius` limits - * traversal by edge distance from the nearest start node. + * the iterator is empty. Distinct starts are prioritized in supplied order and + * duplicates are ignored. `direction` chooses whether traversal follows outgoing + * edges, incoming edges, or ignores edge direction. `radius` limits traversal + * by edge distance from the nearest start node and accepts non-negative integers + * or `Infinity`; omitting it means unbounded traversal. * * **Gotchas** * - * Traversal creation throws a `GraphError` when any configured `start` node - * does not exist. + * Traversal creation validates and copies `start`, and throws a `GraphError` + * when a start node does not exist or `radius` is invalid. Each fresh iterator + * revalidates those starts against the graph snapshot it captures. Later + * mutations are not observed by an active iterator. * - * @see {@link dfs} for depth-first traversal - * @see {@link bfs} for breadth-first traversal - * @see {@link dfsPostOrder} for depth-first postorder traversal - * - * @category models + * @category configuration * @since 3.18.0 */ export interface SearchConfig { @@ -5114,12 +7671,22 @@ export interface SearchConfig { * Creates a lazy depth-first traversal iterator from the configured start * nodes. * + * **When to use** + * + * Use when exploring one branch deeply before visiting sibling branches. + * * **Details** * * If no start nodes are supplied, the iterator is empty. The `direction` option * chooses whether to follow outgoing or incoming edges. The `radius` option - * limits traversal by edge distance from the start nodes. Throws a `GraphError` - * if any configured start node does not exist. + * limits traversal by edge distance from the start nodes. It accepts + * non-negative integers and `Infinity`; omitting it means unbounded traversal. + * + * **Gotchas** + * + * An invalid radius or missing start node throws a `GraphError`. Traversing a + * mutable graph captures a snapshot when iteration begins; later mutations are + * not observed by that iterator. * * **Example** (Traversing depth-first) * @@ -5137,10 +7704,11 @@ export interface SearchConfig { * // Start from a specific node * Array.from(Graph.indices(Graph.dfs(graph, { start: [0] }))) // => [0, 1, 2] * - * // Empty iterator (no starting nodes) - * Graph.dfs(graph) + * Array.from(Graph.indices(Graph.dfs(graph))) // => [] * ``` * + * @see {@link bfs} for traversal in increasing hop distance + * @see {@link dfsPostOrder} for emitting descendants before ancestors * @category iterators * @since 3.18.0 */ @@ -5156,103 +7724,146 @@ export const dfs: { graph: Graph | MutableGraph, config: SearchConfig = {} ): NodeWalker => { - const start = config.start ?? [] + const radius = traversalRadius(config.radius, Infinity) + const start = traversalStarts(graph, config.start) const direction = config.direction ?? "outgoing" - const radius = config.radius ?? Infinity - // Validate that all start nodes exist - for (const nodeIndex of start) { - if (!hasNode(graph, nodeIndex)) { - throw missingNode(nodeIndex) - } - } + return makeCsrNodeWalker(graph, (cache, f) => { + const startPositions = traversalStartPositions(cache, start) + const view = csr.getAdjacencies(cache, direction) + const yielded = new Uint8Array(cache.nodeIds.length) + const stack: Array = [] - return new Walker((f) => ({ - [Symbol.iterator]: () => { - const depths = radius === Infinity ? undefined : new Map() - const stack: Array = [] - const yielded = new Set() + if (radius === Infinity) { + // Reverse row order before pushing so LIFO traversal observes canonical adjacency order. + for (let i = startPositions.length - 1; i >= 0; i--) { + stack.push(startPositions[i]) + } - if (depths === undefined) { - for (const node of start) { - stack.push([node, 0]) - } - } else { - const starts = new Set() - for (let i = start.length - 1; i >= 0; i--) { - const node = start[i] - if (!starts.has(node)) { - starts.add(node) - stack.push([node, 0]) - depths.set(node, 0) + const pushNeighbors = (targets: Uint32Array, offsets: Uint32Array, current: number) => { + for (let i = offsets[current + 1] - 1; i >= offsets[current]; i--) { + const neighbor = targets[i] + if (yielded[neighbor] === 0) { + stack.push(neighbor) } } - stack.reverse() } - const nextMapped = () => { - while (stack.length > 0) { - const [current, depth] = stack.pop()! - - if (depths === undefined) { - if (yielded.has(current)) { + return { + next() { + while (stack.length > 0) { + const current = stack.pop()! + if (yielded[current] !== 0) { continue } - } else if (depths.get(current) !== depth) { - continue - } - - const nodeDataOption = getNode(graph, current) - if (Option.isNone(nodeDataOption)) { - continue - } - if (depth < radius) { - const neighbors = getTraversalNeighbors(graph, current, direction) - for (let i = neighbors.length - 1; i >= 0; i--) { - const neighbor = neighbors[i] - const nextDepth = depth + 1 - if (depths === undefined) { - if (!yielded.has(neighbor)) { - stack.push([neighbor, nextDepth]) - } - continue - } - const neighborDepth = depths.get(neighbor) - if (neighborDepth === undefined || nextDepth < neighborDepth) { - depths.set(neighbor, nextDepth) - stack.push([neighbor, nextDepth]) - } + if (view.secondary !== undefined) { + pushNeighbors(view.secondary.columnIndices, view.secondary.rowOffsets, current) } + pushNeighbors(view.primary.columnIndices, view.primary.rowOffsets, current) + yielded[current] = 1 + + return { done: false, value: f(cache.nodeIds[current], cache.nodeData[current] as N) } } - if (yielded.has(current)) { + return { done: true, value: undefined } as const + } + } + } + + // Radius is shortest edge distance, so determine membership with BFS before imposing DFS order. + const reached = new Uint8Array(cache.nodeIds.length) + const queue = new Uint32Array(cache.nodeIds.length) + const depths = new Uint32Array(cache.nodeIds.length) + let head = 0 + let tail = 0 + + for (const position of startPositions) { + if (reached[position] === 0) { + reached[position] = 1 + queue[tail++] = position + } + } + + const enqueue = (targets: Uint32Array, offsets: Uint32Array, current: number, depth: number) => { + for (let i = offsets[current]; i < offsets[current + 1]; i++) { + const neighbor = targets[i] + if (reached[neighbor] === 0) { + reached[neighbor] = 1 + queue[tail] = neighbor + depths[tail++] = depth + 1 + } + } + } + + while (head < tail) { + const current = queue[head] + const depth = depths[head++] + if (depth < radius) { + enqueue(view.primary.columnIndices, view.primary.rowOffsets, current, depth) + if (view.secondary !== undefined) { + enqueue(view.secondary.columnIndices, view.secondary.rowOffsets, current, depth) + } + } + } + + for (let i = startPositions.length - 1; i >= 0; i--) { + stack.push(startPositions[i]) + } + + const pushNeighbors = (targets: Uint32Array, offsets: Uint32Array, current: number) => { + for (let i = offsets[current + 1] - 1; i >= offsets[current]; i--) { + const neighbor = targets[i] + if (reached[neighbor] !== 0 && yielded[neighbor] === 0) { + stack.push(neighbor) + } + } + } + + return { + next() { + while (stack.length > 0) { + const current = stack.pop()! + if (yielded[current] !== 0) { continue } - yielded.add(current) + if (view.secondary !== undefined) { + pushNeighbors(view.secondary.columnIndices, view.secondary.rowOffsets, current) + } + pushNeighbors(view.primary.columnIndices, view.primary.rowOffsets, current) + yielded[current] = 1 - return { done: false, value: f(current, nodeDataOption.value) } + return { done: false, value: f(cache.nodeIds[current], cache.nodeData[current] as N) } } return { done: true, value: undefined } as const } - - return { next: nextMapped } } - })) + }) }) /** * Creates a lazy breadth-first traversal iterator from the configured start * nodes. * + * **When to use** + * + * Use when visiting nodes in increasing unweighted distance from the start + * nodes. + * * **Details** * * If no start nodes are supplied, the iterator is empty. The `direction` option * chooses whether to follow outgoing or incoming edges. The `radius` option - * limits traversal by edge distance from the start nodes. Throws a `GraphError` - * if any configured start node does not exist. + * limits traversal by edge distance from the start nodes. It accepts + * non-negative integers and `Infinity`; omitting it means unbounded traversal. + * + * **Gotchas** + * + * An invalid radius or missing start node throws a `GraphError`. Traversing a + * mutable graph captures a snapshot when iteration begins; later mutations are + * not observed by that iterator. * * **Example** (Traversing breadth-first) * @@ -5270,10 +7881,11 @@ export const dfs: { * // Start from a specific node * Array.from(Graph.indices(Graph.bfs(graph, { start: [0] }))) // => [0, 1, 2] * - * // Empty iterator (no starting nodes) - * Graph.bfs(graph) + * Array.from(Graph.indices(Graph.bfs(graph))) // => [] * ``` * + * @see {@link dfs} for branch-first traversal + * @see {@link unweightedDistances} for collecting hop counts * @category iterators * @since 3.18.0 */ @@ -5289,52 +7901,100 @@ export const bfs: { graph: Graph | MutableGraph, config: SearchConfig = {} ): NodeWalker => { - const start = config.start ?? [] + const radius = traversalRadius(config.radius, Infinity) + const start = traversalStarts(graph, config.start) const direction = config.direction ?? "outgoing" - const radius = config.radius ?? Infinity - // Validate that all start nodes exist - for (const nodeIndex of start) { - if (!hasNode(graph, nodeIndex)) { - throw missingNode(nodeIndex) + return makeCsrNodeWalker(graph, (cache, f) => { + const startPositions = traversalStartPositions(cache, start) + const view = csr.getAdjacencies(cache, direction) + const discovered = new Uint8Array(cache.nodeIds.length) + // Each compact node enters the queue once, so a fixed-size typed array is sufficient. + const queue = new Uint32Array(cache.nodeIds.length) + let head = 0 + let tail = 0 + + for (const position of startPositions) { + if (discovered[position] === 0) { + discovered[position] = 1 + queue[tail++] = position + } } - } - return new Walker((f) => ({ - [Symbol.iterator]: () => { - const queue: Array = start.map((node) => [node, 0]) - const discovered = new Set() + const enqueue = (targets: Uint32Array, from: number, to: number) => { + for (let i = from; i < to; i++) { + const neighbor = targets[i] + if (discovered[neighbor] === 0) { + discovered[neighbor] = 1 + queue[tail++] = neighbor + } + } + } - const nextMapped = () => { - while (queue.length > 0) { - const [current, depth] = queue.shift()! - - if (!discovered.has(current)) { - discovered.add(current) - - if (depth < radius) { - const neighbors = getTraversalNeighbors(graph, current, direction) - for (const neighbor of neighbors) { - if (!discovered.has(neighbor)) { - queue.push([neighbor, depth + 1]) - } - } - } + if (radius === Infinity) { + return { + next() { + if (head >= tail) { + return { done: true, value: undefined } as const + } - const nodeData = getNode(graph, current) - if (Option.isSome(nodeData)) { - return { done: false, value: f(current, nodeData.value) } - } - continue + const current = queue[head++] + enqueue(view.primary.columnIndices, view.primary.rowOffsets[current], view.primary.rowOffsets[current + 1]) + if (view.secondary !== undefined) { + enqueue( + view.secondary.columnIndices, + view.secondary.rowOffsets[current], + view.secondary.rowOffsets[current + 1] + ) } + + return { done: false, value: f(cache.nodeIds[current], cache.nodeData[current] as N) } } + } + } - return { done: true, value: undefined } as const + const depths = new Uint32Array(cache.nodeIds.length) + const enqueueBounded = (targets: Uint32Array, from: number, to: number, depth: number) => { + for (let i = from; i < to; i++) { + const neighbor = targets[i] + if (discovered[neighbor] === 0) { + discovered[neighbor] = 1 + queue[tail] = neighbor + depths[tail++] = depth + 1 + } } + } - return { next: nextMapped } + return { + next() { + if (head >= tail) { + return { done: true, value: undefined } as const + } + + const current = queue[head] + const depth = depths[head++] + + if (depth < radius) { + enqueueBounded( + view.primary.columnIndices, + view.primary.rowOffsets[current], + view.primary.rowOffsets[current + 1], + depth + ) + if (view.secondary !== undefined) { + enqueueBounded( + view.secondary.columnIndices, + view.secondary.rowOffsets[current], + view.secondary.rowOffsets[current + 1], + depth + ) + } + } + + return { done: false, value: f(cache.nodeIds[current], cache.nodeData[current] as N) } + } } - })) + }) }) /** @@ -5354,9 +8014,7 @@ export const bfs: { * * Throws a `GraphError` when any initial node has incoming edges. * - * @see {@link topo} for the iterator that consumes this configuration - * - * @category models + * @category configuration * @since 3.18.0 */ export interface TopoConfig { @@ -5364,12 +8022,24 @@ export interface TopoConfig { } /** - * Creates a new topological sort iterator with optional configuration. + * Creates a lazy topological-order iterator for a directed acyclic graph. + * + * **When to use** + * + * Use when processing dependencies so every predecessor is emitted before the + * nodes that depend on it. * * **Details** * - * The iterator uses Kahn's algorithm to lazily produce nodes in topological order. - * Throws an error if the graph contains cycles. + * The iterator uses Kahn's algorithm. Multiple valid orders may exist; + * `initials` prioritizes eligible zero in-degree nodes without excluding other + * nodes. + * + * **Gotchas** + * + * Undirected or cyclic graphs, missing initial nodes, and initial nodes with + * incoming edges throw a `GraphError`. Traversing a mutable graph captures a + * snapshot when iteration begins; later mutations are not observed. * * **Example** (Sorting topologically) * @@ -5384,23 +8054,10 @@ export interface TopoConfig { * Graph.addEdge(mutable, b, c, 1) * }) * - * // Standard topological sort * Array.from(Graph.indices(Graph.topo(graph))) // => [0, 1, 2] - * - * // With initial nodes - * Graph.topo(graph, { initials: [0] }) - * - * // Check before sorting a cyclic graph - * const cyclicGraph = Graph.directed((mutable) => { - * const a = Graph.addNode(mutable, "A") - * const b = Graph.addNode(mutable, "B") - * Graph.addEdge(mutable, a, b, 1) - * Graph.addEdge(mutable, b, a, 2) // Creates cycle - * }) - * - * Graph.isAcyclic(cyclicGraph) // => false * ``` * + * @see {@link isAcyclic} for checking the required graph property * @category iterators * @since 3.18.0 */ @@ -5425,7 +8082,7 @@ export const topo: { throw new GraphError({ message: "Cannot perform topological sort on cyclic graph" }) } - const initials = config.initials ?? [] + const initials = Array.from(config.initials ?? []) // Validate that all initial nodes exist for (const nodeIndex of initials) { @@ -5434,101 +8091,94 @@ export const topo: { } } - return new Walker((f) => ({ - [Symbol.iterator]: () => { - const impl = graphImpl(graph) - const inDegree = new Map() - const remaining = new Set() - const initialSet = new Set(initials) - const queue = [...initials] - - // Initialize in-degree counts - for (const [nodeIndex] of impl.nodes) { - inDegree.set(nodeIndex, 0) - remaining.add(nodeIndex) - } - - // Calculate in-degrees - for (const [, edgeData] of impl.edges) { - const currentInDegree = inDegree.get(edgeData.target) || 0 - inDegree.set(edgeData.target, currentInDegree + 1) + return makeCsrNodeWalker(graph, (cache, f) => { + const outgoing = csr.getOutgoing(cache) + const incoming = csr.getIncoming(cache) + // CSR row lengths are the initial in-degrees used by Kahn's algorithm. + const inDegree = new Uint32Array(cache.nodeIds.length) + const remaining = new Uint8Array(cache.nodeIds.length) + const initialSet = new Uint8Array(cache.nodeIds.length) + const queue: Array = [] + let remainingCount = cache.nodeIds.length + let head = 0 + remaining.fill(1) + + for (let node = 0; node < cache.nodeIds.length; node++) { + inDegree[node] = incoming.rowOffsets[node + 1] - incoming.rowOffsets[node] + } + for (const initial of initials) { + const node = csr.getNodeIndex(cache, initial) + if (node === undefined) { + throw missingNode(initial) } - - for (const nodeIndex of initials) { - if (inDegree.get(nodeIndex)! !== 0) { - throw new GraphError({ message: `Initial node ${nodeIndex} has incoming edges` }) - } + if (inDegree[node] !== 0) { + throw new GraphError({ message: `Initial node ${initial} has incoming edges` }) } - - // Add remaining zero in-degree nodes after prioritized initials. - for (const [nodeIndex, degree] of inDegree) { - if (degree === 0 && !initialSet.has(nodeIndex)) { - queue.push(nodeIndex) - } + initialSet[node] = 1 + queue.push(node) + } + for (let node = 0; node < cache.nodeIds.length; node++) { + if (inDegree[node] === 0 && initialSet[node] === 0) { + queue.push(node) } + } - const nextMapped = () => { - while (queue.length > 0) { - const current = queue.shift()! - - if (remaining.has(current)) { - remaining.delete(current) - - // Process outgoing edges, reducing in-degree of targets - const neighbors = getDirectedNeighbors( - graph as Graph | MutableGraph, - current, - "outgoing" - ) - for (const neighbor of neighbors) { - if (remaining.has(neighbor)) { - const currentInDegree = inDegree.get(neighbor) || 0 - const newInDegree = currentInDegree - 1 - inDegree.set(neighbor, newInDegree) - - // If in-degree becomes 0, add to queue - if (newInDegree === 0) { - queue.push(neighbor) - } + return { + next() { + while (head < queue.length) { + const current = queue[head++] + if (remaining[current] === 0) { + continue + } + remaining[current] = 0 + remainingCount-- + + for (let i = outgoing.rowOffsets[current]; i < outgoing.rowOffsets[current + 1]; i++) { + const neighbor = outgoing.columnIndices[i] + if (remaining[neighbor] !== 0) { + const degree = --inDegree[neighbor] + if (degree === 0) { + queue.push(neighbor) } } - - const nodeData = getNode(graph, current) - if (Option.isSome(nodeData)) { - return { done: false, value: f(current, nodeData.value) } - } - continue } + + return { done: false, value: f(cache.nodeIds[current], cache.nodeData[current] as N) } } - if (remaining.size > 0) { + if (remainingCount > 0) { throw new GraphError({ message: "Cannot perform topological sort on cyclic graph" }) } - return { done: true, value: undefined } as const } - - return { next: nextMapped } } - })) + }) }) /** * Creates a lazy depth-first postorder traversal iterator from the configured * start nodes. * + * **When to use** + * + * Use when reachable descendants must be emitted before the nodes that lead to + * them. + * * **Details** * * Nodes are emitted after their reachable descendants have been processed. If * no start nodes are supplied, the iterator is empty. The `direction` option * chooses whether to follow outgoing or incoming edges. The `radius` option - * limits traversal by edge distance from the start nodes. + * limits traversal by edge distance from the start nodes. It accepts + * non-negative integers and `Infinity`; omitting it means unbounded traversal. + * With a finite `radius`, a bounded breadth-first pass first determines + * shortest-distance membership before nodes are emitted in postorder. * * **Gotchas** * - * With a finite `radius`, iteration first performs a bounded breadth-first - * traversal to determine shortest-distance membership before emitting nodes in - * postorder. + * Invalid radii and missing start nodes throw a `GraphError`. Traversing a + * mutable graph captures a snapshot when iteration begins; later mutations are + * not observed by that iterator. * * **Example** (Traversing in postorder) * @@ -5547,6 +8197,7 @@ export const topo: { * Array.from(Graph.indices(Graph.dfsPostOrder(graph, { start: [0] }))) // => [1, 2, 0] * ``` * + * @see {@link dfs} for emitting nodes when first visited * @category iterators * @since 3.18.0 */ @@ -5562,84 +8213,117 @@ export const dfsPostOrder: { graph: Graph | MutableGraph, config: SearchConfig = {} ): NodeWalker => { - const start = config.start ?? [] + const radius = traversalRadius(config.radius, Infinity) + const start = traversalStarts(graph, config.start) const direction = config.direction ?? "outgoing" - const radius = config.radius ?? Infinity - - // Validate that all start nodes exist - for (const nodeIndex of start) { - if (!hasNode(graph, nodeIndex)) { - throw missingNode(nodeIndex) - } - } - return new Walker((f) => ({ - [Symbol.iterator]: () => { - const reached = radius === Infinity - ? undefined - : new Set(indices(bfs(graph, { start, direction, radius }))) - const stack: Array<{ node: NodeIndex; visitedChildren: boolean }> = [] - const discovered = new Set() - const finished = new Set() - - // Initialize stack with start nodes - for (let i = start.length - 1; i >= 0; i--) { - stack.push({ node: start[i], visitedChildren: false }) + return makeCsrNodeWalker(graph, (cache, f) => { + const startPositions = traversalStartPositions(cache, start) + const view = csr.getAdjacencies(cache, direction) + let reached: Uint8Array | undefined + if (radius !== Infinity) { + // Radius is shortest edge distance, so determine membership with BFS before imposing postorder. + const boundedReached = new Uint8Array(cache.nodeIds.length) + const queue = new Uint32Array(cache.nodeIds.length) + const depths = new Uint32Array(cache.nodeIds.length) + let head = 0 + let tail = 0 + + for (const position of startPositions) { + if (boundedReached[position] === 0) { + boundedReached[position] = 1 + queue[tail++] = position + } } - const nextMapped = () => { - while (stack.length > 0) { - const current = stack[stack.length - 1] + const enqueue = (targets: Uint32Array, offsets: Uint32Array, current: number, depth: number) => { + for (let i = offsets[current]; i < offsets[current + 1]; i++) { + const neighbor = targets[i] + if (boundedReached[neighbor] === 0) { + boundedReached[neighbor] = 1 + queue[tail] = neighbor + depths[tail++] = depth + 1 + } + } + } - if (!discovered.has(current.node)) { - discovered.add(current.node) - current.visitedChildren = false + while (head < tail) { + const current = queue[head] + const depth = depths[head++] + if (depth < radius) { + enqueue(view.primary.columnIndices, view.primary.rowOffsets, current, depth) + if (view.secondary !== undefined) { + enqueue(view.secondary.columnIndices, view.secondary.rowOffsets, current, depth) } + } + } + reached = boundedReached + } - if (!current.visitedChildren) { - current.visitedChildren = true - const neighbors = getTraversalNeighbors(graph, current.node, direction) - - for (let i = neighbors.length - 1; i >= 0; i--) { - const neighbor = neighbors[i] - if ( - (reached === undefined || reached.has(neighbor)) && - !discovered.has(neighbor) && - !finished.has(neighbor) - ) { - stack.push({ node: neighbor, visitedChildren: false }) - } - } - } else { - const nodeToEmit = stack.pop()!.node + const stack: Array = [] + const primaryPositions: Array = [] + const secondaryPositions: Array = [] + const discovered = new Uint8Array(cache.nodeIds.length) + + const push = (node: number) => { + if ((reached === undefined || reached[node] !== 0) && discovered[node] === 0) { + discovered[node] = 1 + stack.push(node) + primaryPositions.push(view.primary.rowOffsets[node]) + secondaryPositions.push(view.secondary?.rowOffsets[node] ?? 0) + } + } - if (!finished.has(nodeToEmit)) { - finished.add(nodeToEmit) + let startPosition = 0 - const nodeData = getNode(graph, nodeToEmit) - if (Option.isSome(nodeData)) { - return { done: false, value: f(nodeToEmit, nodeData.value) } - } + return { + next() { + while (true) { + while (stack.length === 0 && startPosition < startPositions.length) { + push(startPositions[startPosition++]) + } + if (stack.length === 0) { + return { done: true, value: undefined } as const + } + const index = stack.length - 1 + const current = stack[index] + const primaryPosition = primaryPositions[index] + if (primaryPosition < view.primary.rowOffsets[current + 1]) { + primaryPositions[index] = primaryPosition + 1 + push(view.primary.columnIndices[primaryPosition]) + continue + } + if (view.secondary !== undefined) { + const secondaryPosition = secondaryPositions[index] + if (secondaryPosition < view.secondary.rowOffsets[current + 1]) { + secondaryPositions[index] = secondaryPosition + 1 + push(view.secondary.columnIndices[secondaryPosition]) continue } } - } - return { done: true, value: undefined } as const + stack.pop() + primaryPositions.pop() + secondaryPositions.pop() + return { done: false, value: f(cache.nodeIds[current], cache.nodeData[current] as N) } + } } - - return { next: nextMapped } } - })) + }) }) /** - * Creates an iterator over all node indices in the graph. + * Creates a walker over all node index and payload entries in the graph. * * **Details** * - * The iterator produces node indices in the order they were added to the graph. - * This provides access to all nodes regardless of connectivity. + * Entries follow graph node order and include all nodes regardless of + * connectivity. Use `indices` or `values` to project one side of each entry. + * + * **Gotchas** + * + * Mutable graphs are not snapshotted; mutations may affect the remaining + * iteration. * * **Example** (Iterating all nodes) * @@ -5664,7 +8348,7 @@ export const nodes = ( ): NodeWalker => new Walker((f) => ({ [Symbol.iterator]() { - const nodeMap = graphImpl(graph).nodes + const nodeMap = internal.toImpl(graph).nodes const iterator = nodeMap.entries() return { @@ -5674,19 +8358,24 @@ export const nodes = ( return { done: true, value: undefined } } const [nodeIndex, nodeData] = result.value - return { done: false, value: f(nodeIndex, nodeData) } + return { done: false, value: withMutationGuard(graph, () => f(nodeIndex, nodeData)) } } } } })) /** - * Creates an iterator over all edge indices in the graph. + * Creates a walker over all edge index and edge entries in the graph. * * **Details** * - * The iterator produces edge indices in the order they were added to the graph. - * This provides access to all edges regardless of connectivity. + * Entries follow graph edge order and include all edges regardless of + * connectivity. Use `indices` or `values` to project one side of each entry. + * + * **Gotchas** + * + * Mutable graphs are not snapshotted; mutations may affect the remaining + * iteration. * * **Example** (Iterating all edges) * @@ -5712,7 +8401,7 @@ export const edges = ( ): EdgeWalker => new Walker((f) => ({ [Symbol.iterator]() { - const edgeMap = graphImpl(graph).edges + const edgeMap = internal.toImpl(graph).edges const iterator = edgeMap.entries() return { @@ -5722,7 +8411,7 @@ export const edges = ( return { done: true, value: undefined } } const [edgeIndex, edgeData] = result.value - return { done: false, value: f(edgeIndex, edgeData) } + return { done: false, value: withMutationGuard(graph, () => f(edgeIndex, copyEdge(edgeData))) } } } } @@ -5743,9 +8432,7 @@ export const edges = ( * nodes with no incoming edges. If omitted, `direction` defaults to * `"outgoing"`. * - * @see {@link externals} for the iterator that consumes this configuration - * - * @category models + * @category configuration * @since 3.18.0 */ export interface ExternalsConfig { @@ -5755,11 +8442,20 @@ export interface ExternalsConfig { /** * Creates an iterator over external nodes (nodes without edges in the specified direction). * + * **When to use** + * + * Use when locating sources, sinks, or isolated boundary nodes. + * * **Details** * * External nodes have no outgoing edges (`direction: "outgoing"`) or no - * incoming edges (`direction: "incoming"`). These are useful for finding - * sources, sinks, or isolated nodes. + * incoming edges (`direction: "incoming"`). + * + * **Gotchas** + * + * For undirected graphs, incoming and outgoing adjacency are equivalent, so + * only isolated nodes are external. Mutable graphs are not snapshotted; + * mutations may affect the remaining iteration. * * **Example** (Iterating external nodes) * @@ -5802,7 +8498,7 @@ export const externals: { return new Walker((f) => ({ [Symbol.iterator]: () => { - const impl = graphImpl(graph) + const impl = internal.toImpl(graph) const nodeMap = impl.nodes const adjacencyMap = direction === "incoming" ? impl.reverseAdjacency @@ -5818,7 +8514,7 @@ export const externals: { // Node is external if it has no edges in the specified direction if (adjacencyList === undefined || adjacencyList.length === 0) { - return { done: false, value: f(nodeIndex, nodeData) } + return { done: false, value: withMutationGuard(graph, () => f(nodeIndex, nodeData)) } } current = nodeIterator.next() } diff --git a/repos/effect/packages/effect/src/Inspectable.ts b/repos/effect/packages/effect/src/Inspectable.ts index e2c06932a2..47843fffac 100644 --- a/repos/effect/packages/effect/src/Inspectable.ts +++ b/repos/effect/packages/effect/src/Inspectable.ts @@ -130,19 +130,20 @@ export interface Inspectable { } /** - * Converts a value to a JSON-serializable representation safely. + * Converts a value to its structured inspection representation. * * **When to use** * - * Use when you need a safe, JSON-serializable representation of a value + * Use when you need the structured representation of an inspectable value * without risking unhandled errors. * * **Details** * - * This function attempts to extract JSON data from objects that implement the - * `toJSON` method, recursively processes arrays, and handles errors gracefully. - * For objects that don't have a `toJSON` method, it applies redaction to - * protect sensitive information. + * This function applies redaction before extracting data from objects that + * implement `toJSON`, recursively processes arrays, and handles errors + * gracefully. Plain objects are returned unchanged, so the result is not + * guaranteed to be accepted by `JSON.stringify`; it may still contain values + * such as `BigInt`, functions, or circular references. * * @see {@link toStringUnknown} for converting unknown values to strings * @@ -151,6 +152,7 @@ export interface Inspectable { */ export const toJson = (input: unknown): unknown => { try { + input = redact(input) if ( Predicate.hasProperty(input, "toJSON") && Predicate.isFunction(input["toJSON"]) && @@ -160,10 +162,10 @@ export const toJson = (input: unknown): unknown => { } else if (Array.isArray(input)) { return input.map(toJson) } + return input } catch { return "[toJSON threw]" } - return redact(input) } /** @@ -187,7 +189,7 @@ export const toStringUnknown = (u: unknown, whitespace: number | string | undefi return u } try { - return typeof u === "object" ? formatJson(u, { space: whitespace }) : String(u) + return typeof u === "object" ? formatJson(u, { space: whitespace }) : format(u, { space: whitespace }) } catch { return String(u) } diff --git a/repos/effect/packages/effect/src/Iterable.ts b/repos/effect/packages/effect/src/Iterable.ts index 39e588f8b0..8529204a26 100644 --- a/repos/effect/packages/effect/src/Iterable.ts +++ b/repos/effect/packages/effect/src/Iterable.ts @@ -1276,6 +1276,13 @@ export const group: (self: Iterable) => Iterable> = group * that produced that key. Unlike `group`, matching elements do not need to be * consecutive. * + * **Gotchas** + * + * When the key function returns a finite union of string literals or unique + * symbols, the result preserves those keys as optional properties because the + * input may not produce every key. Open `string` and `symbol` key types retain + * their record index signatures. + * * **Example** (Grouping by a key) * * ```ts import.meta.vitest @@ -1313,15 +1320,15 @@ export const group: (self: Iterable) => Iterable> = group export const groupBy: { ( f: (a: A) => K - ): (self: Iterable) => Record, NonEmptyArray> + ): (self: Iterable) => Record.ReadonlyRecord.GroupByResult> ( self: Iterable, f: (a: A) => K - ): Record, NonEmptyArray> + ): Record.ReadonlyRecord.GroupByResult> } = dual(2, ( self: Iterable, f: (a: A) => K -): Record, NonEmptyArray> => { +): Record.ReadonlyRecord.GroupByResult> => { const out: Record> = {} for (const a of self) { const k = f(a) diff --git a/repos/effect/packages/effect/src/JsonSchema.ts b/repos/effect/packages/effect/src/JsonSchema.ts index 5fb6c0c300..054b8e6b65 100644 --- a/repos/effect/packages/effect/src/JsonSchema.ts +++ b/repos/effect/packages/effect/src/JsonSchema.ts @@ -4,15 +4,13 @@ * OpenAPI 3.0, and OpenAPI 3.1; conversions normalize through * `Document<"draft-2020-12">` before emitting another dialect, including * JSON Schema Draft-04. The module also defines document types, meta-schema - * constants, OpenAPI component-key helpers, and `$ref` resolution utilities. + * constants, and OpenAPI component-key helpers. * * @since 4.0.0 */ -import * as Arr from "./Array.ts" import * as InternalRecord from "./internal/record.ts" -import { unescapeToken } from "./JsonPointer.ts" +import { escapeToken, unescapeToken } from "./JsonPointer.ts" import * as Predicate from "./Predicate.ts" -import * as Rec from "./Record.ts" /** * A plain object representing a single JSON Schema node. @@ -83,8 +81,6 @@ export type Type = "string" | "number" | "boolean" | "array" | "object" | "null" * * @see {@link Document} for a single root schema with definitions * @see {@link MultiDocument} for multiple root schemas sharing definitions - * @see {@link resolve$ref} for resolving a `$ref` against definitions - * * @category models * @since 4.0.0 */ @@ -215,47 +211,20 @@ export const META_SCHEMA_URI_DRAFT_07 = "http://json-schema.org/draft-07/schema# */ export const META_SCHEMA_URI_DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema" -const RE_DEFINITIONS = /^#\/definitions(?=\/|$)/ -const RE_DEFS = /^#\/\$defs(?=\/|$)/ -const RE_COMPONENTS_SCHEMAS = /^#\/components\/schemas(?=\/|$)/ - -const DRAFT_04_COPY_KEYWORDS = new Set([ - "$ref", - "type", - "required", - "enum", - "title", - "description", - "default", - "format", - "pattern", - "minLength", - "maxLength", - "minItems", - "maxItems", - "minProperties", - "maxProperties", - "multipleOf", - "uniqueItems" -]) +const META_SCHEMA_URI_OPEN_API_3_1 = "https://spec.openapis.org/oas/3.1/dialect/base" -const DRAFT_07_COPY_KEYWORDS = new Set([ - ...DRAFT_04_COPY_KEYWORDS, - "const", - "examples", - "readOnly", - "writeOnly", - "minimum", - "maximum", - "exclusiveMinimum", - "exclusiveMaximum" -]) +function isMetaSchemaUri(value: unknown, uri: string): boolean { + return value === uri || value === (uri.endsWith("#") ? uri.slice(0, -1) : `${uri}#`) +} -const DRAFT_04_SINGLE_SUBSCHEMA_KEYWORDS = new Set(["not"]) -const DRAFT_07_SINGLE_SUBSCHEMA_KEYWORDS = new Set(["not", "additionalProperties", "propertyNames"]) +function rewriteOpenApiComponentsReference(reference: string): string { + const path = reference.startsWith("#") ? parsePointerFragment(reference) : undefined + return path !== undefined && path[0] === "components" && path[1] === "schemas" + ? formatPointerFragment(["$defs", ...path.slice(2)]) + : reference +} -const MAP_SUBSCHEMA_KEYWORDS = new Set(["properties", "patternProperties"]) -const ARRAY_SUBSCHEMA_KEYWORDS = new Set(["allOf", "anyOf", "oneOf"]) +const OPEN_API_31_TARGET_COLLISIONS = ["example", "discriminator", "xml", "externalDocs"] /** * Parses a raw Draft-07 JSON Schema into a `Document<"draft-2020-12">`. @@ -268,13 +237,21 @@ const ARRAY_SUBSCHEMA_KEYWORDS = new Set(["allOf", "anyOf", "oneOf"]) * **Details** * * This converts Draft-07 tuple syntax (`items` as array plus - * `additionalItems`) to Draft-2020-12 form (`prefixItems` plus `items`), - * rewrites `#/definitions/...` refs to `#/$defs/...`, and extracts root-level - * `definitions` into the `definitions` field. + * `additionalItems`) to Draft-2020-12 form (`prefixItems` plus `items`), splits + * `dependencies` into `dependentRequired` and `dependentSchemas`, converts + * plain-name `$id` fragments to `$anchor`, and extracts root-level + * `definitions` into the `definitions` field. Local JSON Pointer refs are + * relocated when one of these structural conversions moves its target. * * **Gotchas** * - * Unsupported keywords, such as `if`/`then`/`else` and `$id`, are dropped. + * Unknown and custom keywords are copied as opaque values. Their contents are + * not treated as nested schemas. Draft-07 keywords such as `if` / `then` / + * `else` and `contains` are preserved and their subschemas are converted. + * Siblings of a valid Draft-07 `$ref` are ignored according to Draft-07 + * semantics. The conversion throws when a Draft-07 `$id` fragment cannot be + * represented as a Draft-2020-12 `$anchor`, or when an unknown Draft-07 + * keyword would become an active Draft-2020-12 keyword after copying. * * **Example** (Parsing a Draft-07 schema) * @@ -303,76 +280,7 @@ const ARRAY_SUBSCHEMA_KEYWORDS = new Set(["allOf", "anyOf", "oneOf"]) * @since 4.0.0 */ export function fromSchemaDraft07(js: JsonSchema): Document<"draft-2020-12"> { - let definitions: Definitions | undefined - - const schema = walk(js, true) as JsonSchema - return { - dialect: "draft-2020-12", - schema, - definitions: definitions ?? {} - } - - function walk(node: unknown, isRoot: boolean): unknown { - if (Array.isArray(node)) return node.map(walkNested) - if (!Predicate.isObject(node)) return node - - const out: Record = {} - - let prefixItems: unknown = undefined - let additionalItems: unknown = undefined - - for (const k of Object.keys(node)) { - const v = node[k] - - if (k === "$ref") { - out.$ref = typeof v === "string" ? v.replace(RE_DEFINITIONS, "#/$defs") : v - continue - } - if (DRAFT_07_COPY_KEYWORDS.has(k)) { - out[k] = v - continue - } - if (rewriteSubschemaKeyword(out, k, v, walkNested, DRAFT_07_SINGLE_SUBSCHEMA_KEYWORDS)) continue - - switch (k) { - case "definitions": { - const mapped = mapObject(v, walkNested) - if (isRoot) { - definitions = mapped as Definitions | undefined - } else { - out.definitions = mapped ?? v - } - break - } - - case "items": - prefixItems = v - break - case "additionalItems": - additionalItems = v - break - - default: - break - } - } - - // Draft-07 tuples -> 2020-12 tuples - if (prefixItems !== undefined) { - if (Array.isArray(prefixItems)) { - out.prefixItems = prefixItems.map(walkNested) - if (additionalItems !== undefined) out.items = walkNested(additionalItems) - } else { - out.items = walkNested(prefixItems) - } - } - - return out - } - - function walkNested(node: unknown): unknown { - return walk(node, false) - } + return fromSchemaDraft2020_12(convertDraft07(js)) } /** @@ -427,8 +335,17 @@ export function fromSchemaDraft2020_12(js: JsonSchema): Document<"draft-2020-12" * * **Details** * - * This rewrites `#/components/schemas/...` refs to `#/$defs/...`, then delegates - * to {@link fromSchemaDraft2020_12}. + * This rewrites `#/components/schemas/...` refs to `#/$defs/...`, normalizes the + * OpenAPI base dialect URI to Draft 2020-12, converts the deprecated singular + * `example` field to `examples`, then delegates to + * {@link fromSchemaDraft2020_12}. + * + * **Gotchas** + * + * When both `example` and `examples` are present, the singular example is + * prepended to the array. Custom `$schema` dialect URIs and unknown keywords + * are copied opaquely. Component references inside a schema resource identified + * by `$id` are left unchanged because they are relative to that resource. * * **Example** (Parsing an OpenAPI 3.1 schema) * @@ -452,7 +369,20 @@ export function fromSchemaDraft2020_12(js: JsonSchema): Document<"draft-2020-12" * @since 4.0.0 */ export function fromSchemaOpenApi3_1(js: JsonSchema): Document<"draft-2020-12"> { - const schema = rewriteRefs(js, (ref) => ref.replace(RE_COMPONENTS_SCHEMAS, "#/$defs")) + const isRootResource = createsResource(js.$id) + const schema = transformSchema(js, (schema, inEmbeddedResource) => { + if (!isRootResource && !inEmbeddedResource) rewriteSchemaRef(schema, rewriteOpenApiComponentsReference) + if (isMetaSchemaUri(schema.$schema, META_SCHEMA_URI_OPEN_API_3_1)) { + InternalRecord.assignProperty(schema, "$schema", META_SCHEMA_URI_DRAFT_2020_12) + } + if (Object.hasOwn(schema, "example")) { + const examples = schema.examples + if (examples === undefined || Array.isArray(examples)) { + InternalRecord.assignProperty(schema, "examples", [schema.example, ...(examples ?? [])]) + delete schema.example + } + } + }) as JsonSchema return fromSchemaDraft2020_12(schema) } @@ -466,10 +396,19 @@ export function fromSchemaOpenApi3_1(js: JsonSchema): Document<"draft-2020-12"> * * **Details** * - * This handles OpenAPI 3.0 extensions, including `nullable`, singular - * `example`, and boolean `exclusiveMinimum` or `exclusiveMaximum`. It - * normalizes the schema to Draft-07 first, then converts to Draft-2020-12 via - * {@link fromSchemaDraft07}. + * This directly converts OpenAPI 3.0 schema objects to Draft-2020-12. It + * handles `nullable`, singular `example`, boolean `exclusiveMinimum` and + * `exclusiveMaximum`, and OpenAPI component refs. Only values in OpenAPI + * schema positions are traversed as schemas. + * + * **Gotchas** + * + * OpenAPI 3.0 `nullable` is applied only when the same Schema Object has an + * explicit string `type`; other constraints such as `enum` are left + * unchanged. Unknown keywords, vendor extensions, and annotation values are + * copied opaquely unless their name would become active in Draft 2020-12 and + * change meaning, in which case conversion throws. Siblings of a valid + * OpenAPI 3.0 `$ref` are ignored. * * **Example** (Parsing an OpenAPI 3.0 nullable schema) * @@ -491,8 +430,7 @@ export function fromSchemaOpenApi3_1(js: JsonSchema): Document<"draft-2020-12"> * @since 4.0.0 */ export function fromSchemaOpenApi3_0(schema: JsonSchema): Document<"draft-2020-12"> { - const normalized = normalizeOpenApi3_0ToDraft07(schema) - return fromSchemaDraft07(normalized as JsonSchema) + return fromSchemaDraft2020_12(convertOpenApi30(schema)) } /** @@ -507,12 +445,20 @@ export function fromSchemaOpenApi3_0(schema: JsonSchema): Document<"draft-2020-1 * * This rewrites `#/$defs/...` refs to `#/definitions/...`, converts * Draft-2020-12 tuple syntax (`prefixItems` plus `items`) to Draft-07 form - * (`items` as array plus `additionalItems`), and converts both the root schema - * and all definitions. + * (`items` as array plus `additionalItems`), merges `dependentRequired` and + * `dependentSchemas` into `dependencies`, and converts both the root schema + * and all definitions. Local JSON Pointer refs are relocated when structural + * keywords move. * * **Gotchas** * - * Unsupported Draft-2020-12 keywords are dropped. + * Unknown and custom keywords are copied as opaque values. Known keywords + * that Draft-07 cannot represent cause the conversion to throw + * instead of being dropped. These include dynamic references, + * `unevaluatedProperties`, `unevaluatedItems`, and non-default `minContains` + * or `maxContains` constraints. Conversion also throws when an opaque + * Draft-2020-12 keyword would collide with an active Draft-07 keyword, or when + * `$id` and `$anchor` occur together because Draft-07 cannot preserve both identifiers. * * **Example** (Converting to Draft-07) * @@ -540,8 +486,7 @@ export function fromSchemaOpenApi3_0(schema: JsonSchema): Document<"draft-2020-1 export function toDocumentDraft07(document: Document<"draft-2020-12">): Document<"draft-07"> { return { dialect: "draft-07", - schema: toSchemaDraft07(document.schema), - definitions: Rec.map(document.definitions, toSchemaDraft07) + ...convertDocument(document, draft07Adapter) } } @@ -555,14 +500,23 @@ export function toDocumentDraft07(document: Document<"draft-2020-12">): Document * * **Details** * - * This rewrites `#/$defs/...` refs to `#/definitions/...`, converts tuple - * syntax, lowers `const` to `enum`, converts numeric exclusive bounds to the - * Draft-04 boolean form, and converts both the root schema and all definitions. + * This directly rewrites `#/$defs/...` refs to `#/definitions/...`, converts + * tuple syntax, merges canonical dependencies, lowers `const` to `enum`, + * converts numeric exclusive bounds to the Draft-04 boolean form, lowers + * conditionals and basic `contains` through boolean applicators, and converts + * both the root schema and all definitions. * * **Gotchas** * - * Unsupported Draft-2020-12 and Draft-07 keywords are dropped. For example, - * `propertyNames` has no general Draft-04 equivalent and is omitted. + * Unknown and custom keywords are copied as opaque values. Newer annotation + * keywords are preserved as Draft-04 extensions. Known keywords + * without a Draft-04 equivalent, including `propertyNames`, non-default + * `contains` cardinality, dynamic references, and unevaluated constraints, + * cause the conversion to throw instead of being dropped. A conditional with + * both branches also throws when lowering it would duplicate a nested schema + * identifier. Conversion also throws when an opaque Draft-2020-12 keyword + * would collide with an active Draft-04 keyword, or when `$id` and `$anchor` + * occur together because Draft-04 cannot preserve both identifiers. * * **Example** (Converting exclusive bounds) * @@ -582,166 +536,14 @@ export function toDocumentDraft07(document: Document<"draft-2020-12">): Document * @since 4.0.0 */ export function toDocumentDraft04(document: Document<"draft-2020-12">): Document<"draft-04"> { - const draft07 = toDocumentDraft07(document) return { dialect: "draft-04", - schema: toSchemaDraft04(draft07.schema), - definitions: Rec.map(draft07.definitions, toSchemaDraft04) - } -} - -function toSchemaDraft04(schema: JsonSchema): JsonSchema { - return walk(schema) as JsonSchema - - function walk(node: unknown): unknown { - if (node === true) return {} - if (node === false) return { not: {} } - if (Array.isArray(node)) return node.map(walk) - if (!Predicate.isObject(node)) return node - - const src = node as Record - const out: Record = {} - - let hasConst = false - let constValue: unknown = undefined - - for (const k of Object.keys(src)) { - const v = src[k] - - if (DRAFT_04_COPY_KEYWORDS.has(k)) { - out[k] = v - continue - } - if (rewriteSubschemaKeyword(out, k, v, walk, DRAFT_04_SINGLE_SUBSCHEMA_KEYWORDS)) continue - - switch (k) { - case "const": - hasConst = true - constValue = v - break - - case "minimum": - case "maximum": - case "exclusiveMinimum": - case "exclusiveMaximum": - break - - case "additionalProperties": - case "additionalItems": - out[k] = typeof v === "boolean" ? v : walk(v) - break - - case "items": - out.items = Array.isArray(v) ? v.map(walk) : walk(v) - break - - default: - break - } - } - - convertExclusiveBound(src, out, "minimum", "exclusiveMinimum", (bound, exclusive) => bound > exclusive) - convertExclusiveBound(src, out, "maximum", "exclusiveMaximum", (bound, exclusive) => bound < exclusive) - - if (hasConst) { - const constSchema = { enum: [constValue] } - if (Object.hasOwn(src, "enum")) { - out.allOf = Array.isArray(out.allOf) ? [...out.allOf, constSchema] : [constSchema] - } else { - out.enum = constSchema.enum - } - } - - return out - } -} - -function convertExclusiveBound( - src: Record, - out: Record, - boundKey: "minimum" | "maximum", - exclusiveKey: "exclusiveMinimum" | "exclusiveMaximum", - isBoundStricter: (bound: number, exclusive: number) => boolean -): void { - const bound = src[boundKey] - const exclusive = src[exclusiveKey] - - if (typeof exclusive === "number") { - if (typeof bound === "number" && isBoundStricter(bound, exclusive)) { - out[boundKey] = bound - } else { - out[boundKey] = exclusive - out[exclusiveKey] = true - } - } else if (bound !== undefined) { - out[boundKey] = bound + ...convertDocument(document, draft04Adapter, { + booleanAdapter: (schema) => schema ? {} : { not: {} } + }) } } -function toSchemaDraft07(schema: JsonSchema): JsonSchema { - return transformSchema(schema, (src) => { - rewriteSchemaRef(src, (ref) => ref.replace(RE_DEFS, "#/definitions")) - const out: Record = {} - - let prefixItems: unknown = undefined - let items: unknown = undefined - - for (const k of Object.keys(src)) { - const v = src[k] - - if (k === "required" && Array.isArray(v) && v.length === 0) continue - if (DRAFT_07_COPY_KEYWORDS.has(k)) { - out[k] = v - continue - } - if ( - MAP_SUBSCHEMA_KEYWORDS.has(k) || - ARRAY_SUBSCHEMA_KEYWORDS.has(k) || - DRAFT_07_SINGLE_SUBSCHEMA_KEYWORDS.has(k) - ) { - out[k] = v - continue - } - - switch (k) { - // Tuple handling (2020-12 form) - case "prefixItems": - prefixItems = v - break - case "items": - items = v - break - - default: - // drop everything else (subset) - break - } - } - - // 2020-12 tuples -> Draft-07 tuples - if (prefixItems !== undefined) { - if (Array.isArray(prefixItems)) { - out.items = prefixItems - if (items !== undefined) out.additionalItems = items - } else { - // Non-standard, but keep a reasonable behavior - out.items = prefixItems - } - } else if (items !== undefined) { - // Regular items schema stays as items - out.items = items - } - - const $ref = out.$ref - if (typeof $ref === "string" && Object.keys(out).length > 1) { - delete out.$ref - out.allOf = [{ $ref }, ...(Array.isArray(out.allOf) ? out.allOf : [])] - } - - return out - }) as JsonSchema -} - /** * Converts a `MultiDocument<"draft-2020-12">` to a * `MultiDocument<"openapi-3.1">`. @@ -763,7 +565,12 @@ function toSchemaDraft07(schema: JsonSchema): JsonSchema { * * **Gotchas** * - * External refs and local refs outside `#/$defs` are left unchanged. + * External refs and local refs outside `#/$defs` are left unchanged. Conversion + * throws when a custom keyword would become an active OpenAPI keyword and + * therefore change meaning. References inside schema resources identified by + * `$id` are left unchanged. Conversion throws when an identified root schema + * references the detached shared definitions pool because OpenAPI cannot + * preserve that fragment reference. * * **Example** (Converting to OpenAPI 3.1) * @@ -811,26 +618,52 @@ export function toMultiDocumentOpenApi3_1(multiDocument: MultiDocument<"draft-20 keyMap.set(key, candidate) } - function rewrite(schema: JsonSchema): JsonSchema { - return rewriteRefs(schema, ($ref) => { - if (!$ref.startsWith("#/$defs/")) return $ref + function rewrite( + schema: JsonSchema, + rejectSharedDefinitionRefs = false + ): JsonSchema { + const isRootResource = createsResource(schema.$id) + const localDefinitions = Predicate.isObject(schema.$defs) ? schema.$defs : undefined + return transformSchema(schema, (schema, inEmbeddedResource) => { + rejectKeywordCollisions(schema, OPEN_API_31_TARGET_COLLISIONS, "OpenAPI 3.1", "Draft 2020-12") + rewriteSchemaRef(schema, (reference, keyword) => { + const path = reference.startsWith("#") ? parsePointerFragment(reference) : undefined + if (path === undefined || path[0] !== "$defs" || path.length < 2) return reference + const key = path[1] + if (isRootResource) { + if ( + rejectSharedDefinitionRefs && + !inEmbeddedResource && + Object.hasOwn(multiDocument.definitions, key) && + (localDefinitions === undefined || !Object.hasOwn(localDefinitions, key)) + ) { + unsupported(keyword, "OpenAPI 3.1", "a schema resource cannot reference the shared definitions pool") + } + return reference + } + return inEmbeddedResource + ? reference + : formatPointerFragment(["components", "schemas", keyMap.get(key) ?? key, ...path.slice(2)]) + }) + }) as JsonSchema + } - const path = $ref.slice("#/$defs/".length) - const separatorIndex = path.indexOf("/") - const token = separatorIndex === -1 ? path : path.slice(0, separatorIndex) - const rest = separatorIndex === -1 ? "" : path.slice(separatorIndex) - const key = keyMap.get(unescapeToken(token)) ?? token - return `#/components/schemas/${key}${rest}` - }) + const schemas = multiDocument.schemas.map((schema) => rewrite(schema, true)) as unknown as MultiDocument< + "openapi-3.1" + >["schemas"] + const definitions: Definitions = {} + for (const key of definitionKeys) { + InternalRecord.assignProperty( + definitions, + keyMap.get(key) ?? key, + rewrite(multiDocument.definitions[key]) + ) } return { dialect: "openapi-3.1", - schemas: Arr.map(multiDocument.schemas, rewrite), - definitions: Rec.mapEntries( - multiDocument.definitions, - (definition, key) => [keyMap.get(key) ?? key, rewrite(definition)] - ) + schemas, + definitions } } @@ -847,14 +680,23 @@ export function sanitizeOpenApiComponentsSchemasKey(s: string): string { return s.length === 0 ? "_" : s.replace(/[^a-zA-Z0-9._-]/gu, "_") } +/** @internal */ +export function getReferenceKey($ref: string): string | undefined { + const path = $ref.startsWith("#") ? parsePointerFragment($ref) : undefined + return path !== undefined && path.length === 2 && path[0] === "$defs" + ? path[1] + : undefined +} + function transformSchema( node: unknown, - transform: (schema: Record) => Record + transform: (schema: Record, inEmbeddedResource: boolean) => void ): unknown { - return walk(node) + return walk(node, false, true) - function walk(node: unknown): unknown { + function walk(node: unknown, inheritedResource: boolean, isRoot = false): unknown { if (!Predicate.isObject(node)) return node + const inEmbeddedResource = inheritedResource || (!isRoot && createsResource(node.$id)) const out: Record = {} for (const key of Object.keys(node)) { @@ -865,13 +707,13 @@ function transformSchema( case "properties": case "patternProperties": case "dependentSchemas": - transformed = mapObject(value, walk) ?? value + transformed = mapObject(value, (value) => walk(value, inEmbeddedResource)) ?? value break case "allOf": case "anyOf": case "oneOf": case "prefixItems": - transformed = Array.isArray(value) ? value.map(walk) : value + transformed = Array.isArray(value) ? value.map((value) => walk(value, inEmbeddedResource)) : value break case "not": case "additionalProperties": @@ -884,222 +726,920 @@ function transformSchema( case "then": case "else": case "contentSchema": - transformed = walk(value) + transformed = walk(value, inEmbeddedResource) } InternalRecord.assignProperty(out, key, transformed) } - return transform(out) + transform(out, inEmbeddedResource) + return out } } /** @internal */ export function rewriteRefs(schema: JsonSchema, rewrite: ($ref: string) => string): JsonSchema { - return transformSchema(schema, (schema) => rewriteSchemaRef(schema, rewrite)) as JsonSchema + return transformSchema(schema, (schema) => { + rewriteSchemaRef(schema, rewrite) + }) as JsonSchema } function rewriteSchemaRef( schema: Record, - rewrite: ($ref: string) => string -): Record { + rewrite: ($ref: string, keyword: "$ref" | "$dynamicRef") => string +): void { if (typeof schema.$ref === "string") { - InternalRecord.assignProperty(schema, "$ref", rewrite(schema.$ref)) + InternalRecord.assignProperty(schema, "$ref", rewrite(schema.$ref, "$ref")) + } + if (typeof schema.$dynamicRef === "string") { + InternalRecord.assignProperty(schema, "$dynamicRef", rewrite(schema.$dynamicRef, "$dynamicRef")) + } +} + +function mapObject( + value: unknown, + f: (node: unknown, key: string) => unknown +): Record | undefined { + if (!Predicate.isObject(value)) return undefined + const out: Record = {} + for (const key of Object.keys(value)) { + InternalRecord.assignProperty(out, key, f(value[key], key)) + } + return out +} + +type Path = ReadonlyArray +type Convert = (root: unknown, sourcePath?: Path, targetPath?: Path) => unknown + +interface Context { + readonly isDocumentRoot: boolean + readonly schema: (value: unknown, sourceKey: string, targetKey?: string) => unknown + readonly schemaAt: (value: unknown, sourcePath: Path, targetPath: Path) => unknown + readonly schemaArray: (value: unknown, sourceKey: string, targetKey?: string) => unknown + readonly schemaMap: (value: unknown, sourceKey: string, targetKey?: string) => unknown + readonly reference: (out: JsonSchema, value: unknown) => void +} + +type Adapter = (schema: JsonSchema, context: Context) => JsonSchema + +type PendingReference = readonly [out: JsonSchema, value: string, sourceResource: string] + +interface ResourceScope { + readonly parent?: ResourceScope + readonly sourceRoot: Path + readonly targetRoot: Path + readonly uri: string +} + +interface ConverterOptions { + readonly booleanAdapter?: (schema: boolean) => JsonSchema | boolean + readonly trackIds?: boolean + readonly ignoreRefSiblings?: boolean +} + +// Adapters decide which values are schemas. The kernel only handles recursion, +// resource scopes, and reference relocation between structural source/target paths. +function runConverter(adapter: Adapter, options: ConverterOptions | undefined, use: (convert: Convert) => A): A { + const locations = new Map() + const references: Array = [] + let rootUri = ROOT_URI + + function convert(root: unknown, sourcePath: Path = [], targetPath: Path = []): unknown { + if (sourcePath.length === 0 && options?.trackIds) { + const id = Predicate.isObject(root) ? getResourceId(root) : undefined + rootUri = resolveResourceUri(id, ROOT_URI) ?? ROOT_URI + } + return loop(root, sourcePath, targetPath, { sourceRoot: [], targetRoot: [], uri: rootUri }) + } + + function finish(): void { + for (const [out, value, sourceResource] of references) { + let reference = value + const resolved = resolveUrl(value, sourceResource) + if (resolved !== undefined) { + const sourcePointer = parsePointerFragment(resolved.hash) + resolved.hash = "" + if (sourcePointer !== undefined) { + const targetPath = locations.get(locationKey(resolved.href, sourcePointer)) + if (targetPath !== undefined) reference = relocateReference(value, targetPath) + } + } + InternalRecord.assignProperty(out, "$ref", reference) + } + } + + const out = use(convert) + finish() + return out + + function loop( + node: unknown, + sourcePath: Path, + targetPath: Path, + resourceScope: ResourceScope + ): unknown { + if (typeof node === "boolean") { + recordLocations(sourcePath, targetPath, resourceScope) + return options?.booleanAdapter?.(node) ?? node + } + if (!Predicate.isObject(node)) return node + + let currentResourceScope = resourceScope + const id = getResourceId(node) + if (sourcePath.length > 0 && options?.trackIds && createsResource(id)) { + const uri = resolveResourceUri(id, resourceScope.uri) + if (uri !== undefined) { + currentResourceScope = { + parent: resourceScope, + sourceRoot: sourcePath, + targetRoot: targetPath, + uri + } + } + } + recordLocations(sourcePath, targetPath, currentResourceScope) + const currentResource = currentResourceScope.uri + + const context: Context = { + isDocumentRoot: sourcePath.length === 0, + schema(value, sourceKey, targetKey = sourceKey) { + return loop(value, [...sourcePath, sourceKey], [...targetPath, targetKey], currentResourceScope) + }, + schemaAt(value, sourceSuffix, targetSuffix) { + return loop(value, [...sourcePath, ...sourceSuffix], [...targetPath, ...targetSuffix], currentResourceScope) + }, + schemaArray(value, sourceKey, targetKey = sourceKey) { + return Array.isArray(value) + ? value.map((item, index) => + loop( + item, + [...sourcePath, sourceKey, String(index)], + [...targetPath, targetKey, String(index)], + currentResourceScope + ) + ) + : value + }, + schemaMap(value, sourceKey, targetKey = sourceKey) { + if (!Predicate.isObject(value)) return value + return mapObject(value, (item, key) => + loop( + item, + [...sourcePath, sourceKey, key], + [...targetPath, targetKey, key], + currentResourceScope + )) + }, + reference(out, value) { + if (typeof value === "string") { + references.push([out, value, currentResource]) + } else { + InternalRecord.assignProperty(out, "$ref", value) + } + } + } + return adapter(node, context) + } + + function getResourceId(schema: JsonSchema): unknown { + return options?.ignoreRefSiblings === true && typeof schema.$ref === "string" ? undefined : schema.$id + } + + function recordLocations(sourcePath: Path, targetPath: Path, scope: ResourceScope): void { + // A JSON Pointer can address an embedded schema from any containing resource. + if (scope.parent !== undefined) recordLocations(sourcePath, targetPath, scope.parent) + locations.set( + locationKey(scope.uri, sourcePath.slice(scope.sourceRoot.length)), + targetPath.slice(scope.targetRoot.length) + ) } - return schema } -function mapObject(value: unknown, f: (node: unknown) => unknown): Record | undefined { - return Predicate.isObject(value) ? Rec.map(value, f) : undefined +const ROOT_URI = "https://effect.invalid/.json-schema/" + +function resolveUrl(value: string, base: string): URL | undefined { + return URL.canParse(value, base) ? new URL(value, base) : undefined +} + +function resolveResourceUri(value: unknown, base: string): string | undefined { + if (typeof value !== "string") return undefined + const url = resolveUrl(value, base) + if (url === undefined) return undefined + url.hash = "" + return url.href } -function rewriteSubschemaKeyword( - out: Record, +function parsePointerFragment(hash: string): Path | undefined { + if (hash.length === 0) return [] + let pointer: string + try { + pointer = decodeURIComponent(hash.slice(1)) + } catch { + return undefined + } + if (!pointer.startsWith("/")) return undefined + return /~(?:[^01]|$)/.test(pointer) ? undefined : pointer.slice(1).split("/").map(unescapeToken) +} + +function relocateReference(reference: string, targetPath: Path): string { + const index = reference.indexOf("#") + if (index === -1 && targetPath.length === 0) return reference + const uri = index === -1 ? reference : reference.slice(0, index) + return `${uri}${formatPointerFragment(targetPath)}` +} + +function formatPointerFragment(path: Path): string { + return path.length === 0 + ? "#" + : `#/${path.map((token) => encodeURI(escapeToken(token)).replace(/#/g, "%23")).join("/")}` +} + +function locationKey(resource: string, pointer: Path): string { + return `${resource}\u0000${JSON.stringify(pointer)}` +} + +function createsResource(id: unknown): boolean { + return typeof id === "string" && id.length > 0 && id[0] !== "#" +} + +function convertSchema(root: JsonSchema, adapter: Adapter, options?: ConverterOptions): JsonSchema { + return runConverter(adapter, options, (convert) => convert(root) as JsonSchema) +} + +function convertDocument( + document: Document<"draft-2020-12">, + adapter: Adapter, + options?: ConverterOptions +): { readonly schema: JsonSchema; readonly definitions: Definitions } { + return runConverter(adapter, { ...options, trackIds: true }, (convert) => ({ + schema: convert(document.schema) as JsonSchema, + definitions: mapObject( + document.definitions, + (definition, key) => convert(definition, ["$defs", key], ["definitions", key]) as JsonSchema + ) as Definitions + })) +} + +const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties"]) +const SCHEMA_ARRAY_KEYWORDS = new Set(["allOf", "anyOf", "oneOf"]) +const JSON_SCHEMA_SINGLE_KEYWORDS = new Set([ + "not", + "additionalProperties", + "propertyNames", + "contains", + "if", + "then", + "else", + "contentSchema" +]) +const OPEN_API_30_SCHEMA_MAP_KEYWORDS = new Set(["properties"]) +const OPEN_API_30_SCHEMA_SINGLE_KEYWORDS = new Set(["not", "items", "additionalProperties"]) +const DRAFT_04_SCHEMA_SINGLE_KEYWORDS = new Set(["not", "additionalProperties", "contentSchema"]) + +function convertSubschemaKeyword( + out: JsonSchema, key: string, value: unknown, - rewrite: (node: unknown) => unknown, - singleKeywords: ReadonlySet + context: Context, + singleKeywords: ReadonlySet, + mapKeywords: ReadonlySet = SCHEMA_MAP_KEYWORDS ): boolean { - if (MAP_SUBSCHEMA_KEYWORDS.has(key)) { - out[key] = mapObject(value, rewrite) ?? value - return true - } - if (ARRAY_SUBSCHEMA_KEYWORDS.has(key)) { - out[key] = Array.isArray(value) ? value.map(rewrite) : value - return true - } - if (!singleKeywords.has(key)) return false - out[key] = rewrite(value) + let converted: unknown + if (mapKeywords.has(key)) converted = context.schemaMap(value, key) + else if (SCHEMA_ARRAY_KEYWORDS.has(key)) converted = context.schemaArray(value, key) + else if (singleKeywords.has(key)) converted = context.schema(value, key) + else return false + InternalRecord.assignProperty(out, key, converted) return true } -function normalizeOpenApi3_0ToDraft07(node: unknown): unknown { - if (Array.isArray(node)) return node.map(normalizeOpenApi3_0ToDraft07) - if (!Predicate.isObject(node)) return node +const PRE_2020_TO_2020_COLLISIONS = [ + "$anchor", + "$defs", + "$dynamicAnchor", + "$dynamicRef", + "$vocabulary", + "contentSchema", + "dependentRequired", + "dependentSchemas", + "maxContains", + "minContains", + "prefixItems", + "unevaluatedItems", + "unevaluatedProperties" +] +const DRAFT_07_TO_2020_COLLISIONS = [...PRE_2020_TO_2020_COLLISIONS, "deprecated"] +const OPEN_API_30_TO_2020_COLLISIONS = [ + ...PRE_2020_TO_2020_COLLISIONS, + "$comment", + "$id", + "$schema", + "const", + "contains", + "contentEncoding", + "contentMediaType", + "else", + "examples", + "if", + "patternProperties", + "propertyNames", + "then" +] +const ANCHOR_REGEXP = /^[A-Za-z_][-A-Za-z0-9._]*$/ +const LEGACY_ID_FRAGMENT_REGEXP = /^[A-Za-z][-A-Za-z0-9._:]*$/ + +function convertDraft07(root: JsonSchema): JsonSchema { + return convertSchema(root, (source, context) => { + const out: JsonSchema = {} + + if (typeof source.$ref === "string") { + context.reference(out, source.$ref) + if (Object.hasOwn(source, "definitions")) { + InternalRecord.assignProperty(out, "$defs", context.schemaMap(source.definitions, "definitions", "$defs")) + } + return out + } + rejectKeywordCollisions(source, DRAFT_07_TO_2020_COLLISIONS, "Draft 2020-12", "Draft-07") - const src = node as Record - let out: Record = {} + let items: unknown = undefined + let additionalItems: unknown = undefined - for (const k of Object.keys(src)) { - const v = src[k] - if (k === "$ref" && typeof v === "string") { - InternalRecord.assignProperty(out, k, v.replace(RE_COMPONENTS_SCHEMAS, "#/definitions")) - } else if (k === "example") { - if (src.examples === undefined) { - out.examples = [v] + for (const key of Object.keys(source)) { + const value = source[key] + if (convertSubschemaKeyword(out, key, value, context, JSON_SCHEMA_SINGLE_KEYWORDS)) continue + switch (key) { + case "$schema": + InternalRecord.assignProperty( + out, + key, + isMetaSchemaUri(value, META_SCHEMA_URI_DRAFT_07) ? META_SCHEMA_URI_DRAFT_2020_12 : value + ) + break + case "$id": + convertDraft07Id(out, value) + break + case "definitions": + InternalRecord.assignProperty(out, "$defs", context.schemaMap(value, key, "$defs")) + break + case "dependencies": { + if (!Predicate.isObject(value)) { + InternalRecord.assignProperty(out, key, value) + break + } + const dependentRequired: JsonSchema = {} + const dependentSchemas: JsonSchema = {} + for (const dependency of Object.keys(value)) { + const dependencyValue = value[dependency] + InternalRecord.assignProperty( + Array.isArray(dependencyValue) ? dependentRequired : dependentSchemas, + dependency, + Array.isArray(dependencyValue) + ? dependencyValue + : context.schemaAt( + dependencyValue, + ["dependencies", dependency], + ["dependentSchemas", dependency] + ) + ) + } + if (Object.keys(dependentRequired).length > 0) { + InternalRecord.assignProperty(out, "dependentRequired", dependentRequired) + } + if (Object.keys(dependentSchemas).length > 0) { + InternalRecord.assignProperty(out, "dependentSchemas", dependentSchemas) + } + break + } + case "items": + items = value + break + case "additionalItems": + additionalItems = value + break + default: + InternalRecord.assignProperty(out, key, value) + } + } + + if (items !== undefined) { + if (Array.isArray(items)) { + InternalRecord.assignProperty(out, "prefixItems", context.schemaArray(items, "items", "prefixItems")) + if (additionalItems !== undefined) { + InternalRecord.assignProperty(out, "items", context.schema(additionalItems, "additionalItems", "items")) + } + } else { + InternalRecord.assignProperty(out, "items", context.schema(items, "items")) } - } else if (Array.isArray(v) || Predicate.isObject(v)) { - InternalRecord.assignProperty(out, k, normalizeOpenApi3_0ToDraft07(v)) - } else { - InternalRecord.assignProperty(out, k, v) + } + + return out + }, { trackIds: true, ignoreRefSiblings: true }) +} + +function convertDraft07Id(out: JsonSchema, value: unknown): void { + if (typeof value !== "string" || !value.includes("#")) { + InternalRecord.assignProperty(out, "$id", value) + return + } + const fragmentIndex = value.indexOf("#") + const id = value.slice(0, fragmentIndex) + const anchor = value.slice(fragmentIndex + 1) + if (anchor.length === 0) { + if (id.length > 0) InternalRecord.assignProperty(out, "$id", id) + return + } + if (!ANCHOR_REGEXP.test(anchor)) { + unsupported("$id", "Draft 2020-12", `fragment "#${anchor}" is not a valid $anchor`) + } + if (id.length > 0) InternalRecord.assignProperty(out, "$id", id) + InternalRecord.assignProperty(out, "$anchor", anchor) +} + +function unsupported(keyword: string, dialect: string, details: string): never { + throw new Error(`Cannot convert JSON Schema keyword "${keyword}" to ${dialect}: ${details}`) +} + +function rejectKeywordCollisions( + source: JsonSchema, + keywords: ReadonlyArray, + targetDialect: string, + sourceDialect: string +): void { + for (const keyword of keywords) { + if (Object.hasOwn(source, keyword)) { + unsupported(keyword, targetDialect, `it is not active in ${sourceDialect} but would become active in the target`) + } + } +} + +const DRAFT_07_TARGET_COLLISIONS = ["additionalItems", "definitions", "dependencies"] + +function convertMetaSchemaKeyword( + out: JsonSchema, + value: unknown, + context: Context, + targetUri: string, + targetDialect: string +): void { + if (context.isDocumentRoot) { + InternalRecord.assignProperty( + out, + "$schema", + isMetaSchemaUri(value, META_SCHEMA_URI_DRAFT_2020_12) ? targetUri : value + ) + } else if (!isMetaSchemaUri(value, META_SCHEMA_URI_DRAFT_2020_12)) { + unsupported("$schema", targetDialect, "an embedded resource cannot declare a different dialect") + } +} + +function draft07Adapter(source: JsonSchema, context: Context): JsonSchema { + rejectKeywordCollisions(source, DRAFT_07_TARGET_COLLISIONS, "Draft-07", "Draft 2020-12") + const out: JsonSchema = {} + let reference: unknown = undefined + let prefixItems: unknown = undefined + let items: unknown = undefined + + for (const key of Object.keys(source)) { + const value = source[key] + if (convertSubschemaKeyword(out, key, value, context, JSON_SCHEMA_SINGLE_KEYWORDS)) continue + switch (key) { + case "$ref": + reference = value + break + case "$schema": + convertMetaSchemaKeyword(out, value, context, META_SCHEMA_URI_DRAFT_07, "Draft-07") + break + case "$id": + case "$anchor": + break + case "$defs": + InternalRecord.assignProperty(out, "definitions", context.schemaMap(value, key, "definitions")) + break + case "prefixItems": + prefixItems = value + break + case "items": + items = value + break + case "dependentRequired": + case "dependentSchemas": + case "minContains": + case "maxContains": + break + case "$dynamicRef": + case "$dynamicAnchor": + case "$vocabulary": + case "unevaluatedProperties": + case "unevaluatedItems": + unsupported(key, "Draft-07", "the target dialect has no equivalent") + case "required": + if (Array.isArray(value) && value.length === 0) break + InternalRecord.assignProperty(out, key, value) + break + default: + InternalRecord.assignProperty(out, key, value) } } - // Draft-04-style numeric exclusivity booleans - out = adjustExclusivity(out) + convertTuple(out, prefixItems, items, context) - // OpenAPI 3.0 nullable - if (out.nullable === true) { - out = applyNullable(out) + if (Object.hasOwn(source, "contains")) { + const minContains = source.minContains + const maxContains = source.maxContains + if ((minContains !== undefined && minContains !== 1) || maxContains !== undefined) { + unsupported("minContains/maxContains", "Draft-07", "contains cardinality cannot be represented") + } + if (Object.hasOwn(source, "minContains")) InternalRecord.assignProperty(out, "minContains", minContains) + } else { + if (Object.hasOwn(source, "minContains")) InternalRecord.assignProperty(out, "minContains", source.minContains) + if (Object.hasOwn(source, "maxContains")) InternalRecord.assignProperty(out, "maxContains", source.maxContains) } - delete out.nullable + + convertDependencies(source, out, context, "draft-07") + convertLegacyId(source, out, "$id", "Draft-07") + + convertReference(out, reference, context) return out } -function adjustExclusivity(node: Record): Record { - return adjustExclusiveBound( - adjustExclusiveBound(node, "minimum", "exclusiveMinimum"), - "maximum", - "exclusiveMaximum" - ) +function convertTuple(out: JsonSchema, prefixItems: unknown, items: unknown, context: Context): void { + if (prefixItems === undefined) { + if (items !== undefined) InternalRecord.assignProperty(out, "items", context.schema(items, "items")) + return + } + InternalRecord.assignProperty(out, "items", context.schemaArray(prefixItems, "prefixItems", "items")) + if (items !== undefined) { + InternalRecord.assignProperty(out, "additionalItems", context.schema(items, "items", "additionalItems")) + } } -function adjustExclusiveBound( - node: Record, - boundKey: "minimum" | "maximum", - exclusiveKey: "exclusiveMinimum" | "exclusiveMaximum" -): Record { - const exclusive = node[exclusiveKey] - if (typeof exclusive !== "boolean") return node - - const out = { ...node } - if (exclusive && typeof node[boundKey] === "number") { - out[exclusiveKey] = node[boundKey] - delete out[boundKey] +function convertReference(out: JsonSchema, reference: unknown, context: Context): void { + if (reference === undefined) return + if (typeof reference === "string" && Object.keys(out).length > 0) { + const referenceSchema: JsonSchema = {} + context.reference(referenceSchema, reference) + appendAllOf(out, referenceSchema) } else { - delete out[exclusiveKey] + context.reference(out, reference) + } +} + +function convertDependencies( + source: JsonSchema, + out: JsonSchema, + context: Context, + targetDialect: "draft-04" | "draft-07" +): void { + const dependentRequired = Predicate.isObject(source.dependentRequired) ? source.dependentRequired : undefined + const dependentSchemas = Predicate.isObject(source.dependentSchemas) ? source.dependentSchemas : undefined + if (dependentRequired === undefined && dependentSchemas === undefined) return + + const dependencies: JsonSchema = {} + const keys = new Set([ + ...Object.keys(dependentRequired ?? {}), + ...Object.keys(dependentSchemas ?? {}) + ]) + for (const key of keys) { + const required = dependentRequired?.[key] + const dependency = dependentSchemas?.[key] + const omitRequired = targetDialect === "draft-04" && Array.isArray(required) && required.length === 0 + if (dependency === undefined) { + if (!omitRequired) InternalRecord.assignProperty(dependencies, key, required) + } else if (required === undefined || omitRequired) { + InternalRecord.assignProperty( + dependencies, + key, + context.schemaAt(dependency, ["dependentSchemas", key], ["dependencies", key]) + ) + } else { + InternalRecord.assignProperty(dependencies, key, { + allOf: [ + context.schemaAt(dependency, ["dependentSchemas", key], ["dependencies", key, "allOf", "0"]), + { required } + ] + }) + } } + if (Object.keys(dependencies).length > 0) InternalRecord.assignProperty(out, "dependencies", dependencies) +} + +function convertOpenApi30(root: JsonSchema): JsonSchema { + return convertSchema(root, (source, context) => { + const out: JsonSchema = {} + + if (typeof source.$ref === "string") { + context.reference(out, rewriteOpenApiComponentsReference(source.$ref)) + return out + } + rejectKeywordCollisions(source, OPEN_API_30_TO_2020_COLLISIONS, "Draft 2020-12", "OpenAPI 3.0") + + for (const key of Object.keys(source)) { + const value = source[key] + if ( + convertSubschemaKeyword( + out, + key, + value, + context, + OPEN_API_30_SCHEMA_SINGLE_KEYWORDS, + OPEN_API_30_SCHEMA_MAP_KEYWORDS + ) + ) { + continue + } + switch (key) { + case "example": + InternalRecord.assignProperty(out, "examples", [value]) + break + case "nullable": + case "exclusiveMinimum": + case "exclusiveMaximum": + break + default: + InternalRecord.assignProperty(out, key, value) + } + } + + convertOpenApiExclusiveBound(source, out, "minimum") + convertOpenApiExclusiveBound(source, out, "maximum") + + if (source.nullable === true && typeof source.type === "string") { + InternalRecord.assignProperty(out, "type", [source.type, "null"]) + } + + return out + }) +} + +function convertOpenApiExclusiveBound( + source: JsonSchema, + out: JsonSchema, + boundKey: "minimum" | "maximum" +): void { + const exclusiveKey = boundKey === "minimum" ? "exclusiveMinimum" : "exclusiveMaximum" + const exclusive = source[exclusiveKey] + if (typeof exclusive !== "boolean") { + if (exclusive !== undefined) InternalRecord.assignProperty(out, exclusiveKey, exclusive) + return + } + if (exclusive && typeof source[boundKey] === "number") { + InternalRecord.assignProperty(out, exclusiveKey, source[boundKey]) + delete out[boundKey] + } +} + +const DRAFT_04_TARGET_COLLISIONS = ["additionalItems", "definitions", "dependencies", "id"] + +function draft04Adapter(source: JsonSchema, context: Context): JsonSchema { + rejectKeywordCollisions(source, DRAFT_04_TARGET_COLLISIONS, "Draft-04", "Draft 2020-12") + const out: JsonSchema = {} + let reference: unknown = undefined + let prefixItems: unknown = undefined + let items: unknown = undefined + let constSchema: JsonSchema | undefined + + for (const key of Object.keys(source)) { + const value = source[key] + if (convertSubschemaKeyword(out, key, value, context, DRAFT_04_SCHEMA_SINGLE_KEYWORDS)) continue + switch (key) { + case "$ref": + reference = value + break + case "$schema": + convertMetaSchemaKeyword(out, value, context, META_SCHEMA_URI_DRAFT_04, "Draft-04") + break + case "$id": + case "$anchor": + break + case "$defs": + InternalRecord.assignProperty(out, "definitions", context.schemaMap(value, key, "definitions")) + break + case "prefixItems": + prefixItems = value + break + case "items": + items = value + break + case "$dynamicRef": + case "$dynamicAnchor": + case "$vocabulary": + case "unevaluatedProperties": + case "unevaluatedItems": + case "propertyNames": + unsupported(key, "Draft-04", "the target dialect has no equivalent") + case "dependentRequired": + case "dependentSchemas": + case "contains": + case "minContains": + case "maxContains": + case "if": + case "then": + case "else": + case "minimum": + case "maximum": + case "exclusiveMinimum": + case "exclusiveMaximum": + break + case "const": + constSchema = { enum: [value] } + break + case "required": + if (Array.isArray(value) && value.length === 0) break + InternalRecord.assignProperty(out, key, value) + break + default: + InternalRecord.assignProperty(out, key, value) + } + } + + convertTuple(out, prefixItems, items, context) + + convertDraft04ExclusiveBound(source, out, "minimum") + convertDraft04ExclusiveBound(source, out, "maximum") + convertDependencies(source, out, context, "draft-04") + convertLegacyId(source, out, "id", "Draft-04") + convertDraft04Conditionals(source, out, context) + convertDraft04Contains(source, out, context) + + if (constSchema !== undefined) { + if (Object.hasOwn(source, "enum")) { + appendAllOf(out, constSchema) + } else { + InternalRecord.assignProperty(out, "enum", constSchema.enum) + } + } + + convertReference(out, reference, context) + return out } -function applyNullable(node: Record): Record { - // enum widening - if (Array.isArray(node.enum)) { - return widenType({ - ...node, - enum: node.enum.includes(null) ? node.enum : [...node.enum, null] - }) +function convertDraft04Conditionals(source: JsonSchema, out: JsonSchema, context: Context): void { + const hasIf = Object.hasOwn(source, "if") + const hasThen = Object.hasOwn(source, "then") + const hasElse = Object.hasOwn(source, "else") + if (!hasIf || (!hasThen && !hasElse)) { + if (hasIf) InternalRecord.assignProperty(out, "if", context.schema(source.if, "if")) + if (hasThen) InternalRecord.assignProperty(out, "then", context.schema(source.then, "then")) + if (hasElse) InternalRecord.assignProperty(out, "else", context.schema(source.else, "else")) + return + } + + const index = Array.isArray(out.allOf) ? out.allOf.length : 0 + const base = ["allOf", String(index), "anyOf"] as const + const convertBranch = (key: "if" | "then" | "else", targetPath: Path): unknown => + context.schemaAt(source[key], [key], [...base, ...targetPath]) + let conditional: JsonSchema + if (hasThen && hasElse) { + if (hasSchemaIdentifier(source.if)) { + unsupported("if", "Draft-04", "lowering both branches would duplicate a schema identifier") + } + // (if AND then) OR ((NOT if) AND else) + conditional = { + anyOf: [ + { + allOf: [ + convertBranch("if", ["0", "allOf", "0"]), + convertBranch("then", ["0", "allOf", "1"]) + ] + }, + { + allOf: [ + { not: convertBranch("if", ["1", "allOf", "0", "not"]) }, + convertBranch("else", ["1", "allOf", "1"]) + ] + } + ] + } + } else if (hasThen) { + // (NOT if) OR then + conditional = { + anyOf: [ + { not: convertBranch("if", ["0", "not"]) }, + convertBranch("then", ["1"]) + ] + } + } else { + // if OR else + conditional = { + anyOf: [ + convertBranch("if", ["0"]), + convertBranch("else", ["1"]) + ] + } + } + appendAllOf(out, conditional) +} + +function hasSchemaIdentifier(node: unknown): boolean { + if (!Predicate.isObject(node)) return false + if (Object.hasOwn(node, "$id") || Object.hasOwn(node, "$anchor")) return true + for (const key of Object.keys(node)) { + const value = node[key] + switch (key) { + case "$defs": + case "properties": + case "patternProperties": + case "dependentSchemas": + if (Predicate.isObject(value) && Object.values(value).some(hasSchemaIdentifier)) return true + break + case "allOf": + case "anyOf": + case "oneOf": + case "prefixItems": + if (Array.isArray(value) && value.some(hasSchemaIdentifier)) return true + break + case "not": + case "additionalProperties": + case "propertyNames": + case "unevaluatedProperties": + case "items": + case "contains": + case "unevaluatedItems": + case "if": + case "then": + case "else": + case "contentSchema": + if (hasSchemaIdentifier(value)) return true + } } + return false +} - // type widening - if (node.type !== undefined) return widenType(node) +function convertDraft04Contains(source: JsonSchema, out: JsonSchema, context: Context): void { + if (!Object.hasOwn(source, "contains")) { + if (Object.hasOwn(source, "minContains")) InternalRecord.assignProperty(out, "minContains", source.minContains) + if (Object.hasOwn(source, "maxContains")) InternalRecord.assignProperty(out, "maxContains", source.maxContains) + return + } - // const === null - if (node.const === null) return node + const minContains = source.minContains + const maxContains = source.maxContains + if ((minContains !== undefined && minContains !== 1) || maxContains !== undefined) { + unsupported("minContains/maxContains", "Draft-04", "contains cardinality cannot be represented") + } - // fallback - return { anyOf: [node, { type: "null" }] } + const index = Array.isArray(out.allOf) ? out.allOf.length : 0 + const contains = context.schemaAt( + source.contains, + ["contains"], + ["allOf", String(index), "anyOf", "1", "not", "items", "not"] + ) + appendAllOf(out, { + anyOf: [ + { not: { type: "array" } }, + { not: { items: { not: contains } } } + ] + }) } -function widenType(node: Record): Record { - const t = node.type - if (typeof t === "string") return t === "null" ? node : { ...node, type: [t, "null"] } - if (Array.isArray(t)) return t.includes("null") ? node : { ...node, type: [...t, "null"] } - return node +function appendAllOf(out: JsonSchema, schema: JsonSchema): void { + if (Array.isArray(out.allOf)) out.allOf.push(schema) + else InternalRecord.assignProperty(out, "allOf", [schema]) } -/** - * Resolves a `$ref` string by looking up the last path segment in a - * definitions map. - * - * **When to use** - * - * Use when you need to dereference a `$ref` pointer to get the JSON Schema - * object it points to. - * - * **Details** - * - * This only resolves the final segment of the ref path, such as `"User"` from - * `"#/$defs/User"`. It returns `undefined` if the definition is not found. - * - * **Gotchas** - * - * This function does not follow arbitrary JSON Pointer paths. - * - * **Example** (Resolving a $ref) - * - * ```ts import.meta.vitest - * import { JsonSchema } from "effect" - * - * const definitions: JsonSchema.Definitions = { - * User: { type: "object", properties: { name: { type: "string" } } } - * } - * - * JsonSchema.resolve$ref("#/$defs/User", definitions) // => { type: "object", properties: { name: { type: "string" } } } - * JsonSchema.resolve$ref("#/$defs/Unknown", definitions) // => undefined - * ``` - * - * @see {@link resolveTopLevel$ref} - * @see {@link Definitions} - * @category getters - * @since 4.0.0 - */ -export function resolve$ref($ref: string, definitions: Definitions): JsonSchema | undefined { - const tokens = $ref.split("/") - const identifier = unescapeToken(tokens[tokens.length - 1]) - if (Object.hasOwn(definitions, identifier)) return definitions[identifier] +function convertLegacyId( + source: JsonSchema, + out: JsonSchema, + targetKey: "$id" | "id", + dialect: string +): void { + const id = source.$id + const anchor = source.$anchor + if (anchor === undefined) { + if (id !== undefined) InternalRecord.assignProperty(out, targetKey, id) + return + } + if (typeof anchor !== "string" || !ANCHOR_REGEXP.test(anchor)) { + unsupported("$anchor", dialect, "the anchor is not valid") + } + if (!LEGACY_ID_FRAGMENT_REGEXP.test(anchor)) { + unsupported("$anchor", dialect, "the anchor cannot be represented as a plain-name fragment identifier") + } + if (id === undefined) { + InternalRecord.assignProperty(out, targetKey, `#${anchor}`) + } else { + unsupported("$anchor", dialect, "it cannot be combined with the schema $id") + } } -/** - * Resolves a document whose root schema is a top-level `$ref`. - * - * **When to use** - * - * Use when you need to dereference a top-level `$ref` before inspecting the - * root JSON Schema object's properties directly. - * - * **Details** - * - * This returns the same object if no change is needed, or a shallow copy with - * the resolved schema. - * - * **Example** (Resolving a top-level $ref) - * - * ```ts import.meta.vitest - * import { JsonSchema } from "effect" - * - * const doc: JsonSchema.Document<"draft-2020-12"> = { - * dialect: "draft-2020-12", - * schema: { $ref: "#/$defs/User" }, - * definitions: { - * User: { type: "object", properties: { name: { type: "string" } } } - * } - * } - * - * const resolved = JsonSchema.resolveTopLevel$ref(doc) - * resolved.schema // => { type: "object", properties: { name: { type: "string" } } } - * ``` - * - * @see {@link resolve$ref} - * @see {@link Document} - * @category transforming - * @since 4.0.0 - */ -export function resolveTopLevel$ref(document: Document<"draft-2020-12">): Document<"draft-2020-12"> { - if (typeof document.schema.$ref === "string") { - const schema = resolve$ref(document.schema.$ref, document.definitions) - if (schema !== undefined) { - return { ...document, schema } +function convertDraft04ExclusiveBound( + source: JsonSchema, + out: JsonSchema, + boundKey: "minimum" | "maximum" +): void { + const exclusiveKey = boundKey === "minimum" ? "exclusiveMinimum" : "exclusiveMaximum" + const bound = source[boundKey] + const exclusive = source[exclusiveKey] + if (typeof exclusive === "number") { + const isBoundStricter = typeof bound === "number" && + (boundKey === "minimum" ? bound > exclusive : bound < exclusive) + if (isBoundStricter) { + InternalRecord.assignProperty(out, boundKey, bound) + } else { + InternalRecord.assignProperty(out, boundKey, exclusive) + InternalRecord.assignProperty(out, exclusiveKey, true) } + } else if (bound !== undefined) { + InternalRecord.assignProperty(out, boundKey, bound) } - return document } diff --git a/repos/effect/packages/effect/src/Layer.ts b/repos/effect/packages/effect/src/Layer.ts index 0d1eb01d81..ede9373c18 100644 --- a/repos/effect/packages/effect/src/Layer.ts +++ b/repos/effect/packages/effect/src/Layer.ts @@ -792,11 +792,11 @@ export const buildWithScope: { * readonly query: (sql: string) => Effect.Effect * }>()("Database") {} * - * const DatabaseLive = Layer.succeed(Database, { + * const DatabaseLayer = Layer.succeed(Database, { * query: Effect.fn("Database.query")((sql: string) => Effect.succeed(`Query result: ${sql}`)) * }) * const program = Database.use((database) => database.query("SELECT 1")) - * Effect.runSync(Effect.provide(program, DatabaseLive)) // => "Query result: SELECT 1" + * Effect.runSync(Effect.provide(program, DatabaseLayer)) // => "Query result: SELECT 1" * ``` * * @see {@link sync} for constructing layers from lazy values diff --git a/repos/effect/packages/effect/src/LayerMap.ts b/repos/effect/packages/effect/src/LayerMap.ts index 7b787441ef..eabd4ec59b 100644 --- a/repos/effect/packages/effect/src/LayerMap.ts +++ b/repos/effect/packages/effect/src/LayerMap.ts @@ -15,6 +15,7 @@ import * as Effect from "./Effect.ts" import { identity } from "./Function.ts" import { getStackTraceLimit, setStackTraceLimit } from "./internal/stackTraceLimit.ts" import * as Layer from "./Layer.ts" +import type * as Option from "./Option.ts" import * as RcMap from "./RcMap.ts" import * as Scope from "./Scope.ts" import type { Mutable, NoExcessProperties } from "./Types.ts" @@ -92,6 +93,18 @@ export interface LayerMap { */ contextEffect(key: K): Effect.Effect, E, Scope.Scope> + /** + * Retains and returns the context for a key only when it is currently cached. + * + * **Details** + * + * `Option.none` means no entry is currently cached or the `LayerMap` is closed; + * no layer is built for a missing key. An existing in-flight entry is awaited. + * + * @since 4.0.0 + */ + contextEffectOption(key: K): Effect.Effect>, E, Scope.Scope> + /** * Invalidates the resource associated with the key. */ @@ -183,6 +196,7 @@ export const make: < rcMap, get: (key) => Layer.effectContext(RcMap.get(rcMap, key)), contextEffect: (key) => RcMap.get(rcMap, key), + contextEffectOption: (key) => RcMap.getOption(rcMap, key), invalidate: (key) => RcMap.invalidate(rcMap, key) }) }) @@ -316,6 +330,20 @@ export interface TagClass< */ readonly contextEffect: (key: K) => Effect.Effect, E, Scope.Scope | Self> + /** + * Retains and returns the context for a key only when it is currently cached. + * + * **Details** + * + * `Option.none` means no entry is currently cached or the `LayerMap` is closed; + * no layer is built for a missing key. An existing in-flight entry is awaited. + * + * @since 4.0.0 + */ + readonly contextEffectOption: ( + key: K + ) => Effect.Effect>, E, Scope.Scope | Self> + /** * Invalidates the resource associated with the key. */ @@ -430,6 +458,8 @@ export const Service = () => TagClass_.get = (key: string) => Layer.unwrap(Effect.map(TagClass_, (layerMap) => layerMap.get(key))) TagClass_.contextEffect = (key: string) => Effect.flatMap(TagClass_, (layerMap) => layerMap.contextEffect(key)) + TagClass_.contextEffectOption = (key: string) => + Effect.flatMap(TagClass_, (layerMap) => layerMap.contextEffectOption(key)) TagClass_.invalidate = (key: string) => Effect.flatMap(TagClass_, (layerMap) => layerMap.invalidate(key)) return TagClass as any diff --git a/repos/effect/packages/effect/src/Logger.ts b/repos/effect/packages/effect/src/Logger.ts index 4dcedeafaf..168a53870d 100644 --- a/repos/effect/packages/effect/src/Logger.ts +++ b/repos/effect/packages/effect/src/Logger.ts @@ -413,7 +413,7 @@ const format = ( const append = (label: string, value: string): string => " " + format(label, value) let out = format("timestamp", date.toISOString()) - out += append("level", logLevel) + out += append("level", logLevel.toUpperCase()) out += append("fiber", formatFiberId(fiber.id)) const messages = Array.ensure(message) @@ -515,7 +515,7 @@ export const defaultLogger: Logger = effect.defaultLogger * return yield* TestConsole.logLines * }).pipe(Effect.provide(TestConsole.layer)) * - * await Effect.runPromise(program) // => ["level=Info message=\"Application started\""] + * await Effect.runPromise(program) // => ["level=INFO message=\"Application started\""] * ``` * * @category constructors @@ -550,7 +550,7 @@ export const formatSimple = effect.loggerMake(format(escapeDoubleQuotes)) * return yield* TestConsole.logLines * }).pipe(Effect.provide(TestConsole.layer)) * - * await Effect.runPromise(program) // => ["level=Info message=\"User login\""] + * await Effect.runPromise(program) // => ["level=INFO message=\"User login\""] * ``` * * @category constructors @@ -899,10 +899,10 @@ export const tracerLogger: Logger = effect.tracerLogger * const customLogger = Logger.make((options) => { * messages.push(options.message) * }) - * const CustomLoggerLive = Logger.layer([customLogger]) + * const CustomLoggerLayer = Logger.layer([customLogger]) * * const program = Effect.log("Application started").pipe( - * Effect.provide(CustomLoggerLive) + * Effect.provide(CustomLoggerLayer) * ) * Effect.runSync(program) * messages // => [["Application started"]] diff --git a/repos/effect/packages/effect/src/Match.ts b/repos/effect/packages/effect/src/Match.ts index b97cdd7950..510da5ef69 100644 --- a/repos/effect/packages/effect/src/Match.ts +++ b/repos/effect/packages/effect/src/Match.ts @@ -20,13 +20,24 @@ import type { Unify } from "./Unify.ts" const TypeId = internal.TypeId +/** + * Marker used by `Matcher` to distinguish matchers created with `Match.value`. + * + * @category models + * @since 4.0.0 + */ +export type ValueFlavor = "value" + /** * Union type for matchers created by `Match.type` and `Match.value`. * * **Details** * * A `Matcher` carries the input type, accumulated filters, remaining cases, - * result type, and, for value matchers, the provided value being matched. + * result type, and a flavor distinguishing the two matcher variants: `never` + * for matchers created with `Match.type` and `ValueFlavor` for matchers created + * with `Match.value`. Because the flavor never depends on the input type, + * terminal combinators resolve even when the input contains type parameters. * * **Example** (Matching string and number values) * @@ -53,9 +64,17 @@ const TypeId = internal.TypeId * @category models * @since 4.0.0 */ -export type Matcher = - | TypeMatcher - | ValueMatcher +export type Matcher< + Input, + Filters, + RemainingApplied, + Result, + Flavor, + Return = any, + Args extends Array = [] +> = + | TypeMatcher + | ValueMatcher /** * Represents a pattern matcher that operates on types rather than specific values. @@ -86,7 +105,14 @@ export type Matcher extends Pipeable { +export interface TypeMatcher< + in Input, + out Filters, + out Remaining, + out Result, + out Return = any, + in Args extends Array = [] +> extends Pipeable { readonly _tag: "TypeMatcher" readonly [TypeId]: { readonly _input: T.Contravariant @@ -94,9 +120,11 @@ export interface TypeMatcher readonly _result: T.Covariant readonly _return: T.Covariant + readonly _args: T.Contravariant } readonly cases: ReadonlyArray - add(_case: Case): TypeMatcher + readonly select: (...args: Array) => unknown + add(_case: Case): TypeMatcher } /** @@ -106,7 +134,8 @@ export interface TypeMatcher - extends Pipeable -{ +export interface ValueMatcher< + in Input, + Filters, + out Remaining, + out Result, + Provided, + out Return = any, + out Flavor = ValueFlavor +> extends Pipeable { readonly _tag: "ValueMatcher" readonly [TypeId]: { readonly _input: T.Contravariant readonly _filters: T.Covariant readonly _result: T.Covariant readonly _return: T.Covariant + readonly _flavor: T.Covariant } readonly provided: Provided readonly value: Result.Result - add(_case: Case): ValueMatcher + add(_case: Case): ValueMatcher } /** @@ -196,7 +232,7 @@ export type Case = When | Not export interface When { readonly _tag: "When" guard(u: unknown): boolean - evaluate(input: unknown): any + evaluate(input: unknown, ...args: Array): any } /** @@ -230,7 +266,7 @@ export interface When { export interface Not { readonly _tag: "Not" guard(u: unknown): boolean - evaluate(input: unknown): any + evaluate(input: unknown, ...args: Array): any } /** @@ -277,6 +313,32 @@ export interface Not { */ export const type: () => Matcher, I, never, never> = internal.type +/** + * Creates a reusable matcher from a function that selects the value to match. + * + * The compiled matcher keeps the selector's original argument list. Case + * handlers receive the narrowed selected value followed by those arguments. + * + * @example + * ```ts import.meta.vitest + * import { Match } from "effect" + * + * const format = Match.fn((prefix: string, value: "a" | "b") => value).pipe( + * Match.when("a", (_value, prefix) => `${prefix}: A`), + * Match.when("b", (_value, prefix) => `${prefix}: B`), + * Match.exhaustive + * ) + * + * format("status", "a") // => "status: A" + * ``` + * + * @category constructors + * @since 4.0.0 + */ +export const fn: , I>( + select: (...args: Args) => I +) => Matcher, I, never, never, any, Args> = internal.fn + /** * Creates a matcher from a specific value. * @@ -322,7 +384,7 @@ export const type: () => Matcher, I, never, never> = */ export const value: ( i: I -) => Matcher, I, never, I> = internal.value +) => Matcher, I, never, ValueFlavor> = internal.value /** * Creates a match function for a specific value with discriminated union handling. @@ -464,9 +526,9 @@ export const typeTags: { * @category utility types * @since 4.0.0 */ -export const withReturnType: () => ( - self: Matcher -) => [Ret] extends [[A] extends [never] ? any : A] ? Matcher +export const withReturnType: () => >( + self: Matcher +) => [Ret] extends [[A] extends [never] ? any : A] ? Matcher : "withReturnType constraint does not extend Result type" = internal.withReturnType /** @@ -521,19 +583,21 @@ export const when: < R, const P extends Types.PatternPrimitive | Types.PatternBase, Ret, - Fn extends (_: Types.WhenMatch) => Ret + Args extends Array, + Fn extends (_: Types.WhenMatch, ...args: Args) => Ret >( pattern: P, f: Fn ) => ( - self: Matcher + self: Matcher ) => Matcher< I, Types.AddWithout>, Types.ApplyFilters>>, A | ReturnType, Pr, - Ret + Ret, + Args > = internal.when /** @@ -581,18 +645,20 @@ export const whenOr: < R, const P extends ReadonlyArray | Types.PatternBase>, Ret, - Fn extends (_: Types.WhenMatch) => Ret + Args extends Array, + Fn extends (_: Types.WhenMatch, ...args: Args) => Ret >( ...args: [...patterns: P, f: Fn] ) => ( - self: Matcher + self: Matcher ) => Matcher< I, Types.AddWithout>, Types.ApplyFilters>>, A | ReturnType, Pr, - Ret + Ret, + Args > = internal.whenOr /** @@ -637,17 +703,20 @@ export const whenAnd: < R, const P extends ReadonlyArray | Types.PatternBase>, Ret, - Fn extends (_: Types.WhenMatch>) => Ret + Args extends Array, + Fn extends (_: Types.WhenMatch>, ...args: Args) => Ret >( ...args: [...patterns: P, f: Fn] ) => ( - self: Matcher + self: Matcher ) => Matcher< I, Types.AddWithout>>, Types.ApplyFilters>>>, A | ReturnType, - Pr + Pr, + Ret, + Args > = internal.whenAnd /** @@ -929,18 +998,20 @@ export const tag: < R, P extends Types.Tags<"_tag", R> & string, Ret, - Fn extends (_: Extract>) => Ret + Args extends Array, + Fn extends (_: Extract>, ...args: Args) => Ret >( ...pattern: [first: P, ...values: Array

, f: Fn] ) => ( - self: Matcher + self: Matcher ) => Matcher< I, Types.AddWithout>>, Types.ApplyFilters>>>, ReturnType | A, Pr, - Ret + Ret, + Args > = internal.tag /** @@ -1136,19 +1207,21 @@ export const not: < R, const P extends Types.PatternPrimitive | Types.PatternBase, Ret, - Fn extends (_: Types.NotMatch) => Ret + Args extends Array, + Fn extends (_: Types.NotMatch, ...args: Args) => Ret >( pattern: P, f: Fn ) => ( - self: Matcher + self: Matcher ) => Matcher< I, Types.AddOnly>, Types.ApplyFilters>>, A | ReturnType, Pr, - Ret + Ret, + Args > = internal.not /** @@ -1792,11 +1865,13 @@ export const instanceOfUnsafe: any>( * @category completion * @since 4.0.0 */ -export const orElse: Ret>( +export const orElse: , F extends (_: RA, ...args: Args) => Ret>( f: F ) => ( - self: Matcher -) => [Pr] extends [never] ? (input: I) => Unify | A> : Unify | A> = internal.orElse + self: Matcher +) => [Pr] extends [never] ? [Args] extends [[]] ? (input: I) => Unify | A> + : (...args: Args) => Unify | A> + : Unify | A> = internal.orElse // TODO(4.0): Rename to "orThrow"? Like Result.getOrThrow /** @@ -1842,9 +1917,10 @@ export const orElse: Ret>( * @category completion * @since 4.0.0 */ -export const orElseAbsurd: ( - self: Matcher -) => [Pr] extends [never] ? (input: I) => Unify : Unify = internal.orElseAbsurd +export const orElseAbsurd: >( + self: Matcher +) => [Pr] extends [never] ? [Args] extends [[]] ? (input: I) => Unify : (...args: Args) => Unify : Unify = + internal.orElseAbsurd /** * Wraps the match result in a `Result`, distinguishing matched and unmatched @@ -1883,9 +1959,11 @@ export const orElseAbsurd: ( * @category completion * @since 4.0.0 */ -export const result: ( - self: Matcher -) => [Pr] extends [never] ? (input: I) => Result.Result, R> : Result.Result, R> = internal.result +export const result: >( + self: Matcher +) => [Pr] extends [never] ? [Args] extends [[]] ? (input: I) => Result.Result, R> + : (...args: Args) => Result.Result, R> + : Result.Result, R> = internal.result /** * Wraps the match result in an `Option`, representing an optional match. @@ -1930,9 +2008,11 @@ export const result: ( * @category completion * @since 4.0.0 */ -export const option: ( - self: Matcher -) => [Pr] extends [never] ? (input: I) => Option.Option> : Option.Option> = internal.option +export const option: >( + self: Matcher +) => [Pr] extends [never] ? [Args] extends [[]] ? (input: I) => Option.Option> + : (...args: Args) => Option.Option> + : Option.Option> = internal.option /** * Completes a matcher that handles every remaining input case. @@ -1966,9 +2046,10 @@ export const option: ( * @category completion * @since 4.0.0 */ -export const exhaustive: ( - self: Matcher -) => [Pr] extends [never] ? (u: I) => Unify : Unify = internal.exhaustive +export const exhaustive: >( + self: Matcher +) => [Pr] extends [never] ? [Args] extends [[]] ? (u: I) => Unify : (...args: Args) => Unify : Unify = + internal.exhaustive const SafeRefinementId = "~effect/match/Match/SafeRefinement" diff --git a/repos/effect/packages/effect/src/Optic.ts b/repos/effect/packages/effect/src/Optic.ts index f7fac610ab..f132e6617a 100644 --- a/repos/effect/packages/effect/src/Optic.ts +++ b/repos/effect/packages/effect/src/Optic.ts @@ -12,7 +12,7 @@ * @since 4.0.0 */ -import { identity } from "./Function.ts" +import { dual, identity } from "./Function.ts" import * as InternalRecord from "./internal/record.ts" import * as Option from "./Option.ts" import * as Predicate from "./Predicate.ts" @@ -21,7 +21,7 @@ import type * as Schema from "./Schema.ts" import * as SchemaAST from "./SchemaAST.ts" import * as SchemaIssue from "./SchemaIssue.ts" import * as Struct from "./Struct.ts" -import type { IsUnion } from "./Types.ts" +import type { IsUnion, NoInfer } from "./Types.ts" /** * A lossless, reversible conversion between types `S` and `A`. @@ -1275,8 +1275,180 @@ function composeKind(a: Kind, b: Kind): Kind { // --------------------------------------------- /** - * Returns a function that extracts all elements focused by a - * {@link Traversal} as a plain mutable array. + * Reads the focused value from a `Lens`. + * + * **When to use** + * + * Use when the optic always focuses exactly one value. + * + * **Details** + * + * Supports both data-first and data-last forms. + * + * @see {@link getResult} for optics whose focus may be absent + * + * @category getters + * @since 4.0.0 + */ +export const get: { + (optic: Lens): (self: NoInfer) => A + (self: NoInfer, optic: Lens): A +} = dual< + (optic: Lens) => (self: NoInfer) => A, + (self: NoInfer, optic: Lens) => A +>(2, (self, optic) => optic.get(self)) + +/** + * Attempts to read the focused value from an `Optional`. + * + * **When to use** + * + * Use when the optic may not focus and you need the failure as a `Result`. + * + * **Details** + * + * Supports both data-first and data-last forms. + * + * @see {@link get} for optics that always focus + * + * @category getters + * @since 4.0.0 + */ +export const getResult: { + (optic: Optional): (self: NoInfer) => Result.Result + (self: NoInfer, optic: Optional): Result.Result +} = dual< + (optic: Optional) => (self: NoInfer) => Result.Result, + (self: NoInfer, optic: Optional) => Result.Result +>(2, (self, optic) => optic.getResult(self)) + +/** + * Builds a source value from a focused value using a `Prism`. + * + * **When to use** + * + * Use when the optic can construct the source without an existing source value. + * + * **Details** + * + * Supports both data-first and data-last forms. The focused value is `self`. + * + * @see {@link replace} for updates that use an existing source + * + * @category transforming + * @since 4.0.0 + */ +export const set: { + (optic: Prism): (self: NoInfer) => S + (self: NoInfer, optic: Prism): S +} = dual< + (optic: Prism) => (self: NoInfer) => S, + (self: NoInfer, optic: Prism) => S +>(2, (self, optic) => optic.set(self)) + +/** + * Replaces the focused value in a source. + * + * **When to use** + * + * Use when a failed focus should leave the source unchanged. + * + * **Details** + * + * Supports both data-first and data-last forms. + * + * @see {@link replaceResult} for an explicit replacement failure + * + * @category transforming + * @since 4.0.0 + */ +export const replace: { + (optic: Optional, value: NoInfer): (self: NoInfer) => S + (self: NoInfer, optic: Optional, value: NoInfer): S +} = dual< + (optic: Optional, value: NoInfer) => (self: NoInfer) => S, + (self: NoInfer, optic: Optional, value: NoInfer) => S +>(3, (self, optic, value) => optic.replace(value, self)) + +/** + * Attempts to replace the focused value in a source. + * + * **When to use** + * + * Use when you need an explicit `Result` for a replacement failure. + * + * **Details** + * + * Supports both data-first and data-last forms. + * + * @see {@link replace} for returning the original source on failure + * + * @category transforming + * @since 4.0.0 + */ +export const replaceResult: { + ( + optic: Optional, + value: NoInfer + ): (self: NoInfer) => Result.Result + ( + self: NoInfer, + optic: Optional, + value: NoInfer + ): Result.Result +} = dual< + ( + optic: Optional, + value: NoInfer + ) => (self: NoInfer) => Result.Result, + ( + self: NoInfer, + optic: Optional, + value: NoInfer + ) => Result.Result +>(3, (self, optic, value) => optic.replaceResult(value, self)) + +/** + * Transforms the focused value in a source. + * + * **When to use** + * + * Use when you want to update a focus with a function. + * + * **Details** + * + * Supports both data-first and data-last forms. A failed focus leaves the + * source unchanged. + * + * @see {@link modifyAll} for transforming every value in a traversal + * + * @category transforming + * @since 4.0.0 + */ +export const modify: { + ( + optic: Optional, + f: (value: NoInfer) => NoInfer + ): (self: NoInfer) => S + ( + self: NoInfer, + optic: Optional, + f: (value: NoInfer) => NoInfer + ): S +} = dual< + ( + optic: Optional, + f: (value: NoInfer) => NoInfer + ) => (self: NoInfer) => S, + ( + self: NoInfer, + optic: Optional, + f: (value: NoInfer) => NoInfer + ) => S +>(3, (self, optic, f) => optic.modify(f)(self)) + +/** + * Extracts all values focused by a `Traversal` as a plain mutable array. * * **When to use** * @@ -1287,6 +1459,7 @@ function composeKind(a: Kind, b: Kind): Kind { * * - Returns an empty array when the traversal cannot focus. * - Always returns a fresh array (safe to mutate). + * - Supports both data-first and data-last forms. * * **Example** (Collecting positive numbers) * @@ -1311,13 +1484,57 @@ function composeKind(a: Kind, b: Kind): Kind { * @category getters * @since 4.0.0 */ -export function getAll(traversal: Traversal): (s: S) => Array { - return (s) => - Result.match(traversal.getResult(s), { - onFailure: () => [], - onSuccess: (as) => [...as] - }) -} +export const getAll: { + (traversal: Traversal): (self: NoInfer) => Array + (self: NoInfer, traversal: Traversal): Array +} = dual< + (traversal: Traversal) => (self: NoInfer) => Array, + (self: NoInfer, traversal: Traversal) => Array +>(2, (self, traversal) => + Result.match(traversal.getResult(self), { + onFailure: () => [], + onSuccess: (as) => [...as] + })) + +/** + * Transforms every value focused by a `Traversal`. + * + * **When to use** + * + * Use when you want to update each value selected by a traversal. + * + * **Details** + * + * Supports both data-first and data-last forms. A failed traversal leaves the + * source unchanged. + * + * @see {@link modify} for transforming the focus as a whole + * @see {@link getAll} for reading every focused value + * + * @category transforming + * @since 4.0.0 + */ +export const modifyAll: { + ( + traversal: Traversal, + f: (value: NoInfer) => NoInfer + ): (self: NoInfer) => S + ( + self: NoInfer, + traversal: Traversal, + f: (value: NoInfer) => NoInfer + ): S +} = dual< + ( + traversal: Traversal, + f: (value: NoInfer) => NoInfer + ) => (self: NoInfer) => S, + ( + self: NoInfer, + traversal: Traversal, + f: (value: NoInfer) => NoInfer + ) => S +>(3, (self, traversal, f) => traversal.modifyAll(f)(self)) // --------------------------------------------- // Built-in Optics diff --git a/repos/effect/packages/effect/src/Pool.ts b/repos/effect/packages/effect/src/Pool.ts index 5ef6f5ed80..49294db98d 100644 --- a/repos/effect/packages/effect/src/Pool.ts +++ b/repos/effect/packages/effect/src/Pool.ts @@ -16,9 +16,10 @@ import * as Duration from "./Duration.ts" import * as Effect from "./Effect.ts" import type * as Exit from "./Exit.ts" import * as Fiber from "./Fiber.ts" -import { dual, identity } from "./Function.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 * as Latch from "./Latch.ts" import { type Pipeable, pipeArguments } from "./Pipeable.ts" import { hasProperty } from "./Predicate.ts" import * as Queue from "./Queue.ts" @@ -28,6 +29,14 @@ import * as Semaphore from "./Semaphore.ts" const TypeId = "~effect/Pool" +const Acquire = Symbol() +const AcquireContext = Symbol() + +interface PoolImpl extends Pool { + readonly [Acquire]: Effect.Effect + readonly [AcquireContext]: Context.Context +} + /** * A `Pool` is a pool of items of type `A`, each of which may be * associated with the acquisition and release of resources. An attempt to get @@ -76,6 +85,7 @@ export interface Pool extends Pipeable { export interface Config { readonly acquire: Effect.Effect readonly concurrency: number + readonly isFixed: boolean readonly minSize: number readonly maxSize: number readonly strategy: Strategy @@ -93,10 +103,8 @@ export interface Config { * * **Details** * - * This state tracks the pool scope, active and available items, invalidated - * items, semaphores, waiters, and shutdown status. It is exposed for - * inspection and implementation support; user code should prefer the - * high-level pool operations. + * This state is exposed for inspection and implementation support. User code + * should prefer the high-level pool operations. * * @see {@link Pool} for the pool value exposing this state * @see {@link PoolItem} for the entries stored in the runtime item sets @@ -109,13 +117,13 @@ export interface Config { export interface State { readonly scope: Scope.Scope isShuttingDown: boolean - readonly semaphore: Semaphore.Semaphore + usage: number readonly resizeSemaphore: Semaphore.Semaphore readonly items: Set> - readonly available: Set> - readonly availableLatch: Latch.Latch + availableHead: PoolItem | undefined + availableTail: PoolItem | undefined readonly invalidated: Set> - waiters: number + readonly waiters: Set<() => void> } /** @@ -144,6 +152,10 @@ export interface PoolItem { finalizer: Effect.Effect refCount: number disableReclaim: boolean + isAvailable: boolean + availablePrevious: PoolItem | undefined + availableNext: PoolItem | undefined + release: (exit: Exit.Exit) => Effect.Effect } /** @@ -223,7 +235,7 @@ export const make = (options: { readonly concurrency?: number | undefined readonly targetUtilization?: number | undefined }): Effect.Effect, never, R | Scope.Scope> => - makeWithStrategy({ ...options, min: options.size, max: options.size, strategy: strategyNoop() }) + makeWithStrategy({ ...options, min: options.size, max: options.size, strategy: strategyNoop }) /** * Creates a scoped pool with minimum and maximum sizes and a time-to-live @@ -340,6 +352,7 @@ export const makeWithStrategy = (options: { const config: Config = { acquire, concurrency, + isFixed: options.min === options.max, minSize: options.min, maxSize: options.max, strategy: options.strategy, @@ -348,16 +361,18 @@ export const makeWithStrategy = (options: { const state: State = { scope, isShuttingDown: false, - semaphore: Semaphore.makeUnsafe(concurrency * options.max), + usage: 0, resizeSemaphore: Semaphore.makeUnsafe(1), items: new Set(), - available: new Set(), - availableLatch: Latch.makeUnsafe(false), + availableHead: undefined, + availableTail: undefined, invalidated: new Set(), - waiters: 0 + waiters: new Set() } - const self: Pool = { + const self: PoolImpl = { [TypeId]: TypeId, + [Acquire]: options.acquire as Effect.Effect, + [AcquireContext]: services as Context.Context, config, state, pipe() { @@ -365,14 +380,18 @@ export const makeWithStrategy = (options: { } } yield* Scope.addFinalizer(scope, shutdown(self)) - yield* Effect.tap( - Effect.forkDetach(restore(resize(self))), - (fiber) => Scope.addFinalizer(scope, Fiber.interrupt(fiber)) - ) - yield* Effect.tap( - Effect.forkDetach(restore(options.strategy.run(self))), - (fiber) => Scope.addFinalizer(scope, Fiber.interrupt(fiber)) - ) + if (config.minSize > 0) { + yield* Effect.tap( + Effect.forkDetach(restore(resize(self)), { startImmediately: true }), + (fiber) => Scope.addFinalizer(scope, Fiber.interrupt(fiber)) + ) + } + if (options.strategy !== strategyNoop) { + yield* Effect.tap( + Effect.forkDetach(restore(options.strategy.run(self))), + (fiber) => Scope.addFinalizer(scope, Fiber.interrupt(fiber)) + ) + } return self })) @@ -388,13 +407,17 @@ const shutdown = Effect.fnUntraced(function*(self: Pool) { yield* semaphore.take(1) } else { self.state.items.delete(item) - self.state.available.delete(item) + removeAvailable(self, item) self.state.invalidated.delete(item) yield* item.finalizer } } yield* semaphore.releaseAll - self.state.availableLatch.openUnsafe() + if (self.state.waiters.size > 0) { + const waiters = Array.from(self.state.waiters) + self.state.waiters.clear() + for (const notify of waiters) notify() + } yield* semaphore.take(size) }) @@ -421,73 +444,259 @@ const shutdown = Effect.fnUntraced(function*(self: Pool) { * @since 2.0.0 */ export const get = (self: Pool): Effect.Effect => - Effect.suspend(() => { - if (self.state.isShuttingDown) { - return Effect.interrupt + core.withFiber((fiber) => { + const state = self.state + if (state.isShuttingDown) return internal.interrupt + if (state.availableHead !== undefined) { + state.usage++ + if (self.config.isFixed || targetSize(self) <= activeSize(self)) { + return leaseItem(self, state.availableHead, fiber) + } + state.usage-- } - return Effect.flatMap(getPoolItem(self), (item) => item.exit) + return getSlowWith(self, leaseItemWith) }) -const getPoolItem = (self: Pool): Effect.Effect, never, Scope.Scope> => - Effect.uninterruptibleMask((restore) => - restore(self.state.semaphore.take(1)).pipe( - Effect.flatMap(() => Effect.scope), - Effect.flatMap((scope) => - getPoolItemInner(self).pipe( - Effect.ensuring(Effect.sync(() => self.state.waiters--)), - Effect.tap((item) => { - if (item.exit._tag === "Failure") { - self.state.items.delete(item) - self.state.invalidated.delete(item) - self.state.available.delete(item) - return self.state.semaphore.release(1) - } - item.refCount++ - self.state.available.delete(item) - if (item.refCount < self.config.concurrency) { - self.state.available.add(item) - } - return Scope.addFinalizerExit(scope, () => - Effect.flatMap( - Effect.suspend(() => { - item.refCount-- - if (self.state.invalidated.has(item)) { - return invalidatePoolItem(self, item) - } - self.state.available.add(item) - return Effect.void - }), - () => self.state.semaphore.release(1) - )) - }), - Effect.onInterrupt(() => self.state.semaphore.release(1)) - ) - ) - ) - ) +/** + * Borrows an item while an effect runs and returns it when the effect exits. + * + * **When to use** + * + * Use when an item is needed by one effect. Unlike `Effect.scoped` with + * {@link get}, this avoids allocating a scope and registering a finalizer. + * + * **Example** (Running a single operation with a pooled item) + * + * ```ts import.meta.vitest + * import { Effect, Pool } from "effect" + * + * const program = Effect.scoped( + * Effect.flatMap( + * Pool.make({ acquire: Effect.succeed("resource"), size: 2 }), + * (pool) => Pool.use(pool, (item) => Effect.succeed(item.length)) + * ) + * ) + * + * await Effect.runPromise(program) // => 8 + * ``` + * + * @see {@link get} for borrowing an item for the lifetime of a scope + * + * @category combinators + * @since 4.0.0 + */ +export const use: { + ( + f: (item: A) => Effect.Effect + ): (self: Pool) => Effect.Effect + ( + self: Pool, + f: (item: A) => Effect.Effect + ): Effect.Effect +} = dual(2, ( + self: Pool, + f: (item: A) => Effect.Effect +): Effect.Effect => + internal.suspend(() => { + const state = self.state + if (state.isShuttingDown) return internal.interrupt + if (state.availableHead !== undefined) { + state.usage++ + if (self.config.isFixed || targetSize(self) <= activeSize(self)) { + return useItem(self, state.availableHead, f) + } + state.usage-- + } + return getSlowWith(self, (self, item, _fiber, restore) => useItem(self, item, f, restore)) + })) -const getPoolItemInner = Effect.fnUntraced(function*( - self: Pool -) { - self.state.waiters++ - if (self.state.isShuttingDown) { - return yield* Effect.interrupt - } else if (targetSize(self) > activeSize(self)) { - while (true) { - yield* self.state.resizeSemaphore.withPermitsIfAvailable(1)( - Effect.forkIn(Effect.interruptible(resize(self)), self.state.scope) - ) - if (self.state.isShuttingDown) { - return yield* Effect.interrupt - } else if (self.state.available.size > 0) { - return Iterable.headUnsafe(self.state.available) +const useItem = ( + self: Pool, + item: PoolItem, + f: (item: A) => Effect.Effect, + restore?: (effect: Effect.Effect) => Effect.Effect +): Effect.Effect => { + if (!leaseItemBookkeeping(self, item)) { + return item.exit as Exit.Exit + } + let body: Effect.Effect + try { + body = f((item.exit as Exit.Success).value) + } catch (defect) { + return internal.flatMap(item.release(item.exit), () => core.exitDie(defect)) + } + return internal.onExitPrimitive(restore !== undefined ? restore(body) : body, item.release) +} + +const getSlowWith = ( + self: Pool, + lease: ( + self: Pool, + item: PoolItem, + fiber: Fiber.Fiber, + restore: (effect: Effect.Effect) => Effect.Effect + ) => Effect.Effect +): Effect.Effect => + internal.uninterruptibleMask((restore) => { + const state = self.state + state.usage++ + const wait: Effect.Effect = internal.flatMap( + internal.onInterrupt( + restore(waitForItem(self)), + () => + internal.sync(() => { + state.usage-- + }) + ), + () => loop + ) + const step: Effect.Effect = core.withFiber((fiber) => { + if (state.isShuttingDown) { + state.usage-- + return internal.interrupt } - self.state.availableLatch.closeUnsafe() - yield* self.state.availableLatch.await + if (state.availableHead !== undefined) { + return lease(self, state.availableHead, fiber, restore) + } + return wait + }) + const loop: Effect.Effect = internal.suspend(() => { + if (state.isShuttingDown) { + state.usage-- + return internal.interrupt + } + return targetSize(self) > activeSize(self) + ? internal.flatMap( + state.resizeSemaphore.withPermitsIfAvailable(1)( + Effect.forkIn(Effect.interruptible(resize(self)), state.scope) + ), + () => step + ) + : step + }) + return loop + }) + +const leaseItemBookkeeping = (self: Pool, item: PoolItem): boolean => { + const state = self.state + if (item.exit._tag === "Failure") { + state.usage-- + state.items.delete(item) + state.invalidated.delete(item) + removeAvailable(self, item) + return false + } + item.refCount++ + if (item.refCount >= self.config.concurrency) { + removeAvailable(self, item) + } + return true +} + +const leaseItem = ( + self: Pool, + item: PoolItem, + fiber: Fiber.Fiber +): Effect.Effect => { + if (!leaseItemBookkeeping(self, item)) { + return item.exit + } + const scope = Context.getUnsafe(fiber.context, Scope.Scope) + if (scope.state._tag === "Closed") { + return internal.flatMap(item.release(item.exit), () => item.exit) + } + internal.scopeAddFinalizerUnsafe(scope, {}, item.release) + return item.exit +} + +const leaseItemWith = ( + self: Pool, + item: PoolItem, + fiber: Fiber.Fiber +): Effect.Effect => leaseItem(self, item, fiber) + +const releaseItem = (self: Pool, item: PoolItem): Effect.Effect => + core.withFiber((fiber) => { + const state = self.state + item.refCount-- + state.usage-- + if (state.invalidated.has(item)) { + return invalidatePoolItem(self, item) + } + if (item.refCount === self.config.concurrency - 1) { + addAvailable(self, item) + wakeWaiters(self, fiber, 1) } + return internal.void + }) + +const waitForItem = (self: Pool): Effect.Effect => + internal.callback((resume) => { + const state = self.state + if (state.availableHead !== undefined || state.isShuttingDown) { + return resume(internal.void) + } + const observer = () => { + state.waiters.delete(observer) + resume(internal.void) + } + state.waiters.add(observer) + return internal.sync(() => { + state.waiters.delete(observer) + }) + }) + +const wakeWaiters = (self: Pool, fiber: Fiber.Fiber, count: number) => { + const waiters = self.state.waiters + if (waiters.size === 0) return + fiber.currentDispatcher.scheduleTask(() => { + let remaining = count + const toWake: Array<() => void> = [] + for (const notify of waiters) { + if (remaining-- <= 0) break + toWake.push(notify) + } + for (let i = 0; i < toWake.length; i++) { + toWake[i]() + } + }, 0) +} + +const wakeAll = (self: Pool): Effect.Effect => + core.withFiber((fiber) => { + wakeWaiters(self, fiber, Number.POSITIVE_INFINITY) + return internal.void + }) + +const addAvailable = (self: Pool, item: PoolItem): void => { + if (item.isAvailable) return + item.isAvailable = true + item.availablePrevious = self.state.availableTail + item.availableNext = undefined + if (self.state.availableTail !== undefined) { + self.state.availableTail.availableNext = item + } else { + self.state.availableHead = item } - return Iterable.headUnsafe(self.state.available) -}) + self.state.availableTail = item +} + +const removeAvailable = (self: Pool, item: PoolItem): void => { + if (!item.isAvailable) return + item.isAvailable = false + if (item.availablePrevious !== undefined) { + item.availablePrevious.availableNext = item.availableNext + } else { + self.state.availableHead = item.availableNext + } + if (item.availableNext !== undefined) { + item.availableNext.availablePrevious = item.availablePrevious + } else { + self.state.availableTail = item.availablePrevious + } + item.availablePrevious = undefined + item.availableNext = undefined +} /** * Invalidates the specified item so the pool can remove it and reallocate the @@ -529,15 +738,15 @@ const invalidatePoolItem = (self: Pool, poolItem: PoolItem): E return Effect.void } else if (poolItem.refCount === 0) { self.state.items.delete(poolItem) - self.state.available.delete(poolItem) + removeAvailable(self, poolItem) self.state.invalidated.delete(poolItem) return Effect.asVoid(Effect.flatMap( poolItem.finalizer, - () => Effect.forkIn(Effect.interruptible(resize(self)), self.state.scope) + () => Effect.forkIn(Effect.interruptible(resize(self)), self.state.scope, { startImmediately: true }) )) } self.state.invalidated.add(poolItem) - self.state.available.delete(poolItem) + removeAvailable(self, poolItem) return Effect.void }) @@ -552,52 +761,76 @@ const resizeLoop = (self: Pool): Effect.Effect => return Effect.void } const toAcquire = target - active - return self.config.strategy.reclaim(self).pipe( - Effect.flatMap((item) => item ? Effect.succeed(item) : allocate(self)), + const acquireOne = self.config.strategy === strategyNoop + ? allocate(self) + : Effect.flatMap( + self.config.strategy.reclaim(self), + (item) => item ? Effect.succeed(item) : allocate(self) + ) + if (toAcquire === 1) { + const acquired = Effect.tap(acquireOne, wakeAll(self)) + return self.config.isFixed + ? Effect.asVoid(acquired) + : Effect.flatMap(acquired, (item) => item.exit._tag === "Failure" ? Effect.void : resizeLoop(self)) + } + const acquired = acquireOne.pipe( Effect.replicateEffect(toAcquire, { concurrency: toAcquire }), - Effect.tap(self.state.availableLatch.open), - Effect.flatMap((items) => items.some((_) => _.exit._tag === "Failure") ? Effect.void : resizeLoop(self)) + Effect.tap(wakeAll(self)) ) + return self.config.isFixed + ? Effect.asVoid(acquired) + : Effect.flatMap( + acquired, + (items) => items.some((_) => _.exit._tag === "Failure") ? Effect.void : resizeLoop(self) + ) }) const allocate = (self: Pool): Effect.Effect> => - Effect.acquireUseRelease( - Scope.make(), - (scope) => - self.config.acquire.pipe( - Scope.provide(scope), - Effect.exit, - Effect.flatMap((exit) => { - const item: PoolItem = { - exit, - finalizer: Effect.catchCause(Scope.close(scope, exit), reportUnhandledError), - refCount: 0, - disableReclaim: false - } - self.state.items.add(item) - self.state.available.add(item) - return Effect.as( - exit._tag === "Success" - ? self.config.strategy.onAcquire(item) - : Effect.flatMap(item.finalizer, () => self.config.strategy.onAcquire(item)), - item - ) - }) - ), - (scope, exit) => exit._tag === "Failure" ? Scope.close(scope, exit) : Effect.void + internal.uninterruptibleMask((restore) => + core.withFiber((fiber) => { + const impl = self as PoolImpl + const scope = internal.scopeMakeUnsafe() + const previousContext = fiber.context + fiber.setContext(Context.add(impl[AcquireContext], Scope.Scope, scope)) + const use = Effect.flatMap(Effect.exit(impl[Acquire]), (exit) => { + const item: PoolItem = { + exit, + finalizer: Effect.catchCause(Scope.close(scope, exit), reportUnhandledError), + refCount: 0, + disableReclaim: false, + isAvailable: false, + availablePrevious: undefined, + availableNext: undefined, + release: undefined as any + } + item.release = constant(releaseItem(self, item)) + self.state.items.add(item) + addAvailable(self, item) + if (self.config.strategy === strategyNoop) { + return exit._tag === "Success" ? Effect.succeed(item) : Effect.as(item.finalizer, item) + } + return Effect.as( + exit._tag === "Success" + ? self.config.strategy.onAcquire(item) + : Effect.flatMap(item.finalizer, () => self.config.strategy.onAcquire(item)), + item + ) + }) + return internal.onExitPrimitive( + restore(use) as Effect.Effect>, + (exit) => { + fiber.setContext(previousContext) + return exit._tag === "Failure" ? internal.scopeCloseUnsafe(scope, exit) : undefined + }, + true + ) + }) ) -const currentUsage = (self: Pool) => { - let count = self.state.waiters - for (const item of self.state.items) { - count += item.refCount - } - return count -} - const targetSize = (self: Pool) => { if (self.state.isShuttingDown) return 0 - const utilization = currentUsage(self) / self.config.targetUtilization + if (self.config.isFixed) return self.config.minSize + const utilization = self.state.usage / self.config.targetUtilization const target = Math.ceil(utilization / self.config.concurrency) return Math.min(Math.max(self.config.minSize, target), self.config.maxSize) } @@ -610,11 +843,11 @@ const activeSize = (self: Pool) => { // Strategy // ----------------------------------------------------------------------------- -const strategyNoop = (): Strategy => ({ +const strategyNoop: Strategy = { run: (_) => Effect.void, onAcquire: (_) => Effect.void, reclaim: (_) => Effect.undefined -}) +} const strategyCreationTTL = Effect.fnUntraced(function*(ttl: Duration.Input) { const clock = yield* Clock @@ -680,7 +913,7 @@ const strategyUsageTTL = Effect.fnUntraced(function*(ttl: Duration.Input) } pool.state.invalidated.delete(item.value) if (item.value.refCount < pool.config.concurrency) { - pool.state.available.add(item.value) + addAvailable(pool, item.value) } return Effect.as(Queue.offer(queue, item.value), item.value) }) diff --git a/repos/effect/packages/effect/src/PubSub.ts b/repos/effect/packages/effect/src/PubSub.ts index 740003636a..3127101cba 100644 --- a/repos/effect/packages/effect/src/PubSub.ts +++ b/repos/effect/packages/effect/src/PubSub.ts @@ -540,7 +540,12 @@ export const makeAtomicBounded = ( */ export const makeAtomicUnbounded = (options?: { readonly replay?: number | undefined -}): PubSub.Atomic => new UnboundedPubSub(options?.replay ? new ReplayBuffer(options.replay) : undefined) +}): PubSub.Atomic => { + const replay = options?.replay + return new UnboundedPubSub( + replay && replay > 0 ? new ReplayBuffer(Math.ceil(replay)) : undefined + ) +} /** * Returns the number of elements the queue can hold. diff --git a/repos/effect/packages/effect/src/Pull.ts b/repos/effect/packages/effect/src/Pull.ts index 116e26a311..514b66e02f 100644 --- a/repos/effect/packages/effect/src/Pull.ts +++ b/repos/effect/packages/effect/src/Pull.ts @@ -214,19 +214,35 @@ export const isDoneFailure = ( * * **Details** * - * Returns a successful `Result` with the `Cause.Done` value when one is - * present, otherwise returns a failed `Result` containing the non-done cause. + * Returns a successful `Result` with the `Cause.Done` value when the cause + * contains a done signal and no other failures besides interruptions. When the + * done signal was merged with a real failure (for example a failing + * finalizer), the `Result` fails with the remaining cause, stripped of the + * done signal. Without a done signal the `Result` fails with the original + * cause. * * @category filtering * @since 4.0.0 */ export const filterDone: ( input: Cause.Cause -) => Result.Result, Cause.Cause>> = Filter - .composePassthrough( - Cause.findError, - (e) => Cause.isDone(e) ? Result.succeed(e) : Result.fail(e) - ) as any +) => Result.Result, Cause.Cause>> = ( + cause: Cause.Cause +): Result.Result => { + let done: Cause.Done | undefined + let hasFailure = false + for (const reason of cause.reasons) { + if (isDoneFailure(reason)) { + done ??= reason.error + } else if (reason._tag !== "Interrupt") { + hasFailure = true + } + } + if (done === undefined) return Result.fail(cause) + return hasFailure + ? Result.fail(Cause.fromReasons(cause.reasons.filter((reason) => !isDoneFailure(reason)))) + : Result.succeed(done) +} /** * Finds a `Cause.Done` failure in a cause whose done value is not used. @@ -238,8 +254,8 @@ export const filterDone: ( * * **Details** * - * Returns a successful `Result` with the done marker when present, otherwise - * returns a failed `Result` with the non-done cause. + * Returns a successful `Result` with the done marker when it is the only + * failure, otherwise returns a failed `Result` with the non-done cause. * * @see {@link filterDone} for preserving the typed `Cause.Done` value when the done payload matters * @see {@link filterDoneLeftover} for extracting only the done leftover value @@ -250,10 +266,7 @@ export const filterDone: ( */ export const filterDoneVoid: ( input: Cause.Cause -) => Result.Result>> = Filter.composePassthrough( - Cause.findError, - (e) => Cause.isDone(e) ? Result.succeed(e) : Result.fail(e) -) as any +) => Result.Result>> = filterDone as any /** * Keeps a `Cause` only when it contains no `Cause.Done` failures. @@ -296,10 +309,10 @@ export const filterNoDone: ( */ export const filterDoneLeftover: ( cause: Cause.Cause -) => Result.Result, Cause.Cause>> = Filter.composePassthrough( - Cause.findError, - (e) => Cause.isDone(e) ? Result.succeed(e.value) : Result.fail(e) -) as any +) => Result.Result, Cause.Cause>> = ((cause: Cause.Cause) => { + const done = filterDone(cause) + return Result.isFailure(done) ? done : Result.succeed(done.success.value) +}) as any /** * Converts a `Cause` into an `Exit`, treating `Cause.Done` as successful @@ -313,8 +326,9 @@ export const filterDoneLeftover: ( * * **Details** * - * If the cause contains a done value, that leftover becomes the successful - * value. Otherwise the non-done cause becomes the failure cause. + * If the done signal is the only failure in the cause, its leftover becomes + * the successful value. Otherwise the non-done cause becomes the failure + * cause. * * @see {@link filterDone} for extracting the done signal without converting the cause to an `Exit` * @see {@link matchEffect} for handling `Pull` success, failure, and done outcomes directly diff --git a/repos/effect/packages/effect/src/Queue.ts b/repos/effect/packages/effect/src/Queue.ts index d31edc0e1a..cad92efc3f 100644 --- a/repos/effect/packages/effect/src/Queue.ts +++ b/repos/effect/packages/effect/src/Queue.ts @@ -1570,16 +1570,14 @@ export const takeUnsafe = (self: Dequeue): Exit | undefined => const await_ = (self: Dequeue): Effect> => internalEffect.callback>((resume) => { + const awaiter = (effect: Effect) => resume(Pull.catchDone(effect, () => internalEffect.exitVoid)) if (self.state._tag === "Done") { - if (Pull.isDoneCause(self.state.exit.cause)) { - return resume(internalEffect.exitVoid) - } - return resume(self.state.exit) + return awaiter(self.state.exit) } - self.state.awaiters.add(resume) + self.state.awaiters.add(awaiter) return internalEffect.sync(() => { if (self.state._tag !== "Done") { - self.state.awaiters.delete(resume) + self.state.awaiters.delete(awaiter) } }) }) diff --git a/repos/effect/packages/effect/src/RcMap.ts b/repos/effect/packages/effect/src/RcMap.ts index f1de43441a..1e00423de8 100644 --- a/repos/effect/packages/effect/src/RcMap.ts +++ b/repos/effect/packages/effect/src/RcMap.ts @@ -21,6 +21,7 @@ import * as Exit from "./Exit.ts" import * as Fiber from "./Fiber.ts" import { constant, dual, flow } from "./Function.ts" import * as MutableHashMap from "./MutableHashMap.ts" +import type * as Option from "./Option.ts" import type { Pipeable } from "./Pipeable.ts" import { pipeArguments } from "./Pipeable.ts" import * as Scope from "./Scope.ts" @@ -381,6 +382,73 @@ export const get: { }) ) +/** + * Retains and returns an existing resource without invoking the map's lookup + * function when the key is missing. + * + * **When to use** + * + * Use when you only want to acquire a reference to a resource that is currently + * cached. + * + * **Details** + * + * Returns `Option.none` when the key is not currently stored or the map is + * closed. If an entry exists, its reference count is incremented for the current + * `Scope` before awaiting its result. A successful entry returns + * `Option.some(value)`, while an in-flight or cached failure fails with the same + * error as `get`. + * + * **Example** (Retaining only cached resources) + * + * ```ts import.meta.vitest + * import { Effect, Option, RcMap } from "effect" + * + * const program = Effect.gen(function*() { + * const map = yield* RcMap.make({ + * lookup: (key: string) => Effect.succeed(`Resource: ${key}`), + * idleTimeToLive: "1 minute" + * }) + * + * const missing = yield* RcMap.getOption(map, "database") + * yield* Effect.scoped(RcMap.get(map, "database")) + * const cached = yield* Effect.scoped(RcMap.getOption("database")(map)) + * + * return [missing, cached] as const + * }) + * + * await Effect.runPromise(Effect.scoped(program)) // => [Option.none(), Option.some("Resource: database")] + * ``` + * + * @see {@link get} for acquiring a resource when the key is missing + * @see {@link has} for checking presence without retaining or awaiting the entry + * + * @category combinators + * @since 4.0.0 + */ +export const getOption: { + (key: K): (self: RcMap) => Effect.Effect, E, Scope.Scope> + (self: RcMap, key: K): Effect.Effect, E, Scope.Scope> +} = dual( + 2, + (self: RcMap, key: K): Effect.Effect, E, Scope.Scope> => + Effect.uninterruptibleMask((restore) => { + if (self.state._tag === "Closed") { + return Effect.succeedNone + } + const o = MutableHashMap.get(self.state.map, key) + if (o._tag === "None") { + return Effect.succeedNone + } + const entry = o.value + entry.refCount++ + const scope = Context.getUnsafe(Fiber.getCurrent()!.context, Scope.Scope) + return Scope.addFinalizer(scope, entry.finalizer).pipe( + Effect.andThen(Effect.asSome(restore(Deferred.await(entry.deferred)))) + ) + }) +) + const release = (self: RcMap, key: K, entry: State.Entry) => Effect.withFiber((fiber) => { entry.refCount-- diff --git a/repos/effect/packages/effect/src/Record.ts b/repos/effect/packages/effect/src/Record.ts index 7ac2341a8e..ec47fe3031 100644 --- a/repos/effect/packages/effect/src/Record.ts +++ b/repos/effect/packages/effect/src/Record.ts @@ -126,6 +126,21 @@ export declare namespace ReadonlyRecord { export type IntersectKeys = [string] extends [K1 | K2] ? NonLiteralKey & NonLiteralKey : K1 & K2 + + /** + * Represents a record produced by grouping values under keys of type `K`. + * + * **Details** + * + * Finite string or symbol key types become optional because an input may not + * produce every possible key. Open `string` and `symbol` key types retain their + * record index signatures. + * + * @category utility types + * @since 4.0.0 + */ + export type GroupByResult = [NonLiteralKey] extends [K] ? Record + : Partial> } /** diff --git a/repos/effect/packages/effect/src/Schedule.ts b/repos/effect/packages/effect/src/Schedule.ts index a85e5d4eda..a9d4816dac 100644 --- a/repos/effect/packages/effect/src/Schedule.ts +++ b/repos/effect/packages/effect/src/Schedule.ts @@ -1321,6 +1321,13 @@ export const upTo: { }) const while_: { + >( + predicate: ( + metadata: Metadata + ) => metadata is Meta + ): ( + self: Schedule + ) => Schedule ( predicate: ( metadata: Metadata @@ -1328,6 +1335,12 @@ const while_: { ): ( self: Schedule ) => Schedule + >( + self: Schedule, + predicate: ( + metadata: Metadata + ) => metadata is Meta + ): Schedule ( self: Schedule, predicate: ( diff --git a/repos/effect/packages/effect/src/Scheduler.ts b/repos/effect/packages/effect/src/Scheduler.ts index 376f003827..1a6b7f87d6 100644 --- a/repos/effect/packages/effect/src/Scheduler.ts +++ b/repos/effect/packages/effect/src/Scheduler.ts @@ -94,7 +94,7 @@ const setImmediate = "setImmediate" in globalThis const setMicrotask = (f: () => void) => { let cancelled = false - queueMicrotask(() => { + Promise.resolve().then(() => { if (!cancelled) f() }) return (): void => { diff --git a/repos/effect/packages/effect/src/Schema.ts b/repos/effect/packages/effect/src/Schema.ts index 28961e24c1..2924d92fcd 100644 --- a/repos/effect/packages/effect/src/Schema.ts +++ b/repos/effect/packages/effect/src/Schema.ts @@ -13,7 +13,6 @@ */ /** @effect-diagnostics schemaStructWithTag:skip-file */ -import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" import * as Arr from "./Array.ts" import * as BigDecimal_ from "./BigDecimal.ts" import type * as Brand from "./Brand.ts" @@ -31,9 +30,12 @@ import * as Exit_ from "./Exit.ts" import type { Formatter } from "./Formatter.ts" import { format, formatPropertyKey } from "./Formatter.ts" import { identity, memoize } from "./Function.ts" +import * as Graph_ from "./Graph.ts" import * as HashMap_ from "./HashMap.ts" import * as HashSet_ from "./HashSet.ts" import * as core from "./internal/core.ts" +import { effectIsExit } from "./internal/effect.ts" +import * as InternalGraph from "./internal/graph.ts" import * as InternalRecord from "./internal/record.ts" import * as InternalAnnotations from "./internal/schema/annotations.ts" import * as InternalSchema from "./internal/schema/schema.ts" @@ -41,6 +43,7 @@ import * as InternalArbitrary from "./internal/schema/toArbitrary.ts" import * as InternalEquivalence from "./internal/schema/toEquivalence.ts" import * as InternalToJsonSchemaDocument from "./internal/schema/toJsonSchemaDocument.ts" import * as InternalToRepresentation from "./internal/schema/toRepresentation.ts" +import { getStackTraceLimit, setStackTraceLimit } from "./internal/stackTraceLimit.ts" import * as JsonPatch from "./JsonPatch.ts" import * as JsonSchema from "./JsonSchema.ts" import { remainder } from "./Number.ts" @@ -55,12 +58,12 @@ import * as RegExp_ from "./RegExp.ts" import * as Result_ from "./Result.ts" import * as Scheduler from "./Scheduler.ts" import * as SchemaAST from "./SchemaAST.ts" -import { isSchemaError, SchemaError } from "./SchemaError.ts" import * as SchemaGetter from "./SchemaGetter.ts" import * as SchemaIssue from "./SchemaIssue.ts" import * as SchemaParser from "./SchemaParser.ts" import type * as SchemaRepresentation from "./SchemaRepresentation.ts" import * as SchemaTransformation from "./SchemaTransformation.ts" +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "./StandardSchema.ts" import type { Assign, Lambda, Mutable, Simplify } from "./Struct.ts" import * as Struct_ from "./Struct.ts" import type * as FastCheck from "./testing/FastCheck.ts" @@ -1142,55 +1145,81 @@ export interface Optic extends Schema { readonly "Rebuild": Optic } -export { - /** - * Returns `true` if `u` is a {@link SchemaError}. - * - * **Example** (Narrowing Schema errors) - * - * ```ts import.meta.vitest - * import { Result, Schema } from "effect" - * - * const result = Result.try(() => Schema.decodeUnknownSync(Schema.Number)("oops")) - * const error: unknown = Result.isFailure(result) ? result.failure : undefined - * Schema.isSchemaError(error) // => true - * ``` - * - * @category guards - * @since 4.0.0 - */ - isSchemaError, - /** - * Error thrown (or returned as the error channel value) when schema decoding - * or encoding fails. - * - * **Details** - * - * The `issue` field contains a structured {@link SchemaIssue.Issue} tree describing - * every validation failure, including the path to the problematic value and - * the expected type or constraint. Parsing with `reportInput: true` adds an - * enumerable `input` field to value-bearing issues created by the parser. - * Built-in messages may include reported input. Other issue fields and - * custom annotations or messages are not sanitized. - * `message` renders the issue tree as a human-readable string and can disclose - * retained input. - * - * Use {@link isSchemaError} to narrow an unknown value to `SchemaError`. - * - * **Example** (Inspecting a SchemaError) - * - * ```ts import.meta.vitest - * import { Result, Schema } from "effect" - * - * const result = Schema.decodeUnknownResult(Schema.Number)("not a number") - * const message = Result.isFailure(result) ? result.failure.message : "" - * message // => "Expected number" - * ``` - * - * @category errors - * @since 4.0.0 - */ - SchemaError +const SchemaErrorTypeId = "~effect/SchemaError/SchemaError" + +/** + * Error thrown or returned when schema decoding or encoding fails. + * + * **Details** + * + * The `issue` field contains a structured {@link SchemaIssue.Issue} tree describing + * every validation failure, including the path to the problematic value and + * the expected type or constraint. The `message` field renders the issue tree + * with the default formatter. + * + * **Gotchas** + * + * Parsing with `reportInput: true` adds an enumerable `input` field to + * value-bearing issues. Built-in messages may include reported input, and + * custom annotations or messages are not sanitized. + * + * **Example** (Inspecting a SchemaError) + * + * ```ts import.meta.vitest + * import { Result, Schema } from "effect" + * + * const result = Schema.decodeUnknownResult(Schema.Number)("not a number") + * const message = Result.isFailure(result) ? result.failure.message : "" + * message // => "Expected number" + * ``` + * + * @see {@link isSchemaError} for narrowing unknown values + * @category errors + * @since 4.0.0 + */ +export class SchemaError extends Data.TaggedError("SchemaError")<{ + readonly issue: SchemaIssue.Issue +}> { + readonly [SchemaErrorTypeId]: typeof SchemaErrorTypeId = SchemaErrorTypeId + constructor(issue: SchemaIssue.Issue) { + const stackTraceLimit = getStackTraceLimit() + setStackTraceLimit(0) + try { + super({ issue }) + } finally { + setStackTraceLimit(stackTraceLimit) + } + } + override get message() { + return SchemaIssue.defaultFormatter(this.issue) + } + override toString() { + return `SchemaError(${this.message})` + } +} + +/** + * Returns `true` if `u` is a {@link SchemaError}. + * + * **When to use** + * + * Use when you need to narrow an unknown value to `SchemaError`. + * + * **Example** (Narrowing Schema errors) + * + * ```ts import.meta.vitest + * import { Result, Schema } from "effect" + * + * const result = Result.try(() => Schema.decodeUnknownSync(Schema.Number)("oops")) + * const error: unknown = Result.isFailure(result) ? result.failure : undefined + * Schema.isSchemaError(error) // => true + * ``` + * + * @category guards + * @since 4.0.0 + */ +export function isSchemaError(u: unknown): u is SchemaError { + return Predicate.hasProperty(u, SchemaErrorTypeId) && u[SchemaErrorTypeId] === SchemaErrorTypeId } function makeStandardResult(exit: Exit_.Exit>): StandardSchemaV1.Result { @@ -1497,6 +1526,9 @@ export function decodeUnknownEffect(schema: S, options?: S function fromIssueEffect( self: Effect.Effect ): Effect.Effect { + if (effectIsExit(self)) { + return fromIssueExit(self as Exit_.Exit) + } return Effect.catchCause( self, (cause) => Effect.failCauseSync(() => Cause_.map(cause, (issue) => new SchemaError(issue))) @@ -6176,6 +6208,14 @@ type Flatten = Schemas extends readonly [infer Head, ...infer Tail] : [Head, ...Flatten] : [] +type MatchCasesResult = { + [K in keyof Cases]-?: NonNullable extends (...args: Array) => infer R ? R : never +}[keyof Cases] + +type MatchOrElseResult) => any> = Unify< + MatchCasesResult | ReturnType +> + type TaggedUnionUtils< Tag extends PropertyKey, Members extends ReadonlyArray, @@ -6207,6 +6247,31 @@ type TaggedUnionUtils< ): (value: Members[number]["Type"]) => Cases[keyof Cases] extends (value: any) => infer R ? Unify : never } + readonly matchOrElse: { + < + Cases extends + & { [M in Flattened[number] as M["Type"][Tag]]+?: (value: M["Type"]) => any } + & { [K in Exclude]: never }, + OrElse extends ( + value: Exclude + ) => any + >( + value: Members[number]["Type"], + cases: Cases, + orElse: OrElse + ): MatchOrElseResult + < + Cases extends + & { [M in Flattened[number] as M["Type"][Tag]]+?: (value: M["Type"]) => any } + & { [K in Exclude]: never }, + OrElse extends ( + value: Exclude + ) => any + >( + cases: Cases, + orElse: OrElse + ): (value: Members[number]["Type"]) => MatchOrElseResult + } } /** @@ -6262,7 +6327,7 @@ export function toTaggedUnion(tag: Tag) { walk(self) - return Object.assign(self, { cases, discriminants, isAnyOf, guards, match }) as any + return Object.assign(self, { cases, discriminants, isAnyOf, guards, match, matchOrElse }) as any function walk(schema: Constraint) { const ast = schema.ast @@ -6308,6 +6373,24 @@ export function toTaggedUnion(tag: Tag) { const handler = Object.hasOwn(cases, key) ? cases[key] : undefined return handler(value) } + + function matchOrElse() { + if (arguments.length === 2) { + const cases = arguments[0] + const orElse = arguments[1] + return function(value: any) { + const key = value[tag] + const handler = Object.hasOwn(cases, key) ? cases[key] ?? orElse : orElse + return handler(value) + } + } + const value = arguments[0] + const cases = arguments[1] + const orElse = arguments[2] + const key = value[tag] + const handler = Object.hasOwn(cases, key) ? cases[key] ?? orElse : orElse + return handler(value) + } } } @@ -6344,12 +6427,23 @@ export interface TaggedUnion> extends cases: { [K in keyof Cases]: (value: Cases[K]["Type"]) => Output } ): Output } + readonly matchOrElse: { + ( + value: Cases[keyof Cases]["Type"], + cases: { [K in keyof Cases]?: (value: Cases[K]["Type"]) => Output }, + orElse: (value: Cases[keyof Cases]["Type"]) => Output + ): Output + ( + cases: { [K in keyof Cases]?: (value: Cases[K]["Type"]) => Output }, + orElse: (value: Cases[keyof Cases]["Type"]) => Output + ): (value: Cases[keyof Cases]["Type"]) => Output + } } /** * Builds a discriminated union from a record of field sets, one per variant. * Each key becomes the `_tag` literal and the value is passed to {@link TaggedStruct}. - * The result includes `cases`, `guards`, `isAnyOf`, and `match` utilities. + * The result includes `cases`, `guards`, `isAnyOf`, `match`, and `matchOrElse` utilities. * * **Example** (Pattern matching a discriminated union) * @@ -6384,8 +6478,8 @@ export function TaggedUnion = Graph_.Snapshot + +/** + * Iso representation used for {@link Graph} schemas. + * + * @category utility types + * @since 4.0.0 + */ +export type GraphIso = EncodedGraph< + Node["Iso"], + Edge["Iso"], + T +> + +/** + * Type-level representation returned by {@link Graph}. + * + * @category models + * @since 4.0.0 + */ +export interface Graph + extends + declareConstructor< + Graph_.Graph, + Graph_.Graph, + readonly [Node, Edge], + GraphIso + > +{ + readonly "Rebuild": Graph + readonly type: T + readonly node: Node + readonly edge: Edge +} + +function graphEncodedSchema( + type: T, + node: Node, + edge: Edge +) { + return Struct({ + type: Literal(type), + nodes: ArraySchema(Struct({ index: Natural, data: node })), + edges: ArraySchema(Struct({ index: Natural, source: Natural, target: Natural, data: edge })) + }) +} + +function graphDecode( + input: EncodedGraph, + options: SchemaAST.ParseOptions +): Effect.Effect, SchemaIssue.Issue> { + let previous = -1 + const indexes = new Set() + for (let i = 0; i < input.nodes.length; i++) { + const index = input.nodes[i].index + if (index <= previous) { + return Effect.fail( + new SchemaIssue.Pointer( + ["nodes", i, "index"], + new SchemaIssue.InvalidValue({ expected: "a strictly increasing node index" }, index, options) + ) + ) + } + previous = index + indexes.add(index) + } + + previous = -1 + for (let i = 0; i < input.edges.length; i++) { + const edge = input.edges[i] + if (edge.index <= previous) { + return Effect.fail( + new SchemaIssue.Pointer( + ["edges", i, "index"], + new SchemaIssue.InvalidValue({ expected: "a strictly increasing edge index" }, edge.index, options) + ) + ) + } + previous = edge.index + if (!indexes.has(edge.source)) { + return Effect.fail( + new SchemaIssue.Pointer( + ["edges", i, "source"], + new SchemaIssue.InvalidValue({ expected: "an encoded node index" }, edge.source, options) + ) + ) + } + if (!indexes.has(edge.target)) { + return Effect.fail( + new SchemaIssue.Pointer( + ["edges", i, "target"], + new SchemaIssue.InvalidValue({ expected: "an encoded node index" }, edge.target, options) + ) + ) + } + } + + return Effect.succeed(InternalGraph.hydrate(input)) +} + +function graphEncode( + input: Graph_.Graph, + type: T, + options: SchemaAST.ParseOptions +): Effect.Effect, SchemaIssue.Issue> { + if (!Graph_.isGraph(input) || input.mutable || input.type !== type) { + return Effect.fail(new SchemaIssue.InvalidValue({ expected: `an immutable ${type} Graph` }, input, options)) + } + return Effect.succeed(InternalGraph.snapshot(input)) +} + +function graphToEquivalence( + node: Equivalence.Equivalence, + edge: Equivalence.Equivalence +) { + return (self: Graph_.Graph, that: Graph_.Graph): boolean => { + const a = InternalGraph.snapshot(self) + const b = InternalGraph.snapshot(that) + if (a.type !== b.type || a.nodes.length !== b.nodes.length || a.edges.length !== b.edges.length) return false + for (let i = 0; i < a.nodes.length; i++) { + if (a.nodes[i].index !== b.nodes[i].index || !node(a.nodes[i].data, b.nodes[i].data)) return false + } + for (let i = 0; i < a.edges.length; i++) { + const ae = a.edges[i] + const be = b.edges[i] + const sameEndpoints = a.type === "directed" + ? ae.source === be.source && ae.target === be.target + : (ae.source === be.source && ae.target === be.target) || + (ae.source === be.target && ae.target === be.source) + if ( + ae.index !== be.index || !sameEndpoints || !edge(ae.data, be.data) + ) return false + } + return true + } +} + +function graphToArbitrary( + type: T, + node: Annotations.ToArbitrary.TypeParameter, + edge: Annotations.ToArbitrary.TypeParameter +) { + return (fc: typeof FastCheck, ctx: Annotations.ToArbitrary.Context) => { + const empty = InternalGraph.hydrate({ type, nodes: [], edges: [] }) + const terminal = fc.constant(empty) + const arbitrary = fc.array(node.arbitrary).chain((values) => { + const nodes = values.map((data, index) => ({ index, data })) + if (nodes.length === 0) return terminal + const endpoint = fc.integer({ min: 0, max: nodes.length - 1 }) + return fc.array(fc.tuple(endpoint, endpoint, edge.arbitrary)).map((values) => + InternalGraph.hydrate({ + type, + nodes, + edges: values.map(([source, target, data], index) => ({ index, source, target, data })) + }) + ) + }) + return withRecursion(fc, ctx, terminal, arbitrary) + } +} + +/** + * Creates a schema for immutable directed or undirected Effect graphs. + * + * Encoding preserves active node and edge indexes, payloads, endpoints, + * isolated nodes, self-loops, parallel edges, and stored edge orientation. It + * does not encode removed-ID allocator history; after decoding, future allocation starts + * after the highest active decoded index. Encoding rejects mutable graphs. + * `Graph.toJSON()` remains an inspection summary and is not this wire format. + * + * **Example** (Encoding a directed graph as JSON) + * + * ```ts import.meta.vitest + * import { Graph, Schema } from "effect" + * + * const codec = Schema.toCodecJson(Schema.Graph("directed", Schema.String, Schema.Number)) + * const graph = Graph.directed((mutable) => { + * const source = Graph.addNode(mutable, "A") + * const target = Graph.addNode(mutable, "B") + * Graph.addEdge(mutable, source, target, 1) + * }) + * + * const encoded = Schema.encodeSync(codec)(graph) + * + * encoded.type // => "directed" + * encoded.nodes // => [{ index: 0, data: "A" }, { index: 1, data: "B" }] + * encoded.edges // => [{ index: 0, source: 0, target: 1, data: 1 }] + * ``` + * + * @category schemas + * @since 4.0.0 + */ +export function Graph( + type: "directed", + node: Node, + edge: Edge +): Graph<"directed", Node, Edge> +export function Graph( + type: "undirected", + node: Node, + edge: Edge +): Graph<"undirected", Node, Edge> +export function Graph( + type: T, + node: Node, + edge: Edge +): Graph +export function Graph( + type: T, + node: Node, + edge: Edge +): Graph { + const schema = declareConstructor< + Graph_.Graph, + Graph_.Graph, + GraphIso + >()( + [node, edge], + ([node, edge]) => { + const encoded = graphEncodedSchema(type, node, edge) + return (input, ast, options) => { + if (!Graph_.isGraph(input) || input.mutable || input.type !== type) { + return Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + } + return Effect.flatMap( + SchemaParser.decodeUnknownEffect(encoded)(InternalGraph.snapshot(input), options), + (snapshot) => graphDecode(snapshot, options) + ) + } + }, + { + representation: { + id: "effect/schema/Graph", + payload: type + }, + toCode: ({ typeParameters }) => ({ + runtime: `Schema.Graph(${format(type)}, ${typeParameters[0].runtime}, ${typeParameters[1].runtime})`, + Type: `Graph.Graph<${typeParameters[0].Type}, ${typeParameters[1].Type}, ${format(type)}>`, + importDeclarations: [`import * as Graph from "effect/Graph"`] + }), + expected: `an immutable ${type} Graph`, + toCodec: ([node, edge]) => + link>()( + graphEncodedSchema(type, node, edge), + SchemaTransformation.transformOrFail({ + decode: graphDecode, + encode: (graph, options) => graphEncode(graph, type, options) + }) + ), + toArbitrary: ([node, edge]) => graphToArbitrary(type, node, edge), + toEquivalence: ([node, edge]) => graphToEquivalence(node, edge), + toFormatter: () => globalThis.String + } + ) + return make(schema.ast, { type, node, edge }) +} + +/** + * Reviver for persisted {@link Graph} declarations. + * + * @category schemas + * @since 4.0.0 + */ +export const GraphReviver = InternalSchema.makeDeclarationReviver( + "effect/schema/Graph", + Literals(["directed", "undirected"]), + ({ annotations, payload, typeParameters }) => { + const schema = Graph(payload, typeParameters[0], typeParameters[1]) + return annotations === undefined ? schema : schema.annotate(annotations) + } +) + /** * Type-level representation returned by {@link HashMap}. * @@ -14780,15 +15152,26 @@ export function toEquivalence(schema: Schema): Equivalence.Equivalence * Derives an intermediate `SchemaRepresentation.Document` from the encoded * side of a schema. * + * **When to use** + * + * Use when you have a `Schema` and need its live structural representation for inspection, persistence, or compilation. + * * **Details** * * Use {@link toType} before this function to represent the type side instead. + * The optional reference policy controls which candidates are extracted into the document's reference table. By + * default, only candidates with a resolved identifier become references; recursive candidates always require one. + * + * @see {@link SchemaRepresentation.toRepresentation} for converting a `SchemaAST.AST` directly * * @category converting * @since 4.0.0 */ -export function toRepresentation(schema: Constraint): SchemaRepresentation.Document { - return InternalToRepresentation.toRepresentation(schema.ast) +export function toRepresentation( + schema: Constraint, + options?: SchemaRepresentation.ToRepresentationOptions +): SchemaRepresentation.Document { + return InternalToRepresentation.toRepresentation(schema.ast, options) } // ----------------------------------------------------------------------------- @@ -14796,12 +15179,23 @@ export function toRepresentation(schema: Constraint): SchemaRepresentation.Docum // ----------------------------------------------------------------------------- /** - * Options for {@link toJsonSchemaDocument}. + * Options for reference allocation and JSON Schema generation in {@link toJsonSchemaDocument}. + * + * **Details** + * + * The inherited `referencePolicy` runs after the input schema is converted to its canonical JSON codec, so it receives + * canonical JSON-encoded ASTs. The remaining options control compilation of the resulting live representation. + * + * **Gotchas** + * + * When these options are passed directly to `SchemaRepresentation.toJsonSchemaDocument` or + * `SchemaRepresentation.toJsonSchemaMultiDocument`, reference allocation has already happened and `referencePolicy` + * has no effect. * * @category options * @since 4.0.0 */ -export interface ToJsonSchemaOptions { +export interface ToJsonSchemaOptions extends SchemaRepresentation.ToRepresentationOptions { /** * Controls how additional properties are handled while resolving the JSON * schema. @@ -14867,20 +15261,37 @@ export interface ToJsonSchemaOptions { /** * Returns a JSON Schema document using draft 2020-12. * + * **When to use** + * + * Use when you need a draft-2020-12 description of the canonical JSON form of a runtime schema. + * * **Details** * - * The `options` parameter controls generation details such as additional - * properties and synthesized check descriptions; it does not change the draft - * target. Declarations are lowered through their `toCodecJson` or `toCodec` + * The `options` parameter controls reference extraction and generation details + * such as additional properties and synthesized check descriptions; it does + * not change the draft target. The reference policy receives canonical JSON + * encoded ASTs. By default, anonymous non-recursive candidates remain inline, while candidates with resolved identifiers + * become definitions. Declarations are lowered through their `toCodecJson` or `toCodec` * annotation when available before the representation document is compiled. + * For schemas whose codec JSON AST can be represented exactly in JSON Schema, + * importing the emitted document reconstructs a schema that accepts the same + * JSON values. This is a semantic round-trip guarantee; the reconstructed AST + * may have a different shape. * * **Gotchas** * * JSON Schema generation is best-effort. Some Effect schema semantics cannot * be represented exactly in JSON Schema, and importing an emitted JSON Schema * may produce an equivalent approximation rather than the original schema - * shape. Opaque declarations without a structural codec are represented by an - * unconstrained JSON Schema. + * shape. Such schemas are outside the exact round-trip subset. When canonical + * JSON derivation adds an artificial transformation, checks and annotations on + * its source node are not copied to the JSON target, so they do not appear in + * the emitted document. Opaque declarations without a structural codec are + * represented by an unconstrained JSON Schema. Effect decoding may discard + * excess object properties by default; use `onExcessProperty: "error"` when + * comparing validation semantics with an emitted JSON Schema. + * + * @see {@link SchemaRepresentation.toJsonSchemaDocument} for compiling an existing live representation document * * @category converting * @since 4.0.0 @@ -14891,7 +15302,7 @@ export function toJsonSchemaDocument( ): JsonSchema.Document<"draft-2020-12"> { const document = InternalToRepresentation.toRepresentation( toCodecJsonAST(schema.ast), - InternalToJsonSchemaDocument.toRepresentationOptions + options ) return InternalToJsonSchemaDocument.toJsonSchemaDocument(document, options) } @@ -14932,13 +15343,22 @@ export interface toCodecJson extends * Derives a canonical JSON codec from a schema. The encoded form is `Json`, and * decoding produces the schema's `Type`. * + * **Details** + * + * Derivation does not run transformations. Annotation links may be asynchronous, + * may fail, and may use optional services; the consuming parser chooses the + * execution and failure handling. Because hooks do not widen the returned + * service types, links cannot require services not declared by the input schema. + * * **Gotchas** * * Declarations without a `toCodecJson` or `toCodec` annotation use `Json` as * their encoded schema. This keeps codec construction total, but encoding or * decoding can still fail when declaration values are not JSON values. A * `toCodecJson` callback can return `undefined` when the declaration is already - * in canonical JSON form. + * in canonical JSON form. When derivation adds an artificial transformation, + * checks and annotations remain on its source node rather than being copied to + * the JSON target. Source checks still run after the transformation. * * @category converting * @since 4.0.0 @@ -15057,8 +15477,18 @@ function toCodecJsonASTStep(ast: SchemaAST.AST, recur: (ast: SchemaAST.AST) => S } /** - * Derives an isomorphism codec from a schema. The encoded form is the - * schema's `Iso` type — the intermediate representation used for round-tripping. + * Derives an isomorphism codec from a schema. The encoded form is the schema's + * `Iso` type — the intermediate representation used for round-tripping. + * + * **Details** + * + * Annotation links may be asynchronous, may fail, and may use optional services; + * the consuming parser chooses the execution and failure handling. + * + * **Gotchas** + * + * Links cannot require services because the returned `Codec` does not expose + * service requirements. * * @category converting * @since 4.0.0 @@ -15134,6 +15564,13 @@ export interface toCodecStringTree extends * Converts a schema to the StringTree canonical codec, where every leaf value * becomes a string while preserving the original structure. * + * **Details** + * + * Derivation does not run transformations. Annotation links may be asynchronous, + * may fail, and may use optional services; the consuming parser chooses the + * execution and failure handling. Links cannot require services not declared by + * the input schema because hooks do not widen the returned service types. + * * **Gotchas** * * Declarations must provide a structural `toCodecStringTree`, `toCodecJson`, or @@ -15691,10 +16128,11 @@ export const isBetweenBigIntReviver: SchemaRepresentation.FilterReviver<{ * * **Gotchas** * - * Either direction can throw an `Error` with the generic message - * `"Schema validation failed"` and a `SchemaIssue.Issue` in its `cause`. Format - * the `cause` explicitly with `SchemaIssue.makeFormatterDefault()` when - * human-readable details are needed. + * This API runs synchronously, so failing, asynchronous, or service-dependent + * transformations can throw. Schema failures use `"Schema validation failed"` + * with a `SchemaIssue.Issue` in `cause`; format it with + * `SchemaIssue.makeFormatterDefault()`. Consume {@link toCodecIso} with an + * effectful parser for asynchronous execution or explicit failure handling. * * @category converting * @since 4.0.0 @@ -15802,11 +16240,11 @@ export function overrideToCodecIso( * * **Gotchas** * - * Schema encoding or decoding failures throw an `Error` with the generic message - * `"Schema validation failed"` and a `SchemaIssue.Issue` in its `cause`. Format - * the `cause` explicitly with `SchemaIssue.makeFormatterDefault()` when - * human-readable details are needed. Errors produced by {@link JsonPatch.apply} - * for invalid patch operations are separate from schema validation failures. + * This API runs synchronously, so failing, asynchronous, or service-dependent + * transformations can throw. Schema failures use `"Schema validation failed"` + * with a `SchemaIssue.Issue` in `cause`; format it with + * `SchemaIssue.makeFormatterDefault()`. Invalid patch operations instead produce + * {@link JsonPatch.apply} errors. * * @category converting * @since 4.0.0 @@ -15920,6 +16358,28 @@ export const Json: Codec = make(SchemaAST.annotate(SchemaAST.Json, { }) })) +/** + * Schema for readonly string-keyed records whose values are JSON-compatible. + * + * **When to use** + * + * Use when you need to validate a JSON object rather than any JSON value. + * + * **Example** (Validating a JSON object) + * + * ```ts import.meta.vitest + * import { Option, Schema } from "effect" + * + * Schema.decodeUnknownOption(Schema.JsonObject)({ key: [1, true, null] }) // => Option.some({ key: [1, true, null] }) + * Schema.decodeUnknownOption(Schema.JsonObject)([1, 2, 3]) // => Option.none() + * ``` + * + * @see {@link Json} for a schema that also accepts JSON arrays and primitive values + * @category schemas + * @since 4.0.0 + */ +export const JsonObject = Record(String, Json) + /** * Reviver for persisted `Json` declarations. * @@ -16244,15 +16704,41 @@ export declare namespace Annotations { readonly representation?: | SchemaRepresentation.RepresentationAnnotation | undefined + /** + * Returns the fallback link used by canonical codec derivations. + * + * Transformations may be asynchronous, may fail, and may use optional + * services, but cannot require services absent from the derived codec type. + */ readonly toCodec?: | ((typeParameters: TypeParameters.Encoded) => SchemaAST.Link) | undefined + /** + * Returns the link used to derive the declaration's JSON representation, or + * `undefined` when the declaration is already in canonical JSON form. + * + * Transformations follow the execution and service constraints of `toCodec`. + */ readonly toCodecJson?: | ((typeParameters: TypeParameters.Encoded) => SchemaAST.Link | undefined) | undefined + /** + * Returns the link used to derive the declaration's StringTree + * representation, or `undefined` when it is already canonical. + * + * Transformations follow the execution and service constraints of `toCodec`. + */ readonly toCodecStringTree?: | ((typeParameters: TypeParameters.Encoded) => SchemaAST.Link | undefined) | undefined + /** + * Returns the link used to derive the declaration's isomorphism + * representation. + * + * Transformations may be asynchronous, may fail, and may use optional + * services, but cannot require services because the derived `Codec` exposes + * none. + */ readonly toCodecIso?: | ((typeParameters: TypeParameters.Type) => SchemaAST.Link) | undefined @@ -16263,9 +16749,7 @@ export declare namespace Annotations { /** * Used to collect sentinels from a Declaration SchemaAST. * - * **Details** - * - * Reserved to internal use only. + * @internal */ readonly "~sentinels"?: ReadonlyArray | undefined } diff --git a/repos/effect/packages/effect/src/SchemaAST.ts b/repos/effect/packages/effect/src/SchemaAST.ts index b1a8897873..e99d7f473b 100644 --- a/repos/effect/packages/effect/src/SchemaAST.ts +++ b/repos/effect/packages/effect/src/SchemaAST.ts @@ -657,6 +657,16 @@ export abstract class Base { } } +/** + * Parser factory carried by a {@link Declaration}. + * + * @category models + * @since 4.0.0 + */ +export type DeclarationRun = ( + typeParameters: ReadonlyArray +) => (input: unknown, self: Declaration, options: ParseOptions) => Effect.Effect + /** * AST node for user-defined opaque types with custom parsing logic. * @@ -679,26 +689,29 @@ export abstract class Base { export class Declaration extends Base { readonly _tag = "Declaration" readonly typeParameters: ReadonlyArray - readonly run: ( - typeParameters: ReadonlyArray - ) => (input: unknown, self: Declaration, options: ParseOptions) => Effect.Effect + readonly run: DeclarationRun readonly encodingChecks: Checks | undefined + /** + * Parser factory {@link flip} swaps in, so a declaration can behave + * differently when encoding. `undefined` reuses {@link run}. + */ + readonly encodingRun: DeclarationRun | undefined constructor( typeParameters: ReadonlyArray, - run: ( - typeParameters: ReadonlyArray - ) => (input: unknown, self: Declaration, options: ParseOptions) => Effect.Effect, + run: DeclarationRun, annotations?: Schema.Annotations.Annotations, checks?: Checks, encoding?: Encoding, context?: Context, - encodingChecks?: Checks + encodingChecks?: Checks, + encodingRun?: DeclarationRun ) { super(annotations, checks, encoding, context) this.typeParameters = typeParameters this.run = run this.encodingChecks = encodingChecks + this.encodingRun = encodingRun } /** @internal */ getParser(): SchemaParser.Parser { @@ -708,19 +721,26 @@ export class Declaration extends Base { return (run ??= this.run(this.typeParameters))(input, this, options) } } - private _rebuild(recur: (ast: AST) => AST, checks: Checks | undefined, encodingChecks: Checks | undefined) { + private _rebuild( + recur: (ast: AST) => AST, + checks: Checks | undefined, + encodingChecks: Checks | undefined, + run: DeclarationRun, + encodingRun: DeclarationRun | undefined + ) { const tps = mapOrSame(this.typeParameters, recur) - return tps === this.typeParameters && checks === this.checks && encodingChecks === this.encodingChecks ? + return tps === this.typeParameters && checks === this.checks && encodingChecks === this.encodingChecks && + run === this.run && encodingRun === this.encodingRun ? this : - new Declaration(tps, this.run, this.annotations, checks, undefined, this.context, encodingChecks) + new Declaration(tps, run, this.annotations, checks, undefined, this.context, encodingChecks, encodingRun) } /** @internal */ recur(recur: (ast: AST) => AST) { - return this._rebuild(recur, this.checks, this.encodingChecks) + return this._rebuild(recur, this.checks, this.encodingChecks, this.run, this.encodingRun) } /** @internal */ flip(recur: (ast: AST) => AST) { - return this._rebuild(recur, this.encodingChecks, this.checks) + return this._rebuild(recur, this.encodingChecks, this.checks, this.encodingRun ?? this.run, this.run) } /** @internal */ getExpected(): string { @@ -1432,7 +1452,7 @@ export class Number extends Base { ) { return this } - return replaceEncoding(this, [numberToJson(this.checks)]) + return replaceEncoding(this, [numberToJson]) } /** @internal */ toCodecStringTree(): AST { @@ -1454,20 +1474,6 @@ function hasCheck(checks: ReadonlyArray>, id: string): boolean { ) } -function numberToJson(checks: Checks | undefined): Link { - const encodedFinite = !checks - ? finite - : appendChecks(finite, checks) - - return new Link( - new Union([encodedFinite, nonFiniteLiterals], "anyOf"), - new SchemaTransformation.Transformation( - SchemaGetter.Number(), - SchemaGetter.transform((n) => globalThis.Number.isFinite(n) ? n : globalThis.String(n)) - ) - ) -} - /** * Provides the singleton {@link Number} AST instance. * @@ -2206,15 +2212,7 @@ export class Objects extends Base { }) : undefined - return Effect.fnUntracedEager(function*(input, options) { - if (input === InternalParser.missing) { - return InternalParser.missing - } - - // If the input is not a record, return early with an error - if (!(typeof input === "object" && input !== null && !Array.isArray(input))) { - return yield* Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) - } + const compileMembers = (): Array => { if (!properties) { properties = ast.propertySignatures.map((ps) => ({ parser: compileConstructorDefault(ps.type), @@ -2229,6 +2227,19 @@ export class Objects extends Base { })) : undefined } + return properties + } + + const fallback: SchemaParser.Parser = Effect.fnUntracedEager(function*(input, options) { + if (input === InternalParser.missing) { + return InternalParser.missing + } + + // If the input is not a record, return early with an error + if (!(typeof input === "object" && input !== null && !Array.isArray(input))) { + return yield* Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + } + compileMembers() const record = input as Record const out: Record = {} @@ -2334,6 +2345,68 @@ export class Objects extends Base { } return out }) + + if (indexCount) return fallback + + // Resumes at the property whose parser suspended, without replaying the + // properties already parsed. + const resume = ( + state: ObjectParserState, + index: number, + pending: Effect.Effect + ): Effect.Effect => { + const property = properties![index] + return Effect.flatMap(Effect.exit(pending), (exit) => { + const terminal = stepProperty(state, property, exit) + if (terminal) return terminal + const done = () => InternalParser.succeed(state.out) + const eff = parseProperties(state, properties!.slice(index + 1)) + return eff ? Effect.flatMapEager(eff, done) : done() + }) + } + + // Fast path: a struct without index signatures, under the default parse + // options, needs none of the generator the fallback runs per value. + return (input, options) => { + if (input === InternalParser.missing) return InternalParser.missingExit + if ( + options.errors === "all" || + options.onExcessProperty !== undefined || + options.propertyOrder === "original" || + options.concurrency !== undefined + ) { + return fallback(input, options) + } + if (!(typeof input === "object" && input !== null && !Array.isArray(input))) { + return Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + } + const props = compileMembers() + const record = input as Record + const out: Record = {} + const state: ObjectParserState = { ast, input: record, out, issues: undefined, options } + try { + for (let index = 0; index < props.length; index++) { + const property = props[index] + const name = property.name + const hasKey = Object.hasOwn(record, name) + const value = hasKey ? record[name] : InternalParser.missing + const exit = property.parser(value, options) + if (!effectIsExit(exit)) { + return resume(state, index, exit) + } + if (exit === InternalParser.sameExit) { + if (hasKey) InternalRecord.assignProperty(out, name, value) + continue + } + const terminal = stepProperty(state, property, exit) + if (terminal) return terminal + } + } catch (error) { + // `Effect.fnUntracedEager` turns a synchronous throw into a defect + return Effect.die(error) + } + return InternalParser.succeed(out) + } } private _rebuild( recur: (ast: AST) => AST, @@ -2396,6 +2469,35 @@ type ParsedProperty = { readonly type: AST } +function stepProperty( + s: ObjectParserState, + p: ParsedProperty, + exit: Exit.Exit +): Exit.Exit | void { + if (exit._tag === "Failure") { + return wrapPropertyKeyIssue(s, s.ast, p.name, exit) + } + if (exit === InternalParser.sameExit) return + const value = (exit as InternalParser.Success)[InternalParser.args] + if (value !== InternalParser.missing) { + InternalRecord.assignProperty(s.out, p.name, value) + return + } + delete s.out[p.name] + if (!isOptional(p.type)) { + const issue = new SchemaIssue.Pointer([p.name], new SchemaIssue.MissingKey(p.type.context?.annotations)) + if (s.options.errors === "all") { + if (s.issues) s.issues.push(issue) + else s.issues = [issue] + return + } else { + return Exit.fail( + new SchemaIssue.Composite(s.ast, [issue], s.input, s.options) + ) + } + } +} + const parseProperties = iterateEager()({ onItem(s, p) { if (!Object.hasOwn(s.input, p.name)) { @@ -2405,30 +2507,7 @@ const parseProperties = iterateEager()({ InternalRecord.assignProperty(s.out, p.name, value) return p.parser(value, s.options) }, - step(s, p, exit) { - if (exit._tag === "Failure") { - return wrapPropertyKeyIssue(s, s.ast, p.name, exit) - } - if (exit === InternalParser.sameExit) return - const value = (exit as InternalParser.Success)[InternalParser.args] - if (value !== InternalParser.missing) { - InternalRecord.assignProperty(s.out, p.name, value) - return - } - delete s.out[p.name] - if (!isOptional(p.type)) { - const issue = new SchemaIssue.Pointer([p.name], new SchemaIssue.MissingKey(p.type.context?.annotations)) - if (s.options.errors === "all") { - if (s.issues) s.issues.push(issue) - else s.issues = [issue] - return - } else { - return Exit.fail( - new SchemaIssue.Composite(s.ast, [issue], s.input, s.options) - ) - } - } - } + step: stepProperty }) function combineChecks(a: Checks | undefined, b: Checks | undefined): Checks | undefined { @@ -2614,33 +2693,43 @@ export function collectSentinels(ast: AST): ReadonlyArray { } return [] }) + case "Union": { + if (ast.types.length === 0) return [] + const members = ast.types.map((type) => collectSentinels(toCandidate(type))) + return members[0].filter((s) => + members.every((sentinels) => sentinels.some((o) => o.key === s.key && o.literal === s.literal)) + ) + } case "Suspend": return collectSentinels(ast.thunk()) } } -type CandidateIndex = - | ((input: any, isConstructor: boolean) => ReadonlyArray) - | { - bySentinel?: Map>> - otherwise?: { [K in Type]?: Array } - } +type CandidateIndex = (input: any, isConstructor: boolean) => ReadonlyArray +type SentinelEntry = readonly [ + byValue: Map>, + all: Set +] +type SentinelIndex = Map const candidateIndexCache = new WeakMap, CandidateIndex>() const emptyCandidates: ReadonlyArray = Object.freeze([]) function getIndex(types: ReadonlyArray): CandidateIndex { - let idx = candidateIndexCache.get(types) - if (idx) return idx - - idx = {} - let literalCandidates: Map> | null | undefined + let index = candidateIndexCache.get(types) + if (index) return index + + let bySentinel: SentinelIndex | undefined + let sentinelCandidateCount = 0 + let otherwise: { [K in Type]?: Array } | undefined + let literalCandidates: Map> | undefined + let onlyLiterals = true for (let i = 0; i < types.length; i++) { const a = types[i] const encoded = toCandidate(a) if (isNever(encoded)) continue - if (literalCandidates !== null) { + if (onlyLiterals) { if (isLiteral(encoded) || isUniqueSymbol(encoded)) { literalCandidates ??= new Map() const literal = isLiteral(encoded) ? encoded.literal : encoded.symbol @@ -2648,50 +2737,122 @@ function getIndex(types: ReadonlyArray): CandidateIndex { if (!arr) literalCandidates.set(literal, arr = []) arr.push(a) } else { - literalCandidates = null + onlyLiterals = false } } const sentinels = collectSentinels(encoded) if (sentinels.length) { // discriminated variants - idx.bySentinel ??= new Map() + bySentinel ??= new Map() + sentinelCandidateCount++ for (const { key, literal } of sentinels) { - let m = idx.bySentinel.get(key) - if (!m) idx.bySentinel.set(key, m = new Map()) - let arr = m.get(literal) - if (!arr) m.set(literal, arr = []) - if (arr[arr.length - 1] !== i) arr.push(i) + let entry = bySentinel.get(key) + if (!entry) bySentinel.set(key, entry = [new Map(), new Set()]) + entry[1].add(i) + let indexes = entry[0].get(literal) + if (!indexes) entry[0].set(literal, indexes = new Set()) + indexes.add(i) } } else { // non-discriminated - idx.otherwise ??= {} + otherwise ??= {} const candidateTypes = getCandidateTypes(encoded) - for (const t of candidateTypes) (idx.otherwise[t] ??= []).push(i) + for (const t of candidateTypes) (otherwise[t] ??= []).push(i) } } - if (literalCandidates) { + if (onlyLiterals && literalCandidates) { literalCandidates.forEach(Object.freeze) - idx = (input) => literalCandidates.get(input) ?? emptyCandidates - } else if (idx.bySentinel?.size === 1 && !idx.otherwise) { - for (const [key, byValue] of idx.bySentinel) { - const candidates = byValue as unknown as Map> - for (const [literal, indexes] of byValue) { - candidates.set(literal, Object.freeze(indexes.map((index) => types[index]))) + index = (input) => literalCandidates.get(input) ?? emptyCandidates + } else if (bySentinel?.size === 1 && !otherwise) { + const [key, [byValue]] = bySentinel.entries().next().value! + const candidates = byValue as unknown as Map> + for (const [literal, indexes] of byValue) { + candidates.set(literal, Object.freeze(Array.from(indexes, (index) => types[index]))) + } + index = (input, isConstructor) => { + if (Predicate.isObjectKeyword(input)) { + const value = Object.hasOwn(input, key) ? (input as any)[key] : undefined + if (value !== undefined) return candidates.get(value) ?? emptyCandidates + if (isConstructor) return types + } + return emptyCandidates + } + } else if (bySentinel) { + // A key owned by every discriminated candidate is safe to use as the initial selector: no candidate can + // be excluded merely because it uses a different sentinel key. Prefer the key with the most distinct values + // to minimize the matching bucket. + let commonSentinel: [PropertyKey, SentinelEntry] | undefined + for (const entry of bySentinel) { + if ( + (!commonSentinel || entry[1][0].size > commonSentinel[1][0].size) && + entry[1][1].size === sentinelCandidateCount + ) { + commonSentinel = entry + } + } + + index = (input, isConstructor) => { + const runtimeType: Type = input === null ? "null" : Array.isArray(input) ? "array" : typeof input + const base = otherwise?.[runtimeType] ?? emptyCandidates + if (!Predicate.isObjectKeyword(input)) return base.map((i) => types[i]) + + // Non-discriminated candidates are runtime-type fallbacks and are never removed by sentinel checks. + const selected = new Set(base) + let directKey: PropertyKey | undefined + // An observed common key can seed the selection directly; an unknown value rules out every + // discriminated candidate. + if (commonSentinel) { + const [key, [byValue]] = commonSentinel + const hasKey = Object.hasOwn(input, key) + const value = hasKey ? (input as any)[key] : undefined + if (hasKey && (!isConstructor || value !== undefined)) { + const match = byValue.get(value) + if (!match) return base.map((i) => types[i]) + for (const i of match) selected.add(i) + directKey = key + } + } + + // Without an observed common key, collect positive matches from every sentinel. Constructor mode treats + // absent and undefined keys as unconstrained and therefore selects every candidate that owns the key. + if (directKey === undefined) { + for (const [key, [byValue, all]] of bySentinel) { + const hasKey = Object.hasOwn(input, key) + const value = hasKey ? (input as any)[key] : undefined + if (hasKey && (!isConstructor || value !== undefined)) { + const match = byValue.get(value) + if (match) { + for (const i of match) selected.add(i) + } + } else if (isConstructor) { + for (const i of all) selected.add(i) + } + } } - idx = (input, isConstructor) => { - if (Predicate.isObjectKeyword(input)) { - const value = Object.hasOwn(input, key) ? (input as any)[key] : undefined - if (value !== undefined) return candidates.get(value) ?? emptyCandidates - if (isConstructor) return types + // Missing keys are neutral. An observed key rejects only selected candidates that own it and do not match. + for (const [key, [byValue, all]] of bySentinel) { + if (key === directKey) continue + const hasKey = Object.hasOwn(input, key) + const value = hasKey ? (input as any)[key] : undefined + if (hasKey && (!isConstructor || value !== undefined)) { + const match = byValue.get(value) + for (const i of selected) { + if (all.has(i) && !match?.has(i)) selected.delete(i) + } } - return emptyCandidates } + return Array.from(selected).sort((a, b) => a - b).map((i) => types[i]) + } + } else { + index = (input) => { + const runtimeType: Type = input === null ? "null" : Array.isArray(input) ? "array" : typeof input + return (otherwise?.[runtimeType] ?? emptyCandidates).map((i) => types[i]).filter(filterLiterals(input)) } } - candidateIndexCache.set(types, idx) - return idx + candidateIndexCache.set(types, index) + return index } function filterLiterals(input: any) { @@ -2716,36 +2877,7 @@ export function getCandidates( types: ReadonlyArray, isConstructor = false ): ReadonlyArray { - const idx = getIndex(types) - if (typeof idx === "function") return idx(input, isConstructor) - - const runtimeType: Type = input === null ? "null" : Array.isArray(input) ? "array" : typeof input - - // 1. Try sentinel-based dispatch (most selective) - if (idx.bySentinel) { - const base = idx.otherwise?.[runtimeType] ?? emptyCandidates - if (Predicate.isObjectKeyword(input)) { - const selected = new Set(base) - for (const [k, m] of idx.bySentinel) { - const value = Object.hasOwn(input, k) ? (input as any)[k] : undefined - if (value !== undefined) { - const match = m.get(value) - if (match) { - for (const candidate of match) selected.add(candidate) - } - } else if (isConstructor) { - for (const indexes of m.values()) { - for (const candidate of indexes) selected.add(candidate) - } - } - } - return Array.from(selected).sort((a, b) => a - b).map((i) => types[i]) - } - return base.map((i) => types[i]) - } - - // 2. Fallback: runtime-type dispatch only - return (idx.otherwise?.[runtimeType] ?? emptyCandidates).map((i) => types[i]).filter(filterLiterals(input)) + return getIndex(types)(input, isConstructor) } /** @@ -3208,6 +3340,14 @@ export function isFinite(annotations?: Schema.Annotations.Filter) { /** @internal */ export const finite = appendChecks(number, [isFinite()]) +const numberToJson = new Link( + new Union([finite, nonFiniteLiterals], "anyOf"), + new SchemaTransformation.Transformation( + SchemaGetter.Number(), + SchemaGetter.transform((n) => globalThis.Number.isFinite(n) ? n : globalThis.String(n)) + ) +) + /** * Creates a {@link Filter} that validates strings by running `RegExp.test`. * diff --git a/repos/effect/packages/effect/src/SchemaError.ts b/repos/effect/packages/effect/src/SchemaError.ts deleted file mode 100644 index 87e7afc487..0000000000 --- a/repos/effect/packages/effect/src/SchemaError.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @since 4.0.0 - */ -import * as Data from "./Data.ts" -import * as Predicate from "./Predicate.ts" -import * as SchemaIssue from "./SchemaIssue.ts" - -const TypeId = "~effect/SchemaError/SchemaError" - -/** - * Error thrown (or returned as the error channel value) when schema decoding - * or encoding fails. - * - * **Details** - * - * The `issue` field contains a structured {@link SchemaIssue.Issue} tree describing - * every validation failure, including the path to the problematic value and - * the expected type or constraint. The `message` field renders the issue tree - * with the default formatter. When input reporting is enabled, the message may - * include reported input. Other Issue fields and custom annotations or messages - * are not sanitized. - * - * Use {@link isSchemaError} to narrow an unknown value to `SchemaError`. - * - * **Example** (Catching a SchemaError) - * - * ```ts import.meta.vitest - * import { Schema } from "effect" - * - * try { - * Schema.decodeUnknownSync(Schema.Number)("not a number") - * } catch (err) { - * if (Schema.isSchemaError(err)) { - * err.message // => "Expected number" - * } - * } - * ``` - * - * @category errors - * @since 4.0.0 - */ -export class SchemaError extends Data.TaggedError("SchemaError")<{ - readonly issue: SchemaIssue.Issue -}> { - readonly [TypeId]: typeof TypeId = TypeId - constructor(issue: SchemaIssue.Issue) { - super({ issue }) - } - override get message() { - return SchemaIssue.defaultFormatter(this.issue) - } - override toString() { - return `SchemaError(${this.message})` - } -} - -/** - * Returns `true` if `u` is a {@link SchemaError}. - * - * @category guards - * @since 4.0.0 - */ -export function isSchemaError(u: unknown): u is SchemaError { - return Predicate.hasProperty(u, TypeId) && u[TypeId] === TypeId -} diff --git a/repos/effect/packages/effect/src/SchemaIssue.ts b/repos/effect/packages/effect/src/SchemaIssue.ts index b0b0eb2ddd..07be9d9dce 100644 --- a/repos/effect/packages/effect/src/SchemaIssue.ts +++ b/repos/effect/packages/effect/src/SchemaIssue.ts @@ -9,7 +9,6 @@ * * @since 4.0.0 */ -import type { StandardSchemaV1 } from "@standard-schema/spec" import * as Arr from "./Array.ts" import { format, formatPath, type Formatter as FormatterI } from "./Formatter.ts" import * as InternalAnnotations from "./internal/schema/annotations.ts" @@ -17,6 +16,7 @@ import * as InternalParser from "./internal/schema/parser.ts" import { hasProperty } from "./Predicate.ts" import type * as Schema from "./Schema.ts" import type * as SchemaAST from "./SchemaAST.ts" +import type { StandardSchemaV1 } from "./StandardSchema.ts" const TypeId = "~effect/SchemaIssue/Issue" @@ -1147,49 +1147,63 @@ function formatCheck(check: SchemaAST.Check): string { * @since 4.0.0 */ export function makeFormatterDefault(): Formatter { - return (issue) => - toDefaultIssues(issue, [], defaultLeafHook, defaultCheckHook) - .map(formatDefaultIssue) - .join("\n") + return (issue) => formatIssue(issue, "") } /** @internal */ export const defaultFormatter = makeFormatterDefault() -function formatDefaultIssue(issue: DefaultIssue): string { - let out = issue.message - if (issue.path && issue.path.length > 0) { - const path = formatPath(issue.path as ReadonlyArray) - out += `\n at ${path}` - } - return out -} - -function findMessage(issue: Issue): string | undefined { +function formatIssue(issue: Issue, path: string): string { + let message: string switch (issue._tag) { - case "InvalidType": - case "OneOf": - case "Composite": - case "AnyOf": - return getMessageAnnotation(issue.ast.annotations) - case "InvalidValue": - case "Forbidden": - return getMessageAnnotation(issue.annotations) - case "MissingKey": - return getMessageAnnotation(issue.annotations, "messageMissingKey") - case "UnexpectedKey": - return getMessageAnnotation(issue.ast.annotations, "messageUnexpectedKey") - case "Filter": - return getMessageAnnotation(issue.filter.annotations) + case "Filter": { + const annotated = defaultCheckHook(issue) + if (annotated !== undefined) { + message = annotated + } else { + if (issue.issue._tag !== "InvalidValue") { + return formatIssue(issue.issue, path) + } + const expected = findExpected(issue.issue) + message = expected === undefined + ? getExpectedMessage(formatCheck(issue.filter), issue) + : getExpectedMessage(expected, issue.issue) + } + break + } case "Encoding": - return findMessage(issue.issue) + return formatIssue(issue.issue, path) + case "Pointer": + return formatIssue(issue.issue, path + formatPath(issue.path)) + case "Composite": + case "AnyOf": { + if (issue._tag === "Composite" || issue.issues.length > 0) { + return issue.issues.map((issue) => formatIssue(issue, path)).join("\n") + } + message = findMessage(issue) ?? getExpectedMessage(InternalAnnotations.getExpected(issue.ast), issue) + break + } + default: + message = defaultLeafHook(issue) + break } + return path ? `${message}\n at ${path}` : message } -function getMessageAnnotation( - annotations: Schema.Annotations.Annotations | undefined, - type: "message" | "messageMissingKey" | "messageUnexpectedKey" = "message" -): string | undefined { - const message = annotations?.[type] +function findMessage(issue: Issue): string | undefined { + if (issue._tag === "Pointer") return + if (issue._tag === "Encoding") return findMessage(issue.issue) + const annotations = issue._tag === "Filter" + ? issue.filter.annotations + : "annotations" in issue + ? issue.annotations + : issue.ast.annotations + const message = annotations?.[ + issue._tag === "MissingKey" + ? "messageMissingKey" + : issue._tag === "UnexpectedKey" + ? "messageUnexpectedKey" + : "message" + ] if (typeof message === "string") return message } diff --git a/repos/effect/packages/effect/src/SchemaRepresentation.ts b/repos/effect/packages/effect/src/SchemaRepresentation.ts index 8818ccc81f..c554eed144 100644 --- a/repos/effect/packages/effect/src/SchemaRepresentation.ts +++ b/repos/effect/packages/effect/src/SchemaRepresentation.ts @@ -698,34 +698,115 @@ export interface CodeDocument { readonly artifacts: ReadonlyArray } +/** + * Information supplied to a reference policy for one representation candidate. + * + * @category models + * @since 4.0.0 + */ +export interface ReferencePolicyInput { + /** The encoded-side AST owner for the candidate. Contextual copies can share the same owner. */ + readonly ast: SchemaAST.AST + /** The number of times this candidate was encountered. Structurally equal ASTs remain distinct candidates. */ + readonly occurrences: number + /** The resolved encoded-side identifier, including an inherited `Encoded` suffix when applicable. */ + readonly identifier: string | undefined +} + +/** + * Function that chooses whether a representation candidate is emitted as a named reference. + * + * **When to use** + * + * Use when you need reference allocation based on schema identity, occurrence counts, identifiers, or another + * application-specific rule. + * + * **Details** + * + * Return a reference name to extract the candidate, or `undefined` to keep it inline. The policy is called once per + * candidate after all occurrences have been counted. The `identifier` is the resolved identifier for the encoded AST, + * including an `Encoded` suffix when an identifier is inherited from the source side of an encoding. If different + * candidates request the same name, later names receive numeric suffixes in encounter order. + * + * **Gotchas** + * + * Recursive candidates always require a reference. When the policy returns `undefined` for one, the generator assigns + * a synthetic name. Treat the input AST as immutable and keep the policy deterministic. + * + * @see {@link ToRepresentationOptions} for configuring representation generation + * + * @category models + * @since 4.0.0 + */ +export type ReferencePolicy = (input: ReferencePolicyInput) => string | undefined + +/** + * Options for generating schema representations. + * + * @category configuration + * @since 4.0.0 + */ +export interface ToRepresentationOptions { + /** + * Chooses which representation candidates are extracted as named references. + * + * **Details** + * + * The default policy returns the resolved `identifier`, so anonymous non-recursive candidates remain inline even when + * they occur more than once. + * + * **Gotchas** + * + * Recursive candidates always require a reference and receive a synthetic name when the policy returns `undefined`. + * + * @default ({ identifier }) => identifier + */ + readonly referencePolicy?: ReferencePolicy | undefined +} + /** * Lowers the encoded side of an AST to a live representation document. * + * **When to use** + * + * Use when you have one `SchemaAST.AST` and need a live `Document` for inspection, persistence, or compilation. + * * **Details** * - * Apply `SchemaAST.toType` to the AST first to lower its type side instead. + * Apply `SchemaAST.toType` to the AST first to lower its type side instead. The optional reference policy controls which + * candidates are moved into the document's shared reference table. + * + * @see {@link toRepresentations} for multiple roots sharing one reference table * * @category constructors * @since 4.0.0 */ -export function toRepresentation(ast: SchemaAST.AST): Document { - return InternalToRepresentation.toRepresentation(ast) +export function toRepresentation(ast: SchemaAST.AST, options?: ToRepresentationOptions): Document { + return InternalToRepresentation.toRepresentation(ast, options) } /** * Lowers one or more AST encoded sides in a shared reference environment. * + * **When to use** + * + * Use when several AST roots must share identifiers, occurrence counts, recursion, and allocated reference names. + * * **Details** * - * Apply `SchemaAST.toType` to an AST first to lower its type side instead. + * Apply `SchemaAST.toType` to an AST first to lower its type side instead. The reference policy observes candidates from + * every root before any representation is emitted. + * + * @see {@link toRepresentation} for a single AST root * * @category constructors * @since 4.0.0 */ export function toRepresentations( - asts: readonly [SchemaAST.AST, ...Array] + asts: readonly [SchemaAST.AST, ...Array], + options?: ToRepresentationOptions ): MultiDocument { - return InternalToRepresentation.toRepresentations(asts) + return InternalToRepresentation.toRepresentations(asts, options) } /** @@ -752,12 +833,23 @@ export function toMultiDocument(document: Document): MultiDocument { * * Use when you need JSON Schema output from a representation whose checks carry compiler annotations. * + * **Details** + * + * For representation documents whose validation semantics can be expressed exactly in JSON Schema, importing the + * emitted document with {@link fromJsonSchemaDocument} reconstructs a schema that accepts the same JSON values. This + * is a semantic round-trip guarantee; the emitted document and reconstructed representation may have different shapes. + * * **Gotchas** * - * Opaque declarations are represented by an unconstrained JSON Schema. Check callback results are used directly, and - * exceptions raised by a callback pass through unchanged. Callbacks must treat their input schemas as immutable. Each - * returned value must be a valid JSON Schema object graph and must not be mutated after the callback returns. Local - * definition references returned by callbacks are resolved together with compiler-generated references. + * - Reference allocation is already fixed in the input `Document`. The inherited `referencePolicy` option has no effect + * here; pass it to {@link toRepresentation} when creating the document. + * - Opaque declarations are represented by an unconstrained JSON Schema and are outside the exact round-trip subset. + * - Check callback results are used directly, and exceptions raised by a callback pass through unchanged. Callbacks + * must treat their input schemas as immutable. Each returned value must be a valid JSON Schema object graph and must + * not be mutated after the callback returns. + * - Local definition references returned by callbacks are resolved together with compiler-generated references. + * - Effect decoding may discard excess object properties by default. Use `onExcessProperty: "error"` when comparing + * validation semantics with the emitted JSON Schema. * * @see {@link toJsonSchemaMultiDocument} for multiple roots sharing definitions * @@ -780,10 +872,12 @@ export function toJsonSchemaDocument( * * **Gotchas** * - * Every definition is compiled, including definitions that are not reachable from a root. Check callbacks must treat - * their input schemas as immutable. Each returned value must be a valid JSON Schema object graph and must not be - * mutated after the callback returns. Local definition references returned by callbacks are resolved together with - * compiler-generated references. + * - Reference allocation is already fixed in the input `MultiDocument`. The inherited `referencePolicy` option has no + * effect here; pass it to {@link toRepresentations} when creating the document. + * - Every definition is compiled, including definitions that are not reachable from a root. Check callbacks must treat + * their input schemas as immutable. Each returned value must be a valid JSON Schema object graph and must not be + * mutated after the callback returns. Local definition references returned by callbacks are resolved together with + * compiler-generated references. * * @see {@link toJsonSchemaDocument} for a single root * @@ -830,9 +924,6 @@ const CheckRepresentationAnnotationSchema = Schema.Struct({ schemas: Schema.optional(RepresentationsSchema) }) -const LiveAnnotationsSchema = Schema.Record(Schema.String, Schema.Unknown) -const JsonAnnotationsSchema = Schema.Record(Schema.String, Schema.Json) - function pruneAnnotations( annotations: Readonly> ): Option.Option>> { @@ -845,8 +936,8 @@ function pruneAnnotations( return Object.keys(out).length === 0 ? Option.none() : Option.some(out) } -const AnnotationsSchema = Schema.optional(LiveAnnotationsSchema).pipe( - Schema.encodeTo(Schema.optionalKey(JsonAnnotationsSchema), { +const AnnotationsSchema = Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).pipe( + Schema.encodeTo(Schema.optionalKey(Schema.JsonObject), { decode: SchemaGetter.passthroughSubtype(), encode: SchemaGetter.transformOptional((annotations) => Option.isNone(annotations) || annotations.value === undefined @@ -1178,12 +1269,33 @@ export function fromRepresentations( * * Use when you need to validate or transform values described by an external JSON Schema document. * + * **Details** + * + * For the Draft 2020-12 subset translated exactly by this importer, compiling the imported schema through + * {@link toRepresentation} and {@link toJsonSchemaDocument} produces a document that accepts the same JSON values as + * the input. This is a semantic round-trip guarantee; keyword layout, definitions, and annotations may be normalized. + * * **Gotchas** * - * Import is best-effort. Built-in declarations and checks are reconstructed with importer-owned revivers. Pattern - * constraints reached during translation cause an error by default. Use `patterns: "apply"` only for trusted documents, - * or `patterns: "ignore"` to weaken validation explicitly. Callback results are used directly, and exceptions raised by a - * callback pass through unchanged. + * - `$dynamicRef`, `contains`, `dependentRequired`, `dependentSchemas`, `not`, active `if` / `then` / `else`, + * `unevaluatedItems`, and `unevaluatedProperties` throw an `Unsupported JSON Schema keyword` error. Inactive + * conditional keywords and `minContains` / `maxContains` without `contains` have no validation effect and are ignored. + * - Objects and arrays used as `const` values or `enum` members throw an `Unsupported structured JSON Schema value` + * error. + * - Intersections of overlapping unions are limited to disjoint root-type partitions and finite primitive `anyOf` + * literal sets. Other union intersections, including cases that would duplicate a nested choice, throw an + * `Unsupported intersection of overlapping unions` error. + * - Unknown extension keywords are ignored and their semantics are not enforced. + * - Only direct local references to top-level definitions in the form `#/$defs/` are supported. Root + * references, external references, and pointers below a definition throw an `Unsupported reference` error. A direct + * reference to a missing definition throws an `Invalid reference` error. + * - Built-in declarations and checks are reconstructed with importer-owned revivers. + * - Pattern constraints reached during translation cause an error by default. Use `patterns: "apply"` only for trusted + * documents, or `patterns: "ignore"` to weaken validation explicitly; ignored patterns are outside the round-trip + * guarantee. + * - `onEnter` results replace the corresponding input nodes, so the round-trip guarantee applies to the rewritten + * document. + * - Callback results are used directly, and exceptions raised by a callback pass through unchanged. * * @see {@link fromJsonSchemaMultiDocument} for multiple roots sharing definitions * @see {@link toRepresentation} for converting the result to a representation document @@ -1207,9 +1319,20 @@ export function fromJsonSchemaDocument( * * **Gotchas** * - * Only definitions reachable from a root are translated. Pattern constraints reached during translation cause an error - * by default. Use `patterns: "apply"` only for trusted documents, or `patterns: "ignore"` to weaken validation explicitly. - * Callback results are used directly, and exceptions raised by a callback pass through unchanged. + * - Only definitions reachable from a root are translated. + * - Unsupported standard validation and applicator keywords throw an `Unsupported JSON Schema keyword` error. Unknown + * extension keywords are ignored and their semantics are not enforced. + * - Objects and arrays used as `const` values or `enum` members throw an `Unsupported structured JSON Schema value` + * error. + * - Intersections of overlapping unions are limited to disjoint root-type partitions and finite primitive `anyOf` + * literal sets. Other union intersections, including cases that would duplicate a nested choice, throw an + * `Unsupported intersection of overlapping unions` error. + * - Only direct local references to top-level definitions in the form `#/$defs/` are supported. Root + * references, external references, and pointers below a definition throw an `Unsupported reference` error. A direct + * reference to a missing definition throws an `Invalid reference` error. + * - Pattern constraints reached during translation cause an error by default. Use `patterns: "apply"` only for trusted + * documents, or `patterns: "ignore"` to weaken validation explicitly. + * - Callback results are used directly, and exceptions raised by a callback pass through unchanged. * * @see {@link fromJsonSchemaDocument} for a single root * @see {@link toRepresentations} for converting the returned schema ASTs to a representation document diff --git a/repos/effect/packages/effect/src/Scope.ts b/repos/effect/packages/effect/src/Scope.ts index a7268975cb..12e1c6b1bf 100644 --- a/repos/effect/packages/effect/src/Scope.ts +++ b/repos/effect/packages/effect/src/Scope.ts @@ -126,6 +126,11 @@ export declare namespace State { * Represents an open scope state where finalizers can be added and * the scope is still accepting new resources. * + * **Details** + * + * Stores one finalizer inline and allocates the `finalizers` map when a + * second is added. + * * **Example** (Inspecting an open scope state) * * ```ts import.meta.vitest @@ -138,7 +143,7 @@ export declare namespace State { * if (state._tag !== "Open") throw new Error("unexpected state") * * state._tag // => "Open" - * state.finalizers.size // => 1 + * state.finalizer !== undefined // => true * ``` * * @category models @@ -146,7 +151,9 @@ export declare namespace State { */ export type Open = { readonly _tag: "Open" - readonly finalizers: Map<{}, (exit: Exit) => Effect> + finalizerKey: {} | undefined + finalizer: ((exit: Exit) => Effect) | undefined + finalizers: Map<{}, (exit: Exit) => Effect> | undefined } /** * Represents a closed scope state where finalizers have been executed diff --git a/repos/effect/packages/effect/src/StandardSchema.ts b/repos/effect/packages/effect/src/StandardSchema.ts new file mode 100644 index 0000000000..8857651707 --- /dev/null +++ b/repos/effect/packages/effect/src/StandardSchema.ts @@ -0,0 +1,207 @@ +/** + * Type definitions from the Standard Schema specification. + * + * The declarations below are vendored verbatim from `@standard-schema/spec` 1.1.0. + * + * @since 4.0.0 + */ + +/* +MIT License + +Copyright (c) 2024 Colin McDonnell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// ######################### +// ### Standard Typed ### +// ######################### + +/** The Standard Typed interface. This is a base type extended by other specs. */ +export interface StandardTypedV1 { + /** The Standard properties. */ + readonly "~standard": StandardTypedV1.Props; +} + +export declare namespace StandardTypedV1 { + /** The Standard Typed properties interface. */ + export interface Props { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + } + + /** The Standard Typed types interface. */ + export interface Types { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; + } + + /** Infers the input type of a Standard Typed. */ + export type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** Infers the output type of a Standard Typed. */ + export type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; +} + +// ########################## +// ### Standard Schema ### +// ########################## + +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + /** The Standard Schema properties. */ + readonly "~standard": StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + export interface Props + extends StandardTypedV1.Props { + /** Validates unknown input values. */ + readonly validate: ( + value: unknown, + options?: StandardSchemaV1.Options | undefined + ) => Result | Promise>; + } + + /** The result interface of the validate function. */ + export type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + export interface SuccessResult { + /** The typed output value. */ + readonly value: Output; + /** A falsy value for `issues` indicates success. */ + readonly issues?: undefined; + } + + export interface Options { + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + } + + /** The result interface if validation fails. */ + export interface FailureResult { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + } + + /** The issue interface of the failure output. */ + export interface Issue { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: ReadonlyArray | undefined; + } + + /** The path segment interface of the issue. */ + export interface PathSegment { + /** The key representing a path segment. */ + readonly key: PropertyKey; + } + + /** The Standard types interface. */ + export interface Types + extends StandardTypedV1.Types {} + + /** Infers the input type of a Standard. */ + export type InferInput = + StandardTypedV1.InferInput; + + /** Infers the output type of a Standard. */ + export type InferOutput = + StandardTypedV1.InferOutput; +} + +// ############################### +// ### Standard JSON Schema ### +// ############################### + +/** The Standard JSON Schema interface. */ +export interface StandardJSONSchemaV1 { + /** The Standard JSON Schema properties. */ + readonly "~standard": StandardJSONSchemaV1.Props; +} + +export declare namespace StandardJSONSchemaV1 { + /** The Standard JSON Schema properties interface. */ + export interface Props + extends StandardTypedV1.Props { + /** Methods for generating the input/output JSON Schema. */ + readonly jsonSchema: StandardJSONSchemaV1.Converter; + } + + /** The Standard JSON Schema converter interface. */ + export interface Converter { + /** Converts the input type to JSON Schema. May throw if conversion is not supported. */ + readonly input: ( + options: StandardJSONSchemaV1.Options + ) => Record; + /** Converts the output type to JSON Schema. May throw if conversion is not supported. */ + readonly output: ( + options: StandardJSONSchemaV1.Options + ) => Record; + } + + /** + * The target version of the generated JSON Schema. + * + * It is *strongly recommended* that implementers support `"draft-2020-12"` and `"draft-07"`, as they are both in wide use. All other targets can be implemented on a best-effort basis. Libraries should throw if they don't support a specified target. + * + * The `"openapi-3.0"` target is intended as a standardized specifier for OpenAPI 3.0 which is a superset of JSON Schema `"draft-04"`. + */ + export type Target = + | "draft-2020-12" + | "draft-07" + | "openapi-3.0" + // Accepts any string for future targets while preserving autocomplete + | ({} & string); + + /** The options for the input/output methods. */ + export interface Options { + /** Specifies the target version of the generated JSON Schema. Support for all versions is on a best-effort basis. If a given version is not supported, the library should throw. */ + readonly target: Target; + + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + } + + /** The Standard types interface. */ + export interface Types + extends StandardTypedV1.Types {} + + /** Infers the input type of a Standard. */ + export type InferInput = + StandardTypedV1.InferInput; + + /** Infers the output type of a Standard. */ + export type InferOutput = + StandardTypedV1.InferOutput; +} diff --git a/repos/effect/packages/effect/src/Tracer.ts b/repos/effect/packages/effect/src/Tracer.ts index 967109401b..3ead5c1fbe 100644 --- a/repos/effect/packages/effect/src/Tracer.ts +++ b/repos/effect/packages/effect/src/Tracer.ts @@ -10,6 +10,7 @@ * @since 2.0.0 */ import * as Context from "./Context.ts" +import * as Encoding from "./Encoding.ts" import type * as Exit from "./Exit.ts" import type { Fiber } from "./Fiber.ts" import { constFalse, type LazyArg } from "./Function.ts" @@ -690,8 +691,8 @@ export class NativeSpan implements Span { startTime: options.startTime } this.attributes = new Map() - this.traceId = Option.getOrUndefined(options.parent)?.traceId ?? randomHexString(32) - this.spanId = randomHexString(16) + this.traceId = Option.getOrUndefined(options.parent)?.traceId ?? Encoding.randomHex(32) + this.spanId = Encoding.randomHex(16) } end(endTime: bigint, exit: Exit.Exit): void { @@ -716,15 +717,3 @@ export class NativeSpan implements Span { this.links.push(...links) } } - -const randomHexString = (function() { - const characters = "abcdef0123456789" - const charactersLength = characters.length - return function(length: number) { - let result = "" - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)) - } - return result - } -})() diff --git a/repos/effect/packages/effect/src/index.ts b/repos/effect/packages/effect/src/index.ts index d1774a4d05..67dd10684e 100644 --- a/repos/effect/packages/effect/src/index.ts +++ b/repos/effect/packages/effect/src/index.ts @@ -526,11 +526,6 @@ export * as Schema from "./Schema.ts" */ export * as SchemaAST from "./SchemaAST.ts" -/** - * @since 4.0.0 - */ -export * as SchemaError from "./SchemaError.ts" - /** * @since 4.0.0 */ @@ -581,6 +576,11 @@ export * as Semaphore from "./Semaphore.ts" */ export * as Sink from "./Sink.ts" +/** + * @since 4.0.0 + */ +export * as StandardSchema from "./StandardSchema.ts" + /** * @since 4.0.0 */ diff --git a/repos/effect/packages/effect/src/internal/effect.ts b/repos/effect/packages/effect/src/internal/effect.ts index 370b36ec22..bb6e4bcafb 100644 --- a/repos/effect/packages/effect/src/internal/effect.ts +++ b/repos/effect/packages/effect/src/internal/effect.ts @@ -564,6 +564,7 @@ export class FiberImpl implements Fiber.Fiber { } this._observers.push(cb) return () => { + if (this._exit) return const index = this._observers.indexOf(cb) if (index >= 0) { this._observers.splice(index, 1) @@ -945,10 +946,19 @@ export const suspend: ( }) /** @internal */ -export const fromOption: | LazyArg, E = Cause.NoSuchElementError>( - arg: Arg, - ...rest: [Arg] extends [Option.Option] ? [onNone?: LazyArg] : [] -) => [Arg] extends [Option.Option] ? Effect.Effect +export const fromOption: < + Arg extends Option.Option | LazyArg, + Rest extends [] | [onNone: LazyArg | undefined] = [] +>( + arg: Arg & (Rest extends [] ? unknown : Option.Option), + ...rest: Rest +) => [Arg] extends [Option.Option] ? Effect.Effect< + A, + Rest extends [LazyArg] ? E + : Rest extends [undefined] ? Cause.NoSuchElementError + : Rest extends [LazyArg | undefined] ? E | Cause.NoSuchElementError + : Cause.NoSuchElementError + > : [Arg] extends [LazyArg] ? (option: Option.Option) => Effect.Effect : never = dual( (args) => args.length >= 2 || Option.isOption(args[0]), @@ -1248,7 +1258,7 @@ const makeFn = ( ) => { const body = typeof bodyOrOptions === "function" ? bodyOrOptions - : (pipeables.pop()!).bind(bodyOrOptions.self) + : (pipeables.shift()!).bind(bodyOrOptions.self) return defineFunctionLength(body.length, function(this: any, ...args: Array) { let result = suspend(() => { @@ -2057,11 +2067,7 @@ export const serviceOption = ( export const serviceOptional = ( service: Context.Key ): Effect.Effect => - withFiber((fiber) => - fiber.context.mapUnsafe.has(service.key) - ? succeed(Context.getUnsafe(fiber.context, service)) - : fail(new NoSuchElementError()) - ) + withFiber((fiber) => fromOption(Context.getOption(fiber.context, service))) /** @internal */ export const updateContext: { @@ -2223,12 +2229,7 @@ const provideServiceImpl = ( self: Effect.Effect, service: Context.Key, implementation: S -): Effect.Effect> => - updateContext(self, (s) => { - const prev = s.mapUnsafe.get(service.key) - if (prev === implementation) return s - return Context.add(s, service, implementation) - }) as any +): Effect.Effect> => updateContext(self, Context.add(service, implementation)) as any /** @internal */ export const provideServiceEffect: { @@ -3782,9 +3783,13 @@ export const scopeCloseUnsafe = (self: Scope.Scope, exit_: Exit.Exit self.state = closed return } - const { finalizers } = self.state + const state = self.state self.state = closed - if (finalizers.size === 0) { + if (state.finalizer !== undefined) { + return state.finalizer(exit_) + } + const finalizers = state.finalizers + if (finalizers === undefined || finalizers.size === 0) { return } else if (finalizers.size === 1) { return finalizers.values().next().value!(exit_) @@ -3792,9 +3797,15 @@ export const scopeCloseUnsafe = (self: Scope.Scope, exit_: Exit.Exit return scopeCloseFinalizers(self, finalizers, exit_) } +const combineFinalizerCause = ( + exit_: Exit.Exit, + finalizer: Effect.Effect +): Effect.Effect => + exitIsSuccess(exit_) ? finalizer : catchCause(finalizer, (cause) => failCause(causeCombine(exit_.cause, cause))) + const scopeCloseFinalizers = fnUntraced(function*( self: Scope.Scope, - finalizers: Scope.State.Open["finalizers"], + finalizers: NonNullable, exit_: Exit.Exit ) { let exits: Array> = [] @@ -3859,9 +3870,20 @@ export const scopeAddFinalizerUnsafe = ( finalizer: (exit: Exit.Exit) => Effect.Effect ): void => { if (scope.state._tag === "Empty") { - scope.state = { _tag: "Open", finalizers: new Map([[key, finalizer]]) } + scope.state = { _tag: "Open", finalizerKey: key, finalizer, finalizers: undefined } } else if (scope.state._tag === "Open") { - scope.state.finalizers.set(key, finalizer) + const state = scope.state + if (state.finalizer !== undefined) { + state.finalizers = new Map([[state.finalizerKey!, state.finalizer]]) + state.finalizerKey = undefined + state.finalizer = undefined + state.finalizers.set(key, finalizer) + } else if (state.finalizers === undefined) { + state.finalizerKey = key + state.finalizer = finalizer + } else { + state.finalizers.set(key, finalizer) + } } } @@ -3871,10 +3893,24 @@ export const scopeRemoveFinalizerUnsafe = ( key: {} ): void => { if (scope.state._tag === "Open") { - scope.state.finalizers.delete(key) + const state = scope.state + if (state.finalizerKey === key) { + state.finalizerKey = undefined + state.finalizer = undefined + } else if (state.finalizers !== undefined) { + state.finalizers.delete(key) + } } } +/** @internal */ +export const scopeFinalizerCountUnsafe = (scope: Scope.Scope): number => + scope.state._tag !== "Open" + ? 0 + : scope.state.finalizer !== undefined + ? 1 + : (scope.state.finalizers?.size ?? 0) + /** @internal */ export const scopeMakeUnsafe = (finalizerStrategy: "sequential" | "parallel" = "sequential"): Scope.Closeable => ({ [ScopeCloseableTypeId]: ScopeCloseableTypeId, @@ -3988,7 +4024,7 @@ export const onExitPrimitive: ( [contE](cause, _, exit) { exit ??= exitFailCause(cause) const eff = this[args][1](exit) - return eff ? flatMap(eff, (_) => exit) : exit + return eff ? flatMap(combineFinalizerCause(exit, eff), (_) => exit) : exit } }) @@ -4610,14 +4646,14 @@ export const whileLoop: (options: { /** @internal */ export const forEach: { - , const Discard extends boolean = false>( - f: (a: Arr.ReadonlyArray.Infer, i: number) => Effect.Effect, + = Iterable, const Discard extends boolean = false>( + f: (a: A, i: number) => Effect.Effect, options?: { readonly concurrency?: Concurrency | undefined readonly discard?: Discard | undefined } | undefined ): ( - self: S + self: [S] extends [never] ? Iterable : S ) => Effect.Effect : void, E, R> , const Discard extends boolean = false>( self: S, @@ -4658,6 +4694,15 @@ export const forEach: { return eff ? as(eff, out as any) : succeed(out as any) })) +/** @internal */ +export const head = ( + self: Effect.Effect, E, R> +): Effect.Effect => + flatMap(self, (elements) => { + const result = elements[Symbol.iterator]().next() + return result.done ? fail(new NoSuchElementError()) : succeed(result.value) + }) + const forEachSequential = ( iterable: Iterable, f: (a: A, index: number) => Effect.Effect, @@ -4731,7 +4776,7 @@ const iterateEagerImpl = (options: { if (concurrency === 1) { return runSequential(state, items, 0, end) } - const orderedStep = opts?.orderedStep === true && concurrency > 1 + const orderedStep = opts?.orderedStep === true let done = false let parentFiber: Fiber.Fiber | undefined let fibers: Set> | undefined @@ -5712,7 +5757,9 @@ export const makeSpanUnsafe = ( const links = options?.links !== undefined ? [...linksFromEnv, ...options.links] : - linksFromEnv.slice() + linksFromEnv.length === 0 + ? [] + : linksFromEnv.slice() span = tracer.span({ name, @@ -5728,12 +5775,12 @@ export const makeSpanUnsafe = ( : !isLogLevelGreaterThan(fiber.getRef(Tracer.MinimumTraceLevel), level)) }) - for (const [key, value] of Object.entries(annotationsFromEnv)) { - span.attribute(key, value) + for (const key in annotationsFromEnv) { + span.attribute(key, annotationsFromEnv[key]) } if (options?.attributes !== undefined) { - for (const [key, value] of Object.entries(options.attributes)) { - span.attribute(key, value) + for (const key in options.attributes) { + span.attribute(key, options.attributes[key]) } } } @@ -6302,7 +6349,6 @@ export const formatLogSpan = (self: [label: string, timestamp: number], now: num /** @internal */ export const structuredMessage = (u: unknown): unknown => { switch (typeof u) { - case "bigint": case "function": case "symbol": { return String(u) diff --git a/repos/effect/packages/effect/src/internal/graph.ts b/repos/effect/packages/effect/src/internal/graph.ts new file mode 100644 index 0000000000..ebeb4ed2b3 --- /dev/null +++ b/repos/effect/packages/effect/src/internal/graph.ts @@ -0,0 +1,232 @@ +import * as Equal from "../Equal.ts" +import type * as Graph from "../Graph.ts" +import * as Hash from "../Hash.ts" +import { NodeInspectSymbol } from "../Inspectable.ts" +import * as Option from "../Option.ts" +import { pipeArguments } from "../Pipeable.ts" +import { hasProperty } from "../Predicate.ts" + +/** @internal */ +export const TypeId = "~effect/collections/Graph" + +/** @internal */ +export interface GraphImpl + extends Iterable, Equal.Equal +{ + readonly [TypeId]: unknown + type: T + mutable: boolean + transforming: boolean + nodes: Map + edges: Map> + adjacency: Map> + reverseAdjacency: Map> + nextNodeIndex: Graph.NodeIndex + nextEdgeIndex: Graph.EdgeIndex + acyclic: Option.Option + toJSON(): unknown +} + +/** @internal */ +export const toImpl = ( + graph: Graph.Graph | Graph.MutableGraph +): GraphImpl => graph as unknown as GraphImpl + +/** @internal */ +export const isTransforming = ( + graph: Graph.Graph | Graph.MutableGraph +): boolean => toImpl(graph).transforming + +/** @internal */ +export const withTransformation = ( + graph: Graph.MutableGraph, + evaluate: () => A +): A => { + const impl = toImpl(graph) + const transforming = impl.transforming + impl.transforming = true + try { + return evaluate() + } finally { + impl.transforming = transforming + } +} + +const edgeEquals = (type: Graph.Kind, self: Graph.Edge, that: Graph.Edge): boolean => + (type === "directed" + ? self.source === that.source && self.target === that.target + : (self.source === that.source && self.target === that.target) || + (self.source === that.target && self.target === that.source)) && + Equal.equals(self.data, that.data) + +const edgeHash = (type: Graph.Kind, edge: Graph.Edge): number => + type === "directed" + ? Hash.hash(edge) + : Hash.optimize(Hash.hash(edge.data) ^ (Hash.hash(edge.source) + Hash.hash(edge.target))) + +const ProtoGraph = { + [TypeId]: { + _N: (_: never) => _, + _E: (_: never) => _ + }, + [Symbol.iterator](this: GraphImpl) { + return this.nodes[Symbol.iterator]() + }, + [NodeInspectSymbol](this: GraphImpl) { + return this.toJSON() + }, + [Equal.symbol](this: GraphImpl, that: Equal.Equal): boolean { + if (hasProperty(that, TypeId)) { + const thatImpl = toImpl(that as Graph.Graph) + if ( + this.nodes.size !== thatImpl.nodes.size || + this.edges.size !== thatImpl.edges.size || + this.type !== thatImpl.type + ) { + return false + } + for (const [nodeIndex, nodeData] of this.nodes) { + if (!thatImpl.nodes.has(nodeIndex) || !Equal.equals(nodeData, thatImpl.nodes.get(nodeIndex))) { + return false + } + } + for (const [edgeIndex, edgeData] of this.edges) { + const otherEdge = thatImpl.edges.get(edgeIndex) + if (otherEdge === undefined || !edgeEquals(this.type, edgeData, otherEdge)) { + return false + } + } + return true + } + return false + }, + [Hash.symbol](this: GraphImpl): number { + let hash = Hash.string("Graph") + hash = hash ^ Hash.string(this.type) + hash = hash ^ Hash.number(this.nodes.size) + hash = hash ^ Hash.number(this.edges.size) + for (const [nodeIndex, nodeData] of this.nodes) { + hash = hash ^ (Hash.hash(nodeIndex) + Hash.hash(nodeData)) + } + for (const [edgeIndex, edgeData] of this.edges) { + hash = hash ^ (Hash.hash(edgeIndex) + edgeHash(this.type, edgeData)) + } + return hash + }, + toJSON(this: GraphImpl) { + return { + _id: "Graph", + nodeCount: this.nodes.size, + edgeCount: this.edges.size, + type: this.type + } + }, + toString(this: GraphImpl) { + return `Graph(${this.type}, ${this.nodes.size}, ${this.edges.size})` + }, + pipe() { + return pipeArguments(this, arguments) + } +} + +/** @internal */ +export const make = (type: T, mutable: boolean): GraphImpl => { + const graph: GraphImpl = Object.create(ProtoGraph) + graph.type = type + graph.mutable = mutable + graph.transforming = false + graph.nodes = new Map() + graph.edges = new Map() + graph.adjacency = new Map() + graph.reverseAdjacency = new Map() + graph.nextNodeIndex = 0 + graph.nextEdgeIndex = 0 + graph.acyclic = Option.some(true) + return graph +} + +const cloneAdjacency = ( + adjacency: Map> +): Map> => { + const cloned = new Map>() + for (const [nodeIndex, edges] of adjacency) { + cloned.set(nodeIndex, [...edges]) + } + return cloned +} + +/** @internal */ +export const clone = ( + source: GraphImpl, + mutable: boolean +): GraphImpl => { + const graph = make(source.type, mutable) + graph.nodes = new Map(source.nodes) + graph.edges = new Map(source.edges) + graph.adjacency = cloneAdjacency(source.adjacency) + graph.reverseAdjacency = cloneAdjacency(source.reverseAdjacency) + graph.nextNodeIndex = source.nextNodeIndex + graph.nextEdgeIndex = source.nextEdgeIndex + graph.acyclic = source.acyclic + return graph +} + +/** @internal */ +export const finalize = (source: GraphImpl): GraphImpl => { + const graph: GraphImpl = Object.create(ProtoGraph) + graph.type = source.type + graph.mutable = false + graph.transforming = false + graph.nodes = source.nodes + graph.edges = source.edges + graph.adjacency = source.adjacency + graph.reverseAdjacency = source.reverseAdjacency + graph.nextNodeIndex = source.nextNodeIndex + graph.nextEdgeIndex = source.nextEdgeIndex + graph.acyclic = source.acyclic + return graph +} + +/** @internal */ +export const snapshot = (graph: Graph.Graph): Graph.Snapshot => { + const impl = toImpl(graph) + return { + type: graph.type, + nodes: Array.from(impl.nodes, ([index, data]) => ({ index, data })).sort((a, b) => a.index - b.index), + edges: Array.from(impl.edges, ([index, edge]) => ({ + index, + source: edge.source, + target: edge.target, + data: edge.data + })).sort((a, b) => a.index - b.index) + } +} + +/** @internal */ +export const hydrate = ( + snapshot: Graph.Snapshot +): Graph.Graph => { + const graph = make(snapshot.type, false) + for (const node of snapshot.nodes) { + graph.nodes.set(node.index, node.data) + graph.adjacency.set(node.index, []) + graph.reverseAdjacency.set(node.index, []) + } + for (const edge of snapshot.edges) { + graph.edges.set(edge.index, { + source: edge.source, + target: edge.target, + data: edge.data + }) + graph.adjacency.get(edge.source)!.push(edge.index) + graph.reverseAdjacency.get(edge.target)!.push(edge.index) + if (snapshot.type === "undirected") { + graph.adjacency.get(edge.target)!.push(edge.index) + graph.reverseAdjacency.get(edge.source)!.push(edge.index) + } + } + graph.nextNodeIndex = snapshot.nodes.length === 0 ? 0 : snapshot.nodes[snapshot.nodes.length - 1].index + 1 + graph.nextEdgeIndex = snapshot.edges.length === 0 ? 0 : snapshot.edges[snapshot.edges.length - 1].index + 1 + graph.acyclic = Option.none() + return graph as unknown as Graph.Graph +} diff --git a/repos/effect/packages/effect/src/internal/graphCsr.ts b/repos/effect/packages/effect/src/internal/graphCsr.ts new file mode 100644 index 0000000000..2e083ab68f --- /dev/null +++ b/repos/effect/packages/effect/src/internal/graphCsr.ts @@ -0,0 +1,279 @@ +import type * as Graph from "../Graph.ts" +import { isTransforming, toImpl } from "./graph.ts" + +/** @internal */ +export interface Adjacency { + // Neighbors of compact node i occupy [rowOffsets[i], rowOffsets[i + 1]). + readonly rowOffsets: Uint32Array + // Values are compact node indices rather than public node ids. + readonly columnIndices: Uint32Array +} + +/** @internal */ +export interface AdjacencyWithEdges extends Adjacency { + // Parallel to columnIndices and points into the compact edge domain. + readonly edgeIndices: Uint32Array +} + +/** @internal */ +export interface Adjacencies { + readonly primary: Adjacency + // Present only when directed edges must be traversed without regard to direction. + readonly secondary: Adjacency | undefined +} + +/** @internal */ +export interface EdgeEndpoints { + // Both arrays are parallel to compact edge data and contain compact node indices. + readonly sources: Uint32Array + readonly targets: Uint32Array +} + +/** @internal */ +export interface Csr { + readonly type: Graph.Kind + // Retained as the source for projections that are materialized only when an algorithm needs them. + readonly graph: Graph.Graph | Graph.MutableGraph + // Parallel arrays define the compact node domain: their position is the index stored in CSR columns. + readonly nodeIds: Array + readonly nodeData: Array + // Dense public ids already equal their compact positions, so they do not need a reverse lookup map. + readonly indexByNodeId: Map | undefined + // Adjacency and edge projections stay lazy because most algorithms need only one of them. + outgoingCsr: Adjacency | undefined + incomingCsr: Adjacency | undefined + outgoingEdgeCsr: AdjacencyWithEdges | undefined + // Edge data and endpoints share this insertion-order compact edge domain. + edgesByIndex: Array> | undefined + edgeIdsByIndex: Array | undefined + compactEdgeEndpoints: EdgeEndpoints | undefined + // Null marks the dense id fast path; undefined means the edge domain has not been inspected yet. + indexByEdgeId: Map | null | undefined +} + +const cache = new WeakMap | Graph.MutableGraph, Csr>() + +/** @internal */ +export const getNodeIndex = (csr: Csr, nodeId: Graph.NodeIndex): number | undefined => + csr.indexByNodeId === undefined + ? Number.isInteger(nodeId) && nodeId >= 0 && nodeId < csr.nodeIds.length ? nodeId : undefined + : csr.indexByNodeId.get(nodeId) + +/** @internal */ +const materializeEdges = (csr: Csr): void => { + if (csr.edgesByIndex !== undefined) { + return + } + const graphEdges = toImpl(csr.graph).edges + const edgeIds = new Array(graphEdges.size) + const edges = new Array>(graphEdges.size) + let index = 0 + for (const [edgeId, edge] of graphEdges) { + edgeIds[index] = edgeId + edges[index++] = edge + } + csr.edgeIdsByIndex = edgeIds + csr.edgesByIndex = edges +} + +/** @internal */ +export const getEdges = (csr: Csr): Array> => { + materializeEdges(csr) + return csr.edgesByIndex! +} + +/** @internal */ +export const getEdgeIds = (csr: Csr): Array => { + materializeEdges(csr) + return csr.edgeIdsByIndex! +} + +const makeAdjacency = (csr: Csr, incoming: boolean): Adjacency => { + const impl = toImpl(csr.graph) + const adjacencyMap = incoming ? impl.reverseAdjacency : impl.adjacency + // The first pass allocates exact row ranges; the second preserves canonical adjacency order within each row. + const rowOffsets = new Uint32Array(csr.nodeIds.length + 1) + for (let i = 0; i < csr.nodeIds.length; i++) { + rowOffsets[i + 1] = rowOffsets[i] + (adjacencyMap.get(csr.nodeIds[i])?.length ?? 0) + } + + const columnIndices = new Uint32Array(rowOffsets[csr.nodeIds.length]) + for (let i = 0; i < csr.nodeIds.length; i++) { + const nodeId = csr.nodeIds[i] + const adjacency = adjacencyMap.get(nodeId) + if (adjacency === undefined) { + continue + } + + let offset = rowOffsets[i] + for (let j = 0; j < adjacency.length; j++) { + const edge = impl.edges.get(adjacency[j])! + const neighbor = csr.type === "undirected" + ? edge.source === nodeId ? edge.target : edge.source + : incoming + ? edge.source + : edge.target + columnIndices[offset++] = csr.indexByNodeId === undefined ? neighbor : csr.indexByNodeId.get(neighbor)! + } + } + return { rowOffsets, columnIndices } +} + +/** @internal */ +export const getOutgoing = (csr: Csr): Adjacency => { + return csr.outgoingCsr ?? (csr.outgoingCsr = makeAdjacency(csr, false)) +} + +/** @internal */ +export const getIncoming = (csr: Csr): Adjacency => { + return csr.incomingCsr ?? (csr.incomingCsr = makeAdjacency(csr, true)) +} + +/** @internal */ +export const getOutgoingWithEdges = (csr: Csr): AdjacencyWithEdges => { + if (csr.outgoingEdgeCsr !== undefined) { + return csr.outgoingEdgeCsr + } + + const impl = toImpl(csr.graph) + if (csr.indexByEdgeId === undefined) { + // Avoid a map lookup per adjacency entry when edge ids already match insertion-order positions. + let compactEdgeIds = true + let position = 0 + for (const edgeId of impl.edges.keys()) { + if (edgeId !== position++) { + compactEdgeIds = false + break + } + } + if (compactEdgeIds) { + csr.indexByEdgeId = null + } else { + csr.indexByEdgeId = new Map() + position = 0 + for (const edgeId of impl.edges.keys()) { + csr.indexByEdgeId.set(edgeId, position++) + } + } + } + + const outgoing = getOutgoing(csr) + // Each slot identifies the edge responsible for the neighbor in the matching columnIndices slot. + const edgeIndices = new Uint32Array(outgoing.columnIndices.length) + let position = 0 + for (const nodeId of csr.nodeIds) { + const adjacency = impl.adjacency.get(nodeId) + if (adjacency === undefined) { + continue + } + + for (const edgeId of adjacency) { + edgeIndices[position++] = csr.indexByEdgeId === null ? edgeId : csr.indexByEdgeId.get(edgeId)! + } + } + + return csr.outgoingEdgeCsr = { ...outgoing, edgeIndices } +} + +/** @internal */ +export const getAdjacencies = ( + csr: Csr, + direction: Graph.TraversalDirection +): Adjacencies => { + if (csr.type === "directed" && direction === "incoming") { + return { primary: getIncoming(csr), secondary: undefined } + } + + if (csr.type === "directed" && direction === "undirected") { + // A directed graph needs both matrices to ignore direction; undirected rows already contain both endpoints. + return { primary: getOutgoing(csr), secondary: getIncoming(csr) } + } + + return { primary: getOutgoing(csr), secondary: undefined } +} + +/** @internal */ +export const getEdgeEndpoints = (csr: Csr): EdgeEndpoints => { + if (csr.compactEdgeEndpoints !== undefined) { + return csr.compactEdgeEndpoints + } + + const edges = getEdges(csr) + const sources = new Uint32Array(edges.length) + const targets = new Uint32Array(edges.length) + for (let i = 0; i < edges.length; i++) { + sources[i] = csr.indexByNodeId === undefined ? edges[i].source : csr.indexByNodeId.get(edges[i].source)! + targets[i] = csr.indexByNodeId === undefined ? edges[i].target : csr.indexByNodeId.get(edges[i].target)! + } + + return csr.compactEdgeEndpoints = { sources, targets } +} + +/** @internal */ +export const peek = ( + graph: Graph.Graph | Graph.MutableGraph +): Csr | undefined => cache.get(graph) + +/** @internal */ +export const invalidate = ( + graph: Graph.Graph | Graph.MutableGraph +): void => { + // Existing iterators retain their Csr object; only subsequent lookups rebuild from the mutated graph. + cache.delete(graph) +} + +/** @internal */ +export const get = ( + graph: Graph.Graph | Graph.MutableGraph +): Csr => { + const cacheEnabled = !isTransforming(graph) + if (cacheEnabled) { + const cached = cache.get(graph) + if (cached !== undefined) { + return cached + } + } + + // Node ids and data are captured together so an iterator never consults mutable maps after it starts. + const impl = toImpl(graph) + const nodeIds = new Array(impl.nodes.size) + const nodeData = new Array(impl.nodes.size) + let compactNodeIds = true + let nodePosition = 0 + for (const [nodeId, data] of impl.nodes) { + nodeIds[nodePosition] = nodeId + nodeData[nodePosition] = data + if (nodeId !== nodePosition) { + compactNodeIds = false + } + nodePosition++ + } + + const indexByNodeId = compactNodeIds ? undefined : new Map() + if (indexByNodeId !== undefined) { + for (let i = 0; i < nodeIds.length; i++) { + indexByNodeId.set(nodeIds[i], i) + } + } + + const result: Csr = { + type: impl.type, + graph, + nodeIds, + nodeData, + indexByNodeId, + outgoingCsr: undefined, + incomingCsr: undefined, + outgoingEdgeCsr: undefined, + edgesByIndex: undefined, + edgeIdsByIndex: undefined, + compactEdgeEndpoints: undefined, + indexByEdgeId: undefined + } + + if (cacheEnabled) { + cache.set(graph, result) + } + + return result +} diff --git a/repos/effect/packages/effect/src/internal/matcher.ts b/repos/effect/packages/effect/src/internal/matcher.ts index 3a031787e0..36649c971d 100644 --- a/repos/effect/packages/effect/src/internal/matcher.ts +++ b/repos/effect/packages/effect/src/internal/matcher.ts @@ -1,5 +1,15 @@ import { dual, identity } from "../Function.ts" -import type { Case, Matcher, Not, SafeRefinement, TypeMatcher, Types, ValueMatcher, When } from "../Match.ts" +import type { + Case, + Matcher, + Not, + SafeRefinement, + TypeMatcher, + Types, + ValueFlavor, + ValueMatcher, + When +} from "../Match.ts" import * as Option from "../Option.ts" import { pipeArguments } from "../Pipeable.ts" import type * as Predicate from "../Predicate.ts" @@ -9,30 +19,33 @@ import type { Unify } from "../Unify.ts" /** @internal */ export const TypeId = "~effect/match/Match/Matcher" -const TypeMatcherProto: Omit, "cases"> = { +const TypeMatcherProto: Omit, "cases" | "select"> = { [TypeId]: { _input: identity, _filters: identity, _remaining: identity, _result: identity, - _return: identity + _return: identity, + _args: identity }, _tag: "TypeMatcher", add( - this: TypeMatcher, + this: TypeMatcher, _case: Case - ): TypeMatcher { - return makeTypeMatcher([...this.cases, _case]) + ): TypeMatcher { + return makeTypeMatcher(this.select, [...this.cases, _case]) }, pipe() { return pipeArguments(this, arguments) } } -function makeTypeMatcher( +function makeTypeMatcher>( + select: (...args: any) => I, cases: ReadonlyArray -): TypeMatcher { +): TypeMatcher { const matcher = Object.create(TypeMatcherProto) + matcher.select = select matcher.cases = cases return matcher } @@ -45,13 +58,14 @@ const ValueMatcherProto: Omit< _input: identity, _filters: identity, _result: identity, - _return: identity + _return: identity, + _flavor: identity }, _tag: "ValueMatcher", - add( + add( this: ValueMatcher, _case: Case - ): ValueMatcher { + ): ValueMatcher { if (Result.isSuccess(this.value)) { return this } @@ -75,10 +89,10 @@ const ValueMatcherProto: Omit< } } -function makeValueMatcher( - provided: Pr, - value: Result.Result -): ValueMatcher { +function makeValueMatcher( + provided: Provided, + value: Result.Result +): ValueMatcher { const matcher = Object.create(ValueMatcherProto) matcher.provided = provided matcher.value = value @@ -87,7 +101,7 @@ function makeValueMatcher( const makeWhen = ( guard: (u: unknown) => boolean, - evaluate: (input: unknown) => any + evaluate: (input: unknown, ...args: Array) => any ): When => ({ _tag: "When", guard, @@ -96,7 +110,7 @@ const makeWhen = ( const makeNot = ( guard: (u: unknown) => boolean, - evaluate: (input: unknown) => any + evaluate: (input: unknown, ...args: Array) => any ): Not => ({ _tag: "Not", guard, @@ -189,12 +203,17 @@ export const type = (): Matcher< I, never, never -> => makeTypeMatcher([]) +> => makeTypeMatcher(identity, []) + +/** @internal */ +export const fn = , I>( + select: (...args: Args) => I +): Matcher, I, never, never, any, Args> => makeTypeMatcher(select, []) /** @internal */ export const value = ( i: I -): Matcher, I, never, I> => makeValueMatcher(i, Result.fail(i)) +): Matcher, I, never, ValueFlavor> => makeValueMatcher(i, Result.fail(i)) /** @internal */ export const valueTags: { @@ -218,7 +237,7 @@ export const valueTags: { & { readonly [Tag in Types.Tags<"_tag", I> & string]: (_: Extract) => any } & { readonly [Tag in Exclude>]: never } >(input: I, fields: P): Unify> => { - const match: any = tagsExhaustive(fields as any)(makeTypeMatcher([])) + const match: any = tagsExhaustive(fields as any)(makeTypeMatcher(identity, [])) return match(input) } ) @@ -234,36 +253,39 @@ export const typeTags = () => >( fields: P ) => { - const match: any = tagsExhaustive(fields as any)(makeTypeMatcher([])) + const match: any = tagsExhaustive(fields as any)(makeTypeMatcher(identity, [])) return (input: I): Unify> => match(input) } /** @internal */ -export const withReturnType = () => -(self: Matcher): [Ret] extends [ - [A] extends [never] ? any : A -] ? Matcher - : "withReturnType constraint does not extend Result type" => self as any +export const withReturnType = + () => + >(self: Matcher): [Ret] extends [ + [A] extends [never] ? any : A + ] ? Matcher + : "withReturnType constraint does not extend Result type" => self as any /** @internal */ export const when = < R, const P extends Types.PatternPrimitive | Types.PatternBase, Ret, - Fn extends (_: Types.WhenMatch) => Ret + Args extends Array, + Fn extends (_: Types.WhenMatch, ...args: Args) => Ret >( pattern: P, f: Fn ) => ( - self: Matcher + self: Matcher ): Matcher< I, Types.AddWithout>, Types.ApplyFilters>>, A | ReturnType, Pr, - Ret + Ret, + Args > => (self as any).add(makeWhen(makePredicate(pattern), f as any)) /** @internal */ @@ -273,19 +295,21 @@ export const whenOr = < Types.PatternPrimitive | Types.PatternBase >, Ret, - Fn extends (_: Types.WhenMatch) => Ret + Args extends Array, + Fn extends (_: Types.WhenMatch, ...args: Args) => Ret >( ...args: [...patterns: P, f: Fn] ) => ( - self: Matcher + self: Matcher ): Matcher< I, Types.AddWithout>, Types.ApplyFilters>>, A | ReturnType, Pr, - Ret + Ret, + Args > => { const onMatch = args[args.length - 1] as any const patterns = args.slice(0, -1) as unknown as P @@ -299,12 +323,13 @@ export const whenAnd = < Types.PatternPrimitive | Types.PatternBase >, Ret, - Fn extends (_: Types.WhenMatch>) => Ret + Args extends Array, + Fn extends (_: Types.WhenMatch>, ...args: Args) => Ret >( ...args: [...patterns: P, f: Fn] ) => ( - self: Matcher + self: Matcher ): Matcher< I, Types.AddWithout>>, @@ -313,7 +338,9 @@ export const whenAnd = < Types.AddWithout>> >, A | ReturnType, - Pr + Pr, + Ret, + Args > => { const onMatch = args[args.length - 1] as any const patterns = args.slice(0, -1) as unknown as P @@ -440,7 +467,8 @@ export const tag: < R, P extends Types.Tags<"_tag", R> & string, Ret, - Fn extends (_: Extract>) => Ret + Args extends Array, + Fn extends (_: Extract>, ...args: Args) => Ret >( ...pattern: [ first: P, @@ -448,15 +476,16 @@ export const tag: < f: Fn ] ) => ( - self: Matcher + self: Matcher ) => Matcher< I, Types.AddWithout>>, Types.ApplyFilters>>>, ReturnType | A, Pr, - Ret -> = discriminator("_tag") + Ret, + Args +> = discriminator("_tag") as any /** @internal */ export const tagStartsWith = discriminatorStartsWith("_tag") @@ -472,20 +501,22 @@ export const not = < R, const P extends Types.PatternPrimitive | Types.PatternBase, Ret, - Fn extends (_: Types.NotMatch) => Ret + Args extends Array, + Fn extends (_: Types.NotMatch, ...args: Args) => Ret >( pattern: P, f: Fn ) => ( - self: Matcher + self: Matcher ): Matcher< I, Types.AddOnly>, Types.ApplyFilters>>, A | ReturnType, Pr, - Ret + Ret, + Args > => (self as any).add(makeNot(makePredicate(pattern), f as any)) /** @internal */ @@ -522,35 +553,44 @@ export const instanceOf = any>( /** @internal */ export const orElse = - Ret>(f: F) => - (self: Matcher): [Pr] extends [never] ? (input: I) => Unify | A> + , F extends (_: RA, ...args: Args) => Ret>(f: F) => + ( + self: Matcher + ): [Pr] extends [never] ? [Args] extends [[]] ? (input: I) => Unify | A> + : (...args: Args) => Unify | A> : Unify | A> => { const toResult = result(self) if (Result.isResult(toResult)) { - return toResult._tag === "Success" ? toResult.success as any : f(toResult.failure) as any + return toResult._tag === "Success" ? toResult.success as any : (f as any)(toResult.failure) } // @ts-expect-error - return (input: I) => { - const a = toResult(input) - return Result.isSuccess(a) ? a.success : f(a.failure) + return (...args: Array) => { + const a = (toResult as any)(...args) + return Result.isSuccess(a) ? a.success : (f as any)(a.failure, ...args) } } /** @internal */ -export const orElseAbsurd = ( - self: Matcher -): [Pr] extends [never] ? (input: I) => Unify : Unify => - orElse(() => { - throw new Error("effect/Match/orElseAbsurd: absurd") - })(self) +export const orElseAbsurd: >( + self: Matcher + // oxlint-disable-next-line max-len +) => [Pr] extends [never] ? [Args] extends [[]] ? (input: I) => Unify : (...args: Args) => Unify : Unify = (( + self: Matcher +) => + orElse( + (() => { + throw new Error("effect/Match/orElseAbsurd: absurd") + }) as any + )(self)) as any /** @internal */ -export const result: ( - self: Matcher -) => [Pr] extends [never] ? (input: I) => Result.Result, R> +export const result: >( + self: Matcher +) => [Pr] extends [never] ? [Args] extends [[]] ? (input: I) => Result.Result, R> + : (...args: Args) => Result.Result, R> : Result.Result, R> = ((self: Matcher) => { if (self._tag === "ValueMatcher") { return self.value @@ -559,22 +599,24 @@ export const result: ( const len = self.cases.length if (len === 1) { const _case = self.cases[0] - return (input: I): Result.Result => { + return (...args: Array): Result.Result => { + const input = self.select(...args) if (_case._tag === "When" && _case.guard(input) === true) { - return Result.succeed(_case.evaluate(input)) + return Result.succeed(_case.evaluate(input, ...args)) } else if (_case._tag === "Not" && _case.guard(input) === false) { - return Result.succeed(_case.evaluate(input)) + return Result.succeed(_case.evaluate(input, ...args)) } return Result.fail(input as any) } } - return (input: I): Result.Result => { + return (...args: Array): Result.Result => { + const input = self.select(...args) for (let i = 0; i < len; i++) { const _case = self.cases[i] if (_case._tag === "When" && _case.guard(input) === true) { - return Result.succeed(_case.evaluate(input)) + return Result.succeed(_case.evaluate(input, ...args)) } else if (_case._tag === "Not" && _case.guard(input) === false) { - return Result.succeed(_case.evaluate(input)) + return Result.succeed(_case.evaluate(input, ...args)) } } @@ -583,9 +625,10 @@ export const result: ( }) as any /** @internal */ -export const option: ( - self: Matcher -) => [Pr] extends [never] ? (input: I) => Option.Option> +export const option: >( + self: Matcher +) => [Pr] extends [never] ? [Args] extends [[]] ? (input: I) => Option.Option> + : (...args: Args) => Option.Option> : Option.Option> = ((self: Matcher) => { const toResult = result(self) if (Result.isResult(toResult)) { @@ -594,8 +637,8 @@ export const option: ( onSuccess: Option.some }) } - return (input: I): Option.Option => - Result.match((toResult as any)(input), { + return (...args: Array): Option.Option => + Result.match((toResult as any)(...args), { onFailure: () => Option.none(), onSuccess: Option.some as any }) @@ -604,29 +647,30 @@ export const option: ( const getExhaustiveAbsurdErrorMessage = "effect/match/Match/exhaustive: absurd" /** @internal */ -export const exhaustive: ( - self: Matcher -) => [Pr] extends [never] ? (u: I) => Unify : Unify = (( - self: Matcher -) => { - const toResult = result(self as any) +export const exhaustive: >( + self: Matcher +) => [Pr] extends [never] ? [Args] extends [[]] ? (u: I) => Unify : (...args: Args) => Unify + : Unify = (( + self: Matcher + ) => { + const toResult = result(self as any) + + if (Result.isResult(toResult)) { + if (Result.isSuccess(toResult)) { + return toResult.success + } - if (Result.isResult(toResult)) { - if (Result.isSuccess(toResult)) { - return toResult.success + throw new Error(getExhaustiveAbsurdErrorMessage) } - throw new Error(getExhaustiveAbsurdErrorMessage) - } + return (...args: Array): A => { + // @ts-expect-error + const result = toResult(...args) - return (u: I): A => { - // @ts-expect-error - const result = toResult(u) + if (Result.isSuccess(result)) { + return result.success as any + } - if (Result.isSuccess(result)) { - return result.success as any + throw new Error(getExhaustiveAbsurdErrorMessage) } - - throw new Error(getExhaustiveAbsurdErrorMessage) - } -}) as any + }) as any diff --git a/repos/effect/packages/effect/src/internal/schema/annotations.ts b/repos/effect/packages/effect/src/internal/schema/annotations.ts index 0ba8bb1c07..d5fb684f76 100644 --- a/repos/effect/packages/effect/src/internal/schema/annotations.ts +++ b/repos/effect/packages/effect/src/internal/schema/annotations.ts @@ -55,7 +55,7 @@ export const resolveBrands = resolveAt>("brands") /** @internal */ export const getExpected = memoize((ast: SchemaAST.AST): string => { - const identifier = resolveIdentifier(ast) + const identifier = resolve(ast)?.identifier if (typeof identifier === "string") return identifier return ast.getExpected(getExpected) }) diff --git a/repos/effect/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/repos/effect/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index 590802eff1..da20ca3728 100644 --- a/repos/effect/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/repos/effect/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -1,12 +1,10 @@ -import { unescapeToken } from "../../JsonPointer.ts" -import type * as JsonSchema from "../../JsonSchema.ts" +import * as JsonSchema from "../../JsonSchema.ts" import { remainder } from "../../Number.ts" import * as Schema from "../../Schema.ts" import * as SchemaAST from "../../SchemaAST.ts" import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" import { errorWithPath } from "../errors.ts" import * as InternalRecord from "../record.ts" -import * as InternalAnnotations from "./annotations.ts" import { fromRepresentation, fromRepresentations } from "./fromRepresentation.ts" type Path = ReadonlyArray @@ -29,72 +27,65 @@ type ImportedJsonSchemaRepresentation = Extract +interface ImportedObjectPattern { + readonly source: string + readonly parameter: SchemaRepresentation.String + readonly type: ImportedJsonSchemaRepresentation +} + +interface ImportedObjectProperty { + readonly type: ImportedJsonSchemaRepresentation | undefined + readonly isOptional: boolean +} + +interface ImportedObjectScope { + readonly properties: ReadonlyMap + readonly hasProperties: boolean + readonly patterns: ReadonlyArray + readonly additionalProperties: ImportedJsonSchemaRepresentation +} + const never: ImportedJsonSchemaRepresentation = { _tag: "Never", checks: [] } const unknown: ImportedJsonSchemaRepresentation = { _tag: "Unknown", checks: [] } const string: ImportedJsonSchemaRepresentation = { _tag: "String", checks: [] } -function makeLiteral(literal: string | number | boolean): SchemaRepresentation.Literal { - return { _tag: "Literal", literal, checks: [] } +function makeJsonLiteral(input: unknown): ImportedJsonSchemaRepresentation | undefined { + if (input === null) return { _tag: "Null", checks: [] } + return typeof input === "string" || typeof input === "number" || typeof input === "boolean" + ? { _tag: "Literal", literal: input, checks: [] } + : undefined } -function annotate( - representation: ImportedJsonSchemaRepresentation, - annotations: Schema.Annotations.Annotations | undefined -): ImportedJsonSchemaRepresentation { - if (annotations === undefined) return representation - if (representation._tag === "Reference") { - return { - _tag: "Suspend", - annotations, - checks: [], - thunk: representation - } - } - return { - ...representation, - annotations: { - ...representation.annotations, - ...annotations - } - } -} +const jsonSchemaValueTypes = ["null", "string", "number", "boolean", "object", "array"] as const -const jsonSchemaTypes = new Set([ - "null", - "string", - "number", - "integer", - "boolean", - "object", - "array" -]) - -const jsonSchemaStringKeys = ["minLength", "maxLength", "pattern", "format", "contentMediaType", "contentSchema"] -const jsonSchemaNumberKeys = ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"] -const jsonSchemaObjectKeys = [ +const jsonSchemaTypeSpecificKeys = [ + "minLength", + "maxLength", + "pattern", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", "properties", "required", "additionalProperties", "patternProperties", "propertyNames", "minProperties", - "maxProperties" + "maxProperties", + "items", + "prefixItems", + "minItems", + "maxItems", + "uniqueItems" ] -const jsonSchemaArrayKeys = ["items", "prefixItems", "additionalItems", "minItems", "maxItems", "uniqueItems"] function isImportedJsonSchemaType(input: unknown): input is JsonSchema.Type { - return typeof input === "string" && jsonSchemaTypes.has(input) + return input === "integer" || typeof input === "string" && jsonSchemaValueTypes.some((type) => type === input) } -function inferJsonSchemaType(schema: JsonSchema.JsonSchema): JsonSchema.Type | undefined { - if (jsonSchemaStringKeys.some((key) => schema[key] !== undefined)) return "string" - if (jsonSchemaNumberKeys.some((key) => schema[key] !== undefined)) return "number" - if (jsonSchemaObjectKeys.some((key) => schema[key] !== undefined)) return "object" - if (jsonSchemaArrayKeys.some((key) => schema[key] !== undefined)) return "array" -} - -function jsonSchemaReferenceKey($ref: string): string | undefined { - const token = $ref.slice($ref.lastIndexOf("/") + 1) - return token.length === 0 ? undefined : unescapeToken(token) +function hasTypeSpecificKeywords(schema: JsonSchema.JsonSchema): boolean { + return jsonSchemaTypeSpecificKeys.some((key) => schema[key] !== undefined) } function jsonSchemaFilter( @@ -216,10 +207,29 @@ function translateJsonSchemaMultiDocument( ): SchemaRepresentation.MultiDocument { const definitionCache = new Map() const reachableDefinitions = new Map() + const objectScopesByProperties = new WeakMap< + SchemaRepresentation.Objects["propertySignatures"], + ReadonlyArray + >() const annotatedReferences: Array<{ readonly reference: SchemaRepresentation.Reference readonly path: Path }> = [] + const resolvingChoices = new Set() + + function annotate( + representation: ImportedJsonSchemaRepresentation, + annotations: Schema.Annotations.Annotations | undefined + ): ImportedJsonSchemaRepresentation { + if (annotations === undefined) return representation + if (representation._tag === "Reference") { + return { _tag: "Suspend", annotations, checks: [], thunk: representation } + } + return { + ...representation, + annotations: { ...representation.annotations, ...annotations } + } + } function translateDefinition( key: string, @@ -246,31 +256,25 @@ function translateJsonSchemaMultiDocument( reference: SchemaRepresentation.Reference, path: Path, options?: { readonly recursiveReferenceError?: string }, - seen: ReadonlySet = new Set() + seen: Set = new Set() ): ImportedJsonSchemaRepresentation { if (seen.has(reference.$ref)) { throw errorWithPath(`Invalid reference ${reference.$ref}`, [...path, "$ref"]) } - const nextSeen = new Set(seen).add(reference.$ref) + seen.add(reference.$ref) const representation = translateDefinition(reference.$ref, path, options?.recursiveReferenceError) if (representation._tag === "Reference") { - return resolveReference(representation, path, options, nextSeen) + return resolveReference(representation, path, options, seen) } if (representation._tag === "Suspend" && representation.thunk._tag === "Reference") { return annotate( - resolveReference(representation.thunk, path, options, nextSeen), + resolveReference(representation.thunk, path, options, seen), representation.annotations ) } return representation } - function annotationsOf( - representation: ImportedJsonSchemaRepresentation - ): Schema.Annotations.Annotations | undefined { - return representation._tag === "Reference" ? undefined : representation.annotations - } - function mergeAnnotations( left: Schema.Annotations.Annotations | undefined, right: Schema.Annotations.Annotations | undefined @@ -287,7 +291,10 @@ function translateJsonSchemaMultiDocument( ): ImportedJsonSchemaRepresentation { return annotate( representation, - mergeAnnotations(annotationsOf(left), annotationsOf(right)) + mergeAnnotations( + left._tag === "Reference" ? undefined : left.annotations, + right._tag === "Reference" ? undefined : right.annotations + ) ) } @@ -428,67 +435,305 @@ function translateJsonSchemaMultiDocument( return { elements, rest: rest._tag === "Never" ? [] : [rest] } } - function combineProperties( - left: ReadonlyArray, - right: ReadonlyArray, + function combineTypes( + types: ReadonlyArray, path: Path - ): Array { - const rightByName = new Map(right.map((property) => [property.name, property])) - const names = new Set() - const properties = left.map((property) => { - const name = property.name - names.add(name) - const other = rightByName.get(name) - if (other === undefined) return property - return { - name: property.name, - type: combine( - property.type as ImportedJsonSchemaRepresentation, - other.type as ImportedJsonSchemaRepresentation, - [...path, "properties", globalThis.String(name)] - ), - isOptional: property.isOptional && other.isOptional, - isMutable: false + ): ImportedJsonSchemaRepresentation { + let out = types[0] ?? unknown + for (let index = 1; index < types.length; index++) { + out = combine(out, types[index], [...path, index]) + } + return out + } + + function lowerObject( + scopes: ReadonlyArray, + checks: ReadonlyArray, + annotations: Schema.Annotations.Annotations | undefined, + path: Path + ): ImportedJsonSchemaRepresentation { + const names = new Set() + let hasFiniteKeyDomain = false + let requiresFiniteKeyDomain = false + for (const scope of scopes) { + for (const name of scope.properties.keys()) names.add(name) + hasFiniteKeyDomain ||= scope.additionalProperties._tag === "Never" && scope.patterns.length === 0 + requiresFiniteKeyDomain ||= scope.additionalProperties._tag === "Never" || + scope.additionalProperties._tag !== "Unknown" && (scope.hasProperties || scope.patterns.length > 0) + } + if (!hasFiniteKeyDomain && requiresFiniteKeyDomain) { + throw errorWithPath("Unsupported object keyword scopes", path) + } + + const properties: Array = [] + for (const name of names) { + let isOptional = true + const types: Array = [] + for (const scope of scopes) { + const property = scope.properties.get(name) + if (property !== undefined) { + if (!property.isOptional) isOptional = false + if (property.type !== undefined) types.push(property.type) + } + let matches = false + for (const pattern of scope.patterns) { + if (globalThis.RegExp(pattern.source).test(name)) { + types.push(pattern.type) + matches = true + } + } + if (property?.type === undefined && !matches) types.push(scope.additionalProperties) } - }) - for (const property of right) { - if (!names.has(property.name)) properties.push(property) + const type = combineTypes(types, [...path, "properties", name]) + if (!isOptional && type._tag === "Never") return never + properties.push({ name, type, isOptional, isMutable: false }) + } + + const indexSignatures: Array = [] + if (!hasFiniteKeyDomain) { + const additionalProperties = combineTypes( + scopes.map((scope) => scope.additionalProperties), + [...path, "additionalProperties"] + ) + const patterns = new Map() + for (const scope of scopes) { + for (const pattern of scope.patterns) { + const previous = patterns.get(pattern.source) + patterns.set( + pattern.source, + previous === undefined + ? pattern + : { + ...pattern, + type: combine( + previous.type, + pattern.type, + [...path, "patternProperties", pattern.source] + ) + } + ) + } + } + for (const { parameter, type } of patterns.values()) { + indexSignatures.push({ + parameter, + type: combine(type, additionalProperties, [...path, "indexSignatures"]) + }) + } + indexSignatures.push({ parameter: string, type: additionalProperties }) + } else if (properties.length === 0) { + indexSignatures.push({ parameter: string, type: never }) + } + + objectScopesByProperties.set(properties, scopes) + return { + _tag: "Objects", + propertySignatures: properties, + indexSignatures, + checks, + annotations } - return properties } - function isUnconstrainedString(representation: Representation): boolean { - return representation._tag === "String" && representation.checks.length === 0 && - representation.annotations === undefined + const jsonRootTags = ["Null", "String", "Number", "Boolean", "Objects", "Arrays"] as const + const allMasks = (1 << jsonRootTags.length) - 1 + + function rootMask(representation: ImportedJsonSchemaRepresentation): number { + switch (representation._tag) { + case "Never": + return 0 + case "Unknown": + case "Reference": + return allMasks + case "Literal": + switch (typeof representation.literal) { + case "string": + return 2 + case "number": + return 4 + case "boolean": + return 8 + default: + return 0 + } + case "Suspend": + return rootMask(representation.thunk as ImportedJsonSchemaRepresentation) + case "Union": { + let out = 0 + for (const type of representation.types) { + out |= rootMask(type as ImportedJsonSchemaRepresentation) + } + return out + } + default: + return 1 << jsonRootTags.indexOf(representation._tag) + } + } + + function hasChoices(representation: ImportedJsonSchemaRepresentation): boolean { + switch (representation._tag) { + case "Reference": { + const key = representation.$ref + const cached = definitionCache.get(key) + if (cached === null || resolvingChoices.has(key)) return true + resolvingChoices.add(key) + const out = hasChoices(cached ?? translateDefinition(key, reachableDefinitions.get(key)!)) + resolvingChoices.delete(key) + return out + } + case "Suspend": + return hasChoices(representation.thunk as ImportedJsonSchemaRepresentation) + case "Union": + return representation.types.length > 1 || + representation.types.some((type) => hasChoices(type as ImportedJsonSchemaRepresentation)) + case "Arrays": + return representation.elements.some((element) => + hasChoices(element.type as ImportedJsonSchemaRepresentation) + ) || representation.rest.some((type) => hasChoices(type as ImportedJsonSchemaRepresentation)) + case "Objects": + return representation.propertySignatures.some((property) => + hasChoices(property.type as ImportedJsonSchemaRepresentation) + ) || + representation.indexSignatures.some((indexSignature) => + hasChoices(indexSignature.parameter as ImportedJsonSchemaRepresentation) || + hasChoices(indexSignature.type as ImportedJsonSchemaRepresentation) + ) || representation.checks.some(checkHasChoices) + default: + return false + } } - function combineIndexSignatures( - left: ReadonlyArray, - right: ReadonlyArray, + function checkHasChoices(check: Check): boolean { + return check._tag === "FilterGroup" + ? check.checks.some(checkHasChoices) + : check.representation?.schemas?.some((schema) => hasChoices(schema as ImportedJsonSchemaRepresentation)) === true + } + + function makeUnion( + source: SchemaRepresentation.Union, + types: ReadonlyArray + ): ImportedJsonSchemaRepresentation { + if (types.length === 0) return never + if (types.length === 1 && source.checks.length === 0) return annotate(types[0], source.annotations) + return { ...source, types } + } + + function literalValue( + representation: ImportedJsonSchemaRepresentation + ): string | number | boolean | null | undefined { + if (representation._tag === "Null") return null + if (representation._tag !== "Literal") return undefined + const literal = representation.literal + return typeof literal === "string" || typeof literal === "number" || typeof literal === "boolean" + ? literal + : undefined + } + + function combineLiteralUnions( + left: SchemaRepresentation.Union, + right: SchemaRepresentation.Union, path: Path - ): Array { - if (left.length === 0 || right.length === 0) return [] - const signatures = [...left] - for (const signature of right) { - if (isUnconstrainedString(signature.parameter)) { - const index = signatures.findIndex((candidate) => isUnconstrainedString(candidate.parameter)) - if (index !== -1) { - signatures[index] = { - parameter: signatures[index].parameter, - type: combine( - signatures[index].type as ImportedJsonSchemaRepresentation, - signature.type as ImportedJsonSchemaRepresentation, - [...path, "indexSignatures", index, "type"] - ) - } - } else { - signatures.push(signature) - } - } else { - signatures.push(signature) + ): ImportedJsonSchemaRepresentation | undefined { + if (left.mode !== "anyOf" || right.mode !== "anyOf") return undefined + const rightByValue = new Map() + for (const type of right.types) { + const representation = type as ImportedJsonSchemaRepresentation + const value = literalValue(representation) + if (value === undefined) return undefined + rightByValue.set(value, representation) + } + const types: Array = [] + for (const type of left.types) { + const representation = type as ImportedJsonSchemaRepresentation + const value = literalValue(representation) + if (value === undefined) return undefined + const match = rightByValue.get(value) + if (match !== undefined) types.push(combine(representation, annotate(match, right.annotations), path)) + } + return makeUnion(left, types) + } + + function combineUnionWithType( + union: SchemaRepresentation.Union, + type: ImportedJsonSchemaRepresentation, + path: Path, + unionOnLeft: boolean + ): ImportedJsonSchemaRepresentation { + const mask = rootMask(type) + const members = union.types + .map((member, index) => ({ + index, + type: member as ImportedJsonSchemaRepresentation + })) + .filter((member) => (rootMask(member.type) & mask) !== 0) + if (members.length > 1 && hasChoices(type)) { + return unsupportedIntersection(path) + } + if (members.length === 1 && union.checks.length === 0) { + const member = annotate(members[0].type, union.annotations) + return unionOnLeft + ? combine(member, type, [...path, "types", members[0].index]) + : combine(type, member, [...path, "types", members[0].index]) + } + return makeUnion( + union, + members + .map((member) => + unionOnLeft + ? combine(member.type, type, [...path, "types", member.index]) + : combine(type, member.type, [...path, "types", member.index]) + ) + .filter((type) => type._tag !== "Never") + ) + } + + function isTypePartition(union: SchemaRepresentation.Union): boolean { + let mask = 0 + for (const type of union.types) { + const memberMask = rootMask(type as ImportedJsonSchemaRepresentation) + if ((mask & memberMask) !== 0) return false + mask |= memberMask + } + return true + } + + function unsupportedIntersection(path: Path): never { + throw errorWithPath("Unsupported intersection of overlapping unions", path) + } + + function combinePartition( + partition: SchemaRepresentation.Union, + other: SchemaRepresentation.Union, + path: Path, + partitionOnLeft: boolean + ): ImportedJsonSchemaRepresentation { + const partitionTypes = partition.types as ReadonlyArray + const otherTypes = other.types as ReadonlyArray + for (const type of otherTypes) { + const mask = rootMask(type) + if (partitionTypes.filter((member) => (rootMask(member) & mask) !== 0).length > 1 && hasChoices(type)) { + return unsupportedIntersection(path) } } - return signatures + const types: Array = [] + for (let index = 0; index < partitionTypes.length; index++) { + const member = partitionTypes[index] + const type = combineUnionWithType(other, member, [...path, "types", index], !partitionOnLeft) + if (type._tag !== "Never") types.push(type) + } + return makeUnion(partition, types) + } + + function combineUnions( + left: SchemaRepresentation.Union, + right: SchemaRepresentation.Union, + path: Path + ): ImportedJsonSchemaRepresentation { + const literals = combineLiteralUnions(left, right, path) + if (literals !== undefined) return literals + if (isTypePartition(left)) return combinePartition(left, right, path, true) + if (isTypePartition(right)) return combinePartition(right, left, path, false) + return unsupportedIntersection(path) } function combine( @@ -514,19 +759,9 @@ function translateJsonSchemaMultiDocument( right.annotations ) } - if (left._tag === "Union") { - const types = left.types - .map((type, index) => combine(type as ImportedJsonSchemaRepresentation, right, [...path, "types", index])) - .filter((type) => type._tag !== "Never") - if (types.length === 0) return never - return annotate({ - _tag: "Union", - types, - mode: left.mode, - checks: left.checks - }, left.annotations) - } - if (right._tag === "Union") return combine(right, left, path) + if (left._tag === "Union" && right._tag === "Union") return combineUnions(left, right, path) + if (left._tag === "Union") return combineUnionWithType(left, right, path, true) + if (right._tag === "Union") return combineUnionWithType(right, left, path, false) switch (left._tag) { case "Null": @@ -614,53 +849,91 @@ function translateJsonSchemaMultiDocument( case "Objects": { if (right._tag !== "Objects") return never const objectChecks = combineChecks(left.checks, right.checks, right.annotations) - return annotate( - { - _tag: "Objects", - propertySignatures: combineProperties(left.propertySignatures, right.propertySignatures, path), - indexSignatures: combineIndexSignatures(left.indexSignatures, right.indexSignatures, path), - checks: objectChecks ?? left.checks - }, - mergeAnnotations(left.annotations, objectChecks === undefined ? right.annotations : undefined) + const scopes = [ + ...objectScopesByProperties.get(left.propertySignatures)!, + ...objectScopesByProperties.get(right.propertySignatures)! + ] + return lowerObject( + scopes, + objectChecks ?? left.checks, + mergeAnnotations(left.annotations, objectChecks === undefined ? right.annotations : undefined), + path ) } } } - function enter(input: unknown): JsonSchema.JsonSchema | undefined { - if (typeof input !== "object" || input === null || Array.isArray(input)) { - return undefined - } - const schema = input as JsonSchema.JsonSchema - return options?.onEnter === undefined ? schema : options.onEnter(schema) - } - function recur(input: unknown, path: Path): ImportedJsonSchemaRepresentation { if (input === false) { return never } - const schema = enter(input) + if (typeof input !== "object" || input === null || Array.isArray(input)) return unknown + const schema = options?.onEnter === undefined + ? input as JsonSchema.JsonSchema + : options.onEnter(input as JsonSchema.JsonSchema) if (schema === undefined) { return unknown } + const enumIndex = Array.isArray(schema.enum) + ? schema.enum.findIndex((value) => typeof value === "object" && value !== null) + : -1 + if (enumIndex !== -1) { + throw errorWithPath(`Unsupported structured JSON Schema value for "enum"`, [...path, "enum", enumIndex]) + } + for (const keyword of Object.keys(schema)) { + if (keyword === "if" && !Object.hasOwn(schema, "then") && !Object.hasOwn(schema, "else")) continue + switch (keyword) { + case "if": + case "$dynamicRef": + case "contains": + case "dependentRequired": + case "dependentSchemas": + case "not": + case "unevaluatedItems": + case "unevaluatedProperties": + throw errorWithPath(`Unsupported JSON Schema keyword "${keyword}"`, [...path, keyword]) + } + } let representation = on(schema, path) - if (representation._tag === "Reference") { - const siblingSchema: JsonSchema.JsonSchema = { ...schema, $ref: undefined } - for (const key of InternalAnnotations.jsonSchemaAnnotationKeys) { - delete siblingSchema[key] + if (Object.hasOwn(schema, "const")) { + const literal = makeJsonLiteral(schema.const) + if (literal === undefined && typeof schema.const === "object") { + throw errorWithPath(`Unsupported structured JSON Schema value for "const"`, [...path, "const"]) } - const sibling = on(siblingSchema, path) - if (sibling._tag !== "Unknown") { - const reference = representation - representation = combine( + if (literal !== undefined) representation = combine(representation, literal, [...path, "const"]) + } + if (Array.isArray(schema.enum)) { + const types = schema.enum.map((value) => makeJsonLiteral(value) ?? unknown) + representation = combine( + representation, + types.length === 1 + ? types[0] + : { _tag: "Union", types, mode: "anyOf", checks: [] }, + [...path, "enum"] + ) + } + if (typeof schema.$ref === "string") { + const $ref = JsonSchema.getReferenceKey(schema.$ref) + if ($ref === undefined) { + throw errorWithPath(`Unsupported reference ${JSON.stringify(schema.$ref)}`, [...path, "$ref"]) + } + if (!Object.hasOwn(document.definitions, $ref)) { + throw errorWithPath(`Invalid reference ${JSON.stringify(schema.$ref)}`, [...path, "$ref"]) + } + if (!reachableDefinitions.has($ref)) reachableDefinitions.set($ref, path) + const reference: SchemaRepresentation.Reference = { _tag: "Reference", $ref } + representation = representation._tag === "Unknown" + ? reference + : representation._tag === "Never" + ? never + : combine( resolveReference(reference, path, { recursiveReferenceError: `Unsupported assertion siblings on recursive reference ${reference.$ref}` }), - sibling, + representation, path ) - } } const annotations = jsonSchemaAnnotations(schema) if (annotations !== undefined && representation._tag === "Reference") { @@ -694,49 +967,21 @@ function translateJsonSchemaMultiDocument( } function on(schema: JsonSchema.JsonSchema, path: Path): ImportedJsonSchemaRepresentation { - if (typeof schema.$ref === "string") { - const $ref = jsonSchemaReferenceKey(schema.$ref) - if ($ref !== undefined) { - if (!reachableDefinitions.has($ref)) reachableDefinitions.set($ref, path) - return { _tag: "Reference", $ref } - } - } - if (Object.hasOwn(schema, "const")) { - if (schema.const === null) { - return { _tag: "Null", checks: [] } - } - if (typeof schema.const === "string" || typeof schema.const === "number" || typeof schema.const === "boolean") { - return makeLiteral(schema.const) - } - } - if (Array.isArray(schema.enum)) { - const types: Array = [] - for (let index = 0; index < schema.enum.length; index++) { - const value = schema.enum[index] - if (value === null) { - types.push({ _tag: "Null", checks: [] }) - } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - types.push(makeLiteral(value)) - } else { - types.push(recur(value, [...path, "enum", index])) - } - } - if (types.length === 1) { - return types[0] - } - return { _tag: "Union", types, mode: "anyOf", checks: [] } - } - - if (Array.isArray(schema.type) && schema.type.every(isImportedJsonSchemaType)) { + const types = Array.isArray(schema.type) && schema.type.every(isImportedJsonSchemaType) + ? schema.type + : !isImportedJsonSchemaType(schema.type) && hasTypeSpecificKeywords(schema) + ? jsonSchemaValueTypes + : undefined + if (types !== undefined) { return { _tag: "Union", - types: schema.type.map((type) => on({ ...schema, type }, path)), + types: types.map((type) => on({ ...schema, type }, path)), mode: "anyOf", checks: [] } } - const type = isImportedJsonSchemaType(schema.type) ? schema.type : inferJsonSchemaType(schema) + const type = isImportedJsonSchemaType(schema.type) ? schema.type : undefined switch (type) { case "null": return { _tag: "Null", checks: [] } @@ -779,13 +1024,10 @@ function translateJsonSchemaMultiDocument( checks: collectArrayChecks(schema, isMaxItemsRedundant) } } - case "object": - return { - _tag: "Objects", - propertySignatures: collectProperties(schema, path), - indexSignatures: collectIndexSignatures(schema, path), - checks: collectObjectChecks(schema, path) - } + case "object": { + const scope = collectObjectScope(schema, path) + return lowerObject([scope], collectObjectChecks(schema, path), undefined, path) + } default: return unknown } @@ -824,7 +1066,10 @@ function translateJsonSchemaMultiDocument( function collectArrayChecks(schema: JsonSchema.JsonSchema, isMaxItemsRedundant: boolean): Array { const checks: Array = [] - if (schema.prefixItems === undefined) { + if ( + !Array.isArray(schema.prefixItems) || + typeof schema.minItems === "number" && schema.minItems > schema.prefixItems.length + ) { addNumberCheck(checks, schema.minItems, "effect/schema/isMinLength", "minLength") } if (!isMaxItemsRedundant) { @@ -836,31 +1081,27 @@ function translateJsonSchemaMultiDocument( return checks } - function collectProperties( + function collectObjectScope( schema: JsonSchema.JsonSchema, path: Path - ): Array { - const properties = + ): ImportedObjectScope { + const sourceProperties = typeof schema.properties === "object" && schema.properties !== null && !Array.isArray(schema.properties) ? schema.properties as Record : {} const required = Array.isArray(schema.required) ? schema.required.filter((key): key is string => typeof key === "string") : [] - const keys = new Set([...Object.keys(properties), ...required]) - return Array.from(keys, (name) => ({ - name, - type: recur(properties[name], [...path, "properties", name]), - isOptional: !required.includes(name), - isMutable: false - })) - } - - function collectIndexSignatures( - schema: JsonSchema.JsonSchema, - path: Path - ): Array { - const signatures: Array = [] + const propertyNames = Object.keys(sourceProperties) + const keys = new Set([...propertyNames, ...required]) + const properties = new Map(Array.from(keys, (name) => [name, { + type: Object.hasOwn(sourceProperties, name) + ? recur(sourceProperties[name], [...path, "properties", name]) + : undefined, + isOptional: !required.includes(name) + }])) + const hasProperties = propertyNames.length > 0 + const patterns: Array = [] if ( typeof schema.patternProperties === "object" && schema.patternProperties !== null && @@ -868,8 +1109,9 @@ function translateJsonSchemaMultiDocument( ) { for (const [pattern, value] of Object.entries(schema.patternProperties)) { const checks = importPatternChecks(pattern, [...path, "patternProperties", pattern]) - if (checks.length === 0) return [{ parameter: string, type: unknown }] - signatures.push({ + if (checks.length === 0) return { properties, hasProperties, patterns: [], additionalProperties: unknown } + patterns.push({ + source: pattern, parameter: { _tag: "String", checks @@ -878,18 +1120,12 @@ function translateJsonSchemaMultiDocument( }) } } - if (schema.additionalProperties === undefined || schema.additionalProperties === true) { - signatures.push({ - parameter: string, - type: unknown - }) - } else if (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null) { - signatures.push({ - parameter: string, - type: recur(schema.additionalProperties, [...path, "additionalProperties"]) - }) - } - return signatures + const additionalProperties = schema.additionalProperties === false + ? never + : typeof schema.additionalProperties === "object" && schema.additionalProperties !== null + ? recur(schema.additionalProperties, [...path, "additionalProperties"]) + : unknown + return { properties, hasProperties, patterns, additionalProperties } } function collectObjectChecks( @@ -900,10 +1136,11 @@ function translateJsonSchemaMultiDocument( addNumberCheck(checks, schema.minProperties, "effect/schema/isMinProperties", "minProperties") addNumberCheck(checks, schema.maxProperties, "effect/schema/isMaxProperties", "maxProperties") if (schema.propertyNames !== undefined) { + const propertyNamesPath = [...path, "propertyNames"] checks.push(jsonSchemaFilter( "effect/schema/isPropertyNames", null, - [recur(schema.propertyNames, [...path, "propertyNames"])] + [combine(string, recur(schema.propertyNames, propertyNamesPath), propertyNamesPath)] )) } return checks @@ -922,26 +1159,6 @@ function translateJsonSchemaMultiDocument( return { representations, references } } -/** @internal */ -function toRepresentation( - document: JsonSchema.Document<"draft-2020-12">, - options?: SchemaRepresentation.FromJsonSchemaOptions -): SchemaRepresentation.Document { - const translated = translateJsonSchemaMultiDocument( - { - dialect: document.dialect, - schemas: [document.schema], - definitions: document.definitions - }, - options, - true - ) - return { - representation: translated.representations[0], - references: translated.references - } -} - const jsonSchemaRevivers: ReadonlyArray = [ Schema.JsonReviver, Schema.isPatternReviver, @@ -965,7 +1182,19 @@ export function fromJsonSchemaDocument( document: JsonSchema.Document<"draft-2020-12">, options?: SchemaRepresentation.FromJsonSchemaOptions ): Schema.Top { - return fromRepresentation(toRepresentation(document, options), jsonSchemaRevivers) + const translated = translateJsonSchemaMultiDocument( + { + dialect: document.dialect, + schemas: [document.schema], + definitions: document.definitions + }, + options, + true + ) + return fromRepresentation({ + representation: translated.representations[0], + references: translated.references + }, jsonSchemaRevivers) } /** @internal */ diff --git a/repos/effect/packages/effect/src/internal/schema/toCodeDocument.ts b/repos/effect/packages/effect/src/internal/schema/toCodeDocument.ts index a59a5c57f5..0e6f1804f1 100644 --- a/repos/effect/packages/effect/src/internal/schema/toCodeDocument.ts +++ b/repos/effect/packages/effect/src/internal/schema/toCodeDocument.ts @@ -544,10 +544,16 @@ export function toCodeDocument( ).join(", ") const indexTypes = indexSignatures.map((signature) => `readonly [x: ${signature.parameter.Type}]: ${signature.type.Type}` - ).join(", ") + ) + if (properties.length === 0) { + return makeCode( + `Schema.StructWithRest(Schema.Struct({ ${propertyRuntimes} }), [${indexRuntimes}])`, + `{ ${indexTypes.join(", ")} }` + ) + } return makeCode( `Schema.StructWithRest(Schema.Struct({ ${propertyRuntimes} }), [${indexRuntimes}])`, - `{ ${propertyTypes}${properties.length > 0 ? ", " : ""}${indexTypes} }` + [`{ ${propertyTypes} }`, ...indexTypes.map((indexType) => `{ ${indexType} }`)].join(" & ") ) } case "Union": { diff --git a/repos/effect/packages/effect/src/internal/schema/toJsonSchemaDocument.ts b/repos/effect/packages/effect/src/internal/schema/toJsonSchemaDocument.ts index 89900ee499..a7194d2e85 100644 --- a/repos/effect/packages/effect/src/internal/schema/toJsonSchemaDocument.ts +++ b/repos/effect/packages/effect/src/internal/schema/toJsonSchemaDocument.ts @@ -22,11 +22,6 @@ const jsonSchemaAnnotationExcludedKeys = new Set([ ...InternalAnnotations.jsonSchemaAnnotationKeys ]) -/** @internal */ -export const toRepresentationOptions = { - isAnonymousReferenceAllowed: (ast: SchemaAST.AST): boolean => !SchemaAST.isDeclaration(ast) -} - function collectJsonSchemaAnnotations( annotations: Schema.Annotations.Annotations | undefined, options: Schema.ToJsonSchemaOptions | undefined @@ -111,9 +106,26 @@ function isJsonSchemaNumberEncoding(schema: JsonSchema.JsonSchema): boolean { schema.anyOf.slice(1).every((member) => member.type === "string") } +// Keep this allowlist closed: applicators and dependent keywords can change meaning when moved across schema objects. +const inlineableCheckKeywords = + "|type|format|pattern|multipleOf|minimum|maximum|exclusiveMinimum|exclusiveMaximum|minLength|maxLength|minItems|maxItems|uniqueItems|minProperties|maxProperties|propertyNames|" + +function hasOnlyKeywords(schema: JsonSchema.JsonSchema, allowed: string): boolean { + return Object.keys(schema).every((key) => allowed.includes(`|${key}|`)) +} + +function hasNoCollisions(left: JsonSchema.JsonSchema, rightKeys: ReadonlyArray): boolean { + return typeof left.$ref !== "string" && rightKeys.every((key) => !Object.hasOwn(left, key)) +} + +// `format` and `content*` can affect validation, so they are not treated as pure annotations. +const promotableAnnotationKeywords = "|title|description|default|examples|readOnly|writeOnly|" +const inlineableAnnotatedCheckKeywords = inlineableCheckKeywords + promotableAnnotationKeywords + function appendJsonSchema( left: JsonSchema.JsonSchema, - right: JsonSchema.JsonSchema + right: JsonSchema.JsonSchema, + inlineCheck?: true ): JsonSchema.JsonSchema { if (Object.keys(left).length === 0) return right const rightKeys = Object.keys(right) @@ -126,9 +138,17 @@ function appendJsonSchema( const type = leftType === "integer" || extracted.type === "integer" ? "integer" : "number" const base: JsonSchema.JsonSchema = { ...left, type } if (isNumberEncoding) delete base.anyOf - return Object.keys(extracted.schema).length === 0 ? base : appendJsonSchema(base, extracted.schema) + const extractedKeys = Object.keys(extracted.schema) + if (extractedKeys.length === 0) return base + return hasOnlyKeywords(extracted.schema, promotableAnnotationKeywords) && + hasNoCollisions(base, extractedKeys) + ? { ...base, ...extracted.schema } + : appendJsonSchema(base, extracted.schema, inlineCheck) } } + if (inlineCheck && hasNoCollisions(left, rightKeys)) { + return { ...left, ...right } + } const members = Array.isArray(right.allOf) && rightKeys.length === 1 ? right.allOf : [right] if (Array.isArray(left.allOf)) { return { ...left, allOf: [...left.allOf, ...members] } @@ -231,23 +251,30 @@ function compileJsonSchema( check: SchemaRepresentation.Check, type: JsonSchema.Type | undefined, path: Path - ): JsonSchema.JsonSchema | undefined { + ): readonly [schema: JsonSchema.JsonSchema, inline?: true] | undefined { const annotations = check.annotations const callback = annotations?.toJsonSchema if (callback !== undefined) { const schemas = annotationSchemas(check.representation, [...path, "representation"]) const fragment = (callback as SchemaRepresentation.ToJsonSchema.Check)({ type, schemas }) const ordinary = collectJsonSchemaAnnotations(annotations, options) - return ordinary === undefined ? fragment : { ...fragment, ...ordinary } + const schema = ordinary === undefined ? fragment : { ...fragment, ...ordinary } + const allowed = ordinary === undefined ? inlineableCheckKeywords : inlineableAnnotatedCheckKeywords + return check._tag === "Filter" && + hasOnlyKeywords(schema, allowed) && + (ordinary === undefined || hasOnlyKeywords(ordinary, promotableAnnotationKeywords)) + ? [schema, true] + : [schema] } if (check._tag === "Filter") return undefined const children = check.checks .map((child, index) => compileCheck(child, type, [...path, "checks", index])) - .filter((child): child is JsonSchema.JsonSchema => child !== undefined) + .filter((child): child is NonNullable => child !== undefined) if (children.length === 0) return undefined const ordinary = collectJsonSchemaAnnotations(annotations, options) - return ordinary === undefined ? { allOf: children } : { allOf: children, ...ordinary } + const allOf = children.map(([schema]) => schema) + return [ordinary === undefined ? { allOf } : { allOf, ...ordinary }] } function recur( @@ -270,7 +297,7 @@ function compileJsonSchema( const type = typeof output.type === "string" && isJsonSchemaType(output.type) ? output.type : undefined const check = compileCheck(representation.checks[index], type, [...path, "checks", index]) if (check !== undefined) { - output = appendJsonSchema(output, check) + output = appendJsonSchema(output, ...check) } } compiledRepresentations.set(representation, output) @@ -388,8 +415,8 @@ function compileJsonSchema( } if (representation.propertySignatures.length > 0) out.properties = properties if (required.length > 0) out.required = required - out.additionalProperties = options?.additionalProperties ?? false const patternProperties: Record = {} + const additionalProperties: Array = [] for (let index = 0; index < representation.indexSignatures.length; index++) { const signature = representation.indexSignatures[index] let type: JsonSchema.JsonSchema | false = recur( @@ -403,14 +430,36 @@ function compileJsonSchema( new Set() ) if (patterns.length === 0) { - out.additionalProperties = type + additionalProperties.push(type) } else { - for (const pattern of patterns) InternalRecord.assignProperty(patternProperties, pattern, type) + for (const pattern of patterns) { + const previous = patternProperties[pattern] + InternalRecord.assignProperty( + patternProperties, + pattern, + previous === undefined + ? type + : previous === false || type === false + ? false + : appendJsonSchema(previous, type) + ) + } } } - if (Object.keys(patternProperties).length > 0) { + const hasPatternProperties = Object.keys(patternProperties).length > 0 + if (hasPatternProperties) { out.patternProperties = patternProperties - delete out.additionalProperties + } + if (representation.indexSignatures.length === 0) { + out.additionalProperties = options?.additionalProperties ?? false + } else if ( + additionalProperties.length === 1 && + representation.propertySignatures.length === 0 && + !hasPatternProperties + ) { + out.additionalProperties = additionalProperties[0] + } else if (additionalProperties.length > 0) { + out.allOf = additionalProperties.map((type) => ({ type: "object", additionalProperties: type })) } if ( typeof out.additionalProperties === "object" && @@ -424,7 +473,7 @@ function compileJsonSchema( case "Union": { const types = representation.types.map((type, index) => recur(type, [...path, "types", index])) if (types.length === 0) return { not: {} } - if (types.length > 1) { + if (representation.mode === "anyOf" && types.length > 1) { const compacted = compactEnums(types) if (compacted !== undefined) return compacted } diff --git a/repos/effect/packages/effect/src/internal/schema/toRepresentation.ts b/repos/effect/packages/effect/src/internal/schema/toRepresentation.ts index 0816d04e38..89474d36bf 100644 --- a/repos/effect/packages/effect/src/internal/schema/toRepresentation.ts +++ b/repos/effect/packages/effect/src/internal/schema/toRepresentation.ts @@ -5,118 +5,72 @@ import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" import * as InternalRecord from "../record.ts" import * as InternalAnnotations from "./annotations.ts" -/** @internal */ -export function toRepresentation( - ast: SchemaAST.AST, - options?: Options -): SchemaRepresentation.Document { - const { references, representations } = toRepresentations([ast], options) - return { representation: representations[0], references } -} - -/** @internal */ -export function toRepresentations( - asts: readonly [SchemaAST.AST, ...Array], - options?: Options -): SchemaRepresentation.MultiDocument { - return fromASTs(asts, options) -} - -/** @internal */ -export interface Options { - readonly isAnonymousReferenceAllowed?: ((ast: SchemaAST.AST) => boolean) | undefined -} +const defaultReferencePolicy: SchemaRepresentation.ReferencePolicy = ({ identifier }) => identifier type CheckRepresentationAnnotation = SchemaRepresentation.CheckRepresentationAnnotation< SchemaRepresentation.Representation > -function annotationsField(annotations: A | undefined): { readonly annotations: A } | undefined { - return annotations === undefined ? undefined : { annotations } -} - -function hasShareableStructure( - ast: SchemaAST.AST, - isAnonymousReferenceAllowed: Options["isAnonymousReferenceAllowed"] -): boolean { - if (isAnonymousReferenceAllowed?.(ast) === false) return false - switch (ast._tag) { - case "Arrays": - case "Objects": - case "Suspend": - return true - case "Declaration": - return true - case "Union": - return ast.types.some((ast) => hasShareableStructure(ast, isAnonymousReferenceAllowed)) - default: - return false - } +interface ReferenceCandidate { + readonly ast: SchemaAST.AST + readonly identifier: string | undefined + readonly fallback: string | undefined + occurrences: number + isRecursive: boolean + reference: string | undefined } -function isWorthReferencing(bodyCost: number, occurrences: number): boolean { - return occurrences * bodyCost > bodyCost + occurrences + 1 +function annotationsField(annotations: A | undefined): { readonly annotations: A } | undefined { + return annotations === undefined ? undefined : { annotations } } -function isAnonymousReferenceEligible( +/** @internal */ +export function toRepresentation( ast: SchemaAST.AST, - occurrences: number, - isAnonymousReferenceAllowed: Options["isAnonymousReferenceAllowed"] -): boolean { - if (isAnonymousReferenceAllowed?.(ast) === false) return false - if (hasShareableStructure(ast, isAnonymousReferenceAllowed)) return true - switch (ast._tag) { - case "Union": - return isWorthReferencing(ast.types.length + 1, occurrences) - case "Enum": - return isWorthReferencing(ast.enums.length + 1, occurrences) - case "TemplateLiteral": - return isWorthReferencing(ast.parts.length + 1, occurrences) - case "Literal": - return typeof ast.literal === "string" && - isWorthReferencing(ast.literal.length / 32 + 1, occurrences) - default: - return false - } -} - -interface ReferenceIdentifier { - readonly identifier: string - readonly fallback?: string | undefined -} - -function resolveReferenceIdentifier( - input: SchemaAST.AST, - encoded: SchemaAST.AST -): ReferenceIdentifier | undefined { - const identifier = InternalAnnotations.resolveIdentifier(encoded) - if (identifier !== undefined) return { identifier } - const fallback = (encoded !== input ? InternalAnnotations.resolveIdentifier(input) : undefined) ?? - InternalAnnotations.resolveIdentifierFallback(encoded) - return fallback === undefined - ? undefined - : { identifier: `${fallback}Encoded`, fallback } + options?: SchemaRepresentation.ToRepresentationOptions +): SchemaRepresentation.Document { + const { references, representations } = toRepresentations([ast], options) + return { representation: representations[0], references } } -function fromASTs( +/** @internal */ +export function toRepresentations( asts: readonly [SchemaAST.AST, ...Array], - options: Options | undefined + options?: SchemaRepresentation.ToRepresentationOptions ): SchemaRepresentation.MultiDocument { const references: Record = {} - const anonymousReferences = new Map() - const referenceOwners = new Map() + const referenceOwners = new Map() const buildingReferences = new Set() - const visiting = new Set() - const occurrences = new Map() - const shared = new Set() + const candidates = new Map>() + const visitingCandidates = new Set() for (const ast of asts) visit(ast) + const referencePolicy = options?.referencePolicy ?? defaultReferencePolicy + for (const candidatesByIdentifier of candidates.values()) { + for (const candidate of candidatesByIdentifier.values()) { + const requestedReference = referencePolicy({ + ast: candidate.ast, + occurrences: candidate.occurrences, + identifier: candidate.identifier + }) + if (requestedReference !== undefined) { + const separator = requestedReference === candidate.identifier || + !requestedReference.endsWith("_") + ? "_" + : "" + candidate.reference = getReference(requestedReference, candidate, separator) + } else if (candidate.isRecursive) { + candidate.reference = getReference(`${candidate.ast._tag}_`, candidate, "") + } + } + } + const representations = Arr.map(asts, (ast) => recur(ast)) return { representations, references } - function getReference(prefix: string, owner: SchemaAST.AST, separator = "_"): string { + function getReference(prefix: string, owner: ReferenceCandidate, separator = "_"): string { let candidate = prefix let suffix = 0 while (referenceOwners.has(candidate)) { @@ -129,10 +83,10 @@ function fromASTs( function annotateReference( ast: SchemaAST.AST, - referenceIdentifier: ReferenceIdentifier, + candidate: ReferenceCandidate, reference: string ): SchemaAST.AST { - const fallback = referenceIdentifier.fallback + const fallback = candidate.fallback if (fallback !== undefined) { return InternalAnnotations.resolveIdentifierFallback(ast) === fallback ? ast @@ -140,7 +94,7 @@ function fromASTs( [InternalAnnotations.IDENTIFIER_FALLBACK_KEY]: fallback }) } - return reference === referenceIdentifier.identifier + return reference === candidate.identifier ? ast : SchemaAST.annotate(ast, { identifier: reference }) } @@ -155,18 +109,45 @@ function fromASTs( return { _tag: "Reference", $ref: reference } } - function visit(input: SchemaAST.AST): void { + function getCandidate(input: SchemaAST.AST): ReferenceCandidate { const ast = SchemaAST.getLastEncoding(input) const owner = SchemaAST.getContextOwner(ast) - const count = (occurrences.get(owner) ?? 0) + 1 - occurrences.set(owner, count) - if (count > 1) { - if ( - !shared.has(owner) && - isAnonymousReferenceEligible(owner, count, options?.isAnonymousReferenceAllowed) - ) shared.add(owner) + let identifier = InternalAnnotations.resolveIdentifier(ast) + const fallback = identifier === undefined + ? (ast !== input ? InternalAnnotations.resolveIdentifier(input) : undefined) ?? + InternalAnnotations.resolveIdentifierFallback(ast) + : undefined + if (fallback !== undefined) identifier = `${fallback}Encoded` + let candidatesByIdentifier = candidates.get(owner) + if (candidatesByIdentifier === undefined) { + candidatesByIdentifier = new Map() + candidates.set(owner, candidatesByIdentifier) + } + let candidate = candidatesByIdentifier.get(identifier) + if (candidate === undefined) { + candidate = { + ast: owner, + identifier, + fallback, + occurrences: 0, + isRecursive: false, + reference: undefined + } + candidatesByIdentifier.set(identifier, candidate) + } + return candidate + } + + function visit(input: SchemaAST.AST): void { + const candidate = getCandidate(input) + const ast = candidate.ast + candidate.occurrences++ + if (visitingCandidates.has(candidate)) { + candidate.isRecursive = true return } + if (candidate.occurrences > 1) return + visitingCandidates.add(candidate) visitChecks(ast.checks) switch (ast._tag) { case "Declaration": @@ -185,6 +166,7 @@ function fromASTs( visit(ast.thunk()) break } + visitingCandidates.delete(candidate) } function visitChecks(checks: SchemaAST.Checks | undefined): void { @@ -195,39 +177,14 @@ function fromASTs( } function recur(input: SchemaAST.AST): SchemaRepresentation.Representation { - const ast = SchemaAST.getLastEncoding(input) - const owner = SchemaAST.getContextOwner(ast) - const referenceIdentifier = resolveReferenceIdentifier(input, ast) - if (referenceIdentifier !== undefined) { - const reference = getReference(referenceIdentifier.identifier, owner) - return makeReference(reference, annotateReference(ast, referenceIdentifier, reference)) - } - - const found = anonymousReferences.get(owner) - if (found !== undefined) { - return { _tag: "Reference", $ref: found } - } - - const isShared = shared.has(owner) - if (isShared || visiting.has(owner)) { - const reference = getReference(`${ast._tag}_`, owner, "") - anonymousReferences.set(owner, reference) - return isShared - ? makeReference(reference, ast) - : { _tag: "Reference", $ref: reference } - } - - visiting.add(owner) - const representation = on(ast) - visiting.delete(owner) - - const reference = anonymousReferences.get(owner) + const candidate = getCandidate(input) + const ast = candidate.ast + const reference = candidate.reference if (reference !== undefined) { - InternalRecord.assignProperty(references, reference, representation) - return { _tag: "Reference", $ref: reference } + const annotated = candidate.identifier === undefined ? ast : annotateReference(ast, candidate, reference) + return makeReference(reference, annotated) } - - return representation + return on(ast) } function on(ast: SchemaAST.AST): SchemaRepresentation.Representation { diff --git a/repos/effect/packages/effect/src/internal/uuid.ts b/repos/effect/packages/effect/src/internal/uuid.ts new file mode 100644 index 0000000000..b40334fa83 --- /dev/null +++ b/repos/effect/packages/effect/src/internal/uuid.ts @@ -0,0 +1,51 @@ +const hex = (byte: number): string => byte.toString(16).padStart(2, "0") + +/** @internal */ +export const stringify = (bytes: Uint8Array): string => { + const segments = [ + bytes.subarray(0, 4), + bytes.subarray(4, 6), + bytes.subarray(6, 8), + bytes.subarray(8, 10), + bytes.subarray(10, 16) + ] + + return segments.map((segment) => Array.from(segment, hex).join("")).join("-") +} + +const randomBytes = (): Uint8Array => globalThis.crypto.getRandomValues(new Uint8Array(16)) + +/** @internal */ +export function v4Bytes(): Uint8Array +export function v4Bytes(bytes: Uint8Array): Uint8Array +export function v4Bytes(bytes: Uint8Array = randomBytes()): Uint8Array { + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return bytes +} + +/** @internal */ +export const v4String = (bytes?: Uint8Array): string => stringify(bytes === undefined ? v4Bytes() : v4Bytes(bytes)) + +const maxV7Timestamp = 2 ** 48 - 1 + +/** @internal */ +export function v7Bytes(timestampMillis: number): Uint8Array +export function v7Bytes(timestampMillis: number, bytes: Uint8Array): Uint8Array +export function v7Bytes(timestampMillis: number, bytes: Uint8Array = randomBytes()): Uint8Array { + const timestamp = Math.min(Math.max(0, Math.trunc(timestampMillis)), maxV7Timestamp) + + bytes[0] = Math.floor(timestamp / 2 ** 40) + bytes[1] = Math.floor(timestamp / 2 ** 32) & 0xff + bytes[2] = Math.floor(timestamp / 2 ** 24) & 0xff + bytes[3] = Math.floor(timestamp / 2 ** 16) & 0xff + bytes[4] = Math.floor(timestamp / 2 ** 8) & 0xff + bytes[5] = timestamp & 0xff + bytes[6] = (bytes[6] & 0x0f) | 0x70 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return bytes +} + +/** @internal */ +export const v7String = (timestampMillis: number, bytes?: Uint8Array): string => + stringify(bytes === undefined ? v7Bytes(timestampMillis) : v7Bytes(timestampMillis, bytes)) diff --git a/repos/effect/packages/effect/src/testing/TestClock.ts b/repos/effect/packages/effect/src/testing/TestClock.ts index 0cff8aafa3..3941776423 100644 --- a/repos/effect/packages/effect/src/testing/TestClock.ts +++ b/repos/effect/packages/effect/src/testing/TestClock.ts @@ -340,8 +340,13 @@ export const make = Effect.fnUntraced(function*( }) const runSemaphore = yield* Semaphore.make(1) - const run = Effect.fnUntraced(function*(step: (currentTimestamp: number) => number) { + const run = Effect.fnUntraced(function*( + step: (currentTimestamp: number) => number, + adjustmentNanos?: bigint + ) { yield* Fiber.await(yield* Effect.forkChild(Effect.yieldNow)) + const initialWallNanos = currentWallNanos + const initialMonotonicNanos = currentMonotonicNanos const endTimestamp = step(currentTimestamp) const advanceTo = (timestamp: number) => { const deltaMillis = timestamp - currentTimestamp @@ -361,11 +366,19 @@ export const make = Effect.fnUntraced(function*( yield* Effect.yieldNow } advanceTo(endTimestamp) + if (adjustmentNanos !== undefined && Number.isFinite(endTimestamp)) { + currentWallNanos = initialWallNanos + adjustmentNanos + if (adjustmentNanos > BigInt(0)) { + currentMonotonicNanos = initialMonotonicNanos + adjustmentNanos + } + } }, runSemaphore.withPermits(1)) - function adjust(duration: Duration.Input) { - const millis = Duration.toMillis(Duration.fromInputUnsafe(duration)) - return warningDone.pipe(Effect.andThen(run((timestamp) => timestamp + millis))) + function adjust(input: Duration.Input) { + const duration = Duration.fromInputUnsafe(input) + const millis = Duration.toMillis(duration) + const nanos = Number.isFinite(millis) ? Duration.toNanosUnsafe(duration) : undefined + return warningDone.pipe(Effect.andThen(run((timestamp) => timestamp + millis, nanos))) } function setTime(timestamp: number) { diff --git a/repos/effect/packages/effect/src/unstable/ai/AiError.ts b/repos/effect/packages/effect/src/unstable/ai/AiError.ts index bac73a0df1..e825e46a07 100644 --- a/repos/effect/packages/effect/src/unstable/ai/AiError.ts +++ b/repos/effect/packages/effect/src/unstable/ai/AiError.ts @@ -341,6 +341,52 @@ export const HttpContext = Schema.Struct({ body: Schema.optional(Schema.String) }).annotate({ identifier: "HttpContext" }) +/** + * Builds a description for an HTTP error returned by an AI provider. + * + * @category utilities + * @since 4.0.0 + */ +export const buildErrorDescription = (params: { + readonly status: number + readonly message: string | undefined + readonly method: string + readonly url: string + readonly errorCode?: string | number | null | undefined + readonly errorType?: string | null | undefined + readonly requestId?: string | null | undefined + readonly body: string | undefined +}): string => { + const parts: Array = [] + + if (params.message) { + parts.push(params.message) + } else { + parts.push(`HTTP ${params.status}`) + } + + parts.push(`(${params.method} ${params.url})`) + + if (params.errorCode) { + parts.push(`[code: ${params.errorCode}]`) + } else if (params.errorType) { + parts.push(`[type: ${params.errorType}]`) + } + + if (params.requestId) { + parts.push(`[requestId: ${params.requestId}]`) + } + + if (!params.message && params.body) { + const truncated = params.body.length > 200 + ? params.body.slice(0, 200) + "..." + : params.body + parts.push(`Response: ${truncated}`) + } + + return parts.join(" ") +} + // ============================================================================= // Reason Classes // ============================================================================= @@ -468,6 +514,13 @@ export class QuotaExhaustedError extends Schema.Error( * }) * * const result = [authError.kind, authError.isRetryable] // => ["InvalidKey", false] + * + * const detailed = new AiError.AuthenticationError({ + * kind: "InsufficientPermissions", + * description: "Token expired" + * }) + * + * detailed.message // => "InsufficientPermissions: Your API key lacks required permissions. Token expired" * ``` * * @category errors @@ -478,6 +531,7 @@ export class AuthenticationError extends Schema.Error( )({ _tag: Schema.tag("AuthenticationError"), kind: Schema.Literals(["InvalidKey", "ExpiredKey", "MissingKey", "InsufficientPermissions", "Unknown"]), + description: Schema.optional(Schema.String), metadata: providerMetadataWithDefaults(), http: Schema.optional(HttpContext) }) { @@ -505,7 +559,9 @@ export class AuthenticationError extends Schema.Error( InsufficientPermissions: "Your API key lacks required permissions", Unknown: "Authentication failed. Check your credentials" } - return `${this.kind}: ${suggestions[this.kind]}` + let msg = `${this.kind}: ${suggestions[this.kind]}` + if (this.description) msg += `. ${this.description}` + return msg } } diff --git a/repos/effect/packages/effect/src/unstable/ai/AnthropicStructuredOutput.ts b/repos/effect/packages/effect/src/unstable/ai/AnthropicStructuredOutput.ts index 6b90344492..7578c99b49 100644 --- a/repos/effect/packages/effect/src/unstable/ai/AnthropicStructuredOutput.ts +++ b/repos/effect/packages/effect/src/unstable/ai/AnthropicStructuredOutput.ts @@ -9,7 +9,7 @@ * * @since 4.0.0 */ -import * as JsonSchema from "../../JsonSchema.ts" +import type * as JsonSchema from "../../JsonSchema.ts" import * as Rec from "../../Record.ts" import * as Schema from "../../Schema.ts" import * as InternalStructuredOutput from "./internal/structured-output.ts" @@ -62,7 +62,7 @@ export function toCodecAnthropic( if (hasReferenceCycle(unresolvedDocument.schema, unresolvedDocument.definitions)) { throw new Error("AnthropicStructuredOutput: Recursive schemas are not supported") } - const document = JsonSchema.resolveTopLevel$ref(unresolvedDocument) + const document = InternalStructuredOutput.resolveTopLevelReference(unresolvedDocument) const jsonSchema = rewriteAnthropic(document.schema) if (Object.keys(document.definitions).length > 0) { jsonSchema.$defs = Rec.map(document.definitions, rewriteAnthropic) @@ -85,8 +85,8 @@ function hasReferenceCycle( let cycle = false InternalStructuredOutput.walkJsonSchema(schema, (node) => { if (!cycle && typeof node.$ref === "string") { - const target = node.$ref === "#" ? root : JsonSchema.resolve$ref(node.$ref, definitions) - if (target !== undefined && visit(target)) cycle = true + const target = node.$ref === "#" ? root : InternalStructuredOutput.resolveReference(node.$ref, definitions) + if (visit(target)) cycle = true } return node }) diff --git a/repos/effect/packages/effect/src/unstable/ai/Chat.ts b/repos/effect/packages/effect/src/unstable/ai/Chat.ts index 4ac38dd5cf..df23e283b0 100644 --- a/repos/effect/packages/effect/src/unstable/ai/Chat.ts +++ b/repos/effect/packages/effect/src/unstable/ai/Chat.ts @@ -240,7 +240,7 @@ export interface Service { readonly toolkit: LanguageModel.ToolkitInput } ): Effect.Effect< - LanguageModel.GenerateTextResponse, + LanguageModel.GenerateTextResponse>, LanguageModel.ExtractError, LanguageModel.LanguageModel | LanguageModel.ExtractServices > @@ -253,7 +253,10 @@ export interface Service { readonly toolkit: Options["toolkit"] } ): Effect.Effect< - LanguageModel.GenerateTextResponse>, + LanguageModel.GenerateTextResponse< + LanguageModel.ExtractTools, + LanguageModel.ExtractEncodedToolParameters + >, LanguageModel.ExtractError, LanguageModel.LanguageModel | LanguageModel.ExtractServices > @@ -319,7 +322,7 @@ export interface Service { readonly toolkit: LanguageModel.ToolkitInput } ): Stream.Stream< - Response.StreamPart, + Response.StreamPart>, LanguageModel.ExtractError, LanguageModel.LanguageModel | LanguageModel.ExtractServices > @@ -332,7 +335,10 @@ export interface Service { readonly toolkit: Options["toolkit"] } ): Stream.Stream< - Response.StreamPart>, + Response.StreamPart< + LanguageModel.ExtractTools, + LanguageModel.ExtractEncodedToolParameters + >, LanguageModel.ExtractError, LanguageModel.LanguageModel | LanguageModel.ExtractServices > @@ -390,7 +396,11 @@ export interface Service { >( options: Options & LanguageModel.GenerateObjectOptions, ObjectSchema> ) => Effect.Effect< - LanguageModel.GenerateObjectResponse, ObjectSchema["Type"]>, + LanguageModel.GenerateObjectResponse< + LanguageModel.ExtractTools, + ObjectSchema["Type"], + LanguageModel.ExtractEncodedToolParameters + >, LanguageModel.ExtractError, LanguageModel.ExtractServices | ObjectSchema["DecodingServices"] | LanguageModel.LanguageModel > diff --git a/repos/effect/packages/effect/src/unstable/ai/LanguageModel.ts b/repos/effect/packages/effect/src/unstable/ai/LanguageModel.ts index 0f5f544b0b..17cb9093bf 100644 --- a/repos/effect/packages/effect/src/unstable/ai/LanguageModel.ts +++ b/repos/effect/packages/effect/src/unstable/ai/LanguageModel.ts @@ -115,7 +115,7 @@ export interface Service { >( options: Options & GenerateTextOptions & { readonly toolkit: ToolkitInput } ): Effect.Effect< - GenerateTextResponse, + GenerateTextResponse>, ExtractError, ExtractServices > @@ -127,7 +127,7 @@ export interface Service { >( options: Options & GenerateTextOptions> & { readonly toolkit: Options["toolkit"] } ): Effect.Effect< - GenerateTextResponse>, + GenerateTextResponse, ExtractEncodedToolParameters>, ExtractError, ExtractServices > @@ -147,7 +147,7 @@ export interface Service { >( options: Options & GenerateObjectOptions ) => Effect.Effect< - GenerateObjectResponse, + GenerateObjectResponse>, ExtractError, ExtractServices | StructuredOutputSchema["DecodingServices"] > @@ -174,7 +174,7 @@ export interface Service { >( options: Options & GenerateTextOptions & { readonly toolkit: ToolkitInput } ): Stream.Stream< - Response.StreamPart, + Response.StreamPart>, ExtractError, ExtractServices > @@ -186,7 +186,7 @@ export interface Service { >( options: Options & GenerateTextOptions> & { readonly toolkit: Options["toolkit"] } ): Stream.Stream< - Response.StreamPart>, + Response.StreamPart, ExtractEncodedToolParameters>, ExtractError, ExtractServices > @@ -364,10 +364,13 @@ export type ToolChoice = * @category models * @since 4.0.0 */ -export class GenerateTextResponse> { - readonly content: Array> +export class GenerateTextResponse< + Tools extends Record, + EncodedToolParameters extends boolean = false +> { + readonly content: Array> - constructor(content: Array>) { + constructor(content: Array>) { this.content = content } @@ -407,7 +410,7 @@ export class GenerateTextResponse> { /** * Returns all tool call parts from the response. */ - get toolCalls(): Array> { + get toolCalls(): Array> { return this.content.filter((part) => part.type === "tool-call") } @@ -472,14 +475,15 @@ export class GenerateTextResponse> { */ export class GenerateObjectResponse< Tools extends Record, - A -> extends GenerateTextResponse { + A, + EncodedToolParameters extends boolean = false +> extends GenerateTextResponse { /** * The parsed structured object that conforms to the provided schema. */ readonly value: A - constructor(value: A, content: Array>) { + constructor(value: A, content: Array>) { super(content) this.value = value } @@ -553,6 +557,18 @@ export type ExtractTools = Options extends { } ? ExtractToolsFromToolkitOption> : {} +/** + * Utility type that determines whether language model responses contain + * encoded tool call parameters. + * + * @category utility types + * @since 4.0.0 + */ +export type ExtractEncodedToolParameters = Options extends { + readonly disableToolCallResolution: true +} ? true + : false + type ExtractErrorFromToolkitOption = ToolkitValue extends Toolkit.WithHandler ? | AiError.AiError @@ -1165,10 +1181,11 @@ export const make: (params: { } } - // Construct the response schema with the tools from the toolkit - const ResponseSchema = Schema.mutable( - Schema.Array(Response.Part(toolkit)) - ) + // Construct the response schema with the tools from the toolkit, keeping + // tool call parameters encoded when tool call resolution is disabled + const ResponseSchema = Schema.mutable(Schema.Array(Response.Part( + options.disableToolCallResolution === true ? makeToolkitWithEncodedParameters(toolkit) : toolkit + ))) // If tool call resolution is disabled, return the response without // resolving the tool calls that were generated @@ -1442,11 +1459,16 @@ export const make: (params: { } } + // Construct the response schema with the tools from the toolkit, keeping + // tool call parameters encoded when tool call resolution is disabled + const ResponseSchema = Schema.NonEmptyArray(Response.StreamPart( + options.disableToolCallResolution === true ? makeToolkitWithEncodedParameters(toolkit) : toolkit + )) + const decodeParts = Schema.decodeEffect(ResponseSchema) + // If tool call resolution is disabled, return the response without // resolving the tool calls that were generated if (options.disableToolCallResolution === true) { - const schema = Schema.NonEmptyArray(Response.StreamPart(toolkit)) - const decodeParts = Schema.decodeEffect(schema) return streamWithNonIncrementalFallback().pipe( Stream.mapArrayEffect((parts) => decodeParts(parts).pipe( @@ -1469,9 +1491,6 @@ export const make: (params: { > } - const ResponseSchema = Schema.NonEmptyArray(Response.StreamPart(toolkit)) - const decodeParts = Schema.decodeEffect(ResponseSchema) - // Queue for decoded parts and tool results const queue = yield* Queue.make< Response.StreamPart, @@ -1663,7 +1682,7 @@ export const generateText: { >( options: Options & GenerateTextOptions & { readonly toolkit: ToolkitInput } ): Effect.Effect< - GenerateTextResponse, + GenerateTextResponse>, ExtractError, LanguageModel | ExtractServices > @@ -1675,7 +1694,7 @@ export const generateText: { >( options: Options & GenerateTextOptions> & { readonly toolkit: Options["toolkit"] } ): Effect.Effect< - GenerateTextResponse>, + GenerateTextResponse, ExtractEncodedToolParameters>, ExtractError, ExtractServices | LanguageModel > @@ -1741,7 +1760,11 @@ export const generateObject = < >( options: Options & GenerateObjectOptions, StructuredOutputSchema> ): Effect.Effect< - GenerateObjectResponse, StructuredOutputSchema["Type"]>, + GenerateObjectResponse< + ExtractTools, + StructuredOutputSchema["Type"], + ExtractEncodedToolParameters + >, ExtractError, ExtractServices | StructuredOutputSchema["DecodingServices"] | LanguageModel > => @@ -1807,7 +1830,7 @@ export const streamText: { >( options: Options & GenerateTextOptions & { readonly toolkit: ToolkitInput } ): Stream.Stream< - Response.StreamPart, + Response.StreamPart>, ExtractError, ExtractServices | LanguageModel > @@ -1819,7 +1842,7 @@ export const streamText: { >( options: Options & GenerateTextOptions> & { readonly toolkit: Options["toolkit"] } ): Stream.Stream< - Response.StreamPart>, + Response.StreamPart, ExtractEncodedToolParameters>, ExtractError, ExtractServices | LanguageModel > @@ -2197,6 +2220,13 @@ const resolveToolCalls = >( // Utilities // ============================================================================= +const makeToolkitWithEncodedParameters = >( + toolkit: Toolkit.WithHandler +): Toolkit.Any => + Toolkit.make( + ...Object.values(toolkit.tools).map((tool) => tool.setParameters(Schema.toEncoded(tool.parametersSchema))) + ) + const resolveToolkit = , E, R>( toolkit: ToolkitInput ): Effect.Effect, E, R> => diff --git a/repos/effect/packages/effect/src/unstable/ai/McpProtocol.ts b/repos/effect/packages/effect/src/unstable/ai/McpProtocol.ts index 046f2aa480..88f370c1e5 100644 --- a/repos/effect/packages/effect/src/unstable/ai/McpProtocol.ts +++ b/repos/effect/packages/effect/src/unstable/ai/McpProtocol.ts @@ -3,46 +3,144 @@ * * @since 4.0.0 */ +import type * as Effect from "../../Effect.ts" +import type * as Schema from "../../Schema.ts" +import type * as Scope from "../../Scope.ts" +import type * as Rpc from "../rpc/Rpc.ts" +import type * as RpcClient from "../rpc/RpcClient.ts" import type * as RpcGroup from "../rpc/RpcGroup.ts" -import * as Internal from "./internal/mcpProtocol.ts" -import * as McpSchema from "./McpSchema.ts" +import { protocol as protocol2024_11_05 } from "./internal/mcpProtocol/v2024_11_05.ts" +import { protocol as protocol2025_03_26 } from "./internal/mcpProtocol/v2025_03_26.ts" +import { protocol as protocol2025_06_18 } from "./internal/mcpProtocol/v2025_06_18.ts" +import { protocol as protocol2025_11_25 } from "./internal/mcpProtocol/v2025_11_25.ts" +import type * as McpSchema from "./McpSchema.ts" /** - * The MCP 2025-06-18 protocol implementation. + * The MCP protocol versions implemented by this release. * - * @category protocols + * @category models * @since 4.0.0 */ -export const v2025_06_18: ProtocolAdapter = Internal.make({ - protocolVersion: "2025-06-18", - transport: { - acceptsJsonRpcBatches: false, - requiresVersionHeader: true - }, - clientRpcs: McpSchema.ClientRpcs, - clientNotificationRpcs: McpSchema.ClientNotificationRpcs, - serverRequestRpcs: McpSchema.ServerRequestRpcs, - serverNotificationRpcs: McpSchema.ServerNotificationRpcs -}) +export type ProtocolVersion = "2024-11-05" | "2025-03-26" | "2025-06-18" | "2025-11-25" /** - * An implemented MCP protocol that can be supplied to `McpServer`. + * Payload codecs used by a protocol adapter. * * @category models * @since 4.0.0 */ -export type ProtocolAdapter = Internal.ProtocolAdapter< - "2025-06-18", - RpcGroup.Rpcs, - RpcGroup.Rpcs, - RpcGroup.Rpcs, - RpcGroup.Rpcs -> +export interface PayloadCodecs { + readonly decode: (input: unknown) => Effect.Effect + readonly encode: (input: unknown) => Effect.Effect +} /** - * The MCP protocol versions implemented by this release. + * A notification projected into a protocol-specific payload. + * + * @category models + * @since 4.0.0 + */ +export interface ProjectedNotification { + readonly tag: string + readonly payload: unknown +} + +/** + * The operations required from an RPC group after its RPC union is erased. + * + * @category models + * @since 4.0.0 + */ +export interface ErasedRpcGroup { + readonly requests: ReadonlyMap +} + +/** + * The additional operation required from the complete client RPC group. + * + * @category models + * @since 4.0.0 + */ +export interface ErasedClientRpcGroup extends ErasedRpcGroup { + readonly prefix: (prefix: string) => RpcGroup.RpcGroup +} + +/** + * The operational shape shared by protocol adapters. + * + * @category models + * @since 4.0.0 + */ +export interface AnyProtocolAdapter { + readonly protocolVersion: Version + readonly transport: { + readonly acceptsJsonRpcBatches: boolean + readonly requiresVersionHeader: boolean + } + readonly clientRpcs: ErasedClientRpcGroup + readonly clientNotificationRpcs: ErasedRpcGroup + readonly serverRequestRpcs: RpcGroup.Any + readonly serverNotificationRpcs: ErasedRpcGroup + readonly payloadCodecs: (rpc: Rpc.AnyWithProps) => PayloadCodecs + readonly installHandlers: ( + core: any, + lifecycle: any, + target: any + ) => Effect.Effect + readonly makeReverseClient: ( + profile: any + ) => Effect.Effect + readonly projectNotification: ( + notification: any + ) => Effect.Effect + readonly normalizeCancellation: (payload: unknown) => Effect.Effect +} + +/** + * An MCP protocol adapter that can be supplied to `McpServer`. * * @category models * @since 4.0.0 */ -export type ProtocolVersion = ProtocolAdapter["protocolVersion"] +export interface ProtocolAdapter + extends AnyProtocolAdapter +{} + +/** + * The MCP 2025-11-25 protocol implementation. + * + * @category protocols + * @since 4.0.0 + */ +export const v2025_11_25: ProtocolAdapter<"2025-11-25"> = protocol2025_11_25 + +/** + * The MCP 2025-06-18 protocol implementation. + * + * @category protocols + * @since 4.0.0 + */ +export const v2025_06_18: ProtocolAdapter<"2025-06-18"> = protocol2025_06_18 + +/** + * The MCP 2025-03-26 protocol implementation. + * + * @category protocols + * @since 4.0.0 + */ +export const v2025_03_26: ProtocolAdapter<"2025-03-26"> = protocol2025_03_26 + +/** + * The MCP 2024-11-05 protocol implementation. + * + * **Details** + * + * It provides the dated schema and stdio behavior. When supplied to + * `McpServer.layerHttp`, the server uses its single-endpoint Streamable HTTP + * compatibility transport; it does not implement the historical two-endpoint + * HTTP+SSE transport. + * + * @category protocols + * @since 4.0.0 + */ +export const v2024_11_05: ProtocolAdapter<"2024-11-05"> = protocol2024_11_05 diff --git a/repos/effect/packages/effect/src/unstable/ai/McpSchema.ts b/repos/effect/packages/effect/src/unstable/ai/McpSchema.ts index f88bcccbf8..2c44f02387 100644 --- a/repos/effect/packages/effect/src/unstable/ai/McpSchema.ts +++ b/repos/effect/packages/effect/src/unstable/ai/McpSchema.ts @@ -3,14 +3,17 @@ * * MCP clients and servers use these schemas to describe the JSON-RPC requests, * notifications, results, and errors that can cross the protocol boundary. This - * module focuses on message shapes: it defines the shared protocol data model, - * groups related messages for the RPC layer, and provides helpers for optional - * fields and parameter metadata. Transport and server behavior live in other - * modules. + * This is the stable public compatibility and authoring surface. It is not an + * exact dated wire contract: MCP protocol adapters use frozen schemas under + * `internal/mcpSchema` for decoding and encoding. This module groups the + * current public message model for application authors and provides helpers + * for optional fields and parameter metadata. Transport and server behavior + * live in other modules. * * @since 4.0.0 */ import * as Context from "../../Context.ts" +import * as Data from "../../Data.ts" import * as Effect from "../../Effect.ts" import { constFalse, constTrue } from "../../Function.ts" import * as Option from "../../Option.ts" @@ -19,11 +22,9 @@ import * as Schema from "../../Schema.ts" import * as SchemaGetter from "../../SchemaGetter.ts" import type * as Scope from "../../Scope.ts" import * as Rpc from "../rpc/Rpc.ts" -import type * as RpcClient from "../rpc/RpcClient.ts" -import type { RpcClientError } from "../rpc/RpcClientError.ts" import * as RpcGroup from "../rpc/RpcGroup.ts" import * as RpcMiddleware from "../rpc/RpcMiddleware.ts" -import type * as McpProtocol from "./McpProtocol.ts" +import type { ProtocolVersion } from "./McpProtocol.ts" /** * Schema type returned by `optionalWithDefault`. @@ -172,7 +173,7 @@ export class ResultMeta extends Schema.Opaque()(Schema.Struct({ * This result property is reserved by the protocol to allow clients and * servers to attach additional metadata to their responses. */ - _meta: optional(Schema.Record(Schema.String, Schema.Json)) + _meta: optional(Schema.JsonObject) })) {} /** @@ -191,7 +192,7 @@ export class NotificationMeta extends Schema.Opaque()(Schema.S * This parameter name is reserved by MCP to allow clients and servers to * attach additional metadata to their notifications. */ - _meta: optional(Schema.Record(Schema.String, Schema.Json)) + _meta: optional(Schema.JsonObject) })) {} /** @@ -300,9 +301,39 @@ export class Annotations extends Schema.Opaque()(Schema.Struct({ * effectively required, while 0 means "least important," and indicates that * the data is entirely optional. */ - priority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))) + priority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + /** + * The last time the annotated object was modified, formatted as an ISO 8601 + * timestamp. + */ + lastModified: optional(Schema.String) })) {} +/** + * Schema for an icon that an MCP client can display. + * + * @category schemas + * @since 4.0.0 + */ +export class Icon extends Schema.Class("@effect/ai/McpSchema/Icon")({ + /** + * URI containing the icon image. + */ + src: Schema.String, + /** + * MIME type of the icon, when known. + */ + mimeType: optional(Schema.String), + /** + * Sizes supported by the icon, such as `"48x48"` or `"any"`. + */ + sizes: optional(Schema.Array(Schema.String)), + /** + * Color theme for which the icon was designed. + */ + theme: optional(Schema.Literals(["light", "dark"])) +}) {} + /** * Describes the name and version of an MCP implementation. * @@ -312,7 +343,10 @@ export class Annotations extends Schema.Opaque()(Schema.Struct({ export class Implementation extends Schema.Opaque()(Schema.Struct({ name: Schema.String, title: optional(Schema.String), - version: Schema.String + version: Schema.String, + icons: optional(Schema.Array(Icon)), + description: optional(Schema.String), + websiteUrl: optional(Schema.String) })) {} /** @@ -355,11 +389,29 @@ export class ClientCapabilities extends Schema.Class( /** * Present if the client supports sampling from an LLM. */ - sampling: optional(Schema.Struct({})), + sampling: optional(Schema.Struct({ + /** + * Present if the client supports context inclusion during sampling. + */ + context: optional(Schema.Struct({})), + /** + * Present if the client supports tool use during sampling. + */ + tools: optional(Schema.Struct({})) + })), /** * Present if the client supports elicitation from the server. */ - elicitation: optional(Schema.Struct({})) + elicitation: optional(Schema.Struct({ + /** + * Present if the client supports form-mode elicitation. + */ + form: optional(Schema.Struct({})), + /** + * Present if the client supports URL-mode elicitation. + */ + url: optional(Schema.Struct({})) + })) }) {} /** @@ -798,7 +850,7 @@ export class ProgressNotification extends Rpc.make("notifications/progress", { * The progress thus far. This should increase every time progress is made, * even if the total is unknown. */ - progress: optional(Schema.Finite), + progress: Schema.Finite, /** * Total number of items to process (or total progress required), if known. */ @@ -849,6 +901,10 @@ export class Resource extends Schema.Class( * Optional annotations for the client. */ annotations: optional(Annotations), + /** + * Icons that clients can display for this resource. + */ + icons: optional(Schema.Array(Icon)), /** * The size of the raw resource content, in bytes (i.e., before base64 * encoding or any tokenization), if known. @@ -863,7 +919,7 @@ export class Resource extends Schema.Class( * This parameter name is reserved by MCP to allow clients and servers to * attach additional metadata to resources. */ - _meta: optional(Schema.Record(Schema.String, Schema.Json)) + _meta: optional(Schema.JsonObject) }) {} /** @@ -905,11 +961,15 @@ export class ResourceTemplate extends Schema.Class( * Optional annotations for the client. */ annotations: optional(Annotations), + /** + * Icons that clients can display for this resource template. + */ + icons: optional(Schema.Array(Icon)), /** * Optional additional metadata for the client. */ - _meta: optional(Schema.Record(Schema.String, Schema.Json)) + _meta: optional(Schema.JsonObject) }) {} /** @@ -930,7 +990,7 @@ export class ResourceContents extends Schema.Opaque()(Schema.S /** * Optional additional metadata for the client. */ - _meta: optional(Schema.Record(Schema.String, Schema.Json)) + _meta: optional(Schema.JsonObject) })) {} /** @@ -959,7 +1019,7 @@ export class BlobResourceContents extends Schema.Opaque()( /** * The binary data of the item decoded from a base64-encoded string. */ - blob: Schema.Uint8Array + blob: Schema.Uint8ArrayFromBase64 })) {} /** @@ -1170,7 +1230,12 @@ export class Prompt extends Schema.Class( /** * A list of arguments to use for templating the prompt. */ - arguments: optional(Schema.Array(PromptArgument)) + arguments: optional(Schema.Array(PromptArgument)), + /** + * Icons that clients can display for this prompt. + */ + icons: optional(Schema.Array(Icon)), + _meta: optional(Schema.JsonObject) }) {} /** @@ -1188,7 +1253,8 @@ export class TextContent extends Schema.Opaque()(Schema.Struct({ /** * Optional annotations for the client. */ - annotations: optional(Annotations) + annotations: optional(Annotations), + _meta: optional(Schema.JsonObject) })) {} /** @@ -1202,7 +1268,7 @@ export class ImageContent extends Schema.Opaque()(Schema.Struct({ /** * The image data. */ - data: Schema.Uint8Array, + data: Schema.Uint8ArrayFromBase64, /** * The MIME type of the image. Different providers may support different * image types. @@ -1211,7 +1277,8 @@ export class ImageContent extends Schema.Opaque()(Schema.Struct({ /** * Optional annotations for the client. */ - annotations: optional(Annotations) + annotations: optional(Annotations), + _meta: optional(Schema.JsonObject) })) {} /** @@ -1225,7 +1292,7 @@ export class AudioContent extends Schema.Opaque()(Schema.Struct({ /** * The audio data. */ - data: Schema.Uint8Array, + data: Schema.Uint8ArrayFromBase64, /** * The MIME type of the audio. Different providers may support different * audio types. @@ -1234,7 +1301,8 @@ export class AudioContent extends Schema.Opaque()(Schema.Struct({ /** * Optional annotations for the client. */ - annotations: optional(Annotations) + annotations: optional(Annotations), + _meta: optional(Schema.JsonObject) })) {} /** @@ -1254,7 +1322,8 @@ export class EmbeddedResource extends Schema.Opaque()(Schema.S /** * Optional annotations for the client. */ - annotations: optional(Annotations) + annotations: optional(Annotations), + _meta: optional(Schema.JsonObject) })) {} /** @@ -1449,6 +1518,38 @@ export class ToolAnnotations extends Schema.Opaque()(Schema.Str openWorldHint: optionalWithDefault(Schema.Boolean, constTrue) })) {} +/** + * Object-root JSON Schema used by MCP tool inputs and outputs. + * + * **Details** + * + * Property definitions and additional root keywords are constrained to JSON + * values. The open root supports generated keywords such as `$defs`. + * + * @category tools + * @since 4.0.0 + */ +export type ToolJsonSchema = Schema.JsonObject & { + readonly type: "object" + readonly properties?: Readonly> | undefined + readonly required?: ReadonlyArray | undefined +} + +/** + * Schema for {@link ToolJsonSchema}. + * + * @category tools + * @since 4.0.0 + */ +export const ToolJsonSchema: Schema.Codec = Schema.StructWithRest( + Schema.Struct({ + type: Schema.Literal("object"), + properties: optional(Schema.Record(Schema.String, Schema.JsonObject)), + required: optional(Schema.Array(Schema.String)) + }), + [Schema.JsonObject] +) + /** * Schema for the definition of a tool the client can call. * @@ -1472,22 +1573,26 @@ export class Tool extends Schema.Class( /** * A JSON Schema object defining the expected parameters for the tool. */ - inputSchema: Schema.Any, + inputSchema: ToolJsonSchema, /** - * An optional JSON Schema object defining the expected output of the tool. + * An optional JSON Schema object defining the structure of the tool output. */ - outputSchema: optional(Schema.Any), + outputSchema: optional(ToolJsonSchema), /** * Optional additional tool information. */ annotations: optional(ToolAnnotations), + /** + * Icons that clients can display for this tool. + */ + icons: optional(Schema.Array(Icon)), /** * Optional additional metadata for the client. * * This parameter name is reserved by MCP to allow clients and servers to * attach additional metadata to resources. */ - _meta: optional(Schema.Record(Schema.String, Schema.Json)) + _meta: optional(Schema.JsonObject) }) {} /** @@ -1533,7 +1638,10 @@ export class ListTools extends Rpc.make("tools/list", { export class CallToolResult extends Schema.Class("@effect/ai/McpSchema/CallToolResult")({ ...ResultMeta.fields, content: Schema.Array(ContentBlock), - structuredContent: optional(Schema.Any), + /** + * An optional JSON value containing the structured result of the tool call. + */ + structuredContent: optional(Schema.Json), /** * Whether the tool call ended in an error. * @@ -1686,6 +1794,70 @@ export class LoggingMessageNotification extends Rpc.make("notifications/message" // Sampling // ============================================================================= +/** + * Schema for a tool-use request produced during MCP sampling. + * + * @category sampling + * @since 4.0.0 + */ +export class ToolUseContent extends Schema.Class("@effect/ai/McpSchema/ToolUseContent")({ + type: Schema.tag("tool_use"), + /** + * Identifier used to associate a later tool result with this request. + */ + id: Schema.String, + /** + * Name of the tool to invoke. + */ + name: Schema.String, + /** + * Arguments supplied to the tool. + */ + input: Schema.Record(Schema.String, Schema.Unknown), + _meta: optional(Schema.JsonObject) +}) {} + +/** + * Schema for the result of a tool use supplied in a sampling message. + * + * @category sampling + * @since 4.0.0 + */ +export class ToolResultContent extends Schema.Class("@effect/ai/McpSchema/ToolResultContent")({ + type: Schema.tag("tool_result"), + /** + * Identifier of the tool-use request that produced this result. + */ + toolUseId: Schema.String, + /** + * Content returned by the tool. + */ + content: Schema.Array(ContentBlock), + /** + * Optional structured result returned by the tool. + */ + structuredContent: optional(Schema.Record(Schema.String, Schema.Unknown)), + /** + * Whether tool execution ended in an error. + */ + isError: optional(Schema.Boolean), + _meta: optional(Schema.JsonObject) +}) {} + +/** + * Schema for content blocks accepted in MCP sampling messages. + * + * @category sampling + * @since 4.0.0 + */ +export const SamplingMessageContentBlock = Schema.Union([ + TextContent, + ImageContent, + AudioContent, + ToolUseContent, + ToolResultContent +]) + /** * Describes a message issued to or received from an LLM API. * @@ -1694,9 +1866,23 @@ export class LoggingMessageNotification extends Rpc.make("notifications/message" */ export class SamplingMessage extends Schema.Opaque()(Schema.Struct({ role: Role, - content: Schema.Union([TextContent, ImageContent, AudioContent]) + content: Schema.Union([SamplingMessageContentBlock, Schema.Array(SamplingMessageContentBlock)]), + _meta: optional(Schema.JsonObject) })) {} +/** + * Schema for controlling tool selection during MCP sampling. + * + * @category sampling + * @since 4.0.0 + */ +export class ToolChoice extends Schema.Class("@effect/ai/McpSchema/ToolChoice")({ + /** + * Tool-selection mode requested from the client. + */ + mode: optional(Schema.Literals(["auto", "required", "none"])) +}) {} + /** * Schema for model selection hints. * @@ -1796,7 +1982,9 @@ export class ModelPreferences extends Schema.Class( export class CreateMessageResult extends Schema.Class( "@effect/ai/McpSchema/CreateMessageResult" )({ - ...SamplingMessage.fields, + role: Role, + content: Schema.Union([SamplingMessageContentBlock, Schema.Array(SamplingMessageContentBlock)]), + _meta: optional(Schema.JsonObject), /** * The name of the model that generated the message. */ @@ -1854,7 +2042,15 @@ export class CreateMessage extends Rpc.make("sampling/createMessage", { * Optional metadata to pass through to the LLM provider. The format of * this metadata is provider-specific. */ - metadata: optional(Schema.Record(Schema.String, Schema.Unknown)) + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + /** + * Tools that the model may call while producing the response. + */ + tools: optional(Schema.Array(Schema.Struct(Tool.fields))), + /** + * Controls whether the model may or must call a tool. + */ + toolChoice: optional(Schema.Struct(ToolChoice.fields)) } }) {} @@ -1898,6 +2094,7 @@ export class PromptReference extends Schema.Opaque()(Schema.Str * @since 4.0.0 */ export class CompleteResult extends Schema.Opaque()(Schema.Struct({ + ...ResultMeta.fields, completion: Schema.Struct({ /** * An array of completion values. Must not exceed 100 items. @@ -1995,7 +2192,11 @@ export class Root extends Schema.Class( * identifier for the root, which may be useful for display purposes or for * referencing the root in other parts of the application. */ - name: optional(Schema.String) + name: optional(Schema.String), + /** + * Optional additional metadata associated with the root. + */ + _meta: optional(Schema.JsonObject) }) {} /** @@ -2057,6 +2258,243 @@ export class RootsListChangedNotification extends Rpc.make("notifications/roots/ // Elicitation // ============================================================================= +/** + * Schema for a string field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export class StringSchema extends Schema.Class("@effect/ai/McpSchema/StringSchema")({ + type: Schema.tag("string"), + title: optional(Schema.String), + description: optional(Schema.String), + minLength: optional(Schema.Int), + maxLength: optional(Schema.Int), + format: optional(Schema.Literals(["email", "uri", "date", "date-time"])), + default: optional(Schema.String) +}) {} + +/** + * Schema for a numeric field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export class NumberSchema extends Schema.Class("@effect/ai/McpSchema/NumberSchema")({ + type: Schema.Literals(["number", "integer"]), + title: optional(Schema.String), + description: optional(Schema.String), + minimum: optional(Schema.Finite), + maximum: optional(Schema.Finite), + default: optional(Schema.Finite) +}) {} + +/** + * Schema for a boolean field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export class BooleanSchema extends Schema.Class("@effect/ai/McpSchema/BooleanSchema")({ + type: Schema.tag("boolean"), + title: optional(Schema.String), + description: optional(Schema.String), + default: optional(Schema.Boolean) +}) {} + +/** + * Schema for an untitled single-select field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export class UntitledSingleSelectEnumSchema extends Schema.Class( + "@effect/ai/McpSchema/UntitledSingleSelectEnumSchema" +)({ + type: Schema.tag("string"), + title: optional(Schema.String), + description: optional(Schema.String), + enum: Schema.Array(Schema.String), + default: optional(Schema.String) +}) {} + +/** + * Schema for a titled single-select field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export class TitledSingleSelectEnumSchema extends Schema.Class( + "@effect/ai/McpSchema/TitledSingleSelectEnumSchema" +)({ + type: Schema.tag("string"), + title: optional(Schema.String), + description: optional(Schema.String), + oneOf: Schema.Array(Schema.Struct({ + const: Schema.String, + title: Schema.String + })), + default: optional(Schema.String) +}) {} + +/** + * Schema for every single-select field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export const SingleSelectEnumSchema = Schema.Union([ + UntitledSingleSelectEnumSchema, + TitledSingleSelectEnumSchema +]) + +/** + * Schema for an untitled multi-select field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export class UntitledMultiSelectEnumSchema extends Schema.Class( + "@effect/ai/McpSchema/UntitledMultiSelectEnumSchema" +)({ + type: Schema.tag("array"), + title: optional(Schema.String), + description: optional(Schema.String), + minItems: optional(Schema.Int), + maxItems: optional(Schema.Int), + items: Schema.Struct({ + type: Schema.tag("string"), + enum: Schema.Array(Schema.String) + }), + default: optional(Schema.Array(Schema.String)) +}) {} + +/** + * Schema for a titled multi-select field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export class TitledMultiSelectEnumSchema extends Schema.Class( + "@effect/ai/McpSchema/TitledMultiSelectEnumSchema" +)({ + type: Schema.tag("array"), + title: optional(Schema.String), + description: optional(Schema.String), + minItems: optional(Schema.Int), + maxItems: optional(Schema.Int), + items: Schema.Struct({ + anyOf: Schema.Array(Schema.Struct({ + const: Schema.String, + title: Schema.String + })) + }), + default: optional(Schema.Array(Schema.String)) +}) {} + +/** + * Schema for every multi-select field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export const MultiSelectEnumSchema = Schema.Union([ + UntitledMultiSelectEnumSchema, + TitledMultiSelectEnumSchema +]) + +/** + * Schema for the legacy titled single-select elicitation field. + * + * @deprecated Use {@link TitledSingleSelectEnumSchema} instead. + * @category elicitation + * @since 4.0.0 + */ +export class LegacyTitledEnumSchema extends Schema.Class( + "@effect/ai/McpSchema/LegacyTitledEnumSchema" +)({ + type: Schema.tag("string"), + title: optional(Schema.String), + description: optional(Schema.String), + enum: Schema.Array(Schema.String), + enumNames: optional(Schema.Array(Schema.String)), + default: optional(Schema.String) +}) {} + +/** + * Schema for every enumeration field in an MCP elicitation form. + * + * @category elicitation + * @since 4.0.0 + */ +export const EnumSchema = Schema.Union([ + SingleSelectEnumSchema, + MultiSelectEnumSchema, + LegacyTitledEnumSchema +]) + +/** + * Schema for primitive field definitions accepted by MCP elicitation forms. + * + * @category elicitation + * @since 4.0.0 + */ +export const PrimitiveSchemaDefinition = Schema.Union([ + StringSchema, + NumberSchema, + BooleanSchema, + EnumSchema +]) + +const ElicitationFormSchema = Schema.Struct({ + $schema: optional(Schema.String), + type: Schema.tag("object"), + properties: Schema.Record(Schema.String, PrimitiveSchemaDefinition), + required: optional(Schema.Array(Schema.String)) +}) + +/** + * Schema for form-mode MCP elicitation requests. + * + * @category elicitation + * @since 4.0.0 + */ +export class ElicitRequestFormParams extends Schema.Class( + "@effect/ai/McpSchema/ElicitRequestFormParams" +)({ + ...RequestMeta.fields, + mode: optional(Schema.Literal("form")), + message: Schema.String, + requestedSchema: ElicitationFormSchema +}) {} + +/** + * Schema for URL-mode MCP elicitation requests. + * + * @category elicitation + * @since 4.0.0 + */ +export class ElicitRequestURLParams extends Schema.Class( + "@effect/ai/McpSchema/ElicitRequestURLParams" +)({ + ...RequestMeta.fields, + mode: Schema.Literal("url"), + message: Schema.String, + elicitationId: Schema.String, + url: Schema.String +}) {} + +/** + * Schema for every MCP elicitation request mode. + * + * @category elicitation + * @since 4.0.0 + */ +export const ElicitRequestParams = Schema.Union([ + ElicitRequestFormParams, + ElicitRequestURLParams +]) + /** * Schema for an accepted client response to an elicitation request. * @@ -2078,7 +2516,10 @@ export class ElicitAcceptResult extends Schema.Class( * The submitted form data, only present when action is "accept". * Contains values matching the requested schema. */ - content: Schema.Any + content: optional(Schema.Record( + Schema.String, + Schema.Union([Schema.String, Schema.Finite, Schema.Boolean, Schema.Array(Schema.String)]) + )) }) {} /** @@ -2126,17 +2567,18 @@ export const ElicitResult = Schema.Union([ export class Elicit extends Rpc.make("elicitation/create", { success: ElicitResult, error: McpError, + payload: ElicitRequestParams +}) {} + +/** + * Notifies a client that a URL-mode elicitation completed. + * + * @category elicitation + * @since 4.0.0 + */ +export class ElicitationCompleteNotification extends Rpc.make("notifications/elicitation/complete", { payload: { - /** - * A message to display to the user, explaining what they are being - * elicited for. - */ - message: Schema.String, - /** - * A restricted subset of JSON Schema. - * Only top-level properties are allowed, without nesting. - */ - requestedSchema: Schema.Any + elicitationId: Schema.String } }) {} @@ -2162,23 +2604,68 @@ export class ElicitationDeclined extends Schema.Error("@eff // McpServerClient // ============================================================================= +/** + * Raised when the negotiated MCP revision or client capabilities do not + * support a server-initiated operation. + * + * @category errors + * @since 4.0.0 + */ +export class McpReverseOperationUnsupported extends Data.TaggedError("McpReverseOperationUnsupported")<{ + readonly operation: "roots/list" | "sampling/createMessage" | "elicitation/create" + readonly protocolVersion: ProtocolVersion + readonly reason: string +}> {} + +/** + * A reverse MCP operation failed while being sent or projected through a + * version adapter. + * + * @category errors + * @since 4.0.0 + */ +export class McpReverseOperationError extends Data.TaggedError("McpReverseOperationError")<{ + readonly operation: "roots/list" | "sampling/createMessage" | "elicitation/create" + readonly cause: unknown +}> {} + +/** + * Version-neutral operations that an MCP server may request from its client. + * + * @category client + * @since 4.0.0 + */ +export interface McpReverseClient { + readonly listRoots: ( + request?: typeof ListRoots.payloadSchema.Type + ) => Effect.Effect + readonly createMessage: ( + request: typeof CreateMessage.payloadSchema.Type + ) => Effect.Effect + readonly elicit: ( + request: typeof Elicit.payloadSchema.Type + ) => Effect.Effect +} + /** * Service available while handling an MCP client request. * * **Details** * - * It exposes the current client id, the client's initialize payload, and a - * scoped RPC client for server-initiated requests back to that client. + * It exposes the current client id, normalized initialization data, and a + * scoped version-neutral facade for server-initiated requests. * * @category services * @since 4.0.0 */ export class McpServerClient extends Context.Service, RpcClientError>, + McpReverseClient, never, Scope.Scope > @@ -2411,7 +2898,8 @@ export class ServerNotificationRpcs extends RpcGroup.make( ResourceUpdatedNotification, ResourceListChangedNotification, ToolListChangedNotification, - PromptListChangedNotification + PromptListChangedNotification, + ElicitationCompleteNotification ) {} /** diff --git a/repos/effect/packages/effect/src/unstable/ai/McpServer.ts b/repos/effect/packages/effect/src/unstable/ai/McpServer.ts index 4256efd967..0a19f36ce2 100644 --- a/repos/effect/packages/effect/src/unstable/ai/McpServer.ts +++ b/repos/effect/packages/effect/src/unstable/ai/McpServer.ts @@ -24,12 +24,12 @@ import * as Predicate from "../../Predicate.ts" import * as Queue from "../../Queue.ts" import * as RcMap from "../../RcMap.ts" import { CurrentLogLevel } from "../../References.ts" +import * as Result from "../../Result.ts" import * as Schema from "../../Schema.ts" import * as SchemaAST from "../../SchemaAST.ts" import * as Sink from "../../Sink.ts" import type { Stdio } from "../../Stdio.ts" import * as Stream from "../../Stream.ts" -import type * as Types from "../../Types.ts" import * as FindMyWay from "../http/FindMyWay.ts" import * as Headers from "../http/Headers.ts" import { appendPreResponseHandlerUnsafe } from "../http/HttpEffect.ts" @@ -38,43 +38,37 @@ import * as HttpServerRequest from "../http/HttpServerRequest.ts" import * as HttpServerResponse from "../http/HttpServerResponse.ts" import * as Rpc from "../rpc/Rpc.ts" import * as RpcClient from "../rpc/RpcClient.ts" -import type * as RpcGroup from "../rpc/RpcGroup.ts" +import * as RpcGroup from "../rpc/RpcGroup.ts" import * as RpcMessage from "../rpc/RpcMessage.ts" import * as RpcSerialization from "../rpc/RpcSerialization.ts" import * as RpcServer from "../rpc/RpcServer.ts" import * as AiError from "./AiError.ts" +import * as McpCore from "./internal/mcpCore.ts" +import * as McpProtocolInternal from "./internal/mcpProtocol.ts" import * as McpProtocolRegistry from "./internal/mcpProtocolRegistry.ts" import type * as McpProtocol from "./McpProtocol.ts" +import * as McpSchema from "./McpSchema.ts" import { CallToolResult, - CancelledNotification, - ClientRpcs, - Elicit, ElicitationDeclined, EnabledWhen, GetPromptResult, + Initialize, InternalError, - INVALID_REQUEST_ERROR_CODE, InvalidParams, InvalidRequest, isParam, - ListPromptsResult, - ListResourcesResult, - ListResourceTemplatesResult, - ListToolsResult, - LoggingMessageNotification, - McpErrorBase, McpServerClient, McpServerClientMiddleware, MethodNotFound, - ParseError, + Ping, Prompt, Resource, ResourceTemplate, - ResourceUpdatedNotification, ServerNotificationRpcs, TextContent, - Tool as McpTool + Tool as McpTool, + ToolJsonSchema } from "./McpSchema.ts" import type { CallTool, @@ -82,8 +76,7 @@ import type { Complete, CompleteResult, GetPrompt, - Initialize, - LoggingLevel, + McpErrorBase, Param, PromptArgument, PromptMessage, @@ -95,6 +88,76 @@ import type * as Toolkit from "./Toolkit.ts" type CompletionContext = typeof Complete.payloadSchema.Type["context"] +interface QueuedServerNotification { + readonly notification: McpCore.ServerNotification + readonly targetClientId?: number | undefined +} + +const internalState = new WeakMap +}>() +type ServerExtensions = NonNullable +type ServerNotificationRequest< + R extends Rpc.Any = RpcGroup.Rpcs +> = R extends Rpc.Any ? RpcMessage.Request : never + +const BroadcastServerNotificationRpcs = ServerNotificationRpcs.omit("notifications/elicitation/complete") + +const validateStructuredContent = ( + toolName: string, + value: unknown +): Effect.Effect => + Schema.is(Schema.Json)(value) + ? Effect.succeed(value) + : Effect.fail( + new McpCore.ToolResultProjectionError({ + name: toolName, + message: `Tool '${toolName}' returned structured content that is not valid JSON` + }) + ) + +const toInternalServerNotification = ( + message: ServerNotificationRequest +): McpCore.ServerNotification | undefined => { + switch (message.tag) { + case "notifications/cancelled": + return McpCore.ServerNotification.Cancelled({ + requestId: message.payload.requestId, + reason: message.payload.reason, + metadata: message.payload._meta + }) + case "notifications/progress": + return McpCore.ServerNotification.Progress({ + progressToken: message.payload.progressToken, + progress: message.payload.progress, + total: message.payload.total, + message: message.payload.message, + metadata: message.payload._meta + }) + case "notifications/message": + return McpCore.ServerNotification.LoggingMessage({ + level: message.payload.level, + logger: message.payload.logger, + data: message.payload.data, + metadata: message.payload._meta + }) + case "notifications/resources/updated": + return McpCore.ServerNotification.ResourceUpdated({ + uri: message.payload.uri, + metadata: message.payload._meta + }) + case "notifications/resources/list_changed": + return McpCore.ServerNotification.ResourcesChanged({ metadata: message.payload?._meta }) + case "notifications/tools/list_changed": + return McpCore.ServerNotification.ToolsChanged({ metadata: message.payload?._meta }) + case "notifications/prompts/list_changed": + return McpCore.ServerNotification.PromptsChanged({ metadata: message.payload?._meta }) + default: + return undefined + } +} + /** * Service that stores and serves an MCP server's registered tools, resources, * prompts, completions, and outgoing notifications. @@ -108,10 +171,12 @@ type CompletionContext = typeof Complete.payloadSchema.Type["context"] * @since 4.0.0 */ export class McpServer extends Context.Service> - readonly notificationsQueue: Queue.Dequeue> + readonly notifications: RpcClient.RpcClient> + readonly notifyElicitationComplete: (options: { + readonly clientId: number + readonly elicitationId: string + }) => Effect.Effect readonly initializedClients: Set - readonly tools: ReadonlyArray<{ readonly tool: McpTool readonly annotations: Context.Context @@ -198,30 +263,11 @@ export class McpServer extends Context.Service - ) => Effect.Effect< - typeof ReadResourceResult.Type, - InternalError | InvalidParams, - McpServerClient - > - } | { - readonly _tag: "Resource" - readonly effect: Effect.Effect - } - >() + const internalCore = yield* McpCore.make const tools = Arr.empty<{ readonly tool: McpTool readonly annotations: Context.Context }>() - const toolMap = new Map< - string, - (payload: any) => Effect.Effect - >() const resources: Array<{ readonly resource: Resource readonly annotations: Context.Context @@ -234,20 +280,9 @@ export class McpServer extends Context.Service }> = [] - const promptMap = new Map< - string, - (params: Record) => Effect.Effect - >() - const completionsMap = new Map< - string, - ( - input: string, - context: CompletionContext - ) => Effect.Effect - >() - const notificationsQueue = yield* Queue.make>() + const notificationsQueue = yield* Queue.make() const listChangedHandles = new Map() - const notifications = yield* RpcClient.makeNoSerialization(ServerNotificationRpcs, { + const notifications = yield* RpcClient.makeNoSerialization(BroadcastServerNotificationRpcs, { spanPrefix: "McpServer/Notifications", onFromClient: (options) => Effect.suspend((): Effect.Effect => { @@ -255,50 +290,119 @@ export class McpServer extends Context.Service { - Queue.offerUnsafe(notificationsQueue, message) + Queue.offerUnsafe(notificationsQueue, { notification }) listChangedHandles.delete(message.tag) }, 0) ) } } else { - Queue.offerUnsafe(notificationsQueue, message) + Queue.offerUnsafe(notificationsQueue, { notification }) } return notifications.write({ clientId: 0, requestId: message.id, _tag: "Exit", - exit: Exit.void as any + exit: Exit.void }) }) }) - return McpServer.of({ + const service = McpServer.of({ notifications: notifications.client, - notificationsQueue, + notifyElicitationComplete: ({ clientId, elicitationId }) => + Queue.offer(notificationsQueue, { + notification: McpCore.ServerNotification.ElicitationComplete({ elicitationId }), + targetClientId: clientId + }), initializedClients: new Set(), get tools() { return tools }, addTool: (options) => - Effect.suspend(() => { - tools.push(options) - toolMap.set(options.tool.name, options.handle) - return notifications.client["notifications/tools/list_changed"]({}) + Effect.gen(function*() { + const existingIndex = tools.findIndex(({ tool }) => tool.name === options.tool.name) + if (existingIndex === -1) { + tools.push(options) + } else { + tools[existingIndex] = options + } + const enabledWhen = Context.getOrUndefined(options.annotations, EnabledWhen) + yield* internalCore.tools.register({ + descriptor: new McpTool({ + ...options.tool, + title: options.tool.title ?? options.tool.annotations?.title + }), + isVisible: (profile) => + enabledWhen === undefined || enabledWhen( + { + protocolVersion: profile.protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo + } + ), + handle: (call, invocation) => + options.handle(call.arguments).pipe( + Effect.provideService( + McpServerClient, + invocation.requestContext + ), + Effect.catchTags({ + InternalError: (error) => + Effect.fail( + new McpCore.ToolExecutionError({ + name: options.tool.name, + message: error.message + }) + ), + InvalidParams: (error) => + Effect.fail( + new McpCore.InvalidToolInput({ + name: options.tool.name, + message: error.message + }) + ) + }), + Effect.flatMap((result) => + result.structuredContent === undefined + ? Effect.succeed(result) + : validateStructuredContent(options.tool.name, result.structuredContent).pipe( + Effect.as(result) + ) + ) + ) + }) + yield* notifications.client["notifications/tools/list_changed"]({}) }), callTool: (request) => - Effect.suspend((): Effect.Effect => { - const handle = toolMap.get(request.name) - if (!handle) { - return Effect.fail(new InvalidParams({ message: `Tool '${request.name}' not found` })) - } - return handle(request.arguments).pipe( - Effect.catchDefect(() => Effect.fail(new InternalError({ message: "Internal error" }))) + Effect.gen(function*() { + const client = yield* McpServerClient + const result = yield* internalCore.tools.call(request, { + clientId: client.clientId, + protocol: { + protocolVersion: client.protocolVersion, + clientCapabilities: client.initializePayload.capabilities, + clientInfo: client.initializePayload.clientInfo + }, + requestContext: client + }).pipe( + Effect.mapError((error) => + new InvalidParams({ + message: error._tag === "ToolNotFound" + ? `Tool '${error.name}' not found` + : error.message + }) + ) ) + return result }), get resources() { return resources @@ -307,74 +411,179 @@ export class McpServer extends Context.Service - Effect.suspend(() => { - resources.push(options) - matcher.add(options.resource.uri, { _tag: "Resource", effect: options.handle }) - return notifications.client["notifications/resources/list_changed"]({}) + Effect.gen(function*() { + const existingIndex = resources.findIndex(({ resource }) => resource.uri === options.resource.uri) + if (existingIndex === -1) { + resources.push(options) + } else { + resources[existingIndex] = options + } + yield* internalCore.resources.register({ + descriptor: options.resource, + isVisible: (profile) => { + const enabledWhen = Context.getOrUndefined(options.annotations, EnabledWhen) + return enabledWhen === undefined || enabledWhen({ + protocolVersion: profile.protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo + }) + }, + read: (invocation) => + options.handle.pipe( + Effect.provideService(McpServerClient, invocation.requestContext) + ) + }) + yield* notifications.client["notifications/resources/list_changed"]({}) }), addResourceTemplate: ({ annotations, completions, handle, routerPath, template }) => - Effect.suspend(() => { - resourceTemplates.push({ template, annotations }) - matcher.add(routerPath, { _tag: "ResourceTemplate", handle }) + Effect.gen(function*() { + const existingIndex = resourceTemplates.findIndex(({ template: current }) => + current.uriTemplate === template.uriTemplate + ) + if (existingIndex === -1) { + resourceTemplates.push({ template, annotations }) + } else { + resourceTemplates[existingIndex] = { template, annotations } + } + const templateMatcher = makeUriMatcher() + templateMatcher.add(routerPath, true) + yield* internalCore.resources.registerTemplate({ + descriptor: template, + isVisible: (profile) => { + const enabledWhen = Context.getOrUndefined(annotations, EnabledWhen) + return enabledWhen === undefined || enabledWhen({ + protocolVersion: profile.protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo + }) + }, + match: (uri) => { + const match = templateMatcher.find(uri) + if (match === undefined) { + return undefined + } + const params: Array = [] + for (const key of Object.keys(match.params)) { + params[Number(key)] = match.params[key]! + } + return params + }, + read: (uri, params, invocation) => + handle(uri, Array.from(params)).pipe( + Effect.provideService(McpServerClient, invocation.requestContext) + ) + }) for (const [param, handle] of Object.entries(completions)) { - completionsMap.set(`ref/resource/${template.uriTemplate}/${param}`, handle) + yield* internalCore.completions.register( + `resource/${template.uriTemplate}/${param}`, + (request) => + handle(request.argument.value, request.context).pipe( + Effect.map((result) => ({ + values: result.completion.values, + total: result.completion.total, + hasMore: result.completion.hasMore, + metadata: result._meta + })) + ) + ) } - return notifications.client["notifications/resources/list_changed"]({}) + yield* notifications.client["notifications/resources/list_changed"]({}) }), findResource: (uri) => - Effect.suspend(() => { - const match = matcher.find(uri) - if (!match) { - return Effect.fail(new McpErrorBase({ code: -32002, message: `Resource '${uri}' not found` })) - } else if (match.handler._tag === "Resource") { - return match.handler.effect - } - const params: Array = [] - for (const key of Object.keys(match.params)) { - params[Number(key)] = match.params[key]! - } - return match.handler.handle(uri, params) + Effect.gen(function*() { + const client = yield* McpServerClient + return yield* internalCore.resources.read(uri, { + clientId: client.clientId, + protocol: { + protocolVersion: client.protocolVersion, + clientCapabilities: client.clientCapabilities, + clientInfo: client.clientInfo, + requestMetadata: client.initializePayload._meta + }, + requestContext: client + }).pipe( + Effect.catchTag("ResourceNotFound", (error) => + Effect.fail(new InvalidParams({ message: `Resource '${error.uri}' not found` }))) + ) }), get prompts() { return prompts }, addPrompt: (options) => - Effect.suspend(() => { - prompts.push(options) - promptMap.set(options.prompt.name, options.handle) + Effect.gen(function*() { + const existingIndex = prompts.findIndex(({ prompt }) => prompt.name === options.prompt.name) + if (existingIndex === -1) { + prompts.push(options) + } else { + prompts[existingIndex] = options + } + yield* internalCore.prompts.register({ + descriptor: options.prompt, + isVisible: (profile) => { + const enabledWhen = Context.getOrUndefined(options.annotations, EnabledWhen) + return enabledWhen === undefined || enabledWhen({ + protocolVersion: profile.protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo + }) + }, + get: (params, invocation) => + options.handle(params).pipe( + Effect.provideService(McpServerClient, invocation.requestContext) + ) + }) for (const [param, handle] of Object.entries(options.completions)) { - completionsMap.set(`ref/prompt/${options.prompt.name}/${param}`, handle) + yield* internalCore.completions.register( + `prompt/${options.prompt.name}/${param}`, + (request, invocation) => + handle(request.argument.value, request.context).pipe( + Effect.provideService( + McpServerClient, + invocation.requestContext + ), + Effect.map((result) => ({ + values: result.completion.values, + total: result.completion.total, + hasMore: result.completion.hasMore, + metadata: result._meta + })) + ) + ) } - return notifications.client["notifications/prompts/list_changed"]({}) + yield* notifications.client["notifications/prompts/list_changed"]({}) }), getPromptResult: Effect.fnUntraced(function*({ arguments: params, name }) { - const handler = promptMap.get(name) - if (!handler) { - return yield* new InvalidParams({ message: `Prompt '${name}' not found` }) - } - return yield* handler(params ?? {}) + const client = yield* McpServerClient + return yield* internalCore.prompts.get( + name, + params ?? {}, + McpProtocolInternal.invocationFromClient(client) + ).pipe( + Effect.catchTag("PromptNotFound", () => new InvalidParams({ message: `Prompt '${name}' not found` })) + ) }), completion: Effect.fnUntraced(function*(complete) { + const client = yield* McpServerClient const ref = complete.ref - const key = ref.type === "ref/resource" - ? `ref/resource/${ref.uri}/${complete.argument.name}` - : `ref/prompt/${ref.name}/${complete.argument.name}` - const handler = completionsMap.get(key) - if (!handler) { - return yield* new InvalidParams({ message: "Unknown completion reference or argument" }) - } - const result = yield* handler(complete.argument.value, complete.context) - const values = Arr.take(result.completion.values, 100) + const result = yield* internalCore.completions.complete({ + reference: ref.type === "ref/resource" + ? { type: "resourceTemplate", uriTemplate: ref.uri } + : { type: "prompt", name: ref.name }, + argument: complete.argument, + context: complete.context + }, McpProtocolInternal.invocationFromClient(client)) return { + _meta: result.metadata, completion: { - ...result.completion, - values, - hasMore: result.completion.hasMore === true || - values.length < result.completion.values.length + values: result.values, + total: result.total, + hasMore: result.hasMore } } }) }) + internalState.set(service, { core: internalCore, notifications: notificationsQueue }) + return service }) /** @@ -388,24 +597,15 @@ export class McpServer extends Context.Service `${typeof requestId}:${requestId}` type SessionLogLevel = - | { - readonly _tag: "Effect" - readonly level: LogLevel.LogLevel - } - | { - readonly _tag: "Mcp" - readonly level: LoggingLevel - } + | { readonly _tag: "Effect"; readonly level: LogLevel.LogLevel } + | { readonly _tag: "Mcp"; readonly level: McpSchema.LoggingLevel } interface Session { readonly initializePayload: typeof Initialize.payloadSchema.Type + readonly negotiatedProfile: McpCore.NegotiatedProtocolProfile readonly protocol: McpProtocol.ProtocolAdapter readonly resourceSubscriptions: Set | undefined logLevel: SessionLogLevel @@ -418,7 +618,7 @@ interface Sessions { class McpClientKey extends Data.Class<{ readonly clientId: number - readonly protocolVersion: string + readonly profile: McpCore.NegotiatedProtocolProfile }> {} class McpProtocolState extends Context.Service ) { + // TODO: Replace the shared session map with an adapter-owned lifecycle strategy + // before v2026-07-28. The strategy must let sessionful revisions pin a profile + // after initialize while stateless revisions select and derive it per request. return McpProtocolState.of({ sessions: { bySessionId: new Map(), @@ -458,8 +661,11 @@ const layerMcpProtocolState = ( export const run: (options: { readonly name: string readonly version: string + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined readonly protocols: Arr.NonEmptyReadonlyArray - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: ServerExtensions | undefined }) => Effect.Effect< never, Cause.IllegalArgumentError, @@ -467,8 +673,11 @@ export const run: (options: { > = Effect.fnUntraced(function*(options: { readonly name: string readonly version: string + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined readonly protocols: Arr.NonEmptyReadonlyArray - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: ServerExtensions | undefined }) { const protocolStateOption = yield* Effect.serviceOption(McpProtocolState) const protocolState = Option.isSome(protocolStateOption) @@ -480,16 +689,21 @@ export const run: (options: { const runWithProtocolState = Effect.fnUntraced(function*(options: { readonly name: string readonly version: string - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined + readonly extensions?: ServerExtensions | undefined }, protocolState: McpProtocolState["Service"]) { - const serverScope = yield* Effect.scope const protocolRegistry = protocolState.protocolRegistry + const serverScope = yield* Effect.scope const protocol = yield* RpcServer.Protocol const server = yield* McpServer + const defaultLogLevel = yield* CurrentLogLevel const isHttp = Option.isSome(yield* Effect.serviceOption(HttpRouter.HttpRouter)) const sessions = protocolState.sessions const clientProtocols = new Map() const activeRequests = new Map>() + const clientProfiles = new Map() const handlers = yield* Layer.build(layerHandlers(options, { sessions, protocolRegistry @@ -497,33 +711,35 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { const clients = yield* RcMap.make({ lookup: Effect.fnUntraced(function*(key: McpClientKey) { - const selectedProtocol = protocolRegistry.select(key.protocolVersion) + const selectedProtocol = protocolRegistry.select(key.profile.protocolVersion) let write!: (message: RpcMessage.FromServerEncoded) => Effect.Effect - const client = yield* RpcClient.make(selectedProtocol.serverRequestRpcs, { - spanPrefix: "McpServer/Client" - }).pipe( - Effect.provideServiceEffect( - RpcClient.Protocol, - RpcClient.Protocol.make(Effect.fnUntraced(function*(writeResponse) { - let cid = 0 - write = (message) => writeResponse(cid, message) - return { - send(id, request, _transferables) { - cid = id - return protocol.send(key.clientId, { - ...request, - headers: undefined, - traceId: undefined, - spanId: undefined, - sampled: undefined - } as any) - }, - supportsAck: true, - supportsTransferables: false, - supportsStructuredClone: false + const reverseProtocol = yield* RpcClient.Protocol.make(Effect.fnUntraced(function*(writeResponse) { + let cid = 0 + write = (message) => writeResponse(cid, message) + return { + send(id, request, _transferables) { + cid = id + if (request._tag === "Request") { + return protocol.send(key.clientId, { + _tag: "Request", + id: request.id, + tag: request.tag, + payload: request.payload, + headers: [] + }) } - })) - ) + // Ack & co are not part of FromServerEncoded, but the JSON-RPC + // serializer encodes them symmetrically for reverse control flow + return protocol.send(key.clientId, request as any) + }, + supportsAck: true, + supportsTransferables: false, + supportsStructuredClone: false, + codecFor: protocol.codecFor + } + })) + const client = yield* selectedProtocol.makeReverseClient(key.profile).pipe( + Effect.provideService(RpcClient.Protocol, reverseProtocol) ) return { client, write } as const @@ -551,25 +767,39 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { return Effect.die(new Error(`Mcp-Session-Id does not exist`)) } const selectedProtocol = session?.protocol ?? protocolForInternalTag(protocolRegistry, rpc._tag) - return effect.pipe( + // NOTE: RPC middleware erases the correlation between the initialize tag + // and its decoded payload. Restore it once after non-initialize requests + // without a session have been rejected above. + const initializePayload = session?.initializePayload ?? payload as typeof Initialize.payloadSchema.Type + const profile = session?.negotiatedProfile ?? { + protocolVersion: selectedProtocol.protocolVersion, + clientCapabilities: initializePayload.capabilities, + clientInfo: initializePayload.clientInfo + } + clientProfiles.set(client.id, profile) + return Effect.provideService( Effect.provideService( + effect, McpServerClient, McpServerClient.of({ clientId: client.id, - protocolVersion: selectedProtocol.protocolVersion, - initializePayload: session?.initializePayload ?? payload as typeof Initialize.payloadSchema.Type, + protocolVersion: session?.negotiatedProfile.protocolVersion ?? selectedProtocol.protocolVersion, + clientCapabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo, + initializePayload, getClient: RcMap.get( clients, new McpClientKey({ clientId: client.id, - protocolVersion: selectedProtocol.protocolVersion + profile }) ).pipe( Effect.map(({ client }) => client) ) }) ), - Effect.provideService(CurrentLogLevel, effectLogLevel(session?.logLevel)) + CurrentLogLevel, + effectLogLevel(session?.logLevel, defaultLogLevel) ) }) @@ -586,6 +816,22 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { if (cancelled === true) { return Effect.void } + if ( + response.exit._tag === "Failure" && + !response.exit.cause.some((failure) => failure._tag === "Fail") + ) { + return protocol.send(clientId, { + _tag: "Exit", + requestId: response.requestId, + exit: { + _tag: "Failure", + cause: [{ + _tag: "Fail", + error: new InternalError({ message: "Internal error" }) + }] + } + }) + } } return protocol.send(clientId, response) }, @@ -624,6 +870,8 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { (request.tag === "initialize" ? protocolRegistry.select(getOfferedProtocolVersion(request.payload)) : protocolRegistry.protocols[0]) + // Selection happens before dated payload decoding. Once a + // session exists, all later messages reuse its pinned adapter. clientProtocols.set(clientId, selectedProtocol) if (request.tag === MCP_INVALID_BATCH_METHOD) { return protocol.send(clientId, { @@ -654,7 +902,10 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { } const routedRequest = protocolRegistry.routeClientRequest(selectedProtocol, request) const rpc = protocolRegistry.clientRpcs.requests.get(routedRequest.tag) - if (rpc && selectedProtocol.clientNotificationRpcs.requests.has(request.tag)) { + if ( + rpc && + selectedProtocol.clientNotificationRpcs.requests.has(request.tag) + ) { if (!session) { if (httpRequest) { appendPreResponseHandlerUnsafe( @@ -669,46 +920,44 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { } return Effect.void } - if (request.tag === "notifications/cancelled") { - return decodeCancelledNotification(request.payload).pipe( - Effect.flatMap(({ requestId }) => { - const key = requestKey(requestId) - const requests = activeRequests.get(clientId) - if (requests?.has(key) !== true) { - return Effect.void - } - requests.set(key, true) - return f(clientId, { - _tag: "Interrupt", - requestId: RpcMessage.RequestId(requestId) - }) - }), - Effect.catchCause(() => Effect.void) - ) - } - return selectedProtocol.payloadCodecs(rpc).decode(request.payload).pipe( + const decode = selectedProtocol.payloadCodecs(rpc).decode(request.payload) + return decode.pipe( Effect.flatMap((payload) => { if ( request.tag === "notifications/roots/list_changed" && - session.initializePayload.capabilities.roots?.listChanged === true + session.initializePayload.capabilities.roots?.listChanged === true && + httpRequest === undefined ) { - if (httpRequest !== undefined) { - return Effect.void - } return RcMap.get( clients, new McpClientKey({ clientId, - protocolVersion: selectedProtocol.protocolVersion + profile: session.negotiatedProfile }) ).pipe( - Effect.flatMap(({ client }) => client["roots/list"](undefined)), + Effect.flatMap(({ client }) => client.listRoots()), Effect.scoped, Effect.ignoreCause, Effect.forkIn(serverScope), Effect.asVoid ) } + if (request.tag === "notifications/cancelled") { + return selectedProtocol.normalizeCancellation(payload).pipe( + Effect.flatMap((cancellation) => { + const key = requestKey(cancellation.requestId) + const requests = activeRequests.get(clientId) + if (requests?.has(key) !== true) { + return Effect.void + } + requests.set(key, true) + return f(clientId, { + _tag: "Interrupt", + requestId: String(cancellation.requestId) + }) + }) + ) + } const handler = handlers.mapUnsafe.get(rpc.key) as Rpc.Handler | undefined return handler ? handler.handler(payload, { @@ -719,7 +968,7 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { }) as any as Effect.Effect : Effect.void }), - Effect.catchCause(() => Effect.void) + Effect.ignoreCause ) } if (!rpc) { @@ -765,73 +1014,100 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { return f(clientId, request) case "Eof": activeRequests.delete(clientId) + clientProtocols.delete(clientId) + clientProfiles.delete(clientId) + if (!isHttp) { + sessions.byClientId.delete(clientId) + } return f(clientId, request) case "Pong": case "Exit": case "Chunk": case "ClientProtocolError": - case "Defect": + case "Defect": { + const selectedProtocol = getProtocolForClient(clientProtocols, clientId, protocolRegistry) + const profile = clientProfiles.get(clientId) ?? { + protocolVersion: selectedProtocol.protocolVersion, + clientCapabilities: {}, + clientInfo: { name: "unknown", version: "unknown" } + } return RcMap.get( clients, new McpClientKey({ clientId, - protocolVersion: getProtocolForClient(clientProtocols, clientId, protocolRegistry).protocolVersion + profile }) ).pipe( Effect.flatMap(({ write }) => write(request)), Effect.scoped ) + } } }) }) - yield* Queue.take(server.notificationsQueue).pipe( - Effect.flatMap(Effect.fnUntraced(function*(request) { + yield* Queue.take(internalState.get(server)!.notifications).pipe( + Effect.flatMap(Effect.fnUntraced(function*({ notification, targetClientId }) { const clientIds = yield* patchedProtocol.clientIds for (const clientId of clientProtocols.keys()) { if (!clientIds.has(clientId)) { clientProtocols.delete(clientId) - sessions.byClientId.delete(clientId) + clientProfiles.delete(clientId) + // HTTP client IDs are request-scoped; their UUID sessions outlive them. + if (!isHttp) { + sessions.byClientId.delete(clientId) + } } } for (const clientId of server.initializedClients.keys()) { + if (targetClientId !== undefined && clientId !== targetClientId) { + continue + } if (!clientIds.has(clientId)) { server.initializedClients.delete(clientId) continue } - const selectedProtocol = clientProtocols.get(clientId) - if (!selectedProtocol) { + // This must stay below stale-client cleanup so transports without + // notification support still prune initializedClients. + if (!patchedProtocol.supportsNotifications) { continue } - const rpc = selectedProtocol.serverNotificationRpcs.requests.get(request.tag) - if (!rpc) { + const selectedProtocol = clientProtocols.get(clientId) + if (!selectedProtocol) { continue } - if (request.tag === "notifications/message") { - const { level } = yield* Schema.decodeUnknownEffect( - LoggingMessageNotification.payloadSchema - )(request.payload) - if (!isMcpLogLevelEnabled(level, sessions.byClientId.get(clientId)?.logLevel)) { - continue + yield* Effect.gen(function*() { + const projected = yield* selectedProtocol.projectNotification(notification) + if (projected === undefined) { + return } - } - if (request.tag === "notifications/resources/updated") { - const { uri } = yield* Schema.decodeUnknownEffect( - ResourceUpdatedNotification.payloadSchema - )(request.payload) - if (sessions.byClientId.get(clientId)?.resourceSubscriptions?.has(uri) !== true) { - continue + const session = sessions.byClientId.get(clientId) + if ( + notification._tag === "LoggingMessage" && + !isMcpLogLevelEnabled(notification.level, session?.logLevel, defaultLogLevel) + ) { + return } - } - const encoded = yield* selectedProtocol.payloadCodecs(rpc).encode(request.payload) - // TODO: Extend RpcServer.Protocol's outbound message contract with server-originated - // notifications so MCP does not need to treat this notification as an RPC response. - const message: RpcMessage.RequestEncoded = { - _tag: "Request", - tag: request.tag, - payload: encoded - } as any - yield* patchedProtocol.send(clientId, message as any) + if ( + notification._tag === "ResourceUpdated" && + session?.resourceSubscriptions?.has(notification.uri) !== true + ) { + return + } + const rpc = selectedProtocol.serverNotificationRpcs.requests.get(projected.tag) + if (!rpc) { + return + } + const encoded = yield* selectedProtocol.payloadCodecs(rpc).encode(projected.payload) + yield* patchedProtocol.send(clientId, { + _tag: "Request", + id: "", + tag: projected.tag, + payload: encoded, + headers: [], + isNotification: true + }) + }).pipe(Effect.catchCause(() => Effect.void)) } })), Effect.catchCause(() => Effect.void), @@ -881,8 +1157,11 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { export const layer = (options: { readonly name: string readonly version: string + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined readonly protocols: Arr.NonEmptyReadonlyArray - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: ServerExtensions | undefined }): Layer.Layer => layerWithProtocolState(options).pipe( Layer.provide(layerMcpProtocolState(options.protocols)) @@ -891,7 +1170,10 @@ export const layer = (options: { const layerWithProtocolState = (options: { readonly name: string readonly version: string - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined + readonly extensions?: ServerExtensions | undefined }): Layer.Layer => Layer.effectDiscard( Effect.gen(function*() { @@ -902,141 +1184,107 @@ const layerWithProtocolState = (options: { Layer.provideMerge(McpServer.layer) ) -const StdioInitializeRequest = Schema.Struct({ - method: Schema.Literal("initialize"), - params: Schema.Struct({ - protocolVersion: Schema.String - }) -}) - -const StdioInvalidBatchExit = Schema.Struct({ - _tag: Schema.Literal("Exit"), - requestId: Schema.Null, - exit: Schema.Struct({ - cause: Schema.Unknown - }) -}) - -const decodeStdioInitializeRequest = Schema.decodeUnknownOption(StdioInitializeRequest) -const decodeStdioInvalidBatchExit = Schema.decodeUnknownOption(StdioInvalidBatchExit) +/** + * Creates a layer that runs an MCP server over standard input and output. + * + * **When to use** + * + * Use when an MCP client launches the server as a subprocess and communicates + * through newline-delimited JSON-RPC messages. + * + * **Details** + * + * The selected protocol adapter controls the dated RPC schemas and JSON-RPC + * batch policy. The layer provides `McpServer` and `McpServerClient` and + * requires `Stdio`. + * + * @see {@link layer} for running over an existing `RpcServer.Protocol` + * @see {@link layerHttp} for the single-endpoint HTTP transport + * + * @category layers + * @since 4.0.0 + */ +export const layerStdio = (options: { + readonly name: string + readonly version: string + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined + readonly protocols: Arr.NonEmptyReadonlyArray + readonly extensions?: ServerExtensions | undefined +}): Layer.Layer => + layer(options).pipe( + Layer.provide(RpcServer.layerProtocolStdio), + Layer.provide(Layer.succeed( + RpcSerialization.RpcSerialization, + mcpStdioSerialization(options.protocols) + )) + ) -const makeStdioSerialization = ( +const mcpStdioSerialization = ( protocols: Arr.NonEmptyReadonlyArray -): RpcSerialization.RpcSerialization["Service"] => - RpcSerialization.RpcSerialization.of({ - contentType: "application/json-rpc", +): RpcSerialization.RpcSerialization["Service"] => { + const serialization = RpcSerialization.jsonRpc({ + contentType: "application/json-rpc" + }) + return RpcSerialization.RpcSerialization.of({ + contentType: serialization.contentType, includesFraming: true, + codecFor: serialization.codecFor, makeUnsafe: () => { - const framing = RpcSerialization.ndjson.makeUnsafe() - const jsonRpc = RpcSerialization.jsonRpc().makeUnsafe() - const protocolsByVersion = new Map( - protocols.map((protocol) => [protocol.protocolVersion, protocol]) - ) - let selectedProtocol = protocols[0] + const frames = RpcSerialization.ndjson.makeUnsafe() + const parser = serialization.makeUnsafe() + let selectedProtocol: McpProtocol.ProtocolAdapter | undefined return { decode: (data) => { - const frames = framing.decode(data) - const messages: Array = [] - for (const frame of frames) { - const entries = Array.isArray(frame) ? frame : [frame] - const initialize = Arr.findFirst(entries, (entry) => decodeStdioInitializeRequest(entry)) - selectedProtocol = Option.match(initialize, { - onNone: () => selectedProtocol, - onSome: ({ params }) => protocolsByVersion.get(params.protocolVersion) ?? protocols[0] - }) - if (Array.isArray(frame) && !selectedProtocol.transport.acceptsJsonRpcBatches) { - messages.push({ - _tag: "Request", - id: null, - tag: MCP_INVALID_BATCH_METHOD, - payload: null, - headers: [] - }) - } else { - messages.push(...jsonRpc.decode(JSON.stringify(frame))) + const decoded: Array = [] + for (const frame of frames.decode(data)) { + if (Array.isArray(frame)) { + const acceptsBatch = selectedProtocol?.transport.acceptsJsonRpcBatches === true + if ( + !acceptsBatch || + frame.length === 0 || + frame.some(isInitializeJsonRpcMessage) + ) { + decoded.push({ + _tag: "Request", + id: null, + tag: MCP_INVALID_BATCH_METHOD, + payload: null, + headers: [] + }) + continue + } + } else if (isInitializeJsonRpcMessage(frame)) { + const offered = getJsonRpcProtocolVersion(frame) + selectedProtocol = protocols.find((protocol) => protocol.protocolVersion === offered) ?? + protocols[0] } + decoded.push(...parser.decode(JSON.stringify(frame))) } - return messages + return decoded }, encode: (response) => { - const invalidBatchExit = decodeStdioInvalidBatchExit(response) - if (Option.isSome(invalidBatchExit)) { - return framing.encode({ + const invalidBatchExit = decodeInvalidBatchExit(response) + if (Result.isSuccess(invalidBatchExit)) { + return JSON.stringify({ jsonrpc: "2.0", id: null, error: { _tag: "Cause", - code: INVALID_REQUEST_ERROR_CODE, + code: McpSchema.INVALID_REQUEST_ERROR_CODE, message: "JSON-RPC batches are not supported", - data: invalidBatchExit.value.exit.cause + data: invalidBatchExit.success.exit.cause } - }) + }) + "\n" } - const encoded = jsonRpc.encode(response) + const encoded = parser.encode(response) return encoded === undefined ? undefined : `${encoded}\n` } } } }) - -/** - * Runs the McpServer, using stdio for input and output. - * - * **Example** (Configuring an MCP server over stdio) - * - * ```ts import.meta.vitest - * import { Effect, Layer, Schema } from "effect" - * import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai" - * - * const idParam = McpSchema.param("id", Schema.Number) - * - * const ReadmeTemplate = McpServer.resource`file://readme/${idParam}`({ - * name: "README Template", - * completion: { - * id: () => Effect.succeed([1, 2, 3]) - * }, - * content: (_uri, id) => Effect.succeed(`# MCP Server Demo - ID: ${id}`) - * }) - * - * const TestPrompt = McpServer.prompt({ - * name: "Test Prompt", - * description: "Looks up flight booking details", - * parameters: { - * flightNumber: Schema.String - * }, - * completion: { - * flightNumber: () => Effect.succeed(["FL123", "FL456"]) - * }, - * content: ({ flightNumber }) => - * Effect.succeed(`Get the booking details for flight number: ${flightNumber}`) - * }) - * - * const ServerLayer = Layer.mergeAll(ReadmeTemplate, TestPrompt).pipe( - * Layer.provide(McpServer.layerStdio({ - * name: "Demo Server", - * version: "1.0.0", - * protocols: [McpProtocol.v2025_06_18] - * })) - * ) - * - * Layer.isLayer(ServerLayer) // => true - * ``` - * - * @category layers - * @since 4.0.0 - */ -export const layerStdio = (options: { - readonly name: string - readonly version: string - readonly protocols: Arr.NonEmptyReadonlyArray - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined -}): Layer.Layer => - layer(options).pipe( - Layer.provide(RpcServer.layerProtocolStdio), - Layer.provide( - Layer.succeed(RpcSerialization.RpcSerialization)(makeStdioSerialization(options.protocols)) - ) - ) +} /** * Registers a Streamable HTTP MCP endpoint at `options.path`. @@ -1049,7 +1297,16 @@ export const layerStdio = (options: { * * POST serves JSON-RPC and accepted notification-only requests return `202`. * Unsupported protocol versions return `400`; methods without MCP handlers - * return `405`. Browser Origins are rejected unless listed in `allowedOrigins`. + * return `405`. Requests carrying an `Origin` header are rejected unless the + * exact origin appears in `allowedOrigins`; Origin-less non-browser clients + * remain valid. The surrounding HTTP server remains responsible for binding + * to an appropriate interface and installing authentication. + * + * `layerHttp` always implements the single-endpoint Streamable HTTP topology. + * Using `v2024_11_05` here is a custom compatibility transport for that + * revision's schema. It does not implement the historical two-endpoint + * HTTP+SSE transport, GET SSE, event resumption, session expiry, or client + * session termination. * * @see {@link layerStdio} for exposing the server over stdio * @see {@link layer} for the base MCP server layer without a transport protocol @@ -1060,10 +1317,13 @@ export const layerStdio = (options: { export const layerHttp = (options: { readonly name: string readonly version: string + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined readonly path: HttpRouter.PathInput readonly protocols: Arr.NonEmptyReadonlyArray + readonly extensions?: ServerExtensions | undefined readonly allowedOrigins?: ReadonlyArray | undefined - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined }): Layer.Layer => { const protocolState = layerMcpProtocolState(options.protocols) const methodNotAllowedResponse = HttpServerResponse.empty({ @@ -1071,12 +1331,9 @@ export const layerHttp = (options: { headers: { allow: "POST" } }) const methodNotAllowed = (request: HttpServerRequest.HttpServerRequest) => - Effect.succeed( - request.headers.origin !== undefined && - !options.allowedOrigins?.includes(request.headers.origin) - ? HttpServerResponse.empty({ status: 403 }) - : methodNotAllowedResponse - ) + isAllowedMcpOrigin(request, options.allowedOrigins) + ? Effect.succeed(methodNotAllowedResponse) + : Effect.succeed(HttpServerResponse.empty({ status: 403 })) const routes = Layer.mergeAll( HttpRouter.add("GET", options.path, methodNotAllowed), HttpRouter.add("PUT", options.path, methodNotAllowed), @@ -1101,142 +1358,96 @@ const layerMcpProtocolHttp = (options: { > => Layer.effect(RpcServer.Protocol)(Effect.gen(function*() { const state = yield* McpProtocolState - const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffect + const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffect() const router = yield* HttpRouter.HttpRouter yield* router.add("POST", options.path, (request) => { - if ( - request.headers.origin !== undefined && - !options.allowedOrigins?.includes(request.headers.origin) - ) { + if (!isAllowedMcpOrigin(request, options.allowedOrigins)) { return Effect.succeed(HttpServerResponse.empty({ status: 403 })) } - const contentType = request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() - if (contentType !== "application/json") { + if (mcpMediaTypes(request.headers["content-type"])[0] !== "application/json") { return Effect.succeed(HttpServerResponse.empty({ status: 415 })) } - const accepted = new Set() - for (const entry of request.headers.accept?.split(",") ?? []) { - const [mediaType, ...parameters] = entry.split(";").map((part) => part.trim().toLowerCase()) - let quality = 1 - for (const parameter of parameters) { - const [name, value] = parameter.split("=", 2).map((part) => part.trim()) - if (name === "q") { - quality = value === undefined ? Number.NaN : Number(value) - } - } - if (mediaType !== undefined && quality > 0 && quality <= 1) { - accepted.add(mediaType) - } - } - if (!accepted.has("application/json") || !accepted.has("text/event-stream")) { + const accepted = mcpMediaTypes(request.headers["accept"]) + if (!accepted.includes("application/json") || !accepted.includes("text/event-stream")) { return Effect.succeed(HttpServerResponse.empty({ status: 406 })) } const protocolVersion = request.headers[MCP_PROTOCOL_VERSION_HEADER] - if ( - protocolVersion !== undefined && - !state.protocolRegistry.protocols.some((protocol) => protocol.protocolVersion === protocolVersion) - ) { - return Effect.succeed(HttpServerResponse.empty({ status: 400 })) + const sessionId = request.headers[MCP_SESSION_ID_HEADER] + const session = sessionId === undefined + ? undefined + : state.sessions.bySessionId.get(sessionId) + if (sessionId !== undefined && session === undefined) { + return Effect.succeed(HttpServerResponse.empty({ status: 404 })) } + const protocolVersionHeaderRejected = (protocolVersion !== undefined && + !state.protocolRegistry.protocols.some((protocol) => protocol.protocolVersion === protocolVersion)) || + (session?.protocol.transport.requiresVersionHeader === true && + protocolVersion !== session.protocol.protocolVersion) + const parseErrorResponse = protocolVersionHeaderRejected + ? HttpServerResponse.empty({ status: 400 }) + : HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id: null, + error: new McpSchema.ParseError({ message: "Parse error" }) + }) return request.text.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)), Effect.matchEffect({ - onFailure: () => - Effect.succeed( - HttpServerResponse.jsonUnsafe({ + onFailure: () => Effect.succeed(parseErrorResponse), + onSuccess: (input) => { + if (!Array.isArray(input)) { + const hasId = Predicate.hasProperty(input, "id") + const id = hasId && (typeof input.id === "string" || typeof input.id === "number") + ? input.id + : null + const isJsonRpc = Predicate.hasProperty(input, "jsonrpc") && input.jsonrpc === "2.0" + const hasValidRequestId = !hasId || typeof input.id === "string" || typeof input.id === "number" + const isRequest = isJsonRpc && hasValidRequestId && + Predicate.hasProperty(input, "method") && typeof input.method === "string" + const hasValidResponseId = hasId && + (typeof input.id === "string" || typeof input.id === "number" || input.id === null) + const hasResult = Predicate.hasProperty(input, "result") + const hasError = Predicate.hasProperty(input, "error") + const isResponse = isJsonRpc && hasValidResponseId && hasResult !== hasError + const isInitialize = isRequest && isInitializeJsonRpcMessage(input) + // Initialize requests are exempt from the version header check: + // rejecting them with a 400 makes clients treat the endpoint as a + // legacy HTTP+SSE server and retry initialization with a GET. + if (!isInitialize && protocolVersionHeaderRejected) { + return Effect.succeed(HttpServerResponse.empty({ status: 400 })) + } + if (!isRequest && !isResponse) { + return Effect.succeed(HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id, + error: new InvalidRequest({ message: "Invalid Request" }) + })) + } + if (isInitialize && sessionId !== undefined) { + return Effect.succeed(HttpServerResponse.empty({ status: 400 })) + } + if (!isInitialize && isRequest && sessionId === undefined) { + return Effect.succeed(HttpServerResponse.empty({ status: 400 })) + } + return httpEffect + } + if (protocolVersionHeaderRejected) { + return Effect.succeed(HttpServerResponse.empty({ status: 400 })) + } + if (input.length === 0) { + return Effect.succeed(HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: null, - error: new ParseError({ - message: "Parse error" - }) - }) - ), - onSuccess: (body) => { - return Effect.match(Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(body), { - onFailure: () => ({ - _tag: "Error" as const, - id: null, - error: new ParseError({ message: "Parse error" }) - }), - onSuccess: (input) => { - if (Array.isArray(input)) { - const sessionId = request.headers[MCP_SESSION_ID_HEADER] - const session = sessionId === undefined ? undefined : state.sessions.bySessionId.get(sessionId) - let selectedProtocol = session?.protocol ?? state.protocolRegistry.protocols[0] - for (const entry of input) { - if ( - Predicate.hasProperty(entry, "method") && - entry.method === "initialize" && - Predicate.hasProperty(entry, "params") && - Predicate.hasProperty(entry.params, "protocolVersion") && - typeof entry.params.protocolVersion === "string" - ) { - selectedProtocol = state.protocolRegistry.select(entry.params.protocolVersion) - break - } - } - if (!selectedProtocol.transport.acceptsJsonRpcBatches) { - return { _tag: "HttpError" as const, status: 400 } - } - } - const hasId = Predicate.hasProperty(input, "id") - const id = hasId && (typeof input.id === "string" || typeof input.id === "number") - ? input.id - : null - const isJsonRpc = Predicate.hasProperty(input, "jsonrpc") && input.jsonrpc === "2.0" - const hasValidRequestId = hasId === false || typeof input.id === "string" || - typeof input.id === "number" - const isRequest = isJsonRpc && hasValidRequestId && - Predicate.hasProperty(input, "method") && typeof input.method === "string" - const hasValidResponseId = hasId && - (typeof input.id === "string" || typeof input.id === "number" || input.id === null) - const hasResult = Predicate.hasProperty(input, "result") - const hasError = Predicate.hasProperty(input, "error") - const isResponse = isJsonRpc && hasValidResponseId && hasResult !== hasError - const isInitialize = isRequest && input.method === "initialize" - const sessionId = request.headers[MCP_SESSION_ID_HEADER] - const session = sessionId === undefined ? undefined : state.sessions.bySessionId.get(sessionId) - if (isInitialize && sessionId !== undefined) { - return { - _tag: "HttpError" as const, - status: session === undefined ? 404 : 400 - } - } - if (!isInitialize && isRequest && session === undefined) { - return { - _tag: "HttpError" as const, - status: sessionId === undefined ? 400 : 404 - } - } - if ( - session !== undefined && - session.protocol.transport.requiresVersionHeader && - protocolVersion !== session.protocol.protocolVersion - ) { - return { _tag: "HttpError" as const, status: 400 } - } - return isRequest || isResponse - ? { _tag: "Success" as const } - : { - _tag: "Error" as const, - id, - error: new InvalidRequest({ message: "Invalid Request" }) - } - } - }).pipe( - Effect.flatMap((decoded) => - decoded._tag === "HttpError" - ? Effect.succeed(HttpServerResponse.empty({ status: decoded.status })) - : decoded._tag === "Error" - ? Effect.succeed( - HttpServerResponse.jsonUnsafe({ - jsonrpc: "2.0", - id: decoded.id, - error: decoded.error - }) - ) - : httpEffect - ) - ) + error: new InvalidRequest({ message: "Invalid Request" }) + }, { status: 400 })) + } + if (input.some(isInitializeJsonRpcMessage) || session === undefined) { + return Effect.succeed(HttpServerResponse.empty({ status: 400 })) + } + const selectedProtocol = session.protocol + return selectedProtocol.transport.acceptsJsonRpcBatches + ? httpEffect + : Effect.succeed(HttpServerResponse.empty({ status: 400 })) } }) ) @@ -1244,6 +1455,49 @@ const layerMcpProtocolHttp = (options: { return protocol })) +const isAllowedMcpOrigin = ( + request: HttpServerRequest.HttpServerRequest, + allowedOrigins: ReadonlyArray | undefined +): boolean => { + const origin = request.headers["origin"] + return origin === undefined || (allowedOrigins ?? []).includes(origin) +} + +const mcpMediaTypes = (header: string | undefined): ReadonlyArray => + header === undefined + ? [] + : header.split(",").flatMap((part) => { + const [mediaType, ...parameters] = part.split(";") + const quality = parameters + .map((parameter) => parameter.trim().toLowerCase()) + .find((parameter) => parameter.startsWith("q=")) + if (quality !== undefined) { + const value = Number(quality.slice(2)) + if (!Number.isFinite(value) || value <= 0 || value > 1) { + return [] + } + } + return [mediaType.trim().toLowerCase()] + }) + +const InitializeJsonRpcMessage = Schema.Struct({ + method: Schema.Literal("initialize") +}) + +const isInitializeJsonRpcMessage = (message: unknown): boolean => + Result.isSuccess(Schema.decodeUnknownResult(InitializeJsonRpcMessage)(message)) + +const JsonRpcProtocolVersion = Schema.Struct({ + params: Schema.Struct({ + protocolVersion: Schema.String + }) +}) + +const getJsonRpcProtocolVersion = (message: unknown): string | undefined => { + const decoded = Schema.decodeUnknownResult(JsonRpcProtocolVersion)(message) + return Result.isSuccess(decoded) ? decoded.success.params.protocolVersion : undefined +} + const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal server error." const toolErrorResult = (message: string): CallToolResult => @@ -1278,12 +1532,18 @@ export const registerToolkit: >( const annotations = tool.annotations const toolMeta = Context.getOrUndefined(annotations, Tool.Meta) const isDeclaredFailure = Schema.is(tool.failureSchema) - const outputSchema = Tool.getJsonSchemaFromSchema(tool.successSchema) + const outputJsonSchema = Tool.getJsonSchemaFromSchema(tool.successSchema) + const outputSchema = outputJsonSchema.type === "object" + ? yield* Schema.decodeUnknownEffect(ToolJsonSchema)(outputJsonSchema).pipe(Effect.orDie) + : undefined + const inputSchema = yield* Schema.decodeUnknownEffect(ToolJsonSchema)( + Tool.getJsonSchema(tool) + ).pipe(Effect.orDie) const mcpTool = new McpTool({ name: tool.name, description: Tool.getDescription(tool), - inputSchema: Tool.getJsonSchema(tool), - ...(outputSchema.type === "object" ? { outputSchema } : {}), + inputSchema, + ...(outputSchema === undefined ? {} : { outputSchema }), annotations: { ...(Context.getOption(tool.annotations, Tool.Title).pipe( Option.map((title) => ({ title })), @@ -1300,7 +1560,7 @@ export const registerToolkit: >( tool: mcpTool, annotations, handle(payload) { - return built.handle(tool.name as keyof Tools, payload).pipe( + return built.handle(tool.name as keyof Tools, payload ?? {}).pipe( Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), @@ -1748,21 +2008,21 @@ export const registerPrompt = < Effect.mapError((error) => new InvalidParams({ message: error.message })), Effect.flatMap((params) => options.content(params as any).pipe( - Effect.map((messages) => { - messages = typeof messages === "string" ? - [{ - role: "user", - content: TextContent.make({ text: messages }) - }] : - messages - return new GetPromptResult({ messages, description: prompt.description }) - }), Effect.catchCause((cause) => { const prettyError = Cause.prettyErrors(cause)[0] return Effect.fail(new InternalError({ message: prettyError.message })) }) ) ), + Effect.map((messages) => { + messages = typeof messages === "string" ? + [{ + role: "user", + content: TextContent.make({ text: messages }) + }] : + messages + return new GetPromptResult({ messages, description: prompt.description }) + }), Effect.provideContext(services as Context.Context) ) }) @@ -1839,11 +2099,12 @@ export const elicit: const { getClient } = yield* McpServerClient const client = yield* getClient const schema = options.schema - const request = Elicit.payloadSchema.make({ + const request = yield* Schema.decodeUnknownEffect(McpSchema.ElicitRequestFormParams)({ + mode: "form", message: options.message, requestedSchema: Tool.getJsonSchemaFromSchema(schema) - }) - const res = yield* client["elicitation/create"](request).pipe( + }).pipe(Effect.orDie) + const res = yield* client.elicit(request).pipe( Effect.catchCause((cause) => Effect.fail(new ElicitationDeclined({ cause: Cause.squash(cause), request }))) ) switch (res.action) { @@ -1866,7 +2127,7 @@ export const clientCapabilities: Effect.Effect< ClientCapabilities, never, McpServerClient -> = McpServerClient.useSync((_) => _.initializePayload.capabilities) +> = McpServerClient.useSync((_) => _.clientCapabilities) // ----------------------------------------------------------------------------- // Internal @@ -1914,10 +2175,14 @@ const compileUriTemplate = (segments: TemplateStringsArray, ...schemas: Readonly } as const } +const PingRpcs = RpcGroup.make(Ping).middleware(McpServerClientMiddleware) const layerHandlers = (serverInfo: { readonly name: string readonly version: string - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined + readonly extensions?: ServerExtensions | undefined }, options: { readonly sessions: Sessions readonly protocolRegistry: McpProtocolRegistry.ProtocolRegistry @@ -1925,184 +2190,137 @@ const layerHandlers = (serverInfo: { Layer.effectContext( Effect.gen(function*() { const server = yield* McpServer - const currentLogLevel = yield* CurrentLogLevel + const defaultLogLevel = yield* CurrentLogLevel const contextMap = new Map() + const internalCore = internalState.get(server)!.core + const handlerTarget = options.protocolRegistry.handlerTarget(contextMap) for (const protocol of options.protocolRegistry.protocols) { - const selectedProtocol = protocol - const wireHandlers = ClientRpcs.of({ + const wireHandlers = PingRpcs.of({ // Requests - ping: () => Effect.succeed({}), - initialize(params, { client }) { - const capabilities: Types.DeepMutable = { - completions: {}, - logging: {} - } - if (server.tools.length > 0) { - capabilities.tools = { listChanged: true } - } - if (server.resources.length > 0 || server.resourceTemplates.length > 0) { - capabilities.resources = { - listChanged: true, - subscribe: true + ping: () => Effect.succeed({}) + }) + yield* handlerTarget.install(protocol, PingRpcs, wireHandlers) + const lifecycle: McpProtocolInternal.LifecycleRuntime = { + initialize: Effect.fnUntraced( + function*(protocolVersion, profile, clientId) { + const presence = yield* internalCore.registrationPresence + let capabilities: McpCore.CanonicalServerCapabilities = { + completions: true, + logging: true } - } - if (server.prompts.length > 0) { - capabilities.prompts = { listChanged: true } - } - if (serverInfo.extensions) { - capabilities.extensions = serverInfo.extensions as any - } - return Effect.withFiber((fiber) => { - const httpRequest = Context.getOrUndefined(fiber.context, HttpServerRequest.HttpServerRequest) - if (httpRequest !== undefined && capabilities.resources !== undefined) { - capabilities.resources.subscribe = false + if (presence.tools) { + capabilities = { ...capabilities, tools: { listChanged: true } } } - const session: Session = { - initializePayload: params, - protocol: selectedProtocol, - resourceSubscriptions: capabilities.resources?.subscribe === true ? new Set() : undefined, - logLevel: { _tag: "Effect", level: currentLogLevel } + if (presence.resources) { + capabilities = { + ...capabilities, + resources: { + listChanged: true, + subscribe: true + } + } } - if (httpRequest) { - const sessionId = crypto.randomUUID() - options.sessions.bySessionId.set(sessionId, session) - appendPreResponseHandlerUnsafe(httpRequest, (_req, res) => - Effect.succeed(HttpServerResponse.setHeaders(res, { - [MCP_SESSION_ID_HEADER]: sessionId, - [MCP_PROTOCOL_VERSION_HEADER]: selectedProtocol.protocolVersion - }))) - } else { - options.sessions.byClientId.set(client.id, session) + if (presence.prompts) { + capabilities = { ...capabilities, prompts: { listChanged: true } } } - return Effect.succeed({ - capabilities, - serverInfo, - protocolVersion: selectedProtocol.protocolVersion - }) - }) - }, - "completion/complete": (r) => - server.completion(r), - "logging/setLevel": ({ level }, { client, headers }) => - Effect.sync(() => { - const session = getClientSession(options.sessions, client.id, headers) - if (session) { - session.logLevel = { _tag: "Mcp", level } + if (serverInfo.extensions) { + capabilities = { + ...capabilities, + extensions: serverInfo.extensions + } } - return {} - }), - "prompts/get": (r) => - server.getPromptResult(r), - "prompts/list": (_, { client, headers }) => - Effect.sync(() => { - const initialized = getClientSession(options.sessions, client.id, headers)?.initializePayload - return new ListPromptsResult({ prompts: filterByClient(initialized, server.prompts, "prompt") }) - }), - "resources/list": (_, { client, headers }) => - Effect.sync(() => { - const initialized = getClientSession(options.sessions, client.id, headers)?.initializePayload - return new ListResourcesResult({ resources: filterByClient(initialized, server.resources, "resource") }) - }), - "resources/read": ({ uri }) => server.findResource(uri), - "resources/subscribe": ({ uri }, { client, headers }) => - Effect.gen(function*() { - const subscriptions = getClientSession( - options.sessions, - client.id, - headers - )?.resourceSubscriptions - if (subscriptions === undefined) { - return yield* new MethodNotFound({ - message: "Resource subscriptions are not supported" + return yield* Effect.withFiber((fiber) => { + const httpRequest = Context.getOrUndefined(fiber.context, HttpServerRequest.HttpServerRequest) + if (httpRequest !== undefined && capabilities.resources !== undefined) { + capabilities = { + ...capabilities, + resources: { ...capabilities.resources, subscribe: false } + } + } + const initializePayload = Initialize.payloadSchema.make({ + protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo, + _meta: profile.requestMetadata }) - } - subscriptions.add(uri) - return {} - }), - "resources/unsubscribe": ({ uri }, { client, headers }) => - Effect.gen(function*() { - const subscriptions = getClientSession( - options.sessions, - client.id, - headers - )?.resourceSubscriptions - if (subscriptions === undefined) { - return yield* new MethodNotFound({ - message: "Resource subscriptions are not supported" + const session: Session = { + initializePayload, + negotiatedProfile: profile, + protocol, + resourceSubscriptions: httpRequest === undefined && capabilities.resources?.subscribe === true + ? new Set() + : undefined, + logLevel: { _tag: "Effect", level: defaultLogLevel } + } + if (httpRequest) { + const sessionId = crypto.randomUUID() + options.sessions.bySessionId.set(sessionId, session) + appendPreResponseHandlerUnsafe(httpRequest, (_req, res) => + Effect.succeed(HttpServerResponse.setHeaders(res, { + [MCP_SESSION_ID_HEADER]: sessionId, + [MCP_PROTOCOL_VERSION_HEADER]: protocol.protocolVersion + }))) + } else { + options.sessions.byClientId.set(clientId, session) + } + return Effect.succeed({ + capabilities, + serverInfo: McpSchema.Implementation.make({ + name: serverInfo.name, + version: serverInfo.version, + description: serverInfo.description, + websiteUrl: serverInfo.websiteUrl, + icons: serverInfo.icons + }) }) - } - subscriptions.delete(uri) - return {} - }), - "resources/templates/list": (_, { client, headers }) => - Effect.sync(() => { - const initialized = getClientSession(options.sessions, client.id, headers)?.initializePayload - return new ListResourceTemplatesResult({ - resourceTemplates: filterByClient(initialized, server.resourceTemplates, "template") }) - }), - "tools/call": (r) => server.callTool(r), - "tools/list": (_, { client, headers }) => - Effect.sync(() => { - const initialized = getClientSession(options.sessions, client.id, headers)?.initializePayload - return new ListToolsResult({ - tools: filterByClient(initialized, server.tools, "tool") + } + ), + setLogLevel: Effect.fnUntraced(function*(level, clientId, headers) { + const session = getClientSession(options.sessions, clientId, headers) + if (session === undefined) { + return + } + session.logLevel = { _tag: "Mcp", level } + }), + subscribe: Effect.fnUntraced(function*(uri, clientId, headers) { + const subscriptions = getClientSession(options.sessions, clientId, headers)?.resourceSubscriptions + if (subscriptions === undefined) { + return yield* new McpProtocolInternal.ProtocolError({ + code: McpSchema.METHOD_NOT_FOUND_ERROR_CODE, + message: "Resource subscriptions are not supported" }) - }), - - // Notifications - "notifications/cancelled": (_) => Effect.void, - "notifications/initialized": (_, { client, headers }) => - Effect.sync(() => { - server.initializedClients.add(client.id) - const session = getClientSession(options.sessions, client.id, headers) - if (session) { - options.sessions.byClientId.set(client.id, session) - } - }), - "notifications/progress": (_) => Effect.void, - "notifications/roots/list_changed": (_) => Effect.void - }) - yield* addProtocolHandlers( - options.protocolRegistry, - selectedProtocol, - selectedProtocol.clientRpcs, - wireHandlers, - contextMap + } + subscriptions.add(uri) + }), + unsubscribe: Effect.fnUntraced(function*(uri, clientId, headers) { + const subscriptions = getClientSession(options.sessions, clientId, headers)?.resourceSubscriptions + if (subscriptions === undefined) { + return yield* new McpProtocolInternal.ProtocolError({ + code: McpSchema.METHOD_NOT_FOUND_ERROR_CODE, + message: "Resource subscriptions are not supported" + }) + } + subscriptions.delete(uri) + }), + clientNotification: Effect.fnUntraced(function*(notification, clientId) { + if (notification._tag === "Initialized") { + server.initializedClients.add(clientId) + } + return + }) + } + yield* protocol.installHandlers( + internalCore, + lifecycle, + handlerTarget ) } return Context.makeUnsafe(contextMap) }) ) -const addProtocolHandlers = Effect.fnUntraced(function*< - ClientRpcs extends Rpc.Any ->( - registry: McpProtocolRegistry.ProtocolRegistry, - protocol: McpProtocol.ProtocolAdapter, - clientRpcs: RpcGroup.RpcGroup, - handlers: RpcGroup.HandlersFrom, - contextMap: Map -) { - const handlerContext = yield* clientRpcs.toHandlers(handlers) - for (const rpcDefinition of clientRpcs.requests.values()) { - const routed = registry.routeClientRequest(protocol, { - _tag: "Request", - id: 0, - tag: rpcDefinition._tag, - payload: undefined, - headers: [] - }) - const namespacedRpc = registry.clientRpcs.requests.get(routed.tag) - const handler = handlerContext.mapUnsafe.get(rpcDefinition.key) - if (namespacedRpc === undefined || handler === undefined) { - return yield* Effect.die(`MCP handler registration invariant failed for ${routed.tag}`) - } - contextMap.set(namespacedRpc.key, handler) - } -}) - const resolveResourceContent = ( uri: string, content: typeof ReadResourceResult.Type | string | Uint8Array @@ -2125,30 +2343,6 @@ const resolveResourceContent = ( return content } -const filterByClient = < - A extends { - readonly annotations: Context.Context - }, - P extends keyof A ->( - client: typeof Initialize.payloadSchema.Type | undefined, - items: ReadonlyArray, - prop: P -): Array => { - if (!client) { - return items.map((item) => item[prop]) - } - const out = Arr.empty() - for (let i = 0; i < items.length; i++) { - const item = items[i] - const enabledWhen = Context.getOrUndefined(item.annotations, EnabledWhen) - if (!enabledWhen || enabledWhen(client)) { - out.push(item[prop]) - } - } - return out -} - const getClientSession = ( sessions: Sessions, clientId: number, @@ -2161,10 +2355,18 @@ const getClientSession = ( return sessions.bySessionId.get(sessionId) } -const mcpLogLevels: Record = { +const InvalidBatchExit = Schema.Struct({ + _tag: Schema.Literal("Exit"), + requestId: Schema.Null, + exit: Schema.Struct({ + _tag: Schema.Literal("Failure"), + cause: Schema.Unknown + }) +}) + +const decodeInvalidBatchExit = Schema.decodeUnknownResult(InvalidBatchExit) + +const mcpLogLevels: Record = { debug: { effect: "Debug", order: 0 }, info: { effect: "Info", order: 1 }, notice: { effect: "Info", order: 2 }, @@ -2175,24 +2377,26 @@ const mcpLogLevels: Record - logLevel?._tag === "Mcp" ? mcpLogLevels[logLevel.level].effect : logLevel?.level ?? "Info" +const effectLogLevel = (logLevel: SessionLogLevel | undefined, fallback: LogLevel.LogLevel): LogLevel.LogLevel => + logLevel?._tag === "Mcp" ? mcpLogLevels[logLevel.level].effect : logLevel?.level ?? fallback const isMcpLogLevelEnabled = ( - level: LoggingLevel, - minimum: SessionLogLevel | undefined + level: McpSchema.LoggingLevel, + minimum: SessionLogLevel | undefined, + fallback: LogLevel.LogLevel ): boolean => minimum?._tag === "Mcp" ? mcpLogLevels[level].order >= mcpLogLevels[minimum.level].order - : LogLevel.isGreaterThanOrEqualTo(mcpLogLevels[level].effect, minimum?.level ?? "Info") - -const getOfferedProtocolVersion = (payload: unknown): string => - typeof payload === "object" && - payload !== null && - "protocolVersion" in payload && - typeof payload.protocolVersion === "string" - ? payload.protocolVersion - : "" + : LogLevel.isGreaterThanOrEqualTo(mcpLogLevels[level].effect, minimum?.level ?? fallback) + +const OfferedProtocolVersion = Schema.Struct({ + protocolVersion: Schema.String +}) + +const getOfferedProtocolVersion = (payload: unknown): string => { + const decoded = Schema.decodeUnknownResult(OfferedProtocolVersion)(payload) + return Result.isSuccess(decoded) ? decoded.success.protocolVersion : "" +} const protocolForInternalTag = ( registry: McpProtocolRegistry.ProtocolRegistry, diff --git a/repos/effect/packages/effect/src/unstable/ai/OpenAiStructuredOutput.ts b/repos/effect/packages/effect/src/unstable/ai/OpenAiStructuredOutput.ts index 522b0aa886..5ad9784750 100644 --- a/repos/effect/packages/effect/src/unstable/ai/OpenAiStructuredOutput.ts +++ b/repos/effect/packages/effect/src/unstable/ai/OpenAiStructuredOutput.ts @@ -9,7 +9,7 @@ * * @since 4.0.0 */ -import * as JsonSchema from "../../JsonSchema.ts" +import type * as JsonSchema from "../../JsonSchema.ts" import * as Rec from "../../Record.ts" import * as Schema from "../../Schema.ts" import * as InternalStructuredOutput from "./internal/structured-output.ts" @@ -62,7 +62,7 @@ export function toCodecOpenAI( jsonSchema: JsonSchema.JsonSchema } { const codec = InternalStructuredOutput.toCodec(schema) - const document = JsonSchema.resolveTopLevel$ref( + const document = InternalStructuredOutput.resolveTopLevelReference( Schema.toJsonSchemaDocument(codec, { generateDescriptions: true }) ) const jsonSchema = rewriteOpenAI(document.schema) diff --git a/repos/effect/packages/effect/src/unstable/ai/Prompt.ts b/repos/effect/packages/effect/src/unstable/ai/Prompt.ts index 7680780fb7..9eefd03052 100644 --- a/repos/effect/packages/effect/src/unstable/ai/Prompt.ts +++ b/repos/effect/packages/effect/src/unstable/ai/Prompt.ts @@ -1098,7 +1098,7 @@ export type MessageConstructorParams = Omit>( * @category models * @since 4.0.0 */ -export type Part> = +export type Part< + Tools extends Record, + EncodedToolParameters extends boolean = false +> = | TextPart | ReasoningPart - | ToolCallParts + | ToolCallParts | ToolResultParts | ToolApprovalRequestPart | FilePart @@ -302,7 +305,10 @@ export const Part = >( * @category models * @since 4.0.0 */ -export type StreamPart> = +export type StreamPart< + Tools extends Record, + EncodedToolParameters extends boolean = false +> = | TextStartPart | TextDeltaPart | TextEndPart @@ -312,7 +318,7 @@ export type StreamPart> = | ToolParamsStartPart | ToolParamsDeltaPart | ToolParamsEndPart - | ToolCallParts + | ToolCallParts | ToolResultParts | ToolApprovalRequestPart | FilePart @@ -402,8 +408,14 @@ export const StreamPart = >( * @category utility types * @since 4.0.0 */ -export type ToolCallParts> = { - [Name in keyof Tools]: Name extends string ? ToolCallPart> +export type ToolCallParts< + Tools extends Record, + EncodedParameters extends boolean = false +> = { + [Name in keyof Tools]: Name extends string ? ToolCallPart< + Name, + EncodedParameters extends true ? Tool.ParametersEncoded : Tool.Parameters + > : never }[keyof Tools] diff --git a/repos/effect/packages/effect/src/unstable/ai/Toolkit.ts b/repos/effect/packages/effect/src/unstable/ai/Toolkit.ts index 1cedb040c6..39e01beb79 100644 --- a/repos/effect/packages/effect/src/unstable/ai/Toolkit.ts +++ b/repos/effect/packages/effect/src/unstable/ai/Toolkit.ts @@ -206,9 +206,9 @@ export interface WithHandler> { */ name: Name, /** - * Parameters to pass to the tool handler. + * Encoded parameters to decode and pass to the tool handler. */ - params: Tool.Parameters, + params: Tool.ParametersEncoded, /** * The unique identifier of the tool call. */ diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/codec-transformer.ts b/repos/effect/packages/effect/src/unstable/ai/internal/codec-transformer.ts index 7d3b858b79..eac92ca2da 100644 --- a/repos/effect/packages/effect/src/unstable/ai/internal/codec-transformer.ts +++ b/repos/effect/packages/effect/src/unstable/ai/internal/codec-transformer.ts @@ -1,12 +1,13 @@ -import * as JsonSchema from "../../../JsonSchema.ts" +import type * as JsonSchema from "../../../JsonSchema.ts" import * as Schema from "../../../Schema.ts" import type { CodecTransformer } from "../LanguageModel.ts" +import * as InternalStructuredOutput from "./structured-output.ts" const makeDefaultCodecTransformer = ( toJsonSchemaDocument: (schema: Schema.Constraint) => JsonSchema.Document<"draft-2020-12"> ): CodecTransformer => { return (codec) => { - const document = JsonSchema.resolveTopLevel$ref(toJsonSchemaDocument(codec)) + const document = InternalStructuredOutput.resolveTopLevelReference(toJsonSchemaDocument(codec)) const jsonSchema = { ...document.schema } if (Object.keys(document.definitions).length > 0) { jsonSchema.$defs = document.definitions diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpCore.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpCore.ts new file mode 100644 index 0000000000..571efbfa91 --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpCore.ts @@ -0,0 +1,455 @@ +/** + * Version-neutral MCP server records and semantic operations. + * + * @internal + */ + +import * as Arr from "../../../Array.ts" +import * as Data from "../../../Data.ts" +import * as Effect from "../../../Effect.ts" +import type * as Schema from "../../../Schema.ts" +import type * as McpProtocol from "../McpProtocol.ts" +import * as McpSchema from "../McpSchema.ts" + +/** @internal */ +export type CanonicalRequestMetadata = NonNullable< + typeof McpSchema.Initialize.payloadSchema.Type["_meta"] +> + +/** @internal */ +export interface NegotiatedProtocolProfile< + out Version extends string = McpProtocol.ProtocolVersion +> { + // Core decisions receive negotiated facts rather than dated wire requests. + readonly protocolVersion: Version + readonly clientCapabilities: McpSchema.ClientCapabilities + readonly clientInfo: McpSchema.Implementation + readonly requestMetadata?: CanonicalRequestMetadata | undefined +} + +// NOTE: Capabilities remain a normalized core model because dated revisions +// advertise different fields and encode capability presence differently. +/** @internal */ +export interface CanonicalServerCapabilities { + readonly experimental?: Readonly> | undefined + readonly logging?: boolean | undefined + readonly completions?: boolean | undefined + readonly prompts?: Readonly<{ readonly listChanged?: boolean | undefined }> | undefined + readonly resources?: + | Readonly<{ + readonly subscribe?: boolean | undefined + readonly listChanged?: boolean | undefined + }> + | undefined + readonly tools?: Readonly<{ readonly listChanged?: boolean | undefined }> | undefined + readonly extensions?: Schema.JsonObject | undefined +} + +/** @internal */ +export interface CanonicalInitializeResult { + readonly capabilities: CanonicalServerCapabilities + readonly serverInfo: McpSchema.Implementation + readonly instructions?: string | undefined +} + +/** @internal */ +export interface McpInvocation { + readonly clientId: number + readonly protocol: NegotiatedProtocolProfile + readonly requestContext: McpSchema.McpServerClient["Service"] +} + +// NOTE: McpInvocation is runtime context, not a wire DTO. It combines the +// negotiated profile with the request-scoped service used by handlers. + +/** @internal */ +export class ResourceNotFound extends Data.TaggedError("ResourceNotFound")<{ + readonly uri: string +}> {} + +// NOTE: Core errors preserve semantic failure categories before adapters map +// them to dated MCP error codes and messages. +/** @internal */ +export class ToolNotFound extends Data.TaggedError("ToolNotFound")<{ + readonly name: string +}> {} + +/** @internal */ +export class InvalidToolInput extends Data.TaggedError("InvalidToolInput")<{ + readonly name: string + readonly message: string +}> {} + +/** @internal */ +export class ToolExecutionError extends Data.TaggedError("ToolExecutionError")<{ + readonly name: string + readonly message: string +}> {} + +/** @internal */ +export class ToolResultProjectionError extends Data.TaggedError("ToolResultProjectionError")<{ + readonly name: string + readonly message: string +}> {} + +/** @internal */ +/** @internal */ +export class UnsupportedByProtocol extends Data.TaggedError("UnsupportedByProtocol")<{ + readonly protocolVersion: McpProtocol.ProtocolVersion + readonly feature: string +}> {} + +/** @internal */ +export type ToolError = + | ToolNotFound + | InvalidToolInput + | ToolExecutionError + | ToolResultProjectionError + +/** @internal */ +export interface ToolRegistration { + // The canonical Tool copy normalizes its top-level title from + // `tool.title ?? tool.annotations?.title` at the public boundary. + readonly descriptor: McpSchema.Tool + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly handle: ( + call: typeof McpSchema.CallTool.payloadSchema.Type, + invocation: McpInvocation + ) => Effect.Effect< + McpSchema.CallToolResult, + InvalidToolInput | ToolExecutionError | ToolResultProjectionError, + never + > +} + +/** @internal */ +export interface Tools { + readonly register: ( + registration: ToolRegistration + ) => Effect.Effect + readonly list: ( + profile: NegotiatedProtocolProfile + ) => Effect.Effect> + readonly call: ( + call: typeof McpSchema.CallTool.payloadSchema.Type, + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export interface ResourceRegistration { + readonly descriptor: McpSchema.Resource + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly read: ( + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export interface ResourceTemplateRegistration { + readonly descriptor: McpSchema.ResourceTemplate + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly match: (uri: string) => ReadonlyArray | undefined + readonly read: ( + uri: string, + params: ReadonlyArray, + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export interface Resources { + readonly register: (registration: ResourceRegistration) => Effect.Effect + readonly registerTemplate: (registration: ResourceTemplateRegistration) => Effect.Effect + readonly list: ( + profile: NegotiatedProtocolProfile + ) => Effect.Effect> + readonly listTemplates: ( + profile: NegotiatedProtocolProfile + ) => Effect.Effect> + readonly read: ( + uri: string, + invocation: McpInvocation + ) => Effect.Effect< + McpSchema.ReadResourceResult, + ResourceNotFound | McpSchema.InvalidParams | McpSchema.InternalError + > +} + +/** @internal */ +export class PromptNotFound extends Data.TaggedError("PromptNotFound")<{ + readonly name: string +}> {} + +/** @internal */ +export interface PromptRegistration { + readonly descriptor: McpSchema.Prompt + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly get: ( + args: Readonly>, + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export interface Prompts { + readonly register: (registration: PromptRegistration) => Effect.Effect + readonly list: ( + profile: NegotiatedProtocolProfile + ) => Effect.Effect> + readonly get: ( + name: string, + args: Readonly>, + invocation: McpInvocation + ) => Effect.Effect< + McpSchema.GetPromptResult, + PromptNotFound | McpSchema.InvalidParams | McpSchema.InternalError + > +} + +/** @internal */ +export type CompletionReference = + | { readonly type: "prompt"; readonly name: string; readonly title?: string | undefined } + | { readonly type: "resourceTemplate"; readonly uriTemplate: string } + +// NOTE: Completion requests/results normalize dated reference tags, optional +// context, and nested CompleteResult values. + +/** @internal */ +export interface CompletionRequest { + readonly reference: CompletionReference + readonly argument: Readonly<{ readonly name: string; readonly value: string }> + readonly context?: Readonly<{ readonly arguments?: Readonly> }> | undefined + readonly metadata?: CanonicalRequestMetadata | undefined +} + +/** @internal */ +export interface CompletionResult { + readonly values: ReadonlyArray + readonly total?: number | undefined + readonly hasMore?: boolean | undefined + readonly metadata?: Schema.JsonObject | undefined +} + +/** @internal */ +export interface Completions { + readonly register: ( + key: string, + complete: ( + request: CompletionRequest, + invocation: McpInvocation + ) => Effect.Effect + ) => Effect.Effect + readonly complete: ( + request: CompletionRequest, + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export type ClientNotification = Data.TaggedEnum<{ + Initialized: {} + Progress: { + readonly progressToken: string | number + readonly progress: number + readonly total?: number | undefined + readonly message?: string | undefined + readonly metadata?: Schema.JsonObject | undefined + } + RootsChanged: {} +}> + +/** @internal */ +export const ClientNotification = Data.taggedEnum() + +/** @internal */ +export interface Cancellation { + readonly requestId: string | number + readonly reason?: string | undefined + readonly metadata?: Schema.JsonObject | undefined +} + +/** @internal */ +export type ServerNotification = Data.TaggedEnum<{ + Cancelled: { + readonly requestId: string | number + readonly reason?: string | undefined + readonly metadata?: Schema.JsonObject | undefined + } + Progress: { + readonly progressToken: string | number + readonly progress: number + readonly total?: number | undefined + readonly message?: string | undefined + readonly metadata?: Schema.JsonObject | undefined + } + LoggingMessage: { + readonly level: McpSchema.LoggingLevel + readonly logger?: string | undefined + readonly data: unknown + readonly metadata?: Schema.JsonObject | undefined + } + ResourceUpdated: { readonly uri: string; readonly metadata?: Schema.JsonObject | undefined } + ResourcesChanged: { readonly metadata?: Schema.JsonObject | undefined } + ToolsChanged: { readonly metadata?: Schema.JsonObject | undefined } + PromptsChanged: { readonly metadata?: Schema.JsonObject | undefined } + ElicitationComplete: { readonly elicitationId: string } +}> + +/** @internal */ +export const ServerNotification = Data.taggedEnum() + +/** @internal */ +export interface McpCore { + readonly tools: Tools + readonly resources: Resources + readonly prompts: Prompts + readonly completions: Completions + readonly registrationPresence: Effect.Effect<{ + readonly tools: boolean + readonly resources: boolean + readonly prompts: boolean + }> +} + +/** @internal */ +export const make: Effect.Effect = Effect.sync(() => { + const registrations = new Map() + const resourceRegistrations: Array = [] + const resourceTemplateRegistrations: Array = [] + const promptRegistrations = new Map() + const completionRegistrations = new Map< + string, + ( + request: CompletionRequest, + invocation: McpInvocation + ) => Effect.Effect + >() + + const tools: Tools = { + register: (registration) => + Effect.sync(() => { + registrations.set(registration.descriptor.name, registration) + }), + list: (profile) => + Effect.sync(() => { + const descriptors: Array = [] + for (const registration of registrations.values()) { + if (registration.isVisible(profile)) { + descriptors.push(registration.descriptor) + } + } + return descriptors + }), + call: (call, invocation) => + Effect.suspend((): Effect.Effect => { + const registration = registrations.get(call.name) + if (registration === undefined) { + return new ToolNotFound({ name: call.name }) + } + if (!registration.isVisible(invocation.protocol)) { + return new ToolNotFound({ name: call.name }) + } + return registration.handle(call, invocation) + }) + } + + const resources: Resources = { + register: (registration) => + Effect.sync(() => { + const index = resourceRegistrations.findIndex((entry) => entry.descriptor.uri === registration.descriptor.uri) + if (index === -1) { + resourceRegistrations.push(registration) + } else { + resourceRegistrations[index] = registration + } + }), + registerTemplate: (registration) => + Effect.sync(() => { + const index = resourceTemplateRegistrations.findIndex( + (entry) => entry.descriptor.uriTemplate === registration.descriptor.uriTemplate + ) + if (index === -1) { + resourceTemplateRegistrations.push(registration) + } else { + resourceTemplateRegistrations[index] = registration + } + }), + list: (profile) => + Effect.sync(() => + resourceRegistrations + .filter((entry) => entry.isVisible(profile)) + .map((entry) => entry.descriptor) + ), + listTemplates: (profile) => + Effect.sync(() => + resourceTemplateRegistrations + .filter((entry) => entry.isVisible(profile)) + .map((entry) => entry.descriptor) + ), + read: Effect.fnUntraced(function*(uri, invocation) { + const resource = resourceRegistrations.find((entry) => entry.descriptor.uri === uri) + if (resource !== undefined && resource.isVisible(invocation.protocol)) { + return yield* resource.read(invocation) + } + for (const template of resourceTemplateRegistrations) { + const params = template.match(uri) + if (params !== undefined && template.isVisible(invocation.protocol)) { + return yield* template.read(uri, params, invocation) + } + } + return yield* new ResourceNotFound({ uri }) + }) + } + + const prompts: Prompts = { + register: (registration) => + Effect.sync(() => { + promptRegistrations.set(registration.descriptor.name, registration) + }), + list: (profile) => + Effect.sync(() => + Array.from(promptRegistrations.values()) + .filter((entry) => entry.isVisible(profile)) + .map((entry) => entry.descriptor) + ), + get: Effect.fnUntraced(function*(name, args, invocation) { + const registration = promptRegistrations.get(name) + if (registration === undefined || !registration.isVisible(invocation.protocol)) { + return yield* new PromptNotFound({ name }) + } + return yield* registration.get(args, invocation) + }) + } + + const completions: Completions = { + register: (key, complete) => + Effect.sync(() => { + completionRegistrations.set(key, complete) + }), + complete: Effect.fnUntraced(function*(request, invocation) { + const key = request.reference.type === "prompt" + ? `prompt/${request.reference.name}/${request.argument.name}` + : `resource/${request.reference.uriTemplate}/${request.argument.name}` + const complete = completionRegistrations.get(key) + if (complete === undefined) { + return yield* new McpSchema.InvalidParams({ message: "Unknown completion reference or argument" }) + } + const result = yield* complete(request, invocation) + const values = Arr.take(result.values, 100) + return { + ...result, + values, + hasMore: result.hasMore === true || values.length < result.values.length + } + }) + } + + const registrationPresence = Effect.sync(() => ({ + tools: registrations.size > 0, + resources: resourceRegistrations.length > 0 || resourceTemplateRegistrations.length > 0, + prompts: promptRegistrations.size > 0 + })) + + return { tools, resources, prompts, completions, registrationPresence } +}) diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol.ts index fe4dee0349..7a88b015a1 100644 --- a/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol.ts +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol.ts @@ -1,34 +1,234 @@ -import type * as Effect from "../../../Effect.ts" +import * as Data from "../../../Data.ts" +import * as Effect from "../../../Effect.ts" +import * as Match from "../../../Match.ts" +import * as Result from "../../../Result.ts" import * as Schema from "../../../Schema.ts" +import type * as Scope from "../../../Scope.ts" +import type * as Headers from "../../http/Headers.ts" import type * as Rpc from "../../rpc/Rpc.ts" +import * as RpcClient from "../../rpc/RpcClient.ts" +import type { RpcClientError } from "../../rpc/RpcClientError.ts" import type * as RpcGroup from "../../rpc/RpcGroup.ts" +import type * as PublicMcpProtocol from "../McpProtocol.ts" +import * as PublicMcpSchema from "../McpSchema.ts" +import * as McpCore from "./mcpCore.ts" -export interface PayloadCodecs { - readonly decode: (input: unknown) => Effect.Effect - readonly encode: (input: unknown) => Effect.Effect +/** @internal */ +export const profileFromClient = ( + request: PublicMcpSchema.McpServerClient["Service"] +): McpCore.NegotiatedProtocolProfile => ({ + protocolVersion: request.protocolVersion, + clientCapabilities: request.clientCapabilities, + clientInfo: request.clientInfo, + requestMetadata: request.initializePayload._meta +}) + +/** @internal */ +export const invocationFromClient = ( + request: PublicMcpSchema.McpServerClient["Service"] +): McpCore.McpInvocation => ({ + clientId: request.clientId, + protocol: profileFromClient(request), + requestContext: request +}) + +// NOTE: Keep the two codec assertions below as the single documented +// existential-schema boundary. Rpc.AnyWithProps intentionally erases each +// request's payload type, while the runtime schema still performs decoding and +// encoding; do not spread this erasure into MCP domain records. + +/** @internal */ +export interface LifecycleRuntime { + readonly initialize: ( + protocolVersion: PublicMcpProtocol.ProtocolVersion, + profile: McpCore.NegotiatedProtocolProfile, + clientId: number + ) => Effect.Effect + readonly setLogLevel: ( + level: PublicMcpSchema.LoggingLevel, + clientId: number, + headers: Headers.Headers + ) => Effect.Effect + readonly subscribe: (uri: string, clientId: number, headers: Headers.Headers) => Effect.Effect + readonly unsubscribe: (uri: string, clientId: number, headers: Headers.Headers) => Effect.Effect + readonly clientNotification: ( + notification: McpCore.ClientNotification, + clientId: number, + headers: Headers.Headers + ) => Effect.Effect } /** @internal */ -export interface AnyProtocolAdapter { - readonly protocolVersion: string - readonly transport: { - readonly acceptsJsonRpcBatches: boolean - readonly requiresVersionHeader: boolean +export class ProtocolError extends Data.TaggedError("ProtocolError")<{ + readonly code: number + readonly message: string + readonly data?: unknown +}> { + static fromTool( + error: McpCore.ToolError | McpCore.UnsupportedByProtocol + ): ProtocolError { + const message = Match.value(error).pipe( + Match.tag("ToolNotFound", (error) => `Tool '${error.name}' not found`), + Match.tag( + "UnsupportedByProtocol", + (error) => `${error.feature} is not supported by MCP ${error.protocolVersion}` + ), + Match.tags({ + InvalidToolInput: (error) => error.message, + ToolExecutionError: (error) => error.message, + ToolResultProjectionError: (error) => error.message + }), + Match.exhaustive + ) + return new ProtocolError({ code: -32602, message }) } - readonly clientRpcs: RpcGroup.Any - readonly clientNotificationRpcs: RpcGroup.Any - readonly serverRequestRpcs: RpcGroup.Any - readonly serverNotificationRpcs: RpcGroup.Any - readonly payloadCodecs: (rpc: Rpc.AnyWithProps) => PayloadCodecs + + static fromFeature(error: unknown): ProtocolError { + if (error instanceof McpCore.PromptNotFound) { + return new ProtocolError({ code: -32602, message: `Prompt '${error.name}' not found` }) + } + if (error instanceof McpCore.ResourceNotFound) { + return new ProtocolError({ code: -32002, message: `Resource '${error.uri}' not found` }) + } + const decoded = Schema.decodeUnknownResult(ProtocolErrorFields)(error) + if (Result.isSuccess(decoded)) { + return new ProtocolError(decoded.success) + } + return new ProtocolError({ code: -32603, message: "MCP feature handler failed" }) + } +} + +const ProtocolErrorFields = Schema.Struct({ + code: Schema.Number, + message: Schema.String, + data: Schema.optionalKey(Schema.Unknown) +}) + +/** @internal */ +export const reverseError = ( + operation: PublicMcpSchema.McpReverseOperationError["operation"] +) => +(cause: unknown) => + cause instanceof PublicMcpSchema.McpReverseOperationUnsupported + ? cause + : new PublicMcpSchema.McpReverseOperationError({ operation, cause }) + +/** @internal */ +export const transcode = < + From extends Schema.Constraint, + To extends Schema.Constraint +>( + from: From, + to: To, + input: Schema.Schema.Type +) => + Schema.encodeEffect(from)(input).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(to)) + ) + +/** @internal */ +export const transcodeStrict = < + From extends Schema.Constraint, + To extends Schema.Constraint +>( + from: From, + to: To, + input: Schema.Schema.Type +) => + Schema.encodeEffect(from)(input).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(to, { onExcessProperty: "error" })) + ) + +/** @internal */ +export const makeNotificationProjector = Effect.fn(function*( + options: { + readonly supportsProgressMessage: boolean + }, + notification: McpCore.ServerNotification +) { + return McpCore.ServerNotification.$match(notification, { + Cancelled: (notification): PublicMcpProtocol.ProjectedNotification => ({ + tag: PublicMcpSchema.CancelledNotification._tag, + payload: PublicMcpSchema.CancelledNotification.payloadSchema.make({ + _meta: notification.metadata, + requestId: notification.requestId, + reason: notification.reason + }) + }), + Progress: (notification): PublicMcpProtocol.ProjectedNotification => ({ + tag: PublicMcpSchema.ProgressNotification._tag, + payload: PublicMcpSchema.ProgressNotification.payloadSchema.make({ + _meta: notification.metadata, + progressToken: notification.progressToken, + progress: notification.progress, + total: notification.total, + message: options.supportsProgressMessage ? notification.message : undefined + }) + }), + LoggingMessage: (notification): PublicMcpProtocol.ProjectedNotification => ({ + tag: PublicMcpSchema.LoggingMessageNotification._tag, + payload: PublicMcpSchema.LoggingMessageNotification.payloadSchema.make({ + _meta: notification.metadata, + level: notification.level, + logger: notification.logger, + data: notification.data + }) + }), + ResourceUpdated: (notification): PublicMcpProtocol.ProjectedNotification => ({ + tag: PublicMcpSchema.ResourceUpdatedNotification._tag, + payload: PublicMcpSchema.ResourceUpdatedNotification.payloadSchema.make({ + _meta: notification.metadata, + uri: notification.uri + }) + }), + ResourcesChanged: (notification): PublicMcpProtocol.ProjectedNotification => ({ + tag: PublicMcpSchema.ResourceListChangedNotification._tag, + payload: PublicMcpSchema.ResourceListChangedNotification.payloadSchema.make({ + _meta: notification.metadata + }) + }), + ToolsChanged: (notification): PublicMcpProtocol.ProjectedNotification => ({ + tag: PublicMcpSchema.ToolListChangedNotification._tag, + payload: PublicMcpSchema.ToolListChangedNotification.payloadSchema.make({ + _meta: notification.metadata + }) + }), + PromptsChanged: (notification): PublicMcpProtocol.ProjectedNotification => ({ + tag: PublicMcpSchema.PromptListChangedNotification._tag, + payload: PublicMcpSchema.PromptListChangedNotification.payloadSchema.make({ + _meta: notification.metadata + }) + }), + ElicitationComplete: () => undefined + }) +}) + +/** @internal */ +export interface HandlerInstallationTarget { + readonly install: < + Rpcs extends Rpc.Any, + Handlers extends RpcGroup.HandlersFrom + >( + protocol: { + readonly protocolVersion: string + }, + rpcs: RpcGroup.RpcGroup, + handlers: Handlers + ) => Effect.Effect> } +/** @internal */ export interface ProtocolAdapter< out Version extends string = string, ClientRpcs extends Rpc.Any = Rpc.Any, ClientNotificationRpcs extends ClientRpcs = ClientRpcs, ServerRequestRpcs extends Rpc.Any = Rpc.Any, - ServerNotificationRpcs extends Rpc.Any = Rpc.Any + ServerNotificationRpcs extends Rpc.Any = Rpc.Any, + HandlerRpcs extends Rpc.Any = never, + HandlerRequirements = never > { + // Each adapter owns its dated RPC vocabulary, transport policy, handler + // projection, and wire behavior. readonly protocolVersion: Version readonly transport: { readonly acceptsJsonRpcBatches: boolean @@ -38,7 +238,26 @@ export interface ProtocolAdapter< readonly clientNotificationRpcs: RpcGroup.RpcGroup readonly serverRequestRpcs: RpcGroup.RpcGroup readonly serverNotificationRpcs: RpcGroup.RpcGroup - readonly payloadCodecs: (rpc: Rpc.AnyWithProps) => PayloadCodecs + readonly payloadCodecs: (rpc: Rpc.AnyWithProps) => PublicMcpProtocol.PayloadCodecs + readonly handlerRpcs?: RpcGroup.RpcGroup | undefined + readonly installHandlers: ( + core: McpCore.McpCore, + lifecycle: LifecycleRuntime, + target: HandlerInstallationTarget + ) => Effect.Effect + readonly makeReverseClient: ( + profile: McpCore.NegotiatedProtocolProfile + ) => Effect.Effect< + PublicMcpSchema.McpReverseClient, + never, + RpcClient.Protocol | Scope.Scope + > + readonly projectNotification: ( + notification: McpCore.ServerNotification + ) => Effect.Effect + readonly normalizeCancellation: ( + payload: unknown + ) => Effect.Effect } /** @internal */ @@ -47,7 +266,9 @@ export const make = < ClientRpcs extends Rpc.Any, ClientNotificationRpcs extends ClientRpcs, ServerRequestRpcs extends Rpc.Any, - ServerNotificationRpcs extends Rpc.Any + ServerNotificationRpcs extends Rpc.Any, + HandlerRpcs extends Rpc.Any = never, + Handlers extends RpcGroup.HandlersFrom = RpcGroup.HandlersFrom >(options: { readonly protocolVersion: Version readonly transport: { @@ -58,28 +279,72 @@ export const make = < readonly clientNotificationRpcs: RpcGroup.RpcGroup readonly serverRequestRpcs: RpcGroup.RpcGroup readonly serverNotificationRpcs: RpcGroup.RpcGroup + readonly handlerRpcs?: RpcGroup.RpcGroup | undefined + readonly makeHandlers?: + | (( + core: McpCore.McpCore, + lifecycle: LifecycleRuntime + ) => Handlers) + | undefined + readonly toReverseClient: ( + profile: McpCore.NegotiatedProtocolProfile, + client: RpcClient.RpcClient + ) => PublicMcpSchema.McpReverseClient + readonly projectNotification: ( + notification: McpCore.ServerNotification + ) => Effect.Effect + readonly normalizeCancellation: ( + payload: unknown + ) => Effect.Effect }): ProtocolAdapter< Version, ClientRpcs, ClientNotificationRpcs, ServerRequestRpcs, - ServerNotificationRpcs + ServerNotificationRpcs, + HandlerRpcs, + RpcGroup.HandlersServices > => { - const payloadCodecsCache = new WeakMap() - const payloadCodecs = (rpc: Rpc.AnyWithProps): PayloadCodecs => { + const payloadCodecsCache = new WeakMap() + const payloadCodecs = (rpc: Rpc.AnyWithProps): PublicMcpProtocol.PayloadCodecs => { let codecs = payloadCodecsCache.get(rpc) if (codecs === undefined) { const schema = Schema.toCodecJson(rpc.payloadSchema) codecs = { - decode: Schema.decodeUnknownEffect(schema) as PayloadCodecs["decode"], - encode: Schema.encodeUnknownEffect(schema) as PayloadCodecs["encode"] + decode: Schema.decodeUnknownEffect(schema) as PublicMcpProtocol.PayloadCodecs["decode"], + encode: Schema.encodeUnknownEffect(schema) as PublicMcpProtocol.PayloadCodecs["encode"] } payloadCodecsCache.set(rpc, codecs) } return codecs } + + const installHandlers = ( + core: McpCore.McpCore, + lifecycle: LifecycleRuntime, + target: HandlerInstallationTarget + ): Effect.Effect> => + options.handlerRpcs === undefined || options.makeHandlers === undefined + ? Effect.void + : target.install(options, options.handlerRpcs, options.makeHandlers(core, lifecycle)) + + const makeReverseClient = ( + profile: McpCore.NegotiatedProtocolProfile + ): Effect.Effect< + PublicMcpSchema.McpReverseClient, + never, + RpcClient.Protocol | Scope.Scope + > => + RpcClient.make(options.serverRequestRpcs, { + spanPrefix: "McpServer/Client" + }).pipe( + Effect.map((client) => options.toReverseClient(profile, client)) + ) + return { ...options, - payloadCodecs + payloadCodecs, + installHandlers, + makeReverseClient } } diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts new file mode 100644 index 0000000000..a0224ff4a3 --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts @@ -0,0 +1,321 @@ +/** @internal */ +import * as Effect from "../../../../Effect.ts" +import * as Encoding from "../../../../Encoding.ts" +import * as Match from "../../../../Match.ts" +import * as Schema from "../../../../Schema.ts" +import * as PublicMcpSchema from "../../McpSchema.ts" +import * as McpCore from "../mcpCore.ts" +import * as McpProtocol from "../mcpProtocol.ts" +import * as McpSchema from "../mcpSchema/v2024_11_05.ts" + +const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( + PublicMcpSchema.McpServerClientMiddleware +) + +const ClientRpcs = ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) + +const AdapterRpcs = ClientRpcs.omit("ping") + +const profileFromInitialize = ( + initialize: typeof McpSchema.Initialize.payloadSchema.Type +): McpCore.NegotiatedProtocolProfile => ({ + protocolVersion: McpSchema.protocolVersion, + clientCapabilities: PublicMcpSchema.ClientCapabilities.make(initialize.capabilities), + clientInfo: PublicMcpSchema.Implementation.make(initialize.clientInfo), + requestMetadata: initialize._meta +}) + +const unsupported = ( + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + reason: string +) => + new PublicMcpSchema.McpReverseOperationUnsupported({ + operation, + protocolVersion: McpSchema.protocolVersion, + reason + }) + +const requireCapability = ( + profile: McpCore.NegotiatedProtocolProfile, + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + capability: "roots" | "sampling" +) => + Object.hasOwn(profile.clientCapabilities, capability) && + profile.clientCapabilities[capability] !== undefined + ? Effect.void + : Effect.fail(unsupported(operation, `Client did not advertise the ${capability} capability`)) + +const projectCapabilities = ( + capabilities: McpCore.CanonicalServerCapabilities +): typeof McpSchema.ServerCapabilities.Type => ({ + experimental: capabilities.experimental, + logging: capabilities.logging ? {} : undefined, + prompts: capabilities.prompts, + resources: capabilities.resources, + tools: capabilities.tools +}) + +const projectContent = Effect.fnUntraced(function*(content: typeof PublicMcpSchema.ContentBlock.Type) { + const projected = Match.value(content).pipe( + Match.when({ type: "text" }, (content) => ({ + ...content, + annotations: content.annotations + })), + Match.when({ type: "image" }, (content) => + McpSchema.ImageContent.make({ + type: "image", + mimeType: content.mimeType, + data: Encoding.encodeBase64(content.data), + annotations: content.annotations + })), + Match.when({ type: "resource" }, (content) => { + const resource = content.resource + if ("text" in resource) { + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + text: resource.text + }, + annotations: content.annotations + }) + } + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + blob: Encoding.encodeBase64(resource.blob) + }, + annotations: content.annotations + }) + }), + Match.when({ type: Match.is("audio", "resource_link") }, (content) => + new McpCore.UnsupportedByProtocol({ + protocolVersion: McpSchema.protocolVersion, + feature: `${content.type} tool content` + })), + Match.exhaustive + ) + return projected instanceof McpCore.UnsupportedByProtocol + ? yield* projected + : projected +}) + +/** @internal */ +export const protocol = McpProtocol.make({ + protocolVersion: McpSchema.protocolVersion, + transport: { + acceptsJsonRpcBatches: false, + requiresVersionHeader: false + }, + clientRpcs: ClientRpcs, + clientNotificationRpcs: McpSchema.ClientNotificationRpcs, + serverRequestRpcs: McpSchema.ServerRequestRpcs, + serverNotificationRpcs: McpSchema.ServerNotificationRpcs, + handlerRpcs: AdapterRpcs, + makeHandlers: (core, lifecycle) => + AdapterRpcs.of({ + initialize: (request, { client }) => + lifecycle.initialize(McpSchema.protocolVersion, profileFromInitialize(request), client.id).pipe( + Effect.map((result) => + McpSchema.InitializeResult.make({ + protocolVersion: McpSchema.protocolVersion, + capabilities: projectCapabilities(result.capabilities), + serverInfo: result.serverInfo, + instructions: result.instructions + }) + ) + ), + "logging/setLevel": ({ level }, { client, headers }) => + lifecycle.setLogLevel(level, client.id, headers).pipe(Effect.as({})), + "notifications/cancelled": () => Effect.void, + "notifications/initialized": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.Initialized(), client.id, headers), + "notifications/progress": (progress, { client, headers }) => + lifecycle.clientNotification( + McpCore.ClientNotification.Progress({ + progressToken: progress.progressToken, + progress: progress.progress, + total: progress.total, + metadata: progress._meta + }), + client.id, + headers + ), + "notifications/roots/list_changed": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.RootsChanged(), client.id, headers), + "resources/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.resources.list(McpProtocol.profileFromClient(request))) + .pipe(Effect.map((resources) => McpSchema.ListResourcesResult.make({ resources }))), + "resources/templates/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => + core.resources.listTemplates(McpProtocol.profileFromClient(request)) + ).pipe( + Effect.map((resourceTemplates) => McpSchema.ListResourceTemplatesResult.make({ resourceTemplates })) + ), + "resources/read": Effect.fnUntraced(function*({ uri }) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.resources.read(uri, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + return McpSchema.ReadResourceResult.make({ + contents: result.contents.map((content) => + "text" in content + ? { + uri: content.uri, + mimeType: content.mimeType, + text: content.text + } + : { + uri: content.uri, + mimeType: content.mimeType, + blob: Encoding.encodeBase64(content.blob) + } + ), + _meta: result._meta + }) + }), + "resources/subscribe": ({ uri }, { client, headers }) => + lifecycle.subscribe(uri, client.id, headers).pipe(Effect.as({})), + "resources/unsubscribe": ({ uri }, { client, headers }) => + lifecycle.unsubscribe(uri, client.id, headers).pipe(Effect.as({})), + "prompts/list": () => + PublicMcpSchema.McpServerClient.use((request) => core.prompts.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((prompts) => + McpSchema.ListPromptsResult.make({ + prompts: prompts.map((prompt) => ({ + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments?.map((argument) => ({ + name: argument.name, + description: argument.description, + required: argument.required + })) + })) + }) + ) + ), + "prompts/get": Effect.fnUntraced(function*({ arguments: args, name }) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.prompts.get(name, args ?? {}, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + const messages = yield* Effect.forEach(result.messages, (message) => + projectContent(message.content).pipe( + Effect.map((content) => ({ role: message.role, content })), + Effect.mapError(McpProtocol.ProtocolError.fromTool) + )) + return McpSchema.GetPromptResult.make({ + description: result.description, + messages, + _meta: result._meta + }) + }), + "completion/complete": Effect.fnUntraced(function*(completeRequest) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.completions.complete({ + reference: completeRequest.ref.type === "ref/prompt" + ? { type: "prompt", name: completeRequest.ref.name } + : { type: "resourceTemplate", uriTemplate: completeRequest.ref.uri }, + argument: completeRequest.argument, + metadata: completeRequest._meta + }, McpProtocol.invocationFromClient(request)).pipe(Effect.mapError(McpProtocol.ProtocolError.fromFeature)) + return McpSchema.CompleteResult.make({ + completion: { + values: Array.from(result.values), + total: result.total, + hasMore: result.hasMore + }, + _meta: result.metadata + }) + }), + "tools/list": Effect.fnUntraced(function*() { + const request = yield* PublicMcpSchema.McpServerClient + return yield* core.tools.list(McpProtocol.profileFromClient(request)).pipe( + Effect.map((tools) => + McpSchema.ListToolsResult.make({ + tools: tools.map((tool) => + McpSchema.Tool.make({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema + }) + ) + }) + ) + ) + }), + "tools/call": Effect.fnUntraced(function*(call) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.tools.call( + { ...call, arguments: call.arguments ?? {} }, + McpProtocol.invocationFromClient(request) + ).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + const content = yield* Effect.forEach(result.content, projectContent).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + return McpSchema.CallToolResult.make({ + content, + isError: result.isError, + _meta: result._meta + }) + }) + }), + toReverseClient: (profile, client) => ({ + listRoots: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "roots/list", "roots") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.ListRoots.payloadSchema, + McpSchema.ListRoots.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("roots/list", "Request is not representable by this protocol")) + ) + const { roots } = yield* client["roots/list"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("roots/list")) + ) + return new PublicMcpSchema.ListRootsResult({ roots }) + }), + createMessage: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "sampling/createMessage", "sampling") + const wireRequest = yield* McpProtocol.transcodeStrict( + PublicMcpSchema.CreateMessage.payloadSchema, + McpSchema.CreateMessage.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("sampling/createMessage", "Request is not representable by this protocol")) + ) + const result = yield* client["sampling/createMessage"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("sampling/createMessage")) + ) + return yield* McpProtocol.transcode( + McpSchema.CreateMessage.successSchema, + PublicMcpSchema.CreateMessage.successSchema, + result + ).pipe( + Effect.mapError(() => + unsupported("sampling/createMessage", "Response is not representable by the canonical model") + ) + ) + }), + elicit: () => + Effect.fail(unsupported("elicitation/create", "Elicitation was introduced after this protocol revision")) + }), + projectNotification: (notification) => + McpProtocol.makeNotificationProjector({ + supportsProgressMessage: false + }, notification), + normalizeCancellation: (payload) => + Schema.decodeUnknownEffect(McpSchema.CancelledNotification.payloadSchema)(payload).pipe( + Effect.map((request) => ({ + requestId: request.requestId, + reason: request.reason, + metadata: request._meta + })) + ) +}) diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts new file mode 100644 index 0000000000..66dd94ecde --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts @@ -0,0 +1,347 @@ +/** @internal */ +import * as Effect from "../../../../Effect.ts" +import * as Encoding from "../../../../Encoding.ts" +import * as Match from "../../../../Match.ts" +import * as Schema from "../../../../Schema.ts" +import * as PublicMcpSchema from "../../McpSchema.ts" +import * as McpCore from "../mcpCore.ts" +import * as McpProtocol from "../mcpProtocol.ts" +import * as McpSchema from "../mcpSchema/v2025_03_26.ts" + +const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( + PublicMcpSchema.McpServerClientMiddleware +) + +const ClientRpcs = ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) + +const AdapterRpcs = ClientRpcs.omit("ping") + +const profileFromInitialize = ( + initialize: typeof McpSchema.Initialize.payloadSchema.Type +): McpCore.NegotiatedProtocolProfile => ({ + protocolVersion: McpSchema.protocolVersion, + clientCapabilities: PublicMcpSchema.ClientCapabilities.make(initialize.capabilities), + clientInfo: PublicMcpSchema.Implementation.make(initialize.clientInfo), + requestMetadata: initialize._meta +}) + +const unsupported = ( + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + reason: string +) => + new PublicMcpSchema.McpReverseOperationUnsupported({ + operation, + protocolVersion: McpSchema.protocolVersion, + reason + }) + +const requireCapability = ( + profile: McpCore.NegotiatedProtocolProfile, + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + capability: "roots" | "sampling" +) => + Object.hasOwn(profile.clientCapabilities, capability) && + profile.clientCapabilities[capability] !== undefined + ? Effect.void + : Effect.fail(unsupported(operation, `Client did not advertise the ${capability} capability`)) + +const projectContent = Effect.fnUntraced(function*(content: typeof PublicMcpSchema.ContentBlock.Type) { + const projected = Match.value(content).pipe( + Match.when({ type: "text" }, (content) => ({ + ...content, + annotations: content.annotations + })), + Match.when({ type: Match.is("image", "audio") }, (content) => ({ + type: content.type, + mimeType: content.mimeType, + data: Encoding.encodeBase64(content.data), + annotations: content.annotations + })), + Match.when({ type: "resource" }, (content) => { + const resource = content.resource + if ("text" in resource) { + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + text: resource.text + }, + annotations: content.annotations + }) + } + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + blob: Encoding.encodeBase64(resource.blob) + }, + annotations: content.annotations + }) + }), + Match.when({ type: "resource_link" }, () => + new McpCore.UnsupportedByProtocol({ + protocolVersion: McpSchema.protocolVersion, + feature: "resource_link tool content" + })), + Match.exhaustive + ) + return projected instanceof McpCore.UnsupportedByProtocol + ? yield* projected + : projected +}) + +const projectResourceContents = ( + content: PublicMcpSchema.TextResourceContents | PublicMcpSchema.BlobResourceContents +): typeof McpSchema.ResourceContents.Type => + "text" in content + ? { + uri: content.uri, + mimeType: content.mimeType, + text: content.text + } + : { + uri: content.uri, + mimeType: content.mimeType, + blob: Encoding.encodeBase64(content.blob) + } + +/** @internal */ +export const protocol = McpProtocol.make({ + protocolVersion: McpSchema.protocolVersion, + transport: { + acceptsJsonRpcBatches: true, + requiresVersionHeader: false + }, + clientRpcs: ClientRpcs, + clientNotificationRpcs: McpSchema.ClientNotificationRpcs, + serverRequestRpcs: McpSchema.ServerRequestRpcs, + serverNotificationRpcs: McpSchema.ServerNotificationRpcs, + handlerRpcs: AdapterRpcs, + makeHandlers: (core, lifecycle) => + AdapterRpcs.of({ + initialize: (request, { client }) => + lifecycle.initialize(McpSchema.protocolVersion, profileFromInitialize(request), client.id).pipe( + Effect.map((result) => + McpSchema.InitializeResult.make({ + protocolVersion: McpSchema.protocolVersion, + capabilities: ({ + experimental: result.capabilities.experimental, + logging: result.capabilities.logging ? {} : undefined, + completions: result.capabilities.completions ? {} : undefined, + prompts: result.capabilities.prompts, + resources: result.capabilities.resources, + tools: result.capabilities.tools + }), + serverInfo: result.serverInfo, + instructions: result.instructions + }) + ) + ), + "logging/setLevel": ({ level }, { client, headers }) => + lifecycle.setLogLevel(level, client.id, headers).pipe(Effect.as({})), + "notifications/cancelled": () => Effect.void, + "notifications/initialized": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.Initialized(), client.id, headers), + "notifications/progress": (progress, { client, headers }) => + lifecycle.clientNotification( + McpCore.ClientNotification.Progress({ + progressToken: progress.progressToken, + progress: progress.progress, + total: progress.total, + message: progress.message, + metadata: progress._meta + }), + client.id, + headers + ), + "notifications/roots/list_changed": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.RootsChanged(), client.id, headers), + "resources/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.resources.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((resources) => + McpSchema.ListResourcesResult.make({ + resources: resources.map((resource) => ({ + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + size: resource.size, + annotations: resource.annotations + })) + }) + ) + ), + "resources/templates/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => + core.resources.listTemplates(McpProtocol.profileFromClient(request)) + ).pipe( + Effect.map((resourceTemplates) => + McpSchema.ListResourceTemplatesResult.make({ + resourceTemplates: resourceTemplates.map((template) => ({ + uriTemplate: template.uriTemplate, + name: template.name, + description: template.description, + mimeType: template.mimeType, + annotations: template.annotations + })) + }) + ) + ), + "resources/read": Effect.fnUntraced(function*({ uri }) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.resources.read(uri, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + return McpSchema.ReadResourceResult.make({ + contents: result.contents.map(projectResourceContents), + _meta: result._meta + }) + }), + "resources/subscribe": ({ uri }, { client, headers }) => + lifecycle.subscribe(uri, client.id, headers).pipe(Effect.as({})), + "resources/unsubscribe": ({ uri }, { client, headers }) => + lifecycle.unsubscribe(uri, client.id, headers).pipe(Effect.as({})), + "prompts/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.prompts.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((prompts) => + McpSchema.ListPromptsResult.make({ + prompts: prompts.map((prompt) => ({ + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments?.map((argument) => ({ + name: argument.name, + description: argument.description, + required: argument.required + })) + })) + }) + ) + ), + "prompts/get": Effect.fnUntraced(function*({ arguments: args, name }) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.prompts.get(name, args ?? {}, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + const messages = yield* Effect.forEach(result.messages, (message) => + projectContent(message.content).pipe( + Effect.map((content) => ({ role: message.role, content })), + Effect.mapError(McpProtocol.ProtocolError.fromTool) + )) + return McpSchema.GetPromptResult.make({ + description: result.description, + messages, + _meta: result._meta + }) + }), + "completion/complete": Effect.fnUntraced(function*(completeRequest) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.completions.complete({ + reference: completeRequest.ref.type === "ref/prompt" + ? { type: "prompt", name: completeRequest.ref.name } + : { type: "resourceTemplate", uriTemplate: completeRequest.ref.uri }, + argument: completeRequest.argument, + metadata: completeRequest._meta + }, McpProtocol.invocationFromClient(request)).pipe(Effect.mapError(McpProtocol.ProtocolError.fromFeature)) + return McpSchema.CompleteResult.make({ + completion: { + values: Array.from(result.values), + total: result.total, + hasMore: result.hasMore + }, + _meta: result.metadata + }) + }), + "tools/list": Effect.fnUntraced(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const tools = yield* core.tools.list(McpProtocol.profileFromClient(request)) + return McpSchema.ListToolsResult.make({ + tools: tools.map((tool) => + McpSchema.Tool.make({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.title === undefined && tool.annotations === undefined + ? undefined + : McpSchema.ToolAnnotations.make({ + ...tool.annotations, + title: tool.title + }) + }) + ) + }) + }), + "tools/call": Effect.fnUntraced(function*(call) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.tools.call( + { ...call, arguments: call.arguments ?? {} }, + McpProtocol.invocationFromClient(request) + ).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + const content = yield* Effect.forEach(result.content, projectContent).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + return McpSchema.CallToolResult.make({ + content, + isError: result.isError, + _meta: result._meta + }) + }) + }), + toReverseClient: (profile, client) => ({ + listRoots: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "roots/list", "roots") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.ListRoots.payloadSchema, + McpSchema.ListRoots.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("roots/list", "Request is not representable by this protocol")) + ) + const { roots } = yield* client["roots/list"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("roots/list")) + ) + return new PublicMcpSchema.ListRootsResult({ roots }) + }), + createMessage: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "sampling/createMessage", "sampling") + const wireRequest = yield* McpProtocol.transcodeStrict( + PublicMcpSchema.CreateMessage.payloadSchema, + McpSchema.CreateMessage.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("sampling/createMessage", "Request is not representable by this protocol")) + ) + const result = yield* client["sampling/createMessage"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("sampling/createMessage")) + ) + return yield* McpProtocol.transcode( + McpSchema.CreateMessage.successSchema, + PublicMcpSchema.CreateMessage.successSchema, + result + ).pipe( + Effect.mapError(() => + unsupported("sampling/createMessage", "Response is not representable by the canonical model") + ) + ) + }), + elicit: () => + Effect.fail(unsupported("elicitation/create", "Elicitation was introduced after this protocol revision")) + }), + projectNotification: (notification) => + McpProtocol.makeNotificationProjector({ + supportsProgressMessage: true + }, notification), + normalizeCancellation: (payload) => + Schema.decodeUnknownEffect(McpSchema.CancelledNotification.payloadSchema)(payload).pipe( + Effect.map((request) => ({ + requestId: request.requestId, + reason: request.reason, + metadata: request._meta + })) + ) +}) diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts new file mode 100644 index 0000000000..1420b635fa --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts @@ -0,0 +1,364 @@ +import * as Effect from "../../../../Effect.ts" +import * as Encoding from "../../../../Encoding.ts" +import * as Match from "../../../../Match.ts" +import * as Schema from "../../../../Schema.ts" +import * as Struct from "../../../../Struct.ts" +import * as PublicMcpSchema from "../../McpSchema.ts" +import * as McpCore from "../mcpCore.ts" +import * as McpProtocol from "../mcpProtocol.ts" +import * as McpSchema from "../mcpSchema/v2025_06_18.ts" + +const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( + PublicMcpSchema.McpServerClientMiddleware +) + +const ClientRpcs = ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) + +const AdapterRpcs = ClientRpcs.omit("ping") + +const profileFromInitialize = ( + initialize: typeof McpSchema.Initialize.payloadSchema.Type +): McpCore.NegotiatedProtocolProfile => ({ + protocolVersion: McpSchema.protocolVersion, + clientCapabilities: PublicMcpSchema.ClientCapabilities.make(initialize.capabilities), + clientInfo: PublicMcpSchema.Implementation.make(initialize.clientInfo), + requestMetadata: initialize._meta +}) + +const unsupported = ( + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + reason: string +) => + new PublicMcpSchema.McpReverseOperationUnsupported({ + operation, + protocolVersion: McpSchema.protocolVersion, + reason + }) + +const requireCapability = ( + profile: McpCore.NegotiatedProtocolProfile, + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + capability: "roots" | "sampling" | "elicitation" +) => + Object.hasOwn(profile.clientCapabilities, capability) && + profile.clientCapabilities[capability] !== undefined + ? Effect.void + : Effect.fail(unsupported(operation, `Client did not advertise the ${capability} capability`)) + +const projectContent = Effect.fnUntraced(function*(content: typeof PublicMcpSchema.ContentBlock.Type) { + return Match.value(content).pipe( + Match.when({ type: Match.is("text", "resource_link") }, (content) => content), + Match.when({ type: Match.is("image", "audio") }, (content) => ({ + type: content.type, + mimeType: content.mimeType, + data: Encoding.encodeBase64(content.data), + annotations: content.annotations, + _meta: content._meta + })), + Match.when({ type: "resource" }, (content) => { + const resource = content.resource + if ("text" in resource) { + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + _meta: resource._meta, + text: resource.text + }, + annotations: content.annotations, + _meta: content._meta + }) + } + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + _meta: resource._meta, + blob: Encoding.encodeBase64(resource.blob) + }, + annotations: content.annotations, + _meta: content._meta + }) + }), + Match.exhaustive + ) +}) + +const projectResourceContents = ( + content: PublicMcpSchema.TextResourceContents | PublicMcpSchema.BlobResourceContents +): typeof McpSchema.ResourceContents.Type => + "text" in content + ? { + uri: content.uri, + mimeType: content.mimeType, + _meta: content._meta, + text: content.text + } + : { + uri: content.uri, + mimeType: content.mimeType, + _meta: content._meta, + blob: Encoding.encodeBase64(content.blob) + } + +const projectStructuredContent: ( + content: Schema.Json | undefined +) => Effect.Effect< + Schema.JsonObject | undefined, + McpCore.UnsupportedByProtocol +> = Effect.fnUntraced(function*(content) { + if (content === undefined || isJsonObject(content)) { + return content + } + return yield* new McpCore.UnsupportedByProtocol({ + protocolVersion: McpSchema.protocolVersion, + feature: "non-object structured tool content" + }) +}) + +const isJsonObject = (value: Schema.Json): value is Schema.JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** @internal */ +export const protocol = McpProtocol.make({ + protocolVersion: McpSchema.protocolVersion, + transport: { + acceptsJsonRpcBatches: false, + requiresVersionHeader: true + }, + clientRpcs: ClientRpcs, + clientNotificationRpcs: McpSchema.ClientNotificationRpcs, + serverRequestRpcs: McpSchema.ServerRequestRpcs, + serverNotificationRpcs: McpSchema.ServerNotificationRpcs, + handlerRpcs: AdapterRpcs, + makeHandlers: (core, lifecycle) => + AdapterRpcs.of({ + initialize: (request, { client }) => + lifecycle.initialize(McpSchema.protocolVersion, profileFromInitialize(request), client.id).pipe( + Effect.map((result) => + McpSchema.InitializeResult.make({ + protocolVersion: McpSchema.protocolVersion, + capabilities: ({ + experimental: result.capabilities.experimental, + logging: result.capabilities.logging ? {} : undefined, + completions: result.capabilities.completions ? {} : undefined, + prompts: result.capabilities.prompts, + resources: result.capabilities.resources, + tools: result.capabilities.tools + }), + serverInfo: result.serverInfo, + instructions: result.instructions + }) + ) + ), + "logging/setLevel": ({ level }, { client, headers }) => + lifecycle.setLogLevel(level, client.id, headers).pipe(Effect.as({})), + "notifications/cancelled": () => Effect.void, + "notifications/initialized": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.Initialized(), client.id, headers), + "notifications/progress": (progress, { client, headers }) => + lifecycle.clientNotification( + McpCore.ClientNotification.Progress({ + progressToken: progress.progressToken, + progress: progress.progress, + total: progress.total, + message: progress.message, + metadata: progress._meta + }), + client.id, + headers + ), + "notifications/roots/list_changed": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.RootsChanged(), client.id, headers), + "resources/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.resources.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((resources) => McpSchema.ListResourcesResult.make({ resources })) + ), + "resources/templates/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => + core.resources.listTemplates(McpProtocol.profileFromClient(request)) + ).pipe( + Effect.map((resourceTemplates) => McpSchema.ListResourceTemplatesResult.make({ resourceTemplates })) + ), + "resources/read": Effect.fnUntraced(function*({ uri }) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.resources.read(uri, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + return McpSchema.ReadResourceResult.make({ + contents: result.contents.map(projectResourceContents), + _meta: result._meta + }) + }), + "resources/subscribe": ({ uri }, { client, headers }) => + lifecycle.subscribe(uri, client.id, headers).pipe(Effect.as({})), + "resources/unsubscribe": ({ uri }, { client, headers }) => + lifecycle.unsubscribe(uri, client.id, headers).pipe(Effect.as({})), + "prompts/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.prompts.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((prompts) => McpSchema.ListPromptsResult.make({ prompts })) + ), + "prompts/get": Effect.fnUntraced(function*({ arguments: args, name }) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.prompts.get(name, args ?? {}, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + const messages = yield* Effect.forEach(result.messages, (message) => + projectContent(message.content).pipe( + Effect.map((content) => ({ role: message.role, content })), + Effect.mapError(McpProtocol.ProtocolError.fromTool) + )) + return McpSchema.GetPromptResult.make({ + description: result.description, + messages, + _meta: result._meta + }) + }), + "completion/complete": Effect.fnUntraced(function*(completeRequest) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.completions.complete({ + reference: completeRequest.ref.type === "ref/prompt" + ? { + type: "prompt", + name: completeRequest.ref.name, + title: completeRequest.ref.title + } + : { type: "resourceTemplate", uriTemplate: completeRequest.ref.uri }, + argument: completeRequest.argument, + context: completeRequest.context?.arguments === undefined + ? undefined + : { arguments: completeRequest.context.arguments }, + metadata: completeRequest._meta + }, McpProtocol.invocationFromClient(request)).pipe(Effect.mapError(McpProtocol.ProtocolError.fromFeature)) + return McpSchema.CompleteResult.make({ + completion: { + values: Array.from(result.values), + total: result.total, + hasMore: result.hasMore + }, + _meta: result.metadata + }) + }), + "tools/list": Effect.fnUntraced(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const tools = yield* core.tools.list(McpProtocol.profileFromClient(request)) + return McpSchema.ListToolsResult.make({ + tools: tools.map((tool) => + McpSchema.Tool.make({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations === undefined + ? undefined + : McpSchema.ToolAnnotations.make({ + readOnlyHint: tool.annotations.readOnlyHint, + destructiveHint: tool.annotations.destructiveHint, + idempotentHint: tool.annotations.idempotentHint, + openWorldHint: tool.annotations.openWorldHint + }), + _meta: tool._meta + }) + ) + }) + }), + "tools/call": Effect.fnUntraced(function*(call) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.tools.call( + { ...call, arguments: call.arguments ?? {} }, + McpProtocol.invocationFromClient(request) + ).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + const content = yield* Effect.forEach(result.content, projectContent).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + const structuredContent = yield* projectStructuredContent(result.structuredContent).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + return McpSchema.CallToolResult.make({ + content, + structuredContent, + isError: result.isError, + _meta: result._meta + }) + }) + }), + toReverseClient: (profile, client) => ({ + listRoots: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "roots/list", "roots") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.ListRoots.payloadSchema, + McpSchema.ListRoots.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("roots/list", "Request is not representable by this protocol")) + ) + const { roots } = yield* client["roots/list"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("roots/list")) + ) + return new PublicMcpSchema.ListRootsResult({ roots }) + }), + createMessage: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "sampling/createMessage", "sampling") + const wireRequest = yield* McpProtocol.transcodeStrict( + PublicMcpSchema.CreateMessage.payloadSchema, + McpSchema.CreateMessage.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("sampling/createMessage", "Request is not representable by this protocol")) + ) + const result = yield* client["sampling/createMessage"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("sampling/createMessage")) + ) + return yield* McpProtocol.transcode( + McpSchema.CreateMessage.successSchema, + PublicMcpSchema.CreateMessage.successSchema, + result + ).pipe( + Effect.mapError(() => + unsupported("sampling/createMessage", "Response is not representable by the canonical model") + ) + ) + }), + elicit: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "elicitation/create", "elicitation") + const projected = request.mode === "form" + ? Struct.omit(request, ["mode"]) + : request + const wireRequest = yield* Schema.decodeUnknownEffect( + McpSchema.Elicit.payloadSchema, + { onExcessProperty: "error" } + )(projected).pipe( + Effect.mapError(() => unsupported("elicitation/create", "Request is not representable by this protocol")) + ) + const result = yield* client["elicitation/create"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("elicitation/create")) + ) + return yield* McpProtocol.transcode( + McpSchema.Elicit.successSchema, + PublicMcpSchema.Elicit.successSchema, + result + ).pipe( + Effect.mapError(() => unsupported("elicitation/create", "Response is not representable by the canonical model")) + ) + }) + }), + projectNotification: (notification) => + McpProtocol.makeNotificationProjector({ + supportsProgressMessage: true + }, notification), + normalizeCancellation: (payload) => + Schema.decodeUnknownEffect(McpSchema.CancelledNotification.payloadSchema)(payload).pipe( + Effect.map((request) => ({ + requestId: request.requestId, + reason: request.reason, + metadata: request._meta + })) + ) +}) diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts new file mode 100644 index 0000000000..e37feca1ee --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts @@ -0,0 +1,417 @@ +import * as Effect from "../../../../Effect.ts" +import * as Encoding from "../../../../Encoding.ts" +import * as Match from "../../../../Match.ts" +import * as Schema from "../../../../Schema.ts" +import * as PublicMcpSchema from "../../McpSchema.ts" +import * as McpCore from "../mcpCore.ts" +import * as McpProtocol from "../mcpProtocol.ts" +import * as McpSchema from "../mcpSchema/v2025_11_25.ts" + +const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( + PublicMcpSchema.McpServerClientMiddleware +) + +const ClientRpcs = ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) + +const AdapterRpcs = ClientRpcs.omit("ping") + +const unsupported = ( + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + reason: string +) => + new PublicMcpSchema.McpReverseOperationUnsupported({ + operation, + protocolVersion: McpSchema.protocolVersion, + reason + }) + +const requireCapability = ( + profile: McpCore.NegotiatedProtocolProfile, + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + capability: "roots" | "sampling" | "elicitation" +) => + Object.hasOwn(profile.clientCapabilities, capability) && + profile.clientCapabilities[capability] !== undefined + ? Effect.void + : Effect.fail(unsupported(operation, `Client did not advertise the ${capability} capability`)) + +const requiresSamplingTools = (request: typeof PublicMcpSchema.CreateMessage.payloadSchema.Type): boolean => + request.tools !== undefined || + request.toolChoice !== undefined || + request.messages.some((message) => { + const content = message.content + return "type" in content + ? content.type === "tool_use" || content.type === "tool_result" + : content.some((block) => block.type === "tool_use" || block.type === "tool_result") + }) + +const resultRequiresSamplingTools = (result: typeof McpSchema.CreateMessage.successSchema.Type): boolean => + result.stopReason === "toolUse" || + ("type" in result.content + ? result.content.type === "tool_use" || result.content.type === "tool_result" + : result.content.some((block) => block.type === "tool_use" || block.type === "tool_result")) + +const hasElicitationModeCapability = ( + profile: McpCore.NegotiatedProtocolProfile, + mode: "form" | "url" +): boolean => { + const elicitation = profile.clientCapabilities.elicitation + if (elicitation === undefined) return false + return mode === "form" + ? elicitation.form !== undefined || elicitation.url === undefined + : elicitation.url !== undefined +} + +const projectContent = Effect.fnUntraced(function*(content: typeof PublicMcpSchema.ContentBlock.Type) { + return Match.value(content).pipe( + Match.when({ type: Match.is("text", "resource_link") }, (content) => content), + Match.when({ type: Match.is("image", "audio") }, (content) => ({ + type: content.type, + mimeType: content.mimeType, + data: Encoding.encodeBase64(content.data), + annotations: content.annotations, + _meta: content._meta + })), + Match.when({ type: "resource" }, (content) => { + const resource = content.resource + if ("text" in resource) { + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + _meta: resource._meta, + text: resource.text + }, + annotations: content.annotations, + _meta: content._meta + }) + } + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + _meta: resource._meta, + blob: Encoding.encodeBase64(resource.blob) + }, + annotations: content.annotations, + _meta: content._meta + }) + }), + Match.exhaustive + ) +}) + +const projectStructuredContent = Effect.fnUntraced(function*(content: Schema.Json | undefined) { + if (content === undefined || Schema.is(Schema.JsonObject)(content)) { + return content + } + return yield* new McpCore.UnsupportedByProtocol({ + protocolVersion: McpSchema.protocolVersion, + feature: "non-object structured tool content" + }) +}) + +/** @internal */ +export const protocol = McpProtocol.make({ + protocolVersion: McpSchema.protocolVersion, + transport: { + acceptsJsonRpcBatches: false, + requiresVersionHeader: true + }, + clientRpcs: ClientRpcs, + clientNotificationRpcs: McpSchema.ClientNotificationRpcs, + serverRequestRpcs: McpSchema.ServerRequestRpcs, + serverNotificationRpcs: McpSchema.ServerNotificationRpcs, + handlerRpcs: AdapterRpcs, + makeHandlers: (core, lifecycle) => + AdapterRpcs.of({ + initialize: (request, { client }) => + lifecycle.initialize(McpSchema.protocolVersion, { + protocolVersion: McpSchema.protocolVersion, + clientCapabilities: PublicMcpSchema.ClientCapabilities.make(request.capabilities), + clientInfo: PublicMcpSchema.Implementation.make(request.clientInfo), + requestMetadata: request._meta + }, client.id).pipe( + Effect.map((result) => + McpSchema.InitializeResult.make({ + protocolVersion: McpSchema.protocolVersion, + capabilities: ({ + experimental: result.capabilities.experimental, + logging: result.capabilities.logging ? {} : undefined, + completions: result.capabilities.completions ? {} : undefined, + prompts: result.capabilities.prompts, + resources: result.capabilities.resources, + tools: result.capabilities.tools + }), + serverInfo: result.serverInfo, + instructions: result.instructions + }) + ) + ), + "logging/setLevel": ({ level }, { client, headers }) => + lifecycle.setLogLevel(level, client.id, headers).pipe(Effect.as({})), + "notifications/cancelled": () => Effect.void, + "notifications/initialized": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.Initialized(), client.id, headers), + "notifications/progress": (progress, { client, headers }) => + lifecycle.clientNotification( + McpCore.ClientNotification.Progress({ + progressToken: progress.progressToken, + progress: progress.progress, + total: progress.total, + message: progress.message, + metadata: progress._meta + }), + client.id, + headers + ), + "notifications/roots/list_changed": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.RootsChanged(), client.id, headers), + "resources/list": () => + PublicMcpSchema.McpServerClient.use((request) => core.resources.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((resources) => + McpSchema.ListResourcesResult.make({ + resources: resources.map((resource) => McpSchema.Resource.make(resource)) + }) + ) + ), + "resources/templates/list": () => + PublicMcpSchema.McpServerClient.use((request) => + core.resources.listTemplates(McpProtocol.profileFromClient(request)) + ).pipe( + Effect.map((resourceTemplates) => + McpSchema.ListResourceTemplatesResult.make({ + resourceTemplates: resourceTemplates.map((resourceTemplate) => + McpSchema.ResourceTemplate.make(resourceTemplate) + ) + }) + ) + ), + "resources/read": Effect.fnUntraced(function*({ uri }) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.resources.read(uri, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + return McpSchema.ReadResourceResult.make({ + contents: result.contents.map((content) => + "text" in content + ? { + uri: content.uri, + mimeType: content.mimeType, + _meta: content._meta, + text: content.text + } + : { + uri: content.uri, + mimeType: content.mimeType, + _meta: content._meta, + blob: Encoding.encodeBase64(content.blob) + } + ), + _meta: result._meta + }) + }), + "resources/subscribe": ({ uri }, { client, headers }) => + lifecycle.subscribe(uri, client.id, headers).pipe(Effect.as({})), + "resources/unsubscribe": ({ uri }, { client, headers }) => + lifecycle.unsubscribe(uri, client.id, headers).pipe(Effect.as({})), + "prompts/list": () => + PublicMcpSchema.McpServerClient.use((request) => core.prompts.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((prompts) => + McpSchema.ListPromptsResult.make({ + prompts: prompts.map((prompt) => McpSchema.Prompt.make(prompt)) + }) + ) + ), + "prompts/get": Effect.fnUntraced(function*({ arguments: args, name }) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.prompts.get(name, args ?? {}, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + const messages = yield* Effect.forEach(result.messages, (message) => + projectContent(message.content).pipe( + Effect.map((content) => ({ role: message.role, content })), + Effect.mapError(McpProtocol.ProtocolError.fromTool) + )) + return McpSchema.GetPromptResult.make({ + description: result.description, + messages, + _meta: result._meta + }) + }), + "completion/complete": Effect.fnUntraced(function*(completeRequest) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.completions.complete({ + reference: completeRequest.ref.type === "ref/prompt" + ? { + type: "prompt", + name: completeRequest.ref.name, + title: completeRequest.ref.title + } + : { type: "resourceTemplate", uriTemplate: completeRequest.ref.uri }, + argument: completeRequest.argument, + context: completeRequest.context?.arguments === undefined + ? undefined + : { arguments: completeRequest.context.arguments }, + metadata: completeRequest._meta + }, McpProtocol.invocationFromClient(request)).pipe(Effect.mapError(McpProtocol.ProtocolError.fromFeature)) + return McpSchema.CompleteResult.make({ + completion: { + values: Array.from(result.values), + total: result.total, + hasMore: result.hasMore + }, + _meta: result.metadata + }) + }), + "tools/list": Effect.fnUntraced(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const tools = yield* core.tools.list(McpProtocol.profileFromClient(request)) + return McpSchema.ListToolsResult.make({ + tools: tools.map((tool) => + McpSchema.Tool.make({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + icons: tool.icons, + annotations: tool.annotations === undefined + ? undefined + : McpSchema.ToolAnnotations.make({ + readOnlyHint: tool.annotations.readOnlyHint, + destructiveHint: tool.annotations.destructiveHint, + idempotentHint: tool.annotations.idempotentHint, + openWorldHint: tool.annotations.openWorldHint + }), + _meta: tool._meta + }) + ) + }) + }), + "tools/call": Effect.fnUntraced(function*(call) { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.tools.call( + { ...call, arguments: call.arguments ?? {} }, + McpProtocol.invocationFromClient(request) + ).pipe( + Effect.catchTag("InvalidToolInput", (error) => + Effect.succeed(PublicMcpSchema.CallToolResult.make({ + content: [PublicMcpSchema.TextContent.make({ + type: "text", + text: error.message + })], + isError: true + }))), + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + const content = yield* Effect.forEach(result.content, projectContent).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + const structuredContent = yield* projectStructuredContent(result.structuredContent).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + return McpSchema.CallToolResult.make({ + content, + structuredContent, + isError: result.isError, + _meta: result._meta + }) + }) + }), + toReverseClient: (profile, client) => ({ + listRoots: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "roots/list", "roots") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.ListRoots.payloadSchema, + McpSchema.ListRoots.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("roots/list", "Request is not representable by this protocol")) + ) + const { roots } = yield* client["roots/list"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("roots/list")) + ) + return new PublicMcpSchema.ListRootsResult({ roots }) + }), + createMessage: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "sampling/createMessage", "sampling") + if (requiresSamplingTools(request) && profile.clientCapabilities.sampling?.tools == undefined) { + return yield* unsupported("sampling/createMessage", "Client did not advertise the sampling.tools capability") + } + if ( + (request.includeContext === "thisServer" || request.includeContext === "allServers") && + profile.clientCapabilities.sampling?.context == undefined + ) { + return yield* unsupported("sampling/createMessage", "Client did not advertise the sampling.context capability") + } + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.CreateMessage.payloadSchema, + McpSchema.CreateMessage.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("sampling/createMessage", "Request is not representable by this protocol")) + ) + const result = yield* client["sampling/createMessage"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("sampling/createMessage")) + ) + if (resultRequiresSamplingTools(result) && profile.clientCapabilities.sampling?.tools == undefined) { + return yield* unsupported("sampling/createMessage", "Client did not advertise the sampling.tools capability") + } + return yield* McpProtocol.transcode( + McpSchema.CreateMessage.successSchema, + PublicMcpSchema.CreateMessage.successSchema, + result + ).pipe( + Effect.mapError(() => + unsupported("sampling/createMessage", "Response is not representable by the canonical model") + ) + ) + }), + elicit: Effect.fnUntraced(function*(request) { + yield* requireCapability(profile, "elicitation/create", "elicitation") + const mode = request.mode === "url" ? "url" : "form" + if (!hasElicitationModeCapability(profile, mode)) { + return yield* unsupported("elicitation/create", `Client did not advertise the elicitation.${mode} capability`) + } + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.Elicit.payloadSchema, + McpSchema.Elicit.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("elicitation/create", "Request is not representable by this protocol")) + ) + const result = yield* client["elicitation/create"](wireRequest).pipe( + Effect.mapError(McpProtocol.reverseError("elicitation/create")) + ) + return yield* McpProtocol.transcode( + McpSchema.Elicit.successSchema, + PublicMcpSchema.Elicit.successSchema, + result + ).pipe( + Effect.mapError(() => unsupported("elicitation/create", "Response is not representable by the canonical model")) + ) + }) + }), + projectNotification: (notification) => + notification._tag === "ElicitationComplete" + ? Effect.succeed({ + tag: "notifications/elicitation/complete", + payload: { elicitationId: notification.elicitationId } + }) + : McpProtocol.makeNotificationProjector({ + supportsProgressMessage: true + }, notification), + normalizeCancellation: (payload) => + Schema.decodeUnknownEffect(McpSchema.CancelledNotification.payloadSchema)(payload).pipe( + Effect.map((request) => ({ + requestId: request.requestId, + reason: request.reason, + metadata: request._meta + })) + ) +}) diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts index 818bba60c1..0f6762d145 100644 --- a/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts @@ -1,16 +1,17 @@ import type { NonEmptyReadonlyArray } from "../../../Array.ts" import * as Cause from "../../../Cause.ts" import * as Effect from "../../../Effect.ts" +import type * as Rpc from "../../rpc/Rpc.ts" import type * as RpcGroup from "../../rpc/RpcGroup.ts" import type * as RpcMessage from "../../rpc/RpcMessage.ts" -import type * as McpProtocol from "./mcpProtocol.ts" +import type * as McpProtocol from "../McpProtocol.ts" +import type * as McpProtocolInternal from "./mcpProtocol.ts" type AnyRpcGroup = RpcGroup.RpcGroup -const prefix = (protocol: McpProtocol.AnyProtocolAdapter): string => - `@effect/mcp/${encodeURIComponent(protocol.protocolVersion)}/` - -const asRpcGroup = (group: RpcGroup.Any): AnyRpcGroup => group as unknown as AnyRpcGroup +const prefix = (protocol: { + readonly protocolVersion: string +}): string => `@effect/mcp/${encodeURIComponent(protocol.protocolVersion)}/` /** @internal */ export interface ProtocolRegistry< @@ -23,8 +24,14 @@ export interface ProtocolRegistry< protocol: Protocol, request: RpcMessage.RequestEncoded ) => RpcMessage.RequestEncoded + readonly handlerTarget: ( + contextMap: Map + ) => McpProtocolInternal.HandlerInstallationTarget } +// NOTE: Protocol selection and request namespacing happen before an adapter's +// payload codec decodes the request. Canonical McpSchema value reuse must not +// introduce a shared permissive decode-first path. /** @internal */ export const make = Effect.fnUntraced(function*< const Protocols extends NonEmptyReadonlyArray @@ -49,10 +56,9 @@ export const make = Effect.fnUntraced(function*< } byVersion.set(protocol.protocolVersion, protocol) } - - let clientRpcs = asRpcGroup(snapshot[0].clientRpcs).prefix(prefix(snapshot[0])) + let clientRpcs = snapshot[0].clientRpcs.prefix(prefix(snapshot[0])) for (let i = 1; i < snapshot.length; i++) { - clientRpcs = clientRpcs.merge(asRpcGroup(snapshot[i].clientRpcs).prefix(prefix(snapshot[i]))) + clientRpcs = clientRpcs.merge(snapshot[i].clientRpcs.prefix(prefix(snapshot[i]))) } return { @@ -65,6 +71,32 @@ export const make = Effect.fnUntraced(function*< ) => ({ ...request, tag: `${prefix(protocol)}${request.tag}` + }), + handlerTarget: (contextMap: Map): McpProtocolInternal.HandlerInstallationTarget => ({ + install: Effect.fnUntraced(function*< + Rpcs extends Rpc.Any, + Handlers extends RpcGroup.HandlersFrom + >( + protocol: { + readonly protocolVersion: string + }, + rpcs: RpcGroup.RpcGroup, + handlers: Handlers + ) { + const handlerContext = yield* rpcs.toHandlers(handlers) + for (const rpcDefinition of rpcs.requests.values()) { + const namespacedRpc = clientRpcs.requests.get( + `${prefix(protocol)}${rpcDefinition._tag}` + ) + const handler = handlerContext.mapUnsafe.get(rpcDefinition.key) + if (namespacedRpc === undefined || handler === undefined) { + return yield* Effect.die( + `MCP handler registration invariant failed for ${protocol.protocolVersion}/${rpcDefinition._tag}` + ) + } + contextMap.set(namespacedRpc.key, handler) + } + }) }) } satisfies ProtocolRegistry }) diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2024_11_05.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2024_11_05.ts new file mode 100644 index 0000000000..ea4dc4742a --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2024_11_05.ts @@ -0,0 +1,521 @@ +/** + * Exact MCP v2024-11-05 wire schemas. + * + * Transport topology is intentionally not represented here. This module owns + * the dated JSON-RPC method payloads and results only. + * + * @internal + */ +import * as Option from "../../../../Option.ts" +import * as Schema from "../../../../Schema.ts" +import * as SchemaGetter from "../../../../SchemaGetter.ts" +import * as Rpc from "../../../rpc/Rpc.ts" +import * as RpcGroup from "../../../rpc/RpcGroup.ts" + +export const protocolVersion = "2024-11-05" + +export const optional = ( + schema: S +): Schema.decodeTo, Schema.optionalKey> => + Schema.optionalKey(schema).pipe( + Schema.decodeTo(Schema.optional(schema), { + decode: SchemaGetter.passthrough(), + encode: SchemaGetter.transformOptional(Option.flatMap(Option.fromUndefinedOr)) + }) + ) +const JsonObject = Schema.JsonObject + +export const RequestId = Schema.Union([Schema.String, Schema.Finite]) +export const ProgressToken = Schema.Union([Schema.String, Schema.Finite]) +export const Role = Schema.Literals(["user", "assistant"]) +export const LoggingLevel = Schema.Literals([ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency" +]) + +export const RequestMeta = Schema.Struct({ + _meta: optional(Schema.Struct({ + progressToken: optional(ProgressToken) + })) +}) + +export const NotificationMeta = Schema.Struct({ + _meta: optional(JsonObject) +}) + +export const ResultMeta = Schema.Struct({ + _meta: optional(JsonObject) +}) + +export const PaginatedRequest = Schema.Struct({ + ...RequestMeta.fields, + cursor: optional(Schema.String) +}) + +export const PaginatedResult = Schema.Struct({ + ...ResultMeta.fields, + nextCursor: optional(Schema.String) +}) + +export const Implementation = Schema.Struct({ + name: Schema.String, + version: Schema.String +}) + +export const ClientCapabilities = Schema.Struct({ + experimental: optional(Schema.Record(Schema.String, JsonObject)), + roots: optional(Schema.Struct({ + listChanged: optional(Schema.Boolean) + })), + sampling: optional(JsonObject) +}) + +export const ServerCapabilities = Schema.Struct({ + experimental: optional(Schema.Record(Schema.String, JsonObject)), + logging: optional(JsonObject), + prompts: optional(Schema.Struct({ + listChanged: optional(Schema.Boolean) + })), + resources: optional(Schema.Struct({ + subscribe: optional(Schema.Boolean), + listChanged: optional(Schema.Boolean) + })), + tools: optional(Schema.Struct({ + listChanged: optional(Schema.Boolean) + })) +}) + +export const McpError = Schema.Struct({ + code: Schema.Int, + message: Schema.String, + data: optional(Schema.Any) +}) +export type McpError = typeof McpError.Type + +export const Annotation = Schema.Struct({ + audience: optional(Schema.Array(Role)), + priority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))) +}) + +export const TextResourceContents = Schema.Struct({ + uri: Schema.String, + mimeType: optional(Schema.String), + text: Schema.String +}) + +export const BlobResourceContents = Schema.Struct({ + uri: Schema.String, + mimeType: optional(Schema.String), + blob: Schema.String +}) + +export const ResourceContents = Schema.Union([ + TextResourceContents, + BlobResourceContents +]) + +export const TextContent = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + annotations: optional(Annotation) +}) + +export const ImageContent = Schema.Struct({ + type: Schema.Literal("image"), + data: Schema.String, + mimeType: Schema.String, + annotations: optional(Annotation) +}) + +export const EmbeddedResource = Schema.Struct({ + type: Schema.Literal("resource"), + resource: ResourceContents, + annotations: optional(Annotation) +}) + +export const PromptOrToolContent = Schema.Union([ + TextContent, + ImageContent, + EmbeddedResource +]) + +export const SamplingContent = Schema.Union([ + TextContent, + ImageContent +]) + +export const Resource = Schema.Struct({ + uri: Schema.String, + name: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), + size: optional(Schema.Finite), + annotations: optional(Annotation) +}) + +export const ResourceTemplate = Schema.Struct({ + uriTemplate: Schema.String, + name: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), + annotations: optional(Annotation) +}) + +export const PromptArgument = Schema.Struct({ + name: Schema.String, + description: optional(Schema.String), + required: optional(Schema.Boolean) +}) + +export const Prompt = Schema.Struct({ + name: Schema.String, + description: optional(Schema.String), + arguments: optional(Schema.Array(PromptArgument)) +}) + +export const PromptMessage = Schema.Struct({ + role: Role, + content: PromptOrToolContent +}) + +export const ToolInputSchema = Schema.Struct({ + type: Schema.Literal("object"), + properties: optional(Schema.Record(Schema.String, JsonObject)), + required: optional(Schema.Array(Schema.String)) +}) + +export const Tool = Schema.Struct({ + name: Schema.String, + description: optional(Schema.String), + inputSchema: ToolInputSchema +}) + +export const ModelHint = Schema.Struct({ + name: optional(Schema.String) +}) + +export const ModelPreferences = Schema.Struct({ + hints: optional(Schema.Array(ModelHint)), + costPriority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + speedPriority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + intelligencePriority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))) +}) + +export const SamplingMessage = Schema.Struct({ + role: Role, + content: SamplingContent +}) + +export const ResourceReference = Schema.Struct({ + type: Schema.Literal("ref/resource"), + uri: Schema.String +}) + +export const PromptReference = Schema.Struct({ + type: Schema.Literal("ref/prompt"), + name: Schema.String +}) + +export const Root = Schema.Struct({ + uri: Schema.String, + name: optional(Schema.String) +}) + +export const InitializeResult = Schema.Struct({ + ...ResultMeta.fields, + protocolVersion: Schema.String, + capabilities: ServerCapabilities, + serverInfo: Implementation, + instructions: optional(Schema.String) +}) + +export const ListResourcesResult = Schema.Struct({ + ...PaginatedResult.fields, + resources: Schema.Array(Resource) +}) + +export const ListResourceTemplatesResult = Schema.Struct({ + ...PaginatedResult.fields, + resourceTemplates: Schema.Array(ResourceTemplate) +}) + +export const ReadResourceResult = Schema.Struct({ + ...ResultMeta.fields, + contents: Schema.Array(ResourceContents) +}) + +export const ListPromptsResult = Schema.Struct({ + ...PaginatedResult.fields, + prompts: Schema.Array(Prompt) +}) + +export const GetPromptResult = Schema.Struct({ + ...ResultMeta.fields, + description: optional(Schema.String), + messages: Schema.Array(PromptMessage) +}) + +export const ListToolsResult = Schema.Struct({ + ...PaginatedResult.fields, + tools: Schema.Array(Tool) +}) + +export const CallToolResult = Schema.Struct({ + ...ResultMeta.fields, + content: Schema.Array(PromptOrToolContent), + isError: optional(Schema.Boolean) +}) + +export const CreateMessageResult = Schema.Struct({ + ...ResultMeta.fields, + role: Role, + content: SamplingContent, + model: Schema.String, + stopReason: optional(Schema.String) +}) + +export const CompleteResult = Schema.Struct({ + ...ResultMeta.fields, + completion: Schema.Struct({ + values: Schema.Array(Schema.String), + total: optional(Schema.Finite), + hasMore: optional(Schema.Boolean) + }) +}) + +export const ListRootsResult = Schema.Struct({ + ...ResultMeta.fields, + roots: Schema.Array(Root) +}) + +export class Ping extends Rpc.make("ping", { + success: ResultMeta, + error: McpError, + payload: Schema.UndefinedOr(RequestMeta) +}) {} + +export class Initialize extends Rpc.make("initialize", { + success: InitializeResult, + error: McpError, + payload: { + ...RequestMeta.fields, + protocolVersion: Schema.String, + capabilities: ClientCapabilities, + clientInfo: Implementation + } +}) {} + +export class Complete extends Rpc.make("completion/complete", { + success: CompleteResult, + error: McpError, + payload: { + ...RequestMeta.fields, + ref: Schema.Union([PromptReference, ResourceReference]), + argument: Schema.Struct({ + name: Schema.String, + value: Schema.String + }) + } +}) {} + +export class SetLevel extends Rpc.make("logging/setLevel", { + success: ResultMeta, + error: McpError, + payload: { + ...RequestMeta.fields, + level: LoggingLevel + } +}) {} + +export class GetPrompt extends Rpc.make("prompts/get", { + success: GetPromptResult, + error: McpError, + payload: { + ...RequestMeta.fields, + name: Schema.String, + arguments: optional(Schema.Record(Schema.String, Schema.String)) + } +}) {} + +export class ListPrompts extends Rpc.make("prompts/list", { + success: ListPromptsResult, + error: McpError, + payload: Schema.UndefinedOr(PaginatedRequest) +}) {} + +export class ListResources extends Rpc.make("resources/list", { + success: ListResourcesResult, + error: McpError, + payload: Schema.UndefinedOr(PaginatedRequest) +}) {} + +export class ListResourceTemplates extends Rpc.make("resources/templates/list", { + success: ListResourceTemplatesResult, + error: McpError, + payload: Schema.UndefinedOr(PaginatedRequest) +}) {} + +export class ReadResource extends Rpc.make("resources/read", { + success: ReadResourceResult, + error: McpError, + payload: { + ...RequestMeta.fields, + uri: Schema.String + } +}) {} + +export class Subscribe extends Rpc.make("resources/subscribe", { + success: ResultMeta, + error: McpError, + payload: { + ...RequestMeta.fields, + uri: Schema.String + } +}) {} + +export class Unsubscribe extends Rpc.make("resources/unsubscribe", { + success: ResultMeta, + error: McpError, + payload: { + ...RequestMeta.fields, + uri: Schema.String + } +}) {} + +export class CallTool extends Rpc.make("tools/call", { + success: CallToolResult, + error: McpError, + payload: { + ...RequestMeta.fields, + name: Schema.String, + arguments: optional(JsonObject) + } +}) {} + +export class ListTools extends Rpc.make("tools/list", { + success: ListToolsResult, + error: McpError, + payload: Schema.UndefinedOr(PaginatedRequest) +}) {} + +export class CreateMessage extends Rpc.make("sampling/createMessage", { + success: CreateMessageResult, + error: McpError, + payload: { + ...RequestMeta.fields, + messages: Schema.Array(SamplingMessage), + modelPreferences: optional(ModelPreferences), + systemPrompt: optional(Schema.String), + includeContext: optional(Schema.Literals(["none", "thisServer", "allServers"])), + temperature: optional(Schema.Finite), + maxTokens: Schema.Finite, + stopSequences: optional(Schema.Array(Schema.String)), + metadata: optional(JsonObject) + } +}) {} + +export class ListRoots extends Rpc.make("roots/list", { + success: ListRootsResult, + error: McpError, + payload: Schema.UndefinedOr(RequestMeta) +}) {} + +export class CancelledNotification extends Rpc.make("notifications/cancelled", { + payload: { + ...NotificationMeta.fields, + requestId: RequestId, + reason: optional(Schema.String) + } +}) {} + +export class ProgressNotification extends Rpc.make("notifications/progress", { + payload: { + ...NotificationMeta.fields, + progressToken: ProgressToken, + progress: Schema.Finite, + total: optional(Schema.Finite) + } +}) {} + +export class InitializedNotification extends Rpc.make("notifications/initialized", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class RootsListChangedNotification extends Rpc.make("notifications/roots/list_changed", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class LoggingMessageNotification extends Rpc.make("notifications/message", { + payload: { + ...NotificationMeta.fields, + level: LoggingLevel, + logger: optional(Schema.String), + data: Schema.Any + } +}) {} + +export class ResourceUpdatedNotification extends Rpc.make("notifications/resources/updated", { + payload: { + ...NotificationMeta.fields, + uri: Schema.String + } +}) {} + +export class ResourceListChangedNotification extends Rpc.make("notifications/resources/list_changed", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class ToolListChangedNotification extends Rpc.make("notifications/tools/list_changed", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class PromptListChangedNotification extends Rpc.make("notifications/prompts/list_changed", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class ClientRequestRpcs extends RpcGroup.make( + Ping, + Initialize, + Complete, + SetLevel, + GetPrompt, + ListPrompts, + ListResources, + ListResourceTemplates, + ReadResource, + Subscribe, + Unsubscribe, + CallTool, + ListTools +) {} + +export class ClientNotificationRpcs extends RpcGroup.make( + CancelledNotification, + ProgressNotification, + InitializedNotification, + RootsListChangedNotification +) {} + +export class ClientRpcs extends ClientRequestRpcs.merge(ClientNotificationRpcs) {} + +export class ServerRequestRpcs extends RpcGroup.make( + Ping, + CreateMessage, + ListRoots +) {} + +export class ServerNotificationRpcs extends RpcGroup.make( + CancelledNotification, + ProgressNotification, + LoggingMessageNotification, + ResourceUpdatedNotification, + ResourceListChangedNotification, + ToolListChangedNotification, + PromptListChangedNotification +) {} diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_03_26.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_03_26.ts new file mode 100644 index 0000000000..64b4bdbd86 --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_03_26.ts @@ -0,0 +1,204 @@ +/** + * Exact MCP v2025-03-26 wire schemas. + * + * This revision is expressed as a frozen delta from the exact 2024-11-05 + * schemas. Transport envelopes (including JSON-RPC batches) are owned by the + * transport codec rather than Effect RPC payload schemas. + * + * @internal + */ +import * as Schema from "../../../../Schema.ts" +import * as Rpc from "../../../rpc/Rpc.ts" +import * as RpcGroup from "../../../rpc/RpcGroup.ts" +import * as Previous from "./v2024_11_05.ts" + +export * from "./v2024_11_05.ts" + +export const protocolVersion = "2025-03-26" + +const optional = Previous.optional + +export const ServerCapabilities = Schema.Struct({ + ...Previous.ServerCapabilities.fields, + completions: optional(Schema.Struct({})) +}) + +export const AudioContent = Schema.Struct({ + type: Schema.Literal("audio"), + data: Schema.String, + mimeType: Schema.String, + annotations: optional(Previous.Annotation) +}) + +export const PromptOrToolContent = Schema.Union([ + Previous.TextContent, + Previous.ImageContent, + AudioContent, + Previous.EmbeddedResource +]) + +export const SamplingContent = Schema.Union([ + Previous.TextContent, + Previous.ImageContent, + AudioContent +]) + +export const PromptMessage = Schema.Struct({ + role: Previous.Role, + content: PromptOrToolContent +}) + +export const SamplingMessage = Schema.Struct({ + role: Previous.Role, + content: SamplingContent +}) + +export const ToolAnnotations = Schema.Struct({ + title: optional(Schema.String), + readOnlyHint: optional(Schema.Boolean), + destructiveHint: optional(Schema.Boolean), + idempotentHint: optional(Schema.Boolean), + openWorldHint: optional(Schema.Boolean) +}) + +export const Tool = Schema.Struct({ + ...Previous.Tool.fields, + annotations: optional(ToolAnnotations) +}) + +export const InitializeResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + protocolVersion: Schema.String, + capabilities: ServerCapabilities, + serverInfo: Previous.Implementation, + instructions: optional(Schema.String) +}) + +export const GetPromptResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + description: optional(Schema.String), + messages: Schema.Array(PromptMessage) +}) + +export const ListToolsResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + tools: Schema.Array(Tool) +}) + +export const CallToolResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + content: Schema.Array(PromptOrToolContent), + isError: optional(Schema.Boolean) +}) + +export const CreateMessageResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + role: Previous.Role, + content: SamplingContent, + model: Schema.String, + stopReason: optional(Schema.String) +}) + +export class Initialize extends Rpc.make("initialize", { + success: InitializeResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + protocolVersion: Schema.String, + capabilities: Previous.ClientCapabilities, + clientInfo: Previous.Implementation + } +}) {} + +export class GetPrompt extends Rpc.make("prompts/get", { + success: GetPromptResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + name: Schema.String, + arguments: optional(Schema.Record(Schema.String, Schema.String)) + } +}) {} + +export class ListTools extends Rpc.make("tools/list", { + success: ListToolsResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class CallTool extends Rpc.make("tools/call", { + success: CallToolResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + name: Schema.String, + arguments: optional(Schema.JsonObject) + } +}) {} + +export class CreateMessage extends Rpc.make("sampling/createMessage", { + success: CreateMessageResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + messages: Schema.Array(SamplingMessage), + modelPreferences: optional(Previous.ModelPreferences), + systemPrompt: optional(Schema.String), + includeContext: optional(Schema.Literals(["none", "thisServer", "allServers"])), + temperature: optional(Schema.Finite), + maxTokens: Schema.Finite, + stopSequences: optional(Schema.Array(Schema.String)), + metadata: optional(Schema.JsonObject) + } +}) {} + +export class ProgressNotification extends Rpc.make("notifications/progress", { + payload: { + ...Previous.NotificationMeta.fields, + progressToken: Previous.ProgressToken, + progress: Schema.Finite, + total: optional(Schema.Finite), + message: optional(Schema.String) + } +}) {} + +export class ClientRequestRpcs extends RpcGroup.make( + Previous.Ping, + Initialize, + Previous.Complete, + Previous.SetLevel, + GetPrompt, + Previous.ListPrompts, + Previous.ListResources, + Previous.ListResourceTemplates, + Previous.ReadResource, + Previous.Subscribe, + Previous.Unsubscribe, + CallTool, + ListTools +) {} + +export class ClientNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + ProgressNotification, + Previous.InitializedNotification, + Previous.RootsListChangedNotification +) {} + +export class ClientRpcs extends ClientRequestRpcs.merge(ClientNotificationRpcs) {} + +export class ServerRequestRpcs extends RpcGroup.make( + Previous.Ping, + CreateMessage, + Previous.ListRoots +) {} + +export class ServerNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + ProgressNotification, + Previous.LoggingMessageNotification, + Previous.ResourceUpdatedNotification, + Previous.ResourceListChangedNotification, + Previous.ToolListChangedNotification, + Previous.PromptListChangedNotification +) {} diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_06_18.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_06_18.ts new file mode 100644 index 0000000000..e741e868a7 --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_06_18.ts @@ -0,0 +1,404 @@ +/** + * Exact MCP v2025-06-18 wire schemas. + * + * This module is independent of the public compatibility-oriented McpSchema + * surface and is frozen as a dated delta from v2025-03-26. + * + * @internal + */ +import * as Schema from "../../../../Schema.ts" +import * as Rpc from "../../../rpc/Rpc.ts" +import * as RpcGroup from "../../../rpc/RpcGroup.ts" +import * as Previous from "./v2025_03_26.ts" + +export * from "./v2025_03_26.ts" + +export const protocolVersion = "2025-06-18" + +const optional = Previous.optional +const JsonObject = Schema.JsonObject +const Meta = optional(JsonObject) + +export const Implementation = Schema.Struct({ + name: Schema.String, + title: optional(Schema.String), + version: Schema.String +}) + +export const ClientCapabilities = Schema.Struct({ + ...Previous.ClientCapabilities.fields, + elicitation: optional(Schema.Struct({})) +}) + +export const Annotations = Schema.Struct({ + audience: optional(Schema.Array(Previous.Role)), + priority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))) +}) + +export const Resource = Schema.Struct({ + ...Previous.Resource.fields, + title: optional(Schema.String), + annotations: optional(Annotations), + _meta: Meta +}) + +export const ResourceTemplate = Schema.Struct({ + ...Previous.ResourceTemplate.fields, + title: optional(Schema.String), + annotations: optional(Annotations), + _meta: Meta +}) + +export const TextResourceContents = Schema.Struct({ + ...Previous.TextResourceContents.fields, + _meta: Meta +}) + +export const BlobResourceContents = Schema.Struct({ + ...Previous.BlobResourceContents.fields, + _meta: Meta +}) + +export const ResourceContents = Schema.Union([TextResourceContents, BlobResourceContents]) + +export const PromptArgument = Schema.Struct({ + ...Previous.PromptArgument.fields, + title: optional(Schema.String) +}) + +export const Prompt = Schema.Struct({ + ...Previous.Prompt.fields, + title: optional(Schema.String), + arguments: optional(Schema.Array(PromptArgument)), + _meta: Meta +}) + +export const EmbeddedResource = Schema.Struct({ + type: Schema.Literal("resource"), + resource: ResourceContents, + annotations: optional(Annotations), + _meta: Meta +}) + +export const ResourceLink = Schema.Struct({ + ...Resource.fields, + type: Schema.Literal("resource_link") +}) + +export const TextContent = Schema.Struct({ + ...Previous.TextContent.fields, + annotations: optional(Annotations), + _meta: Meta +}) + +export const ImageContent = Schema.Struct({ + ...Previous.ImageContent.fields, + annotations: optional(Annotations), + _meta: Meta +}) + +export const AudioContent = Schema.Struct({ + ...Previous.AudioContent.fields, + annotations: optional(Annotations), + _meta: Meta +}) + +export const ContentBlock = Schema.Union([ + TextContent, + ImageContent, + AudioContent, + EmbeddedResource, + ResourceLink +]) + +export const SamplingContent = Schema.Union([TextContent, ImageContent, AudioContent]) + +export const PromptMessage = Schema.Struct({ + role: Previous.Role, + content: ContentBlock +}) + +export const SamplingMessage = Schema.Struct({ + role: Previous.Role, + content: SamplingContent +}) + +export const ServerCapabilities = Schema.Struct({ + ...Previous.ServerCapabilities.fields +}) + +export const InitializeResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + protocolVersion: Schema.String, + capabilities: ServerCapabilities, + serverInfo: Implementation, + instructions: optional(Schema.String) +}) + +export class Initialize extends Rpc.make("initialize", { + success: InitializeResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + protocolVersion: Schema.String, + capabilities: ClientCapabilities, + clientInfo: Implementation + } +}) {} + +const ToolJsonSchema = Schema.StructWithRest( + Schema.Struct({ + type: Schema.Literal("object"), + properties: optional(Schema.Record(Schema.String, JsonObject)), + required: optional(Schema.Array(Schema.String)) + }), + [Schema.JsonObject] +) + +export const Tool = Schema.Struct({ + name: Schema.String, + title: optional(Schema.String), + description: optional(Schema.String), + inputSchema: ToolJsonSchema, + outputSchema: optional(ToolJsonSchema), + annotations: optional(Previous.ToolAnnotations), + _meta: Meta +}) + +export const CallToolResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + content: Schema.Array(ContentBlock), + structuredContent: optional(JsonObject), + isError: optional(Schema.Boolean) +}) + +export const CreateMessageResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + role: Previous.Role, + content: SamplingContent, + model: Schema.String, + stopReason: optional(Schema.String) +}) + +export class CreateMessage extends Rpc.make("sampling/createMessage", { + success: CreateMessageResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + messages: Schema.Array(SamplingMessage), + modelPreferences: optional(Previous.ModelPreferences), + systemPrompt: optional(Schema.String), + includeContext: optional(Schema.Literals(["none", "thisServer", "allServers"])), + temperature: optional(Schema.Finite), + maxTokens: Schema.Finite, + stopSequences: optional(Schema.Array(Schema.String)), + metadata: optional(JsonObject) + } +}) {} + +export const PromptReference = Schema.Struct({ + ...Previous.PromptReference.fields, + title: optional(Schema.String) +}) + +export const ResourceTemplateReference = Schema.Struct({ + type: Schema.Literal("ref/resource"), + uri: Schema.String +}) + +export const CompleteResult = Previous.CompleteResult + +export class Complete extends Rpc.make("completion/complete", { + success: CompleteResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + ref: Schema.Union([PromptReference, ResourceTemplateReference]), + argument: Schema.Struct({ + name: Schema.String, + value: Schema.String + }), + context: optional(Schema.Struct({ + arguments: optional(Schema.Record(Schema.String, Schema.String)) + })) + } +}) {} + +export const ListResourcesResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + resources: Schema.Array(Resource) +}) + +export const ListResourceTemplatesResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + resourceTemplates: Schema.Array(ResourceTemplate) +}) + +export const ReadResourceResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + contents: Schema.Array(ResourceContents) +}) + +export const ListPromptsResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + prompts: Schema.Array(Prompt) +}) + +export const GetPromptResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + description: optional(Schema.String), + messages: Schema.Array(PromptMessage) +}) + +export const ListToolsResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + tools: Schema.Array(Tool) +}) + +export class ListResources extends Rpc.make("resources/list", { + success: ListResourcesResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class ListResourceTemplates extends Rpc.make("resources/templates/list", { + success: ListResourceTemplatesResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class ReadResource extends Rpc.make("resources/read", { + success: ReadResourceResult, + error: Previous.McpError, + payload: { ...Previous.RequestMeta.fields, uri: Schema.String } +}) {} + +export class ListPrompts extends Rpc.make("prompts/list", { + success: ListPromptsResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class GetPrompt extends Rpc.make("prompts/get", { + success: GetPromptResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + name: Schema.String, + arguments: optional(Schema.Record(Schema.String, Schema.String)) + } +}) {} + +export class ListTools extends Rpc.make("tools/list", { + success: ListToolsResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class CallTool extends Rpc.make("tools/call", { + success: CallToolResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + name: Schema.String, + arguments: optional(JsonObject) + } +}) {} + +export const ElicitResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + action: Schema.Literals(["accept", "decline", "cancel"]), + content: optional(Schema.Record( + Schema.String, + Schema.Union([Schema.String, Schema.Finite, Schema.Boolean]) + )) +}) + +const StringSchema = Schema.Struct({ + type: Schema.Literal("string"), + title: optional(Schema.String), + description: optional(Schema.String), + minLength: optional(Schema.Int), + maxLength: optional(Schema.Int), + format: optional(Schema.Literals(["email", "uri", "date", "date-time"])) +}) +const NumberSchema = Schema.Struct({ + type: Schema.Literals(["number", "integer"]), + title: optional(Schema.String), + description: optional(Schema.String), + minimum: optional(Schema.Finite), + maximum: optional(Schema.Finite) +}) +const BooleanSchema = Schema.Struct({ + type: Schema.Literal("boolean"), + title: optional(Schema.String), + description: optional(Schema.String), + default: optional(Schema.Boolean) +}) +const EnumSchema = Schema.Struct({ + type: Schema.Literal("string"), + title: optional(Schema.String), + description: optional(Schema.String), + enum: Schema.Array(Schema.String), + enumNames: optional(Schema.Array(Schema.String)) +}) +const RequestedSchema = Schema.Struct({ + type: Schema.Literal("object"), + properties: Schema.Record( + Schema.String, + Schema.Union([StringSchema, NumberSchema, BooleanSchema, EnumSchema]) + ), + required: optional(Schema.Array(Schema.String)) +}) + +export class Elicit extends Rpc.make("elicitation/create", { + success: ElicitResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + message: Schema.String, + requestedSchema: RequestedSchema + } +}) {} + +export class ClientRequestRpcs extends RpcGroup.make( + Previous.Ping, + Initialize, + Complete, + Previous.SetLevel, + GetPrompt, + ListPrompts, + ListResources, + ListResourceTemplates, + ReadResource, + Previous.Subscribe, + Previous.Unsubscribe, + CallTool, + ListTools +) {} + +export class ClientNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + Previous.ProgressNotification, + Previous.InitializedNotification, + Previous.RootsListChangedNotification +) {} + +export class ClientRpcs extends ClientRequestRpcs.merge(ClientNotificationRpcs) {} + +export class ServerRequestRpcs extends RpcGroup.make( + Previous.Ping, + CreateMessage, + Previous.ListRoots, + Elicit +) {} + +export class ServerNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + Previous.ProgressNotification, + Previous.LoggingMessageNotification, + Previous.ResourceUpdatedNotification, + Previous.ResourceListChangedNotification, + Previous.ToolListChangedNotification, + Previous.PromptListChangedNotification +) {} diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_11_25.ts b/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_11_25.ts new file mode 100644 index 0000000000..fbbce35621 --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_11_25.ts @@ -0,0 +1,481 @@ +/** + * Supported non-Task MCP v2025-11-25 wire schemas. + * + * This module is a dated delta from v2025-06-18. Experimental Tasks are not + * part of the supported vocabulary for this adapter. + * + * @internal + */ +import * as Schema from "../../../../Schema.ts" +import * as Rpc from "../../../rpc/Rpc.ts" +import * as RpcGroup from "../../../rpc/RpcGroup.ts" +import * as Previous from "./v2025_06_18.ts" + +export * from "./v2025_06_18.ts" + +export const protocolVersion = "2025-11-25" + +const optional = Previous.optional +const JsonObject = Schema.JsonObject +const Meta = optional(JsonObject) + +export const Icon = Schema.Struct({ + src: Schema.String, + mimeType: optional(Schema.String), + sizes: optional(Schema.Array(Schema.String)), + theme: optional(Schema.Literals(["light", "dark"])) +}) + +export const Implementation = Schema.Struct({ + ...Previous.Implementation.fields, + description: optional(Schema.String), + websiteUrl: optional(Schema.String), + icons: optional(Schema.Array(Icon)) +}) + +export const ClientCapabilities = Schema.Struct({ + ...Previous.ClientCapabilities.fields, + sampling: optional(Schema.Struct({ + context: optional(JsonObject), + tools: optional(JsonObject) + })), + elicitation: optional(Schema.Struct({ + form: optional(JsonObject), + url: optional(JsonObject) + })) +}) + +export const Annotations = Schema.Struct({ + ...Previous.Annotations.fields, + lastModified: optional(Schema.String) +}) + +export const Resource = Schema.Struct({ + ...Previous.Resource.fields, + annotations: optional(Annotations), + icons: optional(Schema.Array(Icon)) +}) + +export const ResourceTemplate = Schema.Struct({ + ...Previous.ResourceTemplate.fields, + annotations: optional(Annotations), + icons: optional(Schema.Array(Icon)) +}) + +export const Prompt = Schema.Struct({ + ...Previous.Prompt.fields, + icons: optional(Schema.Array(Icon)) +}) + +export const TextContent = Schema.Struct({ + ...Previous.TextContent.fields, + annotations: optional(Annotations) +}) + +export const ImageContent = Schema.Struct({ + ...Previous.ImageContent.fields, + annotations: optional(Annotations) +}) + +export const AudioContent = Schema.Struct({ + ...Previous.AudioContent.fields, + annotations: optional(Annotations) +}) + +export const EmbeddedResource = Schema.Struct({ + ...Previous.EmbeddedResource.fields, + annotations: optional(Annotations) +}) + +export const ResourceLink = Schema.Struct({ + ...Resource.fields, + type: Schema.Literal("resource_link") +}) + +export const ContentBlock = Schema.Union([ + TextContent, + ImageContent, + AudioContent, + ResourceLink, + EmbeddedResource +]) + +export const PromptMessage = Schema.Struct({ + role: Previous.Role, + content: ContentBlock +}) + +export const Tool = Schema.Struct({ + ...Previous.Tool.fields, + icons: optional(Schema.Array(Icon)) +}) + +export const CallToolResult = Schema.Struct({ + ...Previous.CallToolResult.fields, + content: Schema.Array(ContentBlock) +}) + +export class CallTool extends Rpc.make("tools/call", { + success: CallToolResult, + error: Previous.McpError, + payload: Previous.CallTool.payloadSchema +}) {} + +export const ToolUseContent = Schema.Struct({ + type: Schema.Literal("tool_use"), + id: Schema.String, + name: Schema.String, + input: JsonObject, + _meta: Meta +}) + +export const ToolResultContent = Schema.Struct({ + type: Schema.Literal("tool_result"), + toolUseId: Schema.String, + content: Schema.Array(ContentBlock), + structuredContent: optional(JsonObject), + isError: optional(Schema.Boolean), + _meta: Meta +}) + +export const SamplingMessageContentBlock = Schema.Union([ + TextContent, + ImageContent, + AudioContent, + ToolUseContent, + ToolResultContent +]) + +export const SamplingMessage = Schema.Struct({ + role: Previous.Role, + content: Schema.Union([ + SamplingMessageContentBlock, + Schema.Array(SamplingMessageContentBlock) + ]), + _meta: Meta +}) + +export const ToolChoice = Schema.Struct({ + mode: optional(Schema.Literals(["auto", "required", "none"])) +}) + +export const CreateMessageResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + ...SamplingMessage.fields, + model: Schema.String, + stopReason: optional(Schema.String) +}) + +export class CreateMessage extends Rpc.make("sampling/createMessage", { + success: CreateMessageResult, + error: Previous.McpError, + payload: { + ...Previous.CreateMessage.payloadSchema.fields, + messages: Schema.Array(SamplingMessage), + maxTokens: Schema.Int, + tools: optional(Schema.Array(Tool)), + toolChoice: optional(ToolChoice) + } +}) {} + +export const Root = Schema.Struct({ + ...Previous.Root.fields, + _meta: Meta +}) + +export const ListRootsResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + roots: Schema.Array(Root) +}) + +export class ListRoots extends Rpc.make("roots/list", { + success: ListRootsResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.RequestMeta) +}) {} + +export const StringSchema = Schema.Struct({ + type: Schema.Literal("string"), + title: optional(Schema.String), + description: optional(Schema.String), + minLength: optional(Schema.Int), + maxLength: optional(Schema.Int), + format: optional(Schema.Literals(["email", "uri", "date", "date-time"])), + default: optional(Schema.String) +}) + +export const NumberSchema = Schema.Struct({ + type: Schema.Literals(["number", "integer"]), + title: optional(Schema.String), + description: optional(Schema.String), + minimum: optional(Schema.Finite), + maximum: optional(Schema.Finite), + default: optional(Schema.Finite) +}) + +export const BooleanSchema = Schema.Struct({ + type: Schema.Literal("boolean"), + title: optional(Schema.String), + description: optional(Schema.String), + default: optional(Schema.Boolean) +}) + +const EnumOption = Schema.Struct({ + const: Schema.String, + title: Schema.String +}) + +export const UntitledSingleSelectEnumSchema = Schema.Struct({ + type: Schema.Literal("string"), + title: optional(Schema.String), + description: optional(Schema.String), + enum: Schema.Array(Schema.String), + default: optional(Schema.String) +}) + +export const TitledSingleSelectEnumSchema = Schema.Struct({ + type: Schema.Literal("string"), + title: optional(Schema.String), + description: optional(Schema.String), + oneOf: Schema.Array(EnumOption), + default: optional(Schema.String) +}) + +export const SingleSelectEnumSchema = Schema.Union([ + UntitledSingleSelectEnumSchema, + TitledSingleSelectEnumSchema +]) + +export const UntitledMultiSelectEnumSchema = Schema.Struct({ + type: Schema.Literal("array"), + title: optional(Schema.String), + description: optional(Schema.String), + minItems: optional(Schema.Int), + maxItems: optional(Schema.Int), + items: Schema.Struct({ + type: Schema.Literal("string"), + enum: Schema.Array(Schema.String) + }), + default: optional(Schema.Array(Schema.String)) +}) + +export const TitledMultiSelectEnumSchema = Schema.Struct({ + type: Schema.Literal("array"), + title: optional(Schema.String), + description: optional(Schema.String), + minItems: optional(Schema.Int), + maxItems: optional(Schema.Int), + items: Schema.Struct({ + anyOf: Schema.Array(EnumOption) + }), + default: optional(Schema.Array(Schema.String)) +}) + +export const MultiSelectEnumSchema = Schema.Union([ + UntitledMultiSelectEnumSchema, + TitledMultiSelectEnumSchema +]) + +export const LegacyTitledEnumSchema = Schema.Struct({ + type: Schema.Literal("string"), + title: optional(Schema.String), + description: optional(Schema.String), + enum: Schema.Array(Schema.String), + enumNames: optional(Schema.Array(Schema.String)), + default: optional(Schema.String) +}) + +export const EnumSchema = Schema.Union([ + SingleSelectEnumSchema, + MultiSelectEnumSchema, + LegacyTitledEnumSchema +]) + +export const PrimitiveSchemaDefinition = Schema.Union([ + StringSchema, + NumberSchema, + BooleanSchema, + EnumSchema +]) + +export const RequestedSchema = Schema.Struct({ + $schema: optional(Schema.String), + type: Schema.Literal("object"), + properties: Schema.Record(Schema.String, PrimitiveSchemaDefinition), + required: optional(Schema.Array(Schema.String)) +}) + +export const ElicitRequestFormParams = Schema.Struct({ + ...Previous.RequestMeta.fields, + mode: optional(Schema.Literal("form")), + message: Schema.String, + requestedSchema: RequestedSchema +}) + +export const ElicitRequestURLParams = Schema.Struct({ + ...Previous.RequestMeta.fields, + mode: Schema.Literal("url"), + message: Schema.String, + elicitationId: Schema.String, + url: Schema.String +}) + +export const ElicitRequestParams = Schema.Union([ + ElicitRequestFormParams, + ElicitRequestURLParams +]) + +export const URL_ELICITATION_REQUIRED = -32042 + +export const URLElicitationRequiredError = Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: optional(Previous.RequestId), + error: Schema.Struct({ + code: Schema.Literal(URL_ELICITATION_REQUIRED), + message: Schema.String, + data: Schema.StructWithRest( + Schema.Struct({ + elicitations: Schema.Array(ElicitRequestURLParams) + }), + [Schema.JsonObject] + ) + }) +}) + +export const ElicitResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + action: Schema.Literals(["accept", "decline", "cancel"]), + content: optional(Schema.Record( + Schema.String, + Schema.Union([Schema.String, Schema.Finite, Schema.Boolean, Schema.Array(Schema.String)]) + )) +}) + +export class Elicit extends Rpc.make("elicitation/create", { + success: ElicitResult, + error: Previous.McpError, + payload: ElicitRequestParams +}) {} + +export class ElicitationCompleteNotification extends Rpc.make("notifications/elicitation/complete", { + payload: { + elicitationId: Schema.String + } +}) {} + +export const InitializeResult = Schema.Struct({ + ...Previous.InitializeResult.fields, + serverInfo: Implementation +}) + +export class Initialize extends Rpc.make("initialize", { + success: InitializeResult, + error: Previous.McpError, + payload: { + ...Previous.Initialize.payloadSchema.fields, + capabilities: ClientCapabilities, + clientInfo: Implementation + } +}) {} + +export const ListResourcesResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + resources: Schema.Array(Resource) +}) + +export const ListResourceTemplatesResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + resourceTemplates: Schema.Array(ResourceTemplate) +}) + +export const ListPromptsResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + prompts: Schema.Array(Prompt) +}) + +export const GetPromptResult = Schema.Struct({ + ...Previous.GetPromptResult.fields, + messages: Schema.Array(PromptMessage) +}) + +export const ListToolsResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + tools: Schema.Array(Tool) +}) + +export class ListResources extends Rpc.make("resources/list", { + success: ListResourcesResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class ListResourceTemplates extends Rpc.make("resources/templates/list", { + success: ListResourceTemplatesResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class ListPrompts extends Rpc.make("prompts/list", { + success: ListPromptsResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class GetPrompt extends Rpc.make("prompts/get", { + success: GetPromptResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + name: Schema.String, + arguments: optional(Schema.Record(Schema.String, Schema.String)) + } +}) {} + +export class ListTools extends Rpc.make("tools/list", { + success: ListToolsResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class ClientRequestRpcs extends RpcGroup.make( + Previous.Ping, + Initialize, + Previous.Complete, + Previous.SetLevel, + GetPrompt, + ListPrompts, + ListResources, + ListResourceTemplates, + Previous.ReadResource, + Previous.Subscribe, + Previous.Unsubscribe, + CallTool, + ListTools +) {} + +export class ClientNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + Previous.ProgressNotification, + Previous.InitializedNotification, + Previous.RootsListChangedNotification +) {} + +export class ClientRpcs extends ClientRequestRpcs.merge(ClientNotificationRpcs) {} + +export class ServerRequestRpcs extends RpcGroup.make( + Previous.Ping, + CreateMessage, + ListRoots, + Elicit +) {} + +export class ServerNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + Previous.ProgressNotification, + Previous.LoggingMessageNotification, + Previous.ResourceUpdatedNotification, + Previous.ResourceListChangedNotification, + Previous.ToolListChangedNotification, + Previous.PromptListChangedNotification, + ElicitationCompleteNotification +) {} diff --git a/repos/effect/packages/effect/src/unstable/ai/internal/structured-output.ts b/repos/effect/packages/effect/src/unstable/ai/internal/structured-output.ts index 2d090d162f..6a3cf8db90 100644 --- a/repos/effect/packages/effect/src/unstable/ai/internal/structured-output.ts +++ b/repos/effect/packages/effect/src/unstable/ai/internal/structured-output.ts @@ -1,6 +1,6 @@ import * as Arr from "../../../Array.ts" import * as InternalRecord from "../../../internal/record.ts" -import type * as JsonSchema from "../../../JsonSchema.ts" +import * as JsonSchema from "../../../JsonSchema.ts" import * as Option from "../../../Option.ts" import * as Predicate from "../../../Predicate.ts" import * as Schema from "../../../Schema.ts" @@ -18,6 +18,29 @@ const TUPLE_DESCRIPTION = "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements" const TUPLE_TAIL_DESCRIPTION = `${TUPLE_DESCRIPTION}. Post-rest elements use '__tail_0__', '__tail_1__', and so on` +/** @internal */ +export function resolveReference($ref: string, definitions: JsonSchema.Definitions): JsonSchema.JsonSchema { + const key = JsonSchema.getReferenceKey($ref) + if (key === undefined) { + throw new Error(`Unsupported reference ${JSON.stringify($ref)}`) + } + if (!Object.hasOwn(definitions, key)) { + throw new Error(`Invalid reference ${JSON.stringify($ref)}`) + } + return definitions[key] +} + +/** @internal */ +export function resolveTopLevelReference( + document: JsonSchema.Document<"draft-2020-12"> +): JsonSchema.Document<"draft-2020-12"> { + if (typeof document.schema.$ref !== "string") return document + return { + ...document, + schema: resolveReference(document.schema.$ref, document.definitions) + } +} + /** @internal */ export function toCodec( schema: Schema.ConstraintCodec diff --git a/repos/effect/packages/effect/src/unstable/cli/CliOutput.ts b/repos/effect/packages/effect/src/unstable/cli/CliOutput.ts index 53aa8eaf25..7ff3235721 100644 --- a/repos/effect/packages/effect/src/unstable/cli/CliOutput.ts +++ b/repos/effect/packages/effect/src/unstable/cli/CliOutput.ts @@ -411,7 +411,7 @@ interface Row { const renderTable = (rows: ReadonlyArray, widthCap?: number) => { const maxColumn = Math.max(...rows.map((r) => visualLength(r.left))) + 4 const col = widthCap === undefined ? maxColumn : Math.min(maxColumn, widthCap) - return rows.map(({ left, right }) => ` ${pad(left, col)}${right}`).join("\n") + return rows.map(({ left, right }) => ` ${pad(left, Math.max(col, visualLength(left) + 1))}${right}`).join("\n") } const formatSubcommandName = (name: string, alias: string | undefined): string => alias ? `${name}, ${alias}` : name diff --git a/repos/effect/packages/effect/src/unstable/cli/Command.ts b/repos/effect/packages/effect/src/unstable/cli/Command.ts index d4027c882c..41e6bada0b 100644 --- a/repos/effect/packages/effect/src/unstable/cli/Command.ts +++ b/repos/effect/packages/effect/src/unstable/cli/Command.ts @@ -98,7 +98,7 @@ import * as Prompt from "./Prompt.ts" * never * > = Command.make("deploy", { * env: Flag.string("env"), - * force: Flag.boolean("force"), + * force: Flag.boolean("force").pipe(Flag.withDefault(false)), * files: Argument.string("files").pipe(Argument.variadic()) * }) * @@ -456,7 +456,7 @@ export type Services = C extends Command< * * const parent = Command.make("app").pipe( * Command.withSharedFlags({ - * verbose: Flag.boolean("verbose"), + * verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), * config: Flag.string("config") * }) * ) @@ -582,14 +582,17 @@ export const isCommand = (u: unknown): u is Command.Any => Predicate.hasProperty * port: Flag.integer("port").pipe(Flag.withDefault(3000)) * }, * files: Argument.string("files").pipe(Argument.variadic), - * force: Flag.boolean("force").pipe(Flag.withDescription("Force deployment")) + * force: Flag.boolean("force").pipe( + * Flag.withDescription("Force deployment"), + * Flag.withDefault(false) + * ) * }) * * // Command with handler * const output: Array = [] * const deployWithHandler = Command.make("deploy", { * environment: Flag.string("env"), - * force: Flag.boolean("force") + * force: Flag.boolean("force").pipe(Flag.withDefault(false)) * }, (config) => * Effect.gen(function*() { * yield* Effect.sync(() => output.push(`Starting deployment to ${config.environment}`)) @@ -798,7 +801,7 @@ const normalizeSubcommandEntries = ( * // Parent command with shared flags * const git = Command.make("git").pipe( * Command.withSharedFlags({ - * verbose: Flag.boolean("verbose") + * verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)) * }) * ) * @@ -1623,7 +1626,8 @@ export const wizard = ( options?: { readonly prefix?: ReadonlyArray | undefined } | undefined -): Effect.Effect, CliError.CliError | Terminal.QuitError, Environment> => Wizard.run(command, options) +): Effect.Effect, CliError.CliError | Terminal.QuitError, Environment> => + Effect.map(Wizard.run(command, options), (result) => result.args) const getOutOfScopeGlobalFlagErrors = ( allFlags: ReadonlyArray>, @@ -1898,8 +1902,8 @@ export const runWith = ( command.name, ...args.filter((arg) => arg !== "--wizard" && !arg.startsWith("--wizard=")) ] - const wizardArgs = yield* Wizard.run(command, { commandPath, prefix }) - yield* Console.log(Wizard.renderCompletion(wizardArgs)) + const wizardResult = yield* Wizard.run(command, { commandPath, prefix }) + yield* Console.log(Wizard.renderCompletion(wizardResult.displayArgs)) const shouldRun = yield* Prompt.run(Prompt.toggle({ message: "Run this command?", initial: true, @@ -1908,7 +1912,7 @@ export const runWith = ( })) if (shouldRun) { yield* Console.log() - yield* runWith(command, { ...config, renderErrors: false })(wizardArgs.slice(1)) + yield* runWith(command, { ...config, renderErrors: false })(wizardResult.args.slice(1)) } }).pipe( Effect.catchTag("QuitError", () => Console.log(Wizard.renderQuit())) diff --git a/repos/effect/packages/effect/src/unstable/cli/Flag.ts b/repos/effect/packages/effect/src/unstable/cli/Flag.ts index e467479be9..48aed0520d 100644 --- a/repos/effect/packages/effect/src/unstable/cli/Flag.ts +++ b/repos/effect/packages/effect/src/unstable/cli/Flag.ts @@ -66,6 +66,7 @@ export const string = (name: string): Flag => Param.string(Param.flagKin * * const verboseFlag = Flag.boolean("verbose") * // Usage: --verbose (true) or --no-verbose (false) + * // Omission fails unless the flag is made optional or given a fallback. * verboseFlag.kind // => "flag" * ``` * diff --git a/repos/effect/packages/effect/src/unstable/cli/GlobalFlag.ts b/repos/effect/packages/effect/src/unstable/cli/GlobalFlag.ts index d9c1484af3..d0c0a34755 100644 --- a/repos/effect/packages/effect/src/unstable/cli/GlobalFlag.ts +++ b/repos/effect/packages/effect/src/unstable/cli/GlobalFlag.ts @@ -155,7 +155,8 @@ let settingIdCounter = 0 export const Help: Action = action({ flag: Flag.boolean("help").pipe( Flag.withAlias("h"), - Flag.withDescription("Show help information") + Flag.withDescription("Show help information"), + Flag.withDefault(false) ), run: Effect.fnUntraced(function*(_, { builtIns, command, commandPath }) { const formatter = yield* CliOutput.Formatter @@ -177,7 +178,8 @@ export const Help: Action = action({ export const Version: Action = action({ flag: Flag.boolean("version").pipe( Flag.withAlias("v"), - Flag.withDescription("Show version information") + Flag.withDescription("Show version information"), + Flag.withDefault(false) ), run: Effect.fnUntraced(function*(_, { command, version }) { const formatter = yield* CliOutput.Formatter @@ -198,7 +200,8 @@ export const Version: Action = action({ */ export const Wizard: Action = action({ flag: Flag.boolean("wizard").pipe( - Flag.withDescription("Start wizard mode for a command") + Flag.withDescription("Start wizard mode for a command"), + Flag.withDefault(false) ), run: () => Effect.void }) diff --git a/repos/effect/packages/effect/src/unstable/cli/Param.ts b/repos/effect/packages/effect/src/unstable/cli/Param.ts index d9ed6ded98..710856d218 100644 --- a/repos/effect/packages/effect/src/unstable/cli/Param.ts +++ b/repos/effect/packages/effect/src/unstable/cli/Param.ts @@ -400,8 +400,9 @@ export const string = ( * // Create a boolean argument * const enableArg = Param.boolean(Param.argumentKind, "enable") * - * // Usage in CLI: --verbose (defaults to true when present, false when absent) - * // or as positional: true/false + * // Usage in CLI: --verbose (true) or --no-verbose (false). + * // The flag is required unless made optional or given a fallback. + * // Boolean positional arguments accept true/false. * const kinds = [verboseFlag.kind, enableArg.kind] // => ["flag", "argument"] * ``` * @@ -1272,18 +1273,7 @@ export const optional = ( param: Param ): Param> => { const parse: Parse> = Effect.fnUntraced(function*(args) { - const single = getUnderlyingSingleOrThrow(param) - - // Handle boolean params that are explicitly marked as optional (i.e. the - // end user wants to return `Option.none()` instead of `false` when the - // flag (or its negated variant) are not present on the command line - if ( - isFlagParam(single) && - Primitive.isBoolean(single.primitiveType) && - ![single.name, ...single.aliases].some((name) => (args.flags[name] ?? []).length > 0) - ) { - return [args.arguments, Option.none()] as const - } + getUnderlyingSingleOrThrow(param) return yield* param.parse(args).pipe( Effect.map(([leftover, value]) => [leftover, Option.some(value)] as const), @@ -1967,13 +1957,7 @@ const parseFlag: ( const providedValues = args.flags[name] if (providedValues === undefined || providedValues.length === 0) { - // Option not provided (empty array due to initialization) - if (Primitive.isBoolean(primitiveType)) { - // Boolean params default to false when not present - return [args.arguments, false as any] as const - } else { - return yield* new CliError.MissingOption({ option: name }) - } + return yield* new CliError.MissingOption({ option: name }) } // Parse the first value (later we can handle multiple) diff --git a/repos/effect/packages/effect/src/unstable/cli/Prompt.ts b/repos/effect/packages/effect/src/unstable/cli/Prompt.ts index f21b1f0100..a2520bf60b 100644 --- a/repos/effect/packages/effect/src/unstable/cli/Prompt.ts +++ b/repos/effect/packages/effect/src/unstable/cli/Prompt.ts @@ -13,6 +13,7 @@ import * as Arr from "../../Array.ts" import type { NoSuchElementError } from "../../Cause.ts" import type * as Cause from "../../Cause.ts" +import * as Context from "../../Context.ts" import * as Data from "../../Data.ts" import * as Effect from "../../Effect.ts" import * as Effectable from "../../Effectable.ts" @@ -147,6 +148,65 @@ export interface Handlers { ) => Effect.Effect } +/** + * Defines the symbols used to render built-in prompts. + * + * Set a symbol to an empty string to omit both the symbol and its adjacent + * spacing. + * + * @category models + * @since 4.0.0 + */ +export interface Theme { + /** The leading symbol on an active prompt line. */ + readonly prefix: string + /** The marker for the highlighted choice. */ + readonly pointer: string + /** The trailing prompt and validation marker. */ + readonly pointerSmall: string + /** The marker shown when a prompt has been submitted. */ + readonly ellipsis: string + /** The leading symbol on a completed prompt line. */ + readonly tick: string + /** The marker indicating choices above the current page. */ + readonly arrowUp: string + /** The marker indicating choices below the current page. */ + readonly arrowDown: string + /** The marker for a selected multi-select choice. */ + readonly checkboxOn: string + /** The marker for an unselected multi-select choice. */ + readonly checkboxOff: string + /** The separator between a choice and its description. */ + readonly descriptionSeparator: string + /** The character used to mask password input. */ + readonly passwordMask: string + /** The separator between toggle labels. */ + readonly toggleSeparator: string + /** The color used for active prompt elements. */ + readonly primaryColor: string + /** The color used for secondary prompt elements. */ + readonly mutedColor: string + /** The color used for completed prompt markers. */ + readonly successColor: string + /** The color used for validation errors. */ + readonly errorColor: string + /** The color used for submitted values. */ + readonly submittedColor: string +} + +/** + * Options shared by built-in prompts that support theme overrides. + * + * @category options + * @since 4.0.0 + */ +export interface ThemeOptions { + /** Overrides the context theme for this prompt. */ + readonly theme?: Partial +} + +type OptionsReq = Required> & ThemeOptions + /** * Options for a confirmation prompt that asks the user to choose a boolean * yes/no value. @@ -154,7 +214,7 @@ export interface Handlers { * @category options * @since 4.0.0 */ -export interface ConfirmOptions { +export interface ConfirmOptions extends ThemeOptions { /** * The message to display in the prompt. */ @@ -200,7 +260,7 @@ export interface ConfirmOptions { * @category options * @since 4.0.0 */ -export interface DateOptions { +export interface DateOptions extends ThemeOptions { /** * The message to display in the prompt. */ @@ -275,7 +335,7 @@ export interface DateOptions { * @category options * @since 4.0.0 */ -export interface IntegerOptions { +export interface IntegerOptions extends ThemeOptions { /** * The message to display in the prompt. */ @@ -352,7 +412,7 @@ export interface ListOptions extends TextOptions { * @category options * @since 4.0.0 */ -export interface FileOptions { +export interface FileOptions extends ThemeOptions { /** * The path type that will be selected, defaulting to `"file"`. */ @@ -388,7 +448,7 @@ export interface FileOptions { * @category options * @since 4.0.0 */ -export interface SelectOptions { +export interface SelectOptions extends ThemeOptions { /** * The message to display in the prompt. */ @@ -493,7 +553,7 @@ export interface SelectChoice { * @category options * @since 4.0.0 */ -export interface TextOptions { +export interface TextOptions extends ThemeOptions { /** * The message to display in the prompt. */ @@ -516,7 +576,7 @@ export interface TextOptions { * @category options * @since 4.0.0 */ -export interface ToggleOptions { +export interface ToggleOptions extends ThemeOptions { /** * The message to display in the prompt. */ @@ -537,45 +597,62 @@ export interface ToggleOptions { readonly inactive?: string } -const defaultFigures = { +const defaultTheme: Theme = { + prefix: "?", arrowUp: "↑", arrowDown: "↓", - arrowLeft: "←", - arrowRight: "→", - radioOn: "◉", - radioOff: "◯", checkboxOn: "☒", checkboxOff: "☐", tick: "✔", - cross: "✖", ellipsis: "…", pointerSmall: "›", - line: "─", - pointer: "❯" -} - -const windowsFigures = { - arrowUp: defaultFigures.arrowUp, - arrowDown: defaultFigures.arrowDown, - arrowLeft: defaultFigures.arrowLeft, - arrowRight: defaultFigures.arrowRight, - radioOn: "(*)", - radioOff: "( )", + pointer: "❯", + descriptionSeparator: "- ", + passwordMask: "*", + toggleSeparator: "/", + primaryColor: Ansi.cyanBright, + mutedColor: Ansi.blackBright, + successColor: Ansi.green, + errorColor: Ansi.red, + submittedColor: Ansi.white +} + +const windowsTheme: Theme = { + ...defaultTheme, checkboxOn: "[*]", checkboxOff: "[ ]", tick: "√", - cross: "×", ellipsis: "...", pointerSmall: "»", - line: "─", pointer: ">" } -/** @internal */ -export const platformFigures = Effect.map( - Effect.sync(() => process.platform === "win32"), - (isWindows) => isWindows ? windowsFigures : defaultFigures -) +/** + * Creates a prompt theme using the current platform defaults. + * + * @category constructors + * @since 4.0.0 + */ +export const makeTheme = (options?: Partial): Theme => ({ + ...(process.platform === "win32" ? windowsTheme : defaultTheme), + ...options +}) + +/** + * Context reference for the theme used by built-in prompts. + * + * Provide this reference once to theme every prompt in an application. A + * prompt's `theme` option takes precedence over the context value. + * + * @category services + * @since 4.0.0 + */ +export const Theme: Context.Reference = Context.Reference("effect/unstable/cli/Prompt/Theme", { + defaultValue: makeTheme +}) + +const getTheme = (options: ThemeOptions): Effect.Effect => + Effect.map(Theme, (theme) => ({ ...theme, ...options.theme })) /** * Type alias for any `Prompt`, regardless of its output type. @@ -722,7 +799,21 @@ export const all: < } const annotateLine = (line: string): string => Ansi.annotate(line, Ansi.bold) -const annotateErrorLine = (line: string): string => Ansi.annotate(line, Ansi.combine(Ansi.italicized, Ansi.red)) +const annotateErrorLine = (line: string, color: string): string => + Ansi.annotate(line, Ansi.combine(Ansi.italicized, color)) +const annotateSymbol = (symbol: string, ...styles: Array>): string => + symbol.length === 0 ? "" : Ansi.annotate(symbol, ...styles) +const separateSymbol = (symbol: string, text: string): string => symbol.length === 0 ? text : symbol + " " + text +const renderPagingPrefix = (theme: Theme, showArrowUp: boolean, showArrowDown: boolean): string => { + const width = Math.max(theme.arrowUp.length, theme.arrowDown.length) + if (showArrowUp) { + return theme.arrowUp.padEnd(width) + } + if (showArrowDown) { + return theme.arrowDown.padEnd(width) + } + return " ".repeat(width) +} /** * Creates a confirmation prompt that asks the user to choose a boolean yes/no @@ -743,7 +834,7 @@ const annotateErrorLine = (line: string): string => Ansi.annotate(line, Ansi.com * @since 4.0.0 */ export const confirm = (options: ConfirmOptions): Prompt => { - const opts: Required = { + const opts: ConfirmOptionsReq = { initial: false, ...options, label: { @@ -838,7 +929,7 @@ export const custom: { * @since 4.0.0 */ export const date = (options: DateOptions): Prompt => { - const opts: Required = { + const opts: DateOptionsReq = { initial: new Date(), dateMask: "YYYY-MM-DD HH:mm:ss", validate: Effect.succeed, @@ -872,11 +963,17 @@ export const date = (options: DateOptions): Prompt => { * The prompt can be configured to select files, directories, or either path * type. * + * You can also type to filter the listed entries. Every printable character is + * appended to the filter query, so navigation is bound to the arrow keys, + * `tab`, and the `Ctrl+P` / `Ctrl+N` chords used by `readline` and `fzf` + * (`Ctrl+K` also moves up). `Ctrl+U` clears the query. + * * @category constructors * @since 4.0.0 */ export const file = (options: FileOptions = {}): Prompt => { const opts: FileOptionsReq = { + ...options, type: options.type ?? "file", message: options.message ?? `Choose a file`, startingPath: Option.fromUndefinedOr(options.startingPath), @@ -1149,6 +1246,12 @@ export const select = (options: SelectOptions): Prompt => { /** * Creates a prompt that lets users filter select choices by typing. * + * **Details** + * + * Every printable character is appended to the filter query, so navigation is + * bound to the arrow keys, `tab`, and the `Ctrl+P` / `Ctrl+N` chords used by + * `readline` and `fzf` (`Ctrl+K` also moves up). `Ctrl+U` clears the query. + * * **Example** (Filtering choices with autocomplete) * * ```ts import.meta.vitest @@ -1448,7 +1551,7 @@ const clearOutputWithError = (outputText: string, columns: number, errorText?: s return eraseText(outputText, columns) } -interface ConfirmOptionsReq extends Required {} +interface ConfirmOptionsReq extends OptionsReq {} interface ConfirmState { readonly value: boolean @@ -1462,13 +1565,13 @@ const handleConfirmClear = (options: ConfirmOptionsReq) => { return Effect.fnUntraced(function*(state: ConfirmState, _: Action) { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns - const figures = yield* platformFigures + const figures = yield* getTheme(options) const confirmMessage = state.value ? options.placeholder.defaultConfirm! : options.placeholder.defaultDeny! const promptText = renderConfirmOutput( confirmMessage, - "?", + figures.prefix, figures.pointerSmall, options, { plain: true } @@ -1488,24 +1591,24 @@ const renderConfirmOutput = ( ) => renderPrompt(confirm, options.message, leadingSymbol, trailingSymbol, renderOptions) const renderConfirmNextFrame = Effect.fnUntraced(function*(state: ConfirmState, options: ConfirmOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright) - const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright) + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor) + const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor) // Marking these explicitly as present with `!` because they always will be // and there is really no value in adding a `DeepRequired` type helper just // for these internal cases const confirmMessage = state.value ? options.placeholder.defaultConfirm! : options.placeholder.defaultDeny! - const confirm = Ansi.annotate(confirmMessage, Ansi.blackBright) + const confirm = Ansi.annotate(confirmMessage, figures.mutedColor) const promptMsg = renderConfirmOutput(confirm, leadingSymbol, trailingSymbol, options) return Ansi.cursorHide + promptMsg }) const renderConfirmSubmission = Effect.fnUntraced(function*(value: boolean, options: ConfirmOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) const confirmMessage = value ? options.label.confirm : options.label.deny const promptMsg = renderConfirmOutput(confirmMessage, leadingSymbol, trailingSymbol, options) return promptMsg + "\n" @@ -1538,7 +1641,7 @@ const handleConfirmProcess = (input: Terminal.UserInput, defaultValue: boolean) return Effect.succeed(Action.Beep()) } -interface DateOptionsReq extends Required {} +interface DateOptionsReq extends OptionsReq {} interface DateState { readonly typed: string @@ -1552,14 +1655,14 @@ const handleDateClear = (options: DateOptionsReq) => { return Effect.fnUntraced(function*(state: DateState, _: Action) { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns - const figures = yield* platformFigures + const figures = yield* getTheme(options) const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft const parts = Arr.reduce(state.dateParts, "", (doc, part) => doc + part.toString()) - const promptText = renderDateOutput("?", figures.pointerSmall, parts, options, { plain: true }) + const promptText = renderDateOutput(figures.prefix, figures.pointerSmall, parts, options, { plain: true }) const errorText = Option.isSome(state.error) ? Arr.match(state.error.value.split(NEWLINE_REGEXP), { onEmpty: () => "", - onNonEmpty: (errorLines) => `${figures.pointerSmall} ${errorLines.join("\n")}` + onNonEmpty: (errorLines) => separateSymbol(figures.pointerSmall, errorLines.join("\n")) }) : "" const clearOutput = clearOutputWithError(promptText, columns, errorText) @@ -1567,26 +1670,26 @@ const handleDateClear = (options: DateOptionsReq) => { }) } -const renderDateError = (state: DateState, pointer: string): string => { +const renderDateError = (state: DateState, pointer: string, theme: Theme): string => { if (Option.isSome(state.error)) { const errorLines = state.error.value.split(NEWLINE_REGEXP) if (Arr.isReadonlyArrayNonEmpty(errorLines)) { - const prefix = Ansi.annotate(pointer, Ansi.red) + " " - const lines = Arr.map(errorLines, (str) => annotateErrorLine(str)) - return Ansi.cursorSavePosition + "\n" + prefix + lines.join("\n") + Ansi.cursorRestorePosition + const prefix = annotateSymbol(pointer, theme.errorColor) + const lines = Arr.map(errorLines, (str) => annotateErrorLine(str, theme.errorColor)) + return Ansi.cursorSavePosition + "\n" + separateSymbol(prefix, lines.join("\n")) + Ansi.cursorRestorePosition } } return "" } -const renderParts = (state: DateState, submitted: boolean = false) => { +const renderParts = (state: DateState, theme: Theme, submitted: boolean = false) => { return Arr.reduce( state.dateParts, "", (doc, part, currentIndex) => { const partDoc = part.toString() if (currentIndex === state.cursor && !submitted) { - const annotation = Ansi.combine(Ansi.underlined, Ansi.cyanBright) + const annotation = Ansi.combine(Ansi.underlined, theme.primaryColor) return doc + Ansi.annotate(partDoc, annotation) } return doc + partDoc @@ -1603,20 +1706,20 @@ const renderDateOutput = ( ) => renderPrompt(parts, options.message, leadingSymbol, trailingSymbol, renderOptions) const renderDateNextFrame = Effect.fnUntraced(function*(state: DateState, options: DateOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright) - const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright) - const parts = renderParts(state) + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor) + const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor) + const parts = renderParts(state, figures) const promptMsg = renderDateOutput(leadingSymbol, trailingSymbol, parts, options) - const errorMsg = renderDateError(state, figures.pointerSmall) + const errorMsg = renderDateError(state, figures.pointerSmall, figures) return Ansi.cursorHide + promptMsg + errorMsg }) const renderDateSubmission = Effect.fnUntraced(function*(state: DateState, options: DateOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) - const parts = renderParts(state, true) + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) + const parts = renderParts(state, figures, true) const promptMsg = renderDateOutput(leadingSymbol, trailingSymbol, parts, options) return promptMsg + "\n" }) @@ -2087,7 +2190,7 @@ class Meridiem extends DatePart { } } -interface FileOptionsReq extends Required> { +interface FileOptionsReq extends OptionsReq> { readonly startingPath: Option.Option readonly default: Option.Option } @@ -2198,17 +2301,23 @@ const handleFileClear = (options: FileOptionsReq) => { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns const path = yield* Path.Path - const figures = yield* platformFigures + const figures = yield* getTheme(options) const currentPath = yield* resolveCurrentPath(state.path, options) const selectedPath = state.files[state.cursor] const resolvedPath = selectedPath === undefined ? currentPath : path.resolve(currentPath, selectedPath) - const resolvedPathText = `${figures.pointerSmall} ${resolvedPath}` + const resolvedPathText = separateSymbol(figures.pointerSmall, resolvedPath) const isConfirming = showConfirmation(state.confirm) const promptText = isConfirming - ? renderPrompt("(Y/n)", CONFIRM_MESSAGE, "?", figures.pointerSmall, { plain: true }) - : renderPrompt(renderFileFilter(state, { plain: true }), options.message, figures.tick, figures.ellipsis, { - plain: true - }) + ? renderPrompt("(Y/n)", CONFIRM_MESSAGE, figures.prefix, figures.pointerSmall, { plain: true }) + : renderPrompt( + renderFileFilter(state, figures, { plain: true }), + options.message, + figures.tick, + figures.ellipsis, + { + plain: true + } + ) const filesText = isConfirming ? "" : renderFiles(state, state.files, figures, options, { plain: true }) @@ -2232,15 +2341,27 @@ const renderPrompt = ( trailingSymbol: string, options?: RenderOptions | undefined ) => { - const prefix = leadingSymbol + " " + const prefix = leadingSymbol.length === 0 ? "" : leadingSymbol + " " + const renderLine = (line: string) => { + let output = prefix + line + if (trailingSymbol.length > 0) { + output += " " + trailingSymbol + } + if (confirm.length > 0) { + output += " " + confirm + } else if (trailingSymbol.length > 0) { + output += " " + } + return output + } const annotate = options?.plain === true ? (line: string) => line : annotateLine return Arr.match(message.split(NEWLINE_REGEXP), { - onEmpty: () => prefix + " " + trailingSymbol + " " + confirm, + onEmpty: () => renderLine(""), onNonEmpty: (promptLines) => { const lines = Arr.map(promptLines, (line) => annotate(line)) - return prefix + lines.join("\n") + " " + trailingSymbol + " " + confirm + return renderLine(lines.join("\n")) } }) } @@ -2250,47 +2371,51 @@ const renderPrefix = ( toDisplay: { readonly startIndex: number; readonly endIndex: number }, currentIndex: number, length: number, - figures: Effect.Success, + figures: Theme, renderOptions?: RenderOptions | undefined ) => { - let prefix = " " - if (currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0) { - prefix = figures.arrowUp - } else if (currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < length) { - prefix = figures.arrowDown - } + const prefix = renderPagingPrefix( + figures, + currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0, + currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < length + ) if (state.cursor === currentIndex) { return renderOptions?.plain === true ? figures.pointer + prefix - : Ansi.annotate(figures.pointer, Ansi.cyanBright) + prefix + : annotateSymbol(figures.pointer, figures.primaryColor) + prefix } - return prefix + " " + return prefix + " ".repeat(figures.pointer.length) } -const renderFileName = (file: string, isSelected: boolean, renderOptions?: RenderOptions | undefined) => { +const renderFileName = ( + file: string, + isSelected: boolean, + theme: Theme, + renderOptions?: RenderOptions | undefined +) => { if (renderOptions?.plain === true) { return file } return isSelected - ? Ansi.annotate(file, Ansi.combine(Ansi.underlined, Ansi.cyanBright)) + ? Ansi.annotate(file, Ansi.combine(Ansi.underlined, theme.primaryColor)) : file } -const renderFileFilter = (state: FileState, renderOptions?: RenderOptions | undefined) => { +const renderFileFilter = (state: FileState, theme: Theme, renderOptions?: RenderOptions | undefined) => { const filterValue = state.query.length === 0 ? renderOptions?.plain === true ? FILE_FILTER_PLACEHOLDER - : Ansi.annotate(FILE_FILTER_PLACEHOLDER, Ansi.blackBright) + : Ansi.annotate(FILE_FILTER_PLACEHOLDER, theme.mutedColor) : renderOptions?.plain === true ? state.query - : Ansi.annotate(state.query, Ansi.combine(Ansi.underlined, Ansi.cyanBright)) + : Ansi.annotate(state.query, Ansi.combine(Ansi.underlined, theme.primaryColor)) return `[${FILE_FILTER_LABEL}: ${filterValue}]` } const renderFiles = ( state: FileState, files: ReadonlyArray, - figures: Effect.Success, + figures: Theme, options: FileOptionsReq, renderOptions?: RenderOptions | undefined ) => { @@ -2298,14 +2423,14 @@ const renderFiles = ( if (length === 0) { return renderOptions?.plain === true ? FILE_EMPTY_MESSAGE - : Ansi.annotate(FILE_EMPTY_MESSAGE, Ansi.blackBright) + : Ansi.annotate(FILE_EMPTY_MESSAGE, figures.mutedColor) } const toDisplay = entriesToDisplay(state.cursor, length, options.maxPerPage) const documents: Array = [] for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) { const isSelected = state.cursor === index const prefix = renderPrefix(state, toDisplay, index, length, figures, renderOptions) - const fileName = renderFileName(files[index], isSelected, renderOptions) + const fileName = renderFileName(files[index], isSelected, figures, renderOptions) documents.push(prefix + fileName) } return documents.join("\n") @@ -2313,32 +2438,32 @@ const renderFiles = ( const renderFileNextFrame = Effect.fnUntraced(function*(state: FileState, options: FileOptionsReq) { const path = yield* Path.Path - const figures = yield* platformFigures + const figures = yield* getTheme(options) const currentPath = yield* resolveCurrentPath(state.path, options) const selectedPath = state.files[state.cursor] const resolvedPath = selectedPath === undefined ? currentPath : path.resolve(currentPath, selectedPath) - const resolvedPathMsg = Ansi.annotate(figures.pointerSmall + " " + resolvedPath, Ansi.blackBright) + const resolvedPathMsg = Ansi.annotate(separateSymbol(figures.pointerSmall, resolvedPath), figures.mutedColor) if (showConfirmation(state.confirm)) { - const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright) - const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright) - const confirm = Ansi.annotate("(Y/n)", Ansi.blackBright) + const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor) + const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor) + const confirm = Ansi.annotate("(Y/n)", figures.mutedColor) const promptMsg = renderPrompt(confirm, CONFIRM_MESSAGE, leadingSymbol, trailingSymbol) return Ansi.cursorHide + promptMsg + "\n" + resolvedPathMsg } - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) - const promptMsg = renderPrompt(renderFileFilter(state), options.message, leadingSymbol, trailingSymbol) + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) + const promptMsg = renderPrompt(renderFileFilter(state, figures), options.message, leadingSymbol, trailingSymbol) const files = renderFiles(state, state.files, figures, options) return Ansi.cursorHide + promptMsg + "\n" + resolvedPathMsg + "\n" + files }) const renderFileSubmission = Effect.fnUntraced(function*(state: FileState, value: string, options: FileOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) - const promptMsg = renderPrompt(renderFileFilter(state), options.message, leadingSymbol, trailingSymbol) - return promptMsg + " " + Ansi.annotate(value, Ansi.white) + "\n" + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) + const promptMsg = renderPrompt(renderFileFilter(state, figures), options.message, leadingSymbol, trailingSymbol) + return promptMsg + " " + Ansi.annotate(value, figures.submittedColor) + "\n" }) const handleFileRender = (options: FileOptionsReq) => { @@ -2433,21 +2558,32 @@ const processSelection = Effect.fnUntraced(function*(state: FileState, options: const handleFileProcess = (options: FileOptionsReq) => { return Effect.fnUntraced(function*(input: Terminal.UserInput, state: FileState) { + // Navigation is bound to ctrl chords rather than bare `j`/`k`, so that every + // printable character reaches the filter input. if (input.key.ctrl) { - if (input.key.name === "u") { - if (showConfirmation(state.confirm)) { + switch (input.key.name) { + case "u": { + if (showConfirmation(state.confirm)) { + return Action.Beep() + } + return yield* processFileClear(state) + } + case "p": + case "k": { + return yield* processFileCursorUp(state) + } + case "n": { + return yield* processFileCursorDown(state) + } + default: { return Action.Beep() } - return yield* processFileClear(state) } - return Action.Beep() } switch (input.key.name) { - case "k": case "up": { return yield* processFileCursorUp(state) } - case "j": case "down": case "tab": { return yield* processFileCursorDown(state) @@ -2504,7 +2640,7 @@ const handleFileProcess = (options: FileOptionsReq) => { }) } -interface SelectOptionsReq extends Required> {} +interface SelectOptionsReq extends OptionsReq> {} interface MultiSelectOptionsReq extends MultiSelectOptions {} type MultiSelectState = { @@ -2516,6 +2652,7 @@ type MultiSelectState = { const renderMultiSelectError = ( state: MultiSelectState, pointer: string, + theme: Theme, renderOptions?: RenderOptions | undefined ): string => { if (Option.isSome(state.error)) { @@ -2523,11 +2660,11 @@ const renderMultiSelectError = ( onEmpty: () => "", onNonEmpty: (errorLines) => { if (renderOptions?.plain === true) { - return `${pointer} ${errorLines.join("\n")}` + return separateSymbol(pointer, errorLines.join("\n")) } - const prefix = Ansi.annotate(pointer, Ansi.red) + " " - const lines = Arr.map(errorLines, (str) => annotateErrorLine(str)) - return Ansi.cursorSavePosition + "\n" + prefix + lines.join("\n") + Ansi.cursorRestorePosition + const prefix = annotateSymbol(pointer, theme.errorColor) + const lines = Arr.map(errorLines, (str) => annotateErrorLine(str, theme.errorColor)) + return Ansi.cursorSavePosition + "\n" + separateSymbol(prefix, lines.join("\n")) + Ansi.cursorRestorePosition } }) } @@ -2537,12 +2674,14 @@ const renderMultiSelectError = ( const renderChoiceDescription = ( choice: SelectChoice, isActive: boolean, + theme: Theme, renderOptions?: RenderOptions | undefined ) => { if (!choice.disabled && choice.description && isActive) { + const text = theme.descriptionSeparator + choice.description return renderOptions?.plain === true - ? "- " + choice.description - : Ansi.annotate("- " + choice.description, Ansi.blackBright) + ? text + : Ansi.annotate(text, theme.mutedColor) } return "" } @@ -2552,21 +2691,23 @@ const metaOptionsCount = 2 const renderMultiSelectTitle = ( title: string, isHighlighted: boolean, + theme: Theme, renderOptions?: RenderOptions | undefined ) => { if (renderOptions?.plain === true || !isHighlighted) { return title } - return Ansi.annotate(title, Ansi.combine(Ansi.underlined, Ansi.cyanBright)) + return Ansi.annotate(title, Ansi.combine(Ansi.underlined, theme.primaryColor)) } const renderMultiSelectChoices = ( state: MultiSelectState, options: SelectOptionsReq & MultiSelectOptionsReq, - figures: Effect.Success, + figures: Theme, renderOptions?: RenderOptions | undefined ) => { const choices = options.choices + const checkboxWidth = Math.max(figures.checkboxOn.length, figures.checkboxOff.length) const selectableCount = choices.filter((choice) => !choice.disabled).length const selectedCount = Array.from(state.selectedIndices).filter((index) => !choices[index].disabled).length const allSelected = selectedCount === selectableCount @@ -2587,28 +2728,32 @@ const renderMultiSelectChoices = ( for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) { const choice = allChoices[index] const isHighlighted = state.index === index - let prefix = " " - if (index === toDisplay.startIndex && toDisplay.startIndex > 0) { - prefix = figures.arrowUp - } else if (index === toDisplay.endIndex - 1 && toDisplay.endIndex < allChoices.length) { - prefix = figures.arrowDown - } + const prefix = renderPagingPrefix( + figures, + index === toDisplay.startIndex && toDisplay.startIndex > 0, + index === toDisplay.endIndex - 1 && toDisplay.endIndex < allChoices.length + ) if (index < metaOptions.length) { // Meta options - const title = renderMultiSelectTitle(choice.title, isHighlighted, renderOptions) - documents.push(prefix + " " + title) + const title = renderMultiSelectTitle(choice.title, isHighlighted, figures, renderOptions) + documents.push(prefix + (prefix.length === 0 ? "" : " ") + title) } else { // Regular choices const choiceIndex = index - metaOptions.length const isSelected = state.selectedIndices.has(choiceIndex) const checkbox = isSelected ? figures.checkboxOn : figures.checkboxOff const annotatedCheckbox = isHighlighted && renderOptions?.plain !== true - ? Ansi.annotate(checkbox, Ansi.cyanBright) + ? Ansi.annotate(checkbox, figures.primaryColor) : checkbox const selectChoice = choice as SelectChoice - const title = renderChoiceTitle(selectChoice, isHighlighted, renderOptions) - const description = renderChoiceDescription(selectChoice, isHighlighted, renderOptions) - documents.push(prefix + " " + annotatedCheckbox + " " + title + " " + description) + const title = renderChoiceTitle(selectChoice, isHighlighted, figures, renderOptions) + const description = renderChoiceDescription(selectChoice, isHighlighted, figures, renderOptions) + const checkboxPrefix = checkboxWidth === 0 + ? "" + : annotatedCheckbox + " ".repeat(checkboxWidth - checkbox.length + 1) + documents.push( + prefix + (prefix.length === 0 ? "" : " ") + checkboxPrefix + title + " " + description + ) } } return documents.join("\n") @@ -2616,27 +2761,27 @@ const renderMultiSelectChoices = ( const renderMultiSelectNextFrame = Effect.fnUntraced( function*(state: MultiSelectState, options: SelectOptionsReq) { - const figures = yield* platformFigures + const figures = yield* getTheme(options) const choices = renderMultiSelectChoices(state, options, figures) - const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright) - const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright) + const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor) + const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor) const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options) - const error = renderMultiSelectError(state, figures.pointer) + const error = renderMultiSelectError(state, figures.pointer, figures) return Ansi.cursorHide + promptMsg + "\n" + choices + error } ) const renderMultiSelectSubmission = Effect.fnUntraced( function*(state: MultiSelectState, options: SelectOptionsReq) { - const figures = yield* platformFigures + const figures = yield* getTheme(options) const selectedChoices = Array.from(state.selectedIndices).sort(EffectNumber.Order).map((index) => options.choices[index].title ) const selectedText = selectedChoices.join(", ") - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options) - return promptMsg + " " + Ansi.annotate(selectedText, Ansi.white) + "\n" + return promptMsg + " " + Ansi.annotate(selectedText, figures.submittedColor) + "\n" } ) @@ -2692,11 +2837,11 @@ const handleMultiSelectClear = (options: SelectOptionsReq) => Effect.fnUntraced(function*(state: MultiSelectState, _: Action>) { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns - const figures = yield* platformFigures + const figures = yield* getTheme(options) const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft - const promptText = renderSelectOutput("?", figures.pointerSmall, options, { plain: true }) + const promptText = renderSelectOutput(figures.prefix, figures.pointerSmall, options, { plain: true }) const choicesText = renderMultiSelectChoices(state, options, figures, { plain: true }) - const errorText = renderMultiSelectError(state, figures.pointer, { plain: true }) + const errorText = renderMultiSelectError(state, figures.pointer, figures, { plain: true }) const clearOutput = clearOutputWithError(`${promptText}\n${choicesText}`, columns, errorText) return clearOutput + clearPrompt }) @@ -2751,8 +2896,8 @@ const handleMultiSelectRender = (options: SelectOptionsReq) => { } } -interface IntegerOptionsReq extends Required {} -interface FloatOptionsReq extends Required {} +interface IntegerOptionsReq extends OptionsReq {} +interface FloatOptionsReq extends OptionsReq {} interface NumberState { readonly cursor: number @@ -2764,10 +2909,12 @@ const handleNumberClear = (options: IntegerOptionsReq) => { return Effect.fnUntraced(function*(state: NumberState, _: Action) { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns - const figures = yield* platformFigures + const figures = yield* getTheme(options) const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft - const errorText = renderNumberError(state, figures.pointerSmall, { plain: true }) - const promptText = renderNumberOutput(state, "?", figures.pointerSmall, options, { plain: true }) + const errorText = renderNumberError(state, figures.pointerSmall, figures, { plain: true }) + const promptText = renderNumberOutput(state, figures.prefix, figures.pointerSmall, options, figures, { + plain: true + }) const clearOutput = clearOutputWithError(promptText, columns, errorText) return clearOutput + resetCurrentLine }) @@ -2776,6 +2923,7 @@ const handleNumberClear = (options: IntegerOptionsReq) => { const renderNumberInput = ( state: NumberState, submitted: boolean, + theme: Theme, renderOptions?: RenderOptions | undefined ): string => { const value = state.value === "" ? "" : `${state.value}` @@ -2783,14 +2931,15 @@ const renderNumberInput = ( return value } const annotation = Option.isSome(state.error) ? - Ansi.red : - Ansi.combine(Ansi.underlined, Ansi.cyanBright) + theme.errorColor : + Ansi.combine(Ansi.underlined, theme.primaryColor) return Ansi.annotate(value, annotation) } const renderNumberError = ( state: NumberState, pointer: string, + theme: Theme, renderOptions?: RenderOptions | undefined ) => { if (Option.isSome(state.error)) { @@ -2798,11 +2947,11 @@ const renderNumberError = ( onEmpty: () => "", onNonEmpty: (errorLines) => { if (renderOptions?.plain === true) { - return `${pointer} ${errorLines.join("\n")}` + return separateSymbol(pointer, errorLines.join("\n")) } - const prefix = Ansi.annotate(pointer, Ansi.red) + " " - const lines = Arr.map(errorLines, (str) => annotateErrorLine(str)) - return Ansi.cursorSavePosition + "\n" + prefix + lines.join("\n") + Ansi.cursorRestorePosition + const prefix = annotateSymbol(pointer, theme.errorColor) + const lines = Arr.map(errorLines, (str) => annotateErrorLine(str, theme.errorColor)) + return Ansi.cursorSavePosition + "\n" + separateSymbol(prefix, lines.join("\n")) + Ansi.cursorRestorePosition } }) } @@ -2814,27 +2963,28 @@ const renderNumberOutput = ( leadingSymbol: string, trailingSymbol: string, options: IntegerOptionsReq, + theme: Theme, renderOptions?: RenderOptions | undefined, submitted: boolean = false ) => { - const value = renderNumberInput(state, submitted, renderOptions) + const value = renderNumberInput(state, submitted, theme, renderOptions) return renderPrompt(value, options.message, leadingSymbol, trailingSymbol, renderOptions) } const renderNumberNextFrame = Effect.fnUntraced(function*(state: NumberState, options: IntegerOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright) - const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright) - const errorMsg = renderNumberError(state, figures.pointerSmall) - const promptMsg = renderNumberOutput(state, leadingSymbol, trailingSymbol, options) + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor) + const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor) + const errorMsg = renderNumberError(state, figures.pointerSmall, figures) + const promptMsg = renderNumberOutput(state, leadingSymbol, trailingSymbol, options, figures) return promptMsg + errorMsg }) const renderNumberSubmission = Effect.fnUntraced(function*(nextState: NumberState, options: IntegerOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) - const promptMsg = renderNumberOutput(nextState, leadingSymbol, trailingSymbol, options, undefined, true) + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) + const promptMsg = renderNumberOutput(nextState, leadingSymbol, trailingSymbol, options, figures, undefined, true) return promptMsg + "\n" }) @@ -3056,8 +3206,8 @@ type AutoCompleteState = { readonly filtered: ReadonlyArray } -interface SelectOptionsReq extends Required> {} -interface AutoCompleteOptionsReq extends Required> {} +interface SelectOptionsReq extends OptionsReq> {} +interface AutoCompleteOptionsReq extends OptionsReq> {} const filterAutoCompleteChoices = (choices: ReadonlyArray>, query: string) => { const normalizedQuery = query.toLowerCase() @@ -3098,15 +3248,16 @@ const renderSelectOutput = ( const renderAutoCompleteFilter = ( state: AutoCompleteState, options: AutoCompleteOptionsReq, + theme: Theme, renderOptions?: RenderOptions | undefined ) => { const filterValue = state.query.length === 0 ? renderOptions?.plain === true ? options.filterPlaceholder - : Ansi.annotate(options.filterPlaceholder, Ansi.blackBright) + : Ansi.annotate(options.filterPlaceholder, theme.mutedColor) : renderOptions?.plain === true ? state.query - : Ansi.annotate(state.query, Ansi.combine(Ansi.underlined, Ansi.cyanBright)) + : Ansi.annotate(state.query, Ansi.combine(Ansi.underlined, theme.primaryColor)) return `[${options.filterLabel}: ${filterValue}]` } @@ -3115,9 +3266,10 @@ const renderAutoCompleteOutput = ( leadingSymbol: string, trailingSymbol: string, options: AutoCompleteOptionsReq, + theme: Theme, renderOptions?: RenderOptions | undefined ) => { - const filter = renderAutoCompleteFilter(state, options, renderOptions) + const filter = renderAutoCompleteFilter(state, options, theme, renderOptions) return renderPrompt(filter, options.message, leadingSymbol, trailingSymbol, renderOptions) } @@ -3126,29 +3278,28 @@ const renderChoicePrefix = ( choices: SelectOptionsReq["choices"], toDisplay: { readonly startIndex: number; readonly endIndex: number }, currentIndex: number, - figures: Effect.Success, + figures: Theme, renderOptions?: RenderOptions | undefined ) => { - let prefix = " " - if (currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0) { - prefix = figures.arrowUp - } else if (currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < choices.length) { - prefix = figures.arrowDown - } + const prefix = renderPagingPrefix( + figures, + currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0, + currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < choices.length + ) if (renderOptions?.plain === true) { return state === currentIndex ? figures.pointer + prefix - : prefix + " " + : prefix + " ".repeat(figures.pointer.length) } if (choices[currentIndex].disabled) { - const annotation = Ansi.combine(Ansi.bold, Ansi.blackBright) + const annotation = Ansi.combine(Ansi.bold, figures.mutedColor) return state === currentIndex - ? Ansi.annotate(figures.pointer, annotation) + prefix - : prefix + " " + ? annotateSymbol(figures.pointer, annotation) + prefix + : prefix + " ".repeat(figures.pointer.length) } return state === currentIndex - ? Ansi.annotate(figures.pointer, Ansi.cyanBright) + prefix - : prefix + " " + ? annotateSymbol(figures.pointer, figures.primaryColor) + prefix + : prefix + " ".repeat(figures.pointer.length) } const renderAutoCompleteChoicePrefix = ( @@ -3156,36 +3307,36 @@ const renderAutoCompleteChoicePrefix = ( options: AutoCompleteOptionsReq, toDisplay: { readonly startIndex: number; readonly endIndex: number }, currentIndex: number, - figures: Effect.Success, + figures: Theme, renderOptions?: RenderOptions | undefined ) => { - let prefix = " " - if (currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0) { - prefix = figures.arrowUp - } else if (currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < state.filtered.length) { - prefix = figures.arrowDown - } + const prefix = renderPagingPrefix( + figures, + currentIndex === toDisplay.startIndex && toDisplay.startIndex > 0, + currentIndex === toDisplay.endIndex - 1 && toDisplay.endIndex < state.filtered.length + ) const choiceIndex = state.filtered[currentIndex] if (renderOptions?.plain === true) { return state.index === choiceIndex ? figures.pointer + prefix - : prefix + " " + : prefix + " ".repeat(figures.pointer.length) } const choice = options.choices[choiceIndex] if (choice.disabled) { - const annotation = Ansi.combine(Ansi.bold, Ansi.blackBright) + const annotation = Ansi.combine(Ansi.bold, figures.mutedColor) return state.index === choiceIndex - ? Ansi.annotate(figures.pointer, annotation) + prefix - : prefix + " " + ? annotateSymbol(figures.pointer, annotation) + prefix + : prefix + " ".repeat(figures.pointer.length) } return state.index === choiceIndex - ? Ansi.annotate(figures.pointer, Ansi.cyanBright) + prefix - : prefix + " " + ? annotateSymbol(figures.pointer, figures.primaryColor) + prefix + : prefix + " ".repeat(figures.pointer.length) } const renderChoiceTitle = ( choice: SelectChoice, isSelected: boolean, + theme: Theme, renderOptions?: RenderOptions | undefined ) => { if (renderOptions?.plain === true) { @@ -3194,18 +3345,18 @@ const renderChoiceTitle = ( const title = choice.title if (isSelected) { return choice.disabled - ? Ansi.annotate(title, Ansi.combine(Ansi.underlined, Ansi.blackBright)) - : Ansi.annotate(title, Ansi.combine(Ansi.underlined, Ansi.cyanBright)) + ? Ansi.annotate(title, Ansi.combine(Ansi.underlined, theme.mutedColor)) + : Ansi.annotate(title, Ansi.combine(Ansi.underlined, theme.primaryColor)) } return choice.disabled - ? Ansi.annotate(title, Ansi.combine(Ansi.strikethrough, Ansi.blackBright)) + ? Ansi.annotate(title, Ansi.combine(Ansi.strikethrough, theme.mutedColor)) : title } const renderSelectChoices = ( state: SelectState, options: SelectOptionsReq, - figures: Effect.Success, + figures: Theme, renderOptions?: RenderOptions | undefined ) => { const choices = options.choices @@ -3215,8 +3366,8 @@ const renderSelectChoices = ( const choice = choices[index] const isSelected = state === index const prefix = renderChoicePrefix(state, choices, toDisplay, index, figures, renderOptions) - const title = renderChoiceTitle(choice, isSelected, renderOptions) - const description = renderChoiceDescription(choice, isSelected, renderOptions) + const title = renderChoiceTitle(choice, isSelected, figures, renderOptions) + const description = renderChoiceDescription(choice, isSelected, figures, renderOptions) documents.push(prefix + title + " " + description) } return documents.join("\n") @@ -3225,13 +3376,13 @@ const renderSelectChoices = ( const renderAutoCompleteChoices = ( state: AutoCompleteState, options: AutoCompleteOptionsReq, - figures: Effect.Success, + figures: Theme, renderOptions?: RenderOptions | undefined ) => { if (state.filtered.length === 0) { return renderOptions?.plain === true ? options.emptyMessage - : Ansi.annotate(options.emptyMessage, Ansi.blackBright) + : Ansi.annotate(options.emptyMessage, figures.mutedColor) } const cursor = autoCompleteCursor(state) const toDisplay = entriesToDisplay(cursor, state.filtered.length, options.maxPerPage) @@ -3241,18 +3392,18 @@ const renderAutoCompleteChoices = ( const choice = options.choices[choiceIndex] const isSelected = state.index === choiceIndex const prefix = renderAutoCompleteChoicePrefix(state, options, toDisplay, index, figures, renderOptions) - const title = renderChoiceTitle(choice, isSelected, renderOptions) - const description = renderChoiceDescription(choice, isSelected, renderOptions) + const title = renderChoiceTitle(choice, isSelected, figures, renderOptions) + const description = renderChoiceDescription(choice, isSelected, figures, renderOptions) documents.push(prefix + title + " " + description) } return documents.join("\n") } const renderSelectNextFrame = Effect.fnUntraced(function*(state: SelectState, options: SelectOptionsReq) { - const figures = yield* platformFigures + const figures = yield* getTheme(options) const choices = renderSelectChoices(state, options, figures) - const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright) - const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright) + const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor) + const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor) const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options) return Ansi.cursorHide + promptMsg + "\n" + choices }) @@ -3261,33 +3412,33 @@ const renderAutoCompleteNextFrame = Effect.fnUntraced(function*( state: AutoCompleteState, options: AutoCompleteOptionsReq ) { - const figures = yield* platformFigures + const figures = yield* getTheme(options) const choices = renderAutoCompleteChoices(state, options, figures) - const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright) - const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright) - const promptMsg = renderAutoCompleteOutput(state, leadingSymbol, trailingSymbol, options) + const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor) + const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor) + const promptMsg = renderAutoCompleteOutput(state, leadingSymbol, trailingSymbol, options, figures) return Ansi.cursorHide + promptMsg + "\n" + choices }) const renderSelectSubmission = Effect.fnUntraced(function*(state: SelectState, options: SelectOptionsReq) { - const figures = yield* platformFigures + const figures = yield* getTheme(options) const selected = options.choices[state].title - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) const promptMsg = renderSelectOutput(leadingSymbol, trailingSymbol, options) - return promptMsg + " " + Ansi.annotate(selected, Ansi.white) + "\n" + return promptMsg + " " + Ansi.annotate(selected, figures.submittedColor) + "\n" }) const renderAutoCompleteSubmission = Effect.fnUntraced(function*( state: AutoCompleteState, options: AutoCompleteOptionsReq ) { - const figures = yield* platformFigures + const figures = yield* getTheme(options) const selected = options.choices[state.index].title - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) - const promptMsg = renderAutoCompleteOutput(state, leadingSymbol, trailingSymbol, options) - return promptMsg + " " + Ansi.annotate(selected, Ansi.white) + "\n" + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) + const promptMsg = renderAutoCompleteOutput(state, leadingSymbol, trailingSymbol, options, figures) + return promptMsg + " " + Ansi.annotate(selected, figures.submittedColor) + "\n" }) const processSelectCursorUp = (state: SelectState, choices: SelectOptionsReq["choices"]) => { @@ -3371,9 +3522,9 @@ const handleSelectClear = (options: SelectOptionsReq) => Effect.fnUntraced(function*(state: SelectState, _: Action) { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns - const figures = yield* platformFigures + const figures = yield* getTheme(options) const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft - const promptText = renderSelectOutput("?", figures.pointerSmall, options, { plain: true }) + const promptText = renderSelectOutput(figures.prefix, figures.pointerSmall, options, { plain: true }) const choicesText = renderSelectChoices(state, options, figures, { plain: true }) const clearOutput = eraseText(`${promptText}\n${choicesText}`, columns) return clearOutput + clearPrompt @@ -3383,9 +3534,16 @@ const handleAutoCompleteClear = (options: AutoCompleteOptionsReq) => Effect.fnUntraced(function*(state: AutoCompleteState, _: Action) { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns - const figures = yield* platformFigures + const figures = yield* getTheme(options) const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft - const promptText = renderAutoCompleteOutput(state, "?", figures.pointerSmall, options, { plain: true }) + const promptText = renderAutoCompleteOutput( + state, + figures.prefix, + figures.pointerSmall, + options, + figures, + { plain: true } + ) const choicesText = renderAutoCompleteChoices(state, options, figures, { plain: true }) const clearOutput = eraseText(`${promptText}\n${choicesText}`, columns) return clearOutput + clearPrompt @@ -3422,18 +3580,29 @@ const handleSelectProcess = (options: SelectOptionsReq) => { const handleAutoCompleteProcess = (options: AutoCompleteOptionsReq) => { return (input: Terminal.UserInput, state: AutoCompleteState) => { + // Navigation is bound to ctrl chords rather than bare `j`/`k`, so that every + // printable character reaches the filter input. if (input.key.ctrl) { - if (input.key.name === "u") { - return processAutoCompleteClear(state, options) + switch (input.key.name) { + case "u": { + return processAutoCompleteClear(state, options) + } + case "p": + case "k": { + return processAutoCompleteCursorUp(state) + } + case "n": { + return processAutoCompleteCursorDown(state) + } + default: { + return Effect.succeed(Action.Beep()) + } } - return Effect.succeed(Action.Beep()) } switch (input.key.name) { - case "k": case "up": { return processAutoCompleteCursorUp(state) } - case "j": case "down": { return processAutoCompleteCursorDown(state) } @@ -3461,7 +3630,7 @@ const handleAutoCompleteProcess = (options: AutoCompleteOptionsReq) => { } } -interface TextOptionsReq extends Required { +interface TextOptionsReq extends OptionsReq { /** * The type of the text option. */ @@ -3477,11 +3646,11 @@ interface TextState { const renderClearScreen = Effect.fnUntraced(function*(state: TextState, options: TextOptionsReq) { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns - const figures = yield* platformFigures + const figures = yield* getTheme(options) const resetCurrentLine = Ansi.eraseLine + Ansi.cursorLeft - const errorText = renderTextError(state, figures.pointerSmall, { plain: true }) + const errorText = renderTextError(state, figures.pointerSmall, figures, { plain: true }) const clearOutput = clearOutputWithError( - renderTextOutput(state, "?", figures.pointerSmall, options, { plain: true }), + renderTextOutput(state, figures.prefix, figures.pointerSmall, options, figures, { plain: true }), columns, errorText ) @@ -3491,6 +3660,7 @@ const renderClearScreen = Effect.fnUntraced(function*(state: TextState, options: const renderTextInput = ( nextState: TextState, options: TextOptionsReq, + theme: Theme, submitted: boolean, renderOptions?: RenderOptions | undefined ) => { @@ -3501,7 +3671,7 @@ const renderTextInput = ( return "" } case "password": { - return "*".repeat(text.length) + return theme.passwordMask.repeat(text.length) } case "text": { return text @@ -3509,20 +3679,24 @@ const renderTextInput = ( } } + if (text.length === 0) { + // Avoid wrapping an empty value in ANSI codes, which would make renderPrompt + // add spacing for content that occupies no terminal columns. + return "" + } + const annotation = Option.isSome(nextState.error) ? - Ansi.red + theme.errorColor : submitted ? - Ansi.white - : nextState.value.length === 0 ? - Ansi.blackBright - : Ansi.combine(Ansi.underlined, Ansi.cyanBright) + theme.submittedColor + : Ansi.combine(Ansi.underlined, theme.primaryColor) switch (options.type) { case "hidden": { return "" } case "password": { - return Ansi.annotate("*".repeat(text.length), annotation) + return annotateSymbol(theme.passwordMask.repeat(text.length), annotation) } case "text": { return Ansi.annotate(text, annotation) @@ -3533,6 +3707,7 @@ const renderTextInput = ( const renderTextError = ( nextState: TextState, pointer: string, + theme: Theme, renderOptions?: RenderOptions | undefined ): string => { if (Option.isSome(nextState.error)) { @@ -3540,11 +3715,11 @@ const renderTextError = ( onEmpty: () => "", onNonEmpty: (errorLines) => { if (renderOptions?.plain === true) { - return `${pointer} ${errorLines.join("\n")}` + return separateSymbol(pointer, errorLines.join("\n")) } - const prefix = Ansi.annotate(pointer, Ansi.red) + " " - const lines = Arr.map(errorLines, (str) => annotateErrorLine(str)) - return Ansi.cursorSavePosition + "\n" + prefix + lines.join("\n") + Ansi.cursorRestorePosition + const prefix = annotateSymbol(pointer, theme.errorColor) + const lines = Arr.map(errorLines, (str) => annotateErrorLine(str, theme.errorColor)) + return Ansi.cursorSavePosition + "\n" + separateSymbol(prefix, lines.join("\n")) + Ansi.cursorRestorePosition } }) } @@ -3556,28 +3731,30 @@ const renderTextOutput = ( leadingSymbol: string, trailingSymbol: string, options: TextOptionsReq, + theme: Theme, renderOptions?: RenderOptions | undefined, submitted: boolean = false ) => { - const value = renderTextInput(nextState, options, submitted, renderOptions) + const value = renderTextInput(nextState, options, theme, submitted, renderOptions) return renderPrompt(value, options.message, leadingSymbol, trailingSymbol, renderOptions) } const renderTextNextFrame = Effect.fnUntraced(function*(state: TextState, options: TextOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright) - const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright) - const promptMsg = renderTextOutput(state, leadingSymbol, trailingSymbol, options) - const errorMsg = renderTextError(state, figures.pointerSmall) - const offset = state.cursor - state.value.length + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor) + const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor) + const promptMsg = renderTextOutput(state, leadingSymbol, trailingSymbol, options, figures) + const errorMsg = renderTextError(state, figures.pointerSmall, figures) + const cursorWidth = options.type === "password" ? figures.passwordMask.length : 1 + const offset = (state.cursor - state.value.length) * cursorWidth return promptMsg + errorMsg + Ansi.cursorMove(offset) }) const renderTextSubmission = Effect.fnUntraced(function*(state: TextState, options: TextOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) - const promptMsg = renderTextOutput(state, leadingSymbol, trailingSymbol, options, undefined, true) + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) + const promptMsg = renderTextOutput(state, leadingSymbol, trailingSymbol, options, figures, undefined, true) return promptMsg + "\n" }) @@ -3759,17 +3936,17 @@ const basePrompt = ( }) } -interface ToggleOptionsReq extends Required {} +interface ToggleOptionsReq extends OptionsReq {} type ToggleState = boolean const handleToggleClear = Effect.fnUntraced(function*(options: ToggleOptionsReq) { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns - const figures = yield* platformFigures + const figures = yield* getTheme(options) const clearPrompt = Ansi.eraseLine + Ansi.cursorLeft - const toggleText = `${options.active} / ${options.inactive}` - const promptText = renderPrompt(toggleText, options.message, "?", figures.pointerSmall, { plain: true }) + const toggleText = options.active + " " + separateSymbol(figures.toggleSeparator, options.inactive) + const promptText = renderPrompt(toggleText, options.message, figures.prefix, figures.pointerSmall, { plain: true }) const clearOutput = eraseText(promptText, columns) return clearOutput + clearPrompt }) @@ -3777,17 +3954,21 @@ const handleToggleClear = Effect.fnUntraced(function*(options: ToggleOptionsReq) const renderToggle = ( value: boolean, options: ToggleOptionsReq, + theme: Theme, submitted: boolean = false ) => { - const separator = Ansi.annotate("/", Ansi.blackBright) - const selectedAnnotation = Ansi.combine(Ansi.underlined, submitted ? Ansi.white : Ansi.cyanBright) + const separator = annotateSymbol(theme.toggleSeparator, theme.mutedColor) + const selectedAnnotation = Ansi.combine( + Ansi.underlined, + submitted ? theme.submittedColor : theme.primaryColor + ) const inactive = value ? options.inactive : Ansi.annotate(options.inactive, selectedAnnotation) const active = value ? Ansi.annotate(options.active, selectedAnnotation) : options.active - return active + " " + separator + " " + inactive + return active + " " + separateSymbol(separator, inactive) } const renderToggleOutput = ( @@ -3796,29 +3977,23 @@ const renderToggleOutput = ( trailingSymbol: string, options: ToggleOptionsReq ) => { - const promptLines = options.message.split(NEWLINE_REGEXP) - const prefix = leadingSymbol + " " - if (Arr.isReadonlyArrayNonEmpty(promptLines)) { - const lines = Arr.map(promptLines, (line) => annotateLine(line)) - return prefix + lines.join("\n") + " " + trailingSymbol + " " + toggle - } - return prefix + " " + trailingSymbol + " " + toggle + return renderPrompt(toggle, options.message, leadingSymbol, trailingSymbol) } const renderToggleNextFrame = Effect.fnUntraced(function*(state: ToggleState, options: ToggleOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate("?", Ansi.cyanBright) - const trailingSymbol = Ansi.annotate(figures.pointerSmall, Ansi.blackBright) - const toggle = renderToggle(state, options) + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.prefix, figures.primaryColor) + const trailingSymbol = annotateSymbol(figures.pointerSmall, figures.mutedColor) + const toggle = renderToggle(state, options, figures) const promptMsg = renderToggleOutput(toggle, leadingSymbol, trailingSymbol, options) return Ansi.cursorHide + promptMsg }) const renderToggleSubmission = Effect.fnUntraced(function*(value: boolean, options: ToggleOptionsReq) { - const figures = yield* platformFigures - const leadingSymbol = Ansi.annotate(figures.tick, Ansi.green) - const trailingSymbol = Ansi.annotate(figures.ellipsis, Ansi.blackBright) - const toggle = renderToggle(value, options, true) + const figures = yield* getTheme(options) + const leadingSymbol = annotateSymbol(figures.tick, figures.successColor) + const trailingSymbol = annotateSymbol(figures.ellipsis, figures.mutedColor) + const toggle = renderToggle(value, options, figures, true) const promptMsg = renderToggleOutput(toggle, leadingSymbol, trailingSymbol, options) return promptMsg + "\n" }) diff --git a/repos/effect/packages/effect/src/unstable/cli/internal/completions/bash.ts b/repos/effect/packages/effect/src/unstable/cli/internal/completions/bash.ts index 37f8c5e7d8..e0c882f8d2 100644 --- a/repos/effect/packages/effect/src/unstable/cli/internal/completions/bash.ts +++ b/repos/effect/packages/effect/src/unstable/cli/internal/completions/bash.ts @@ -27,45 +27,97 @@ const flagNamesForWordlist = (flag: Completions.FlagDescriptor): Array = return names } -/** - * Build an associative array mapping each flag form to a group index. - * At completion time, if any form in a group appears in COMP_WORDS, - * all forms in that group are removed from the candidate list. - */ +/** Emit a Bash 3.2-compatible used-flag filter. */ const buildFlagGroupDeclarations = ( flags: ReadonlyArray, lines: Array ): void => { if (flags.length === 0) return - lines.push(` # Build used-flag filter`) - lines.push(` local -A _flag_groups`) - let groupIdx = 0 - for (const flag of flags) { - const forms = flagNamesForWordlist(flag) - for (const form of forms) { - lines.push(` _flag_groups[${form}]=${groupIdx}`) - } - groupIdx++ - } - lines.push(` local -A _used_groups`) + const groups = flags.map(flagNamesForWordlist) + lines.push(` local ${groups.map((_, index) => `_used_${index}=""`).join(" ")}`) lines.push(` for ((i = 1; i < cword; i++)); do`) - lines.push(` local _g="\${_flag_groups[\${words[i]}]:-}"`) - lines.push(` [[ -n "$_g" ]] && _used_groups[$_g]=1`) + lines.push(` case "\${words[i]%%=*}" in`) + groups.forEach((forms, index) => { + lines.push(` ${forms.join("|")}) _used_${index}=1 ;;`) + }) + lines.push(` esac`) lines.push(` done`) lines.push(` local _filtered_flags=""`) - lines.push(` for _f in ${flags.flatMap(flagNamesForWordlist).join(" ")}; do`) - lines.push(` local _g="\${_flag_groups[$_f]:-}"`) - lines.push(` [[ -z "$_g" || -z "\${_used_groups[$_g]:-}" ]] && _filtered_flags+=" $_f"`) + groups.forEach((forms, index) => { + lines.push(` [[ -n "$_used_${index}" ]] || _filtered_flags+=" ${forms.join(" ")}"`) + }) + lines.push(``) +} + +/** + * Complete choices without `compgen -W`, which reparses quoted values. Escape + * only the portion readline replaces for the active quote context. + * + * `!` cannot be escaped inside a user-opened double quote without changing the + * resulting argument. + */ +const choicesHelper = (helperName: string, lines: Array): void => { + lines.push(`${helperName}()`) + lines.push(`{`) + lines.push(` local _cur="$1" _word="$2"; shift 2`) + lines.push(``) + lines.push(` local _head="\${_cur%"$_word"}"`) + lines.push(` local _open=""`) + lines.push(` case "$_head" in`) + lines.push(` *\\') _open="'" ;;`) + lines.push(` *\\") _open='"' ;;`) + lines.push(` esac`) + lines.push(``) + lines.push(` local _prefix="$_cur" _committed="$_head"`) + // Quoting these substitutions breaks quote matching on Bash 3.2. + lines.push(` _prefix=\${_prefix//\\\\/}; _prefix=\${_prefix//\\"/}; _prefix=\${_prefix//\\'/}`) + lines.push(` _committed=\${_committed//\\\\/}; _committed=\${_committed//\\"/}; _committed=\${_committed//\\'/}`) + lines.push(``) + lines.push(` COMPREPLY=()`) + lines.push(` local _choice _rest _match`) + lines.push(` for _choice in "$@"; do`) + lines.push(` [[ "$_choice" == "$_prefix"* ]] || continue`) + lines.push(` _rest="\${_choice#"$_committed"}"`) + lines.push(` case "$_open" in`) + lines.push(` "'")`) + lines.push(` if [[ "$_head" == "'" ]]; then`) + // Use a shell splice for a quote opened at the start of the word. + lines.push(` _match=\${_rest//\\'/\\'\\\\\\'\\'}`) + lines.push(` else`) + // A mid-word single-quote context cannot contain another single quote. + lines.push(` [[ "$_rest" == *\\'* ]] && continue`) + lines.push(` _match="$_rest"`) + lines.push(` fi`) + lines.push(` ;;`) + lines.push(` '"')`) + lines.push(` _match="\${_rest//\\\\/\\\\\\\\}"`) + lines.push(` _match="\${_match//\\$/\\\\$}"`) + lines.push(" _match=\"${_match//\\`/\\\\\\`}\"") + lines.push(` _match="\${_match//\\"/\\\\\\"}"`) + lines.push(` ;;`) + lines.push(` *)`) + lines.push(` printf -v _match '%q' "$_rest"`) + // Bash 3.2 leaves a leading tilde unescaped. + lines.push(` [[ -z "$_head" && "$_match" == '~'* ]] && _match="\\\\$_match"`) + lines.push(` ;;`) + lines.push(` esac`) + // Readline omits a closing quote already present in the match. + lines.push(` [[ -n "$_open" && "$_match" == *"$_open" ]] && _match+="$_open"`) + lines.push(` COMPREPLY+=("$_match")`) lines.push(` done`) + lines.push(`}`) lines.push(``) } -const flagValueCompletion = (type: Completions.FlagType): string | undefined => { +const choiceCompletion = (helperName: string, values: ReadonlyArray): string => + `${helperName} "$cur" "$_comp_word" ${values.map((value) => `'${escapeForBash(value)}'`).join(" ")}` + +const flagValueCompletion = (type: Completions.FlagType, helperName: string): string | undefined => { switch (type._tag) { case "Boolean": return undefined case "Choice": - return `COMPREPLY=( $(compgen -W '${type.values.join(" ")}' -- "$cur") )` + return choiceCompletion(helperName, type.values) case "Path": if (type.pathType === "directory") return `COMPREPLY=( $(compgen -d -- "$cur") )` return `COMPREPLY=( $(compgen -f -- "$cur") )` @@ -74,10 +126,10 @@ const flagValueCompletion = (type: Completions.FlagType): string | undefined => } } -const argCompletion = (type: Completions.ArgumentType): string | undefined => { +const argCompletion = (type: Completions.ArgumentType, helperName: string): string | undefined => { switch (type._tag) { case "Choice": - return `COMPREPLY=( $(compgen -W '${type.values.join(" ")}' -- "$cur") )` + return choiceCompletion(helperName, type.values) case "Path": if (type.pathType === "directory") return `COMPREPLY=( $(compgen -d -- "$cur") )` return `COMPREPLY=( $(compgen -f -- "$cur") )` @@ -93,7 +145,8 @@ const argCompletion = (type: Completions.ArgumentType): string | undefined => { const generateFunction = ( descriptor: Completions.CommandDescriptor, parentPath: ReadonlyArray, - lines: Array + lines: Array, + helperName: string ): void => { const currentPath = [...parentPath, descriptor.name] const funcName = `_${currentPath.map(sanitizeFunctionName).join("_")}` @@ -102,7 +155,12 @@ const generateFunction = ( lines.push(`{`) lines.push(` local cur prev words cword i`) lines.push(parentPath.length === 0 ? ` local _command_index=0` : ` local _command_index="$1"`) - lines.push(` _init_completion || return`) + // Keep values containing COMP_WORDBREAKS characters in one word. + lines.push(` _init_completion -n "$COMP_WORDBREAKS" || return`) + if (parentPath.length === 0) { + // Subcommand functions inherit this through Bash's dynamic scope. + lines.push(` local _comp_word="$2"`) + } lines.push(``) // Build flag-value dispatch @@ -115,7 +173,7 @@ const generateFunction = ( for (const alias of flag.aliases) { longNames.push(alias.length === 1 ? `-${alias}` : `--${alias}`) } - const completion = flagValueCompletion(flag.type) + const completion = flagValueCompletion(flag.type, helperName) if (completion) { lines.push(` ${longNames.join("|")})`) lines.push(` ${completion}`) @@ -171,7 +229,7 @@ const generateFunction = ( // Positional argument completion const argsWithCompletions = descriptor.arguments.flatMap((argument, index) => { - const completion = argCompletion(argument.type) + const completion = argCompletion(argument.type, helperName) return completion === undefined ? [] : [{ argument, completion, index }] }) if (argsWithCompletions.length > 0) { @@ -227,7 +285,7 @@ const generateFunction = ( // Recurse into subcommands for (const sub of descriptor.subcommands) { - generateFunction(sub, currentPath, lines) + generateFunction(sub, currentPath, lines, helperName) } } @@ -238,6 +296,8 @@ export const generate = ( ): string => { const lines: Array = [] const safeName = sanitizeFunctionName(executableName) + // Sanitized command names cannot contain `-`. + const helperName = `_${safeName}--choices` lines.push(`###-begin-${escapeForBash(executableName)}-completions-###`) lines.push(`#`) @@ -248,22 +308,37 @@ export const generate = ( lines.push(`#`) lines.push(``) - // Inline minimal _init_completion fallback for environments without - // bash-completion installed. The real _init_completion handles edge cases - // (= in options, redirections, etc.) but this covers the common path. + // Fallback for environments without bash-completion. Preserve word-break + // characters inside values. lines.push(`if ! type _init_completion &>/dev/null; then`) lines.push(` _init_completion()`) lines.push(` {`) lines.push(` COMPREPLY=()`) - lines.push(` cur="\${COMP_WORDS[COMP_CWORD]}"`) - lines.push(` prev="\${COMP_WORDS[COMP_CWORD-1]}"`) - lines.push(` words=("\${COMP_WORDS[@]}")`) - lines.push(` cword=$COMP_CWORD`) + lines.push(` local _i _j=0 _piece _line="$COMP_LINE"`) + lines.push(` words=("\${COMP_WORDS[0]}")`) + lines.push(` cword=0`) + lines.push(` _line="\${_line#*"\${COMP_WORDS[0]}"}"`) + lines.push(` for ((_i = 1; _i < \${#COMP_WORDS[@]}; _i++)); do`) + lines.push(` _piece="\${COMP_WORDS[_i]}"`) + lines.push(` if [[ "$_line" == [[:blank:]]* ]]; then`) + lines.push(` ((_j++))`) + lines.push(` words[_j]="$_piece"`) + lines.push(` else`) + lines.push(` words[_j]="\${words[_j]}$_piece"`) + lines.push(` fi`) + lines.push(` ((_i == COMP_CWORD)) && cword=$_j`) + lines.push(` _line="\${_line#*"$_piece"}"`) + lines.push(` done`) + lines.push(` cur="\${words[cword]}"`) + lines.push(` prev=""`) + lines.push(` ((cword > 0)) && prev="\${words[cword-1]}"`) + lines.push(` return 0`) lines.push(` }`) lines.push(`fi`) lines.push(``) - generateFunction(descriptor, [], lines) + choicesHelper(helperName, lines) + generateFunction(descriptor, [], lines, helperName) lines.push(`complete -F _${safeName} ${escapeForBash(executableName)}`) lines.push(`###-end-${escapeForBash(executableName)}-completions-###`) diff --git a/repos/effect/packages/effect/src/unstable/cli/internal/completions/fish.ts b/repos/effect/packages/effect/src/unstable/cli/internal/completions/fish.ts index 03db8c35bc..02a8292cc1 100644 --- a/repos/effect/packages/effect/src/unstable/cli/internal/completions/fish.ts +++ b/repos/effect/packages/effect/src/unstable/cli/internal/completions/fish.ts @@ -12,7 +12,10 @@ import type * as Completions from "../../Completions.ts" // Helpers // --------------------------------------------------------------------------- -const escapeFishString = (s: string): string => s.replace(/'/g, "\\'") +const escapeFishString = (s: string): string => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + +/** Escape choices for Fish's second expansion of `complete -a`. */ +const escapeFishChoice = (s: string): string => escapeFishString(s.replace(/[^A-Za-z0-9_.,/@%+-]/gu, "\\$&")) /** * Build a Fish condition that checks the current subcommand context. @@ -64,6 +67,15 @@ const flagContainsOptCondition = (flag: Completions.FlagDescriptor): string => { return `not __fish_contains_opt ${optArgs.join(" ")}` } +/** Hide used value flags without suppressing their value completions. */ +const valueFlagDedupCondition = (flag: Completions.FlagDescriptor): string => { + const forms = [`--${flag.name}`] + for (const alias of flag.aliases) { + forms.push(alias.length === 1 ? `-${alias}` : `--${alias}`) + } + return `begin; ${flagContainsOptCondition(flag)}; or contains -- (commandline -poc)[-1] ${forms.join(" ")}; end` +} + const flagCompletionArgs = (flag: Completions.FlagDescriptor): Array => { const args: Array = [`-l ${flag.name}`] for (const alias of flag.aliases) { @@ -88,7 +100,7 @@ const flagValueArgs = (type: Completions.FlagType): string | undefined => { case "Boolean": return undefined case "Choice": - return `-r -f -a '${type.values.join(" ")}'` + return `-r -f -a '${type.values.map(escapeFishChoice).join(" ")}'` case "Path": if (type.pathType === "directory") return `-r -F` return `-r -F` @@ -101,7 +113,7 @@ const flagValueArgs = (type: Completions.FlagType): string | undefined => { const argValueArgs = (type: Completions.ArgumentType): string | undefined => { switch (type._tag) { case "Choice": - return `-r -f -a '${type.values.join(" ")}'` + return `-r -f -a '${type.values.map(escapeFishChoice).join(" ")}'` case "Path": return `-r -F` default: @@ -147,13 +159,9 @@ const generateCompletions = ( // Flag completions for (const flag of descriptor.flags) { - // Only apply __fish_contains_opt dedup for boolean flags. For value-taking - // flags, the dedup condition would suppress the entry while fish is waiting - // for a value (e.g. typing `--env ` wouldn't show choices). const isBoolean = flag.type._tag === "Boolean" - const flagCondition = isBoolean - ? (condition ? `${condition}; and ${flagContainsOptCondition(flag)}` : flagContainsOptCondition(flag)) - : condition + const dedup = isBoolean ? flagContainsOptCondition(flag) : valueFlagDedupCondition(flag) + const flagCondition = condition ? `${condition}; and ${dedup}` : dedup const flagCondArg = flagCondition ? `-n '${flagCondition}'` : `` const parts = [`complete -c ${executableName}`] diff --git a/repos/effect/packages/effect/src/unstable/cli/internal/completions/zsh.ts b/repos/effect/packages/effect/src/unstable/cli/internal/completions/zsh.ts index 84792df0a1..dd4a14d678 100644 --- a/repos/effect/packages/effect/src/unstable/cli/internal/completions/zsh.ts +++ b/repos/effect/packages/effect/src/unstable/cli/internal/completions/zsh.ts @@ -14,6 +14,9 @@ import type * as Completions from "../../Completions.ts" const escapeZsh = (s: string): string => s.replace(/\\/g, "\\\\").replace(/'/g, "'\\''").replace(/:/g, "\\:") +/** Escape choices for the second parse of a Zsh `_arguments` action. */ +const escapeZshChoice = (s: string): string => s.replace(/[^A-Za-z0-9_.,/@%+-]/gu, "\\$&").replace(/'/g, "'\\''") + const sanitize = (s: string): string => s.replace(/[^a-zA-Z0-9_]/g, "_") /** @@ -35,7 +38,7 @@ const valueAction = (type: Completions.FlagType): string => { case "Boolean": return "" case "Choice": - return `:value:(${type.values.join(" ")})` + return `:value:(${type.values.map(escapeZshChoice).join(" ")})` case "Path": return type.pathType === "directory" ? `:directory:_directories` : `:file:_files` case "Integer": @@ -52,7 +55,7 @@ const valueAction = (type: Completions.FlagType): string => { const argAction = (type: Completions.ArgumentType): string => { switch (type._tag) { case "Choice": - return `(${type.values.join(" ")})` + return `(${type.values.map(escapeZshChoice).join(" ")})` case "Path": return type.pathType === "directory" ? `_directories` : `_files` default: @@ -133,8 +136,14 @@ const generateFunction = ( lines.push(` ${spec}`) } } - for (const arg of descriptor.arguments) { - lines.push(` ${argSpec(arg)}`) + if (descriptor.arguments.length > 0) { + lines.push(` -`) + lines.push(` parent-arguments`) + for (const arg of descriptor.arguments) { + lines.push(` ${argSpec(arg)}`) + } + lines.push(` -`) + lines.push(` subcommands`) } lines.push(` '1:command:->command'`) lines.push(` '*::arg:->args'`) diff --git a/repos/effect/packages/effect/src/unstable/cli/internal/config.ts b/repos/effect/packages/effect/src/unstable/cli/internal/config.ts index 3a1e77ae8d..62cbc6792b 100644 --- a/repos/effect/packages/effect/src/unstable/cli/internal/config.ts +++ b/repos/effect/packages/effect/src/unstable/cli/internal/config.ts @@ -15,7 +15,7 @@ * ```ts * // User declares: * const config = { - * verbose: Flag.boolean("verbose"), + * verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), * server: { * host: Flag.string("host"), * port: Flag.integer("port") diff --git a/repos/effect/packages/effect/src/unstable/cli/internal/wizard.ts b/repos/effect/packages/effect/src/unstable/cli/internal/wizard.ts index 8ab02471b7..34bc185f0d 100644 --- a/repos/effect/packages/effect/src/unstable/cli/internal/wizard.ts +++ b/repos/effect/packages/effect/src/unstable/cli/internal/wizard.ts @@ -16,17 +16,30 @@ export interface Options { readonly prefix?: ReadonlyArray | undefined } +export interface Result { + readonly args: Array + readonly displayArgs: Array +} + +interface CommandLineArg { + readonly value: string + readonly displayValue: string +} + export const run: ( command: Command.Command.Any, options?: Options -) => Effect.Effect, CliError.CliError | Terminal.QuitError, Command.Environment> = Effect.fnUntraced( +) => Effect.Effect = Effect.fnUntraced( function*(command, options) { const commandPath = options?.commandPath ?? [command.name] const selected = getCommandAtPath(command, commandPath) - const commandLine = [...(options?.prefix ?? commandPath)] + const commandLine = (options?.prefix ?? commandPath).map((value) => commandLineArg(value)) yield* logCurrentCommand(commandLine) yield* promptCommand(selected, commandLine, selected === command ? "ROOT" : selected.name) - return commandLine + return { + args: commandLine.map((arg) => arg.value), + displayArgs: commandLine.map((arg) => arg.displayValue) + } } ) @@ -49,7 +62,7 @@ const getCommandAtPath = ( const promptCommand: ( command: Command.Command.Any, - commandLine: Array, + commandLine: Array, sectionName: string ) => Effect.Effect = Effect.fnUntraced( function*(command, commandLine, sectionName) { @@ -94,7 +107,7 @@ const promptCommand: ( })) })) yield* Console.log() - commandLine.push(child.name) + commandLine.push(commandLineArg(child.name)) if (hasWizardSteps(child)) { yield* logCurrentCommand(commandLine) } @@ -107,10 +120,12 @@ const hasWizardSteps = (command: Command.Command.Any): boolean => { return hasVisibleSubcommands || toImpl(command).config.orderedParams.length > 0 } -const promptParam: ( - param: Param.Any -) => Effect.Effect, CliError.CliError | Terminal.QuitError, Command.Environment> = Effect.fnUntraced( - function*(param) { +const promptParam = Effect.fnUntraced( + function*(param: Param.Any): Effect.fn.Return< + Array, + CliError.CliError | Terminal.QuitError, + Command.Environment + > { const single = Param.getUnderlyingSingleOrThrow(param) const metadata = Param.getParamMetadata(param) @@ -133,19 +148,19 @@ const promptParam: ( min: Option.getOrElse(metadata.variadicMin, () => 0), ...(Option.isSome(metadata.variadicMax) ? { max: metadata.variadicMax.value } : {}) })) - const values: Array = [] + const values: Array = [] for (let i = 0; i < count; i++) { values.push(yield* promptSingle(single)) } const parsed = single.kind === Param.flagKind ? { - flags: { [single.name]: values }, + flags: { [single.name]: values.map((arg) => arg.value) }, arguments: [] } : { flags: {}, - arguments: values + arguments: values.map((arg) => arg.value) } yield* param.parse(parsed) yield* Console.log() @@ -153,13 +168,13 @@ const promptParam: ( if (single.kind === Param.argumentKind) { return values } - return values.flatMap((value) => [`--${single.name}`, value]) + return values.flatMap((value) => [commandLineArg(`--${single.name}`), value]) } ) const promptSingle = ( single: Param.Single -): Effect.Effect => { +): Effect.Effect => { const message = renderParamMessage(single) switch (single.primitiveType._tag) { case "Boolean": @@ -175,28 +190,36 @@ const promptSingle = ( defaultDeny: "(t/F)" } })), - String + (value) => commandLineArg(String(value)) ) case "Choice": { const choices = Primitive.getChoiceKeys(single.primitiveType) ?? [] - return Prompt.run(Prompt.select({ - message, - choices: choices.map((choice) => ({ title: choice, value: choice })) - })) + return Effect.map( + Prompt.run(Prompt.select({ + message, + choices: choices.map((choice) => ({ title: choice, value: choice })) + })), + commandLineArg + ) } case "Date": - return Effect.map(Prompt.run(Prompt.date({ message })), (date) => date.toISOString()) + return Effect.map(Prompt.run(Prompt.date({ message })), (date) => commandLineArg(date.toISOString())) case "Float": - return Effect.map(Prompt.run(Prompt.float({ message })), String) + return Effect.map(Prompt.run(Prompt.float({ message })), (value) => commandLineArg(String(value))) case "Integer": - return Effect.map(Prompt.run(Prompt.integer({ message })), String) + return Effect.map(Prompt.run(Prompt.integer({ message })), (value) => commandLineArg(String(value))) case "Redacted": - return Effect.map(Prompt.run(Prompt.password({ message })), Redacted.value) + return Effect.map( + Prompt.run(Prompt.password({ message })), + (value) => commandLineArg(Redacted.value(value), "") + ) default: - return Prompt.run(Prompt.text({ message })) + return Effect.map(Prompt.run(Prompt.text({ message })), commandLineArg) } } +const commandLineArg = (value: string, displayValue: string = value): CommandLineArg => ({ value, displayValue }) + const formatName = (single: Param.Single): string => single.kind === Param.flagKind ? `--${single.name}` : single.name @@ -216,8 +239,8 @@ const humanize = (name: string): string => { return [words[0][0].toUpperCase() + words[0].slice(1), ...words.slice(1)].join(" ") } -const logCurrentCommand = (commandLine: ReadonlyArray): Effect.Effect => - Console.log(renderCommandBlock("Current command", commandLine, Ansi.magenta)) +const logCurrentCommand = (commandLine: ReadonlyArray): Effect.Effect => + Console.log(renderCommandBlock("Current command", commandLine.map((arg) => arg.displayValue), Ansi.magenta)) const renderSection = (commandName: string, section: string): string => `${Ansi.annotate(commandName.toUpperCase(), Ansi.bold, Ansi.cyanBright)} ${Ansi.annotate("·", Ansi.blackBright)} ${ diff --git a/repos/effect/packages/effect/src/unstable/cluster/ClusterError.ts b/repos/effect/packages/effect/src/unstable/cluster/ClusterError.ts index e8faae7fea..6e74365ee7 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/ClusterError.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/ClusterError.ts @@ -171,7 +171,9 @@ export class RunnerUnavailable extends Schema.Error(`${TypeId * * **Details** * - * Carries the `address` whose bounded mailbox is at capacity. + * Carries the `address` whose bounded mailbox is at capacity. It is also used + * when the runner has reached `ShardingConfig.maxResidentEntities` and cannot + * spawn a new entity for the `address`. * * **Gotchas** * diff --git a/repos/effect/packages/effect/src/unstable/cluster/ClusterWorkflowEngine.ts b/repos/effect/packages/effect/src/unstable/cluster/ClusterWorkflowEngine.ts index 1619104871..b243a5701a 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/ClusterWorkflowEngine.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/ClusterWorkflowEngine.ts @@ -127,6 +127,7 @@ export const make = Effect.gen(function*() { }>() const interruptedActivities = new Set() const activityLatches = new Map() + const deferredState = WorkflowEngine.makeDeferredState() const clients = yield* RcMap.make({ lookup: Effect.fnUntraced(function*(workflowName: string) { const entity = entities.get(workflowName) @@ -386,7 +387,7 @@ export const make = Effect.gen(function*() { ) }), Workflow.intoResult, - Effect.provideService(WorkflowEngine.WorkflowInstance, instance) + (effect) => deferredState.trackRun(instance, effect) ) as any }, @@ -403,11 +404,12 @@ export const make = Effect.gen(function*() { yield* latch.await entry = activities.get(activityId) } - const contextMap = new Map(entry.context.mapUnsafe) - contextMap.set(Activity.CurrentAttempt.key, payload.attempt) - contextMap.set(WorkflowEngine.WorkflowInstance.key, instance) + const context = entry.context.pipe( + Context.add(WorkflowEngine.WorkflowInstance, instance), + Context.add(Activity.CurrentAttempt, payload.attempt) + ) return yield* entry.activity.executeEncoded.pipe( - Effect.provideContext(Context.makeUnsafe(contextMap)) + Effect.provideContext(context) ) }).pipe( Workflow.intoResult, @@ -434,13 +436,16 @@ export const make = Effect.gen(function*() { deferred: Effect.fnUntraced(function*(request: Entity.Request) { const payload = request.payload as any + yield* deferredState.deferredDone(executionId, payload.name, payload.exit) yield* ensureSuccess(resume(workflow, executionId)) return payload.exit }), resume: () => ensureSuccess(resume(workflow, executionId)) } - }) + }), + // Reserve a slot for deferred completions to wake the active run. + { concurrency: 2, maxIdleTime: entityMaxIdleTime } ) as Effect.Effect ), @@ -549,24 +554,29 @@ export const make = Effect.gen(function*() { deferredResult: (deferred) => WorkflowEngine.WorkflowInstance.pipe( - Effect.flatMap((instance) => - requestReply({ + Effect.flatMap((instance) => { + const exit = deferredState.pendingResult(instance.executionId, deferred.name) + if (exit) { + return Effect.succeedSome(exit) + } + return requestReply({ workflow: instance.workflow, entityType: `Workflow/${instance.workflow._tag}`, executionId: instance.executionId, tag: "deferred", id: deferred.name - }) - ), - Effect.map((reply) => { - if (Option.isNone(reply)) { - return Option.none>() - } - const decoded = decodeDeferredWithExit(reply.value as any) - return Option.some( - decoded.exit._tag === "Success" - ? decoded.exit.value - : decoded.exit + }).pipe( + Effect.map((reply) => { + if (Option.isNone(reply)) { + return Option.none>() + } + const decoded = decodeDeferredWithExit(reply.value as any) + return Option.some( + decoded.exit._tag === "Success" + ? decoded.exit.value + : decoded.exit + ) + }) ) }), Effect.retry({ @@ -693,6 +703,12 @@ const ResumeRpc = Rpc.make("resume", { const payloadParentKey = "~effect/cluster/ClusterWorkflowEngine/payloadParentKey" +// Workflow state is durable, so an idle entity (completed or suspended) can +// be released quickly and is rebuilt from storage when the next message +// arrives. This keeps completed and suspended executions from holding +// `maxResidentEntities` slots for the full `entityMaxIdleTime`. +const entityMaxIdleTime = Duration.seconds(10) + const makeWorkflowEntity = (workflow: Workflow.Any) => Entity.make(`Workflow/${workflow._tag}`, [ Rpc.make("run", { @@ -747,22 +763,25 @@ const ClockEntity = Entity.make("Workflow/-/DurableClock", [ ClockRpc ]) -const ClockEntityLayer = ClockEntity.toLayer(Effect.gen(function*() { - const engine = yield* WorkflowEngine.WorkflowEngine - const address = yield* Entity.CurrentAddress - const executionId = address.entityId - return { - run(request) { - const deferred = DurableClock.make({ name: request.payload.name, duration: Duration.zero }).deferred - return ensureSuccess(engine.deferredDone(deferred, { - workflowName: request.payload.workflowName, - executionId, - deferredName: deferred.name, - exit: Exit.void - })) +const ClockEntityLayer = ClockEntity.toLayer( + Effect.gen(function*() { + const engine = yield* WorkflowEngine.WorkflowEngine + const address = yield* Entity.CurrentAddress + const executionId = address.entityId + return { + run(request) { + const deferred = DurableClock.make({ name: request.payload.name, duration: Duration.zero }).deferred + return ensureSuccess(engine.deferredDone(deferred, { + workflowName: request.payload.workflowName, + executionId, + deferredName: deferred.name, + exit: Exit.void + })) + } } - } -})) + }), + { maxIdleTime: entityMaxIdleTime } +) const InterruptSignal = DurableDeferred.make("Workflow/InterruptSignal") diff --git a/repos/effect/packages/effect/src/unstable/cluster/EntityResource.ts b/repos/effect/packages/effect/src/unstable/cluster/EntityResource.ts index 31643794c4..6753346205 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/EntityResource.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/EntityResource.ts @@ -10,7 +10,6 @@ * * @since 4.0.0 */ -import type * as v1 from "kubernetes-types/core/v1.d.ts" import * as Context from "../../Context.ts" import * as Duration from "../../Duration.ts" import * as Effect from "../../Effect.ts" @@ -19,6 +18,7 @@ import * as RcRef from "../../RcRef.ts" import * as Scope from "../../Scope.ts" import * as Entity from "./Entity.ts" import * as K8sHttpClient from "./K8sHttpClient.ts" +import type { Pod as K8sPod } from "./K8sTypes.ts" import type { Sharding } from "./Sharding.ts" /** @@ -160,7 +160,7 @@ export const make: (options: { * @since 4.0.0 */ export const makeK8sPod: ( - spec: v1.Pod, + spec: K8sPod, options?: { readonly idleTimeToLive?: Duration.Input | undefined } | undefined @@ -168,7 +168,7 @@ export const makeK8sPod: ( EntityResource, never, Scope.Scope | Sharding | Entity.CurrentAddress | K8sHttpClient.K8sHttpClient -> = Effect.fnUntraced(function*(spec: v1.Pod, options?: { +> = Effect.fnUntraced(function*(spec: K8sPod, options?: { readonly idleTimeToLive?: Duration.Input | undefined }) { const createPod = yield* K8sHttpClient.makeCreatePod diff --git a/repos/effect/packages/effect/src/unstable/cluster/Envelope.ts b/repos/effect/packages/effect/src/unstable/cluster/Envelope.ts index 87cf7d3942..f77459d4ef 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/Envelope.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/Envelope.ts @@ -27,6 +27,35 @@ import { type Snowflake, SnowflakeFromBigInt } from "./Snowflake.ts" */ export const TypeId = "~effect/cluster/Envelope" +/** + * Schema for a value that has already been encoded by the transport's hole + * codec. + * + * **Details** + * + * Cluster payloads are encoded twice: the entity payload is encoded with the + * entity RPC schema, and the result is carried opaquely inside the runner + * envelope. This schema names that hole so the outer runner encode leaves it + * alone. It is the identity under `Schema.toCodecJson`, so JSON, NDJSON, and + * MessagePack transports stay wire-compatible. A binary codec compiles it as a + * bytes leaf. + * + * @category schemas + * @since 4.0.0 + */ +export const OpaqueHole: Schema.declare = Schema.declare( + (_: unknown): _ is any => true, + { + expected: "an already-encoded value", + toCodecJson: () => undefined, + toCodec: () => + Schema.link()( + Schema.Uint8Array, + SchemaTransformation.passthrough() + ) + } +) + /** * Union of cluster envelopes exchanged for an RPC request. * @@ -103,7 +132,7 @@ export class PartialRequest extends Schema.Opaque()(Schema.Struc requestId: SnowflakeFromBigInt, address: EntityAddress, tag: Schema.String, - payload: Schema.Any, + payload: OpaqueHole, headers: Headers.HeadersSchema, traceId: Schema.optional(Schema.String), spanId: Schema.optional(Schema.String), diff --git a/repos/effect/packages/effect/src/unstable/cluster/HttpRunner.ts b/repos/effect/packages/effect/src/unstable/cluster/HttpRunner.ts index 9ec88b96df..d4c0a2f55c 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/HttpRunner.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/HttpRunner.ts @@ -56,14 +56,17 @@ export const layerClientProtocolHttp = (options: { const serialization = yield* RpcSerialization.RpcSerialization const client = yield* HttpClient.HttpClient const https = options.https ?? false - return (address) => { - const clientWithUrl = HttpClient.mapRequest( - client, - HttpClientRequest.prependUrl(`http${https ? "s" : ""}://${address.host}:${address.port}/${options.path}`) - ) - return RpcClient.makeProtocolHttp(clientWithUrl).pipe( - Effect.provideService(RpcSerialization.RpcSerialization, serialization) - ) + return { + codecFor: serialization.codecFor, + make: (address) => { + const clientWithUrl = HttpClient.mapRequest( + client, + HttpClientRequest.prependUrl(`http${https ? "s" : ""}://${address.host}:${address.port}/${options.path}`) + ) + return RpcClient.makeProtocolHttp(clientWithUrl).pipe( + Effect.provideService(RpcSerialization.RpcSerialization, serialization) + ) + } } }) ) @@ -105,17 +108,20 @@ export const layerClientProtocolWebsocket = (options: { const serialization = yield* RpcSerialization.RpcSerialization const https = options.https ?? false const constructor = yield* Socket.WebSocketConstructor - return Effect.fnUntraced(function*(address) { - const socket = yield* Socket.makeWebSocket( - `ws${https ? "s" : ""}://${address.host}:${address.port}/${options.path}` - ).pipe( - Effect.provideService(Socket.WebSocketConstructor, constructor) - ) - return yield* RpcClient.makeProtocolSocket().pipe( - Effect.provideService(Socket.Socket, socket), - Effect.provideService(RpcSerialization.RpcSerialization, serialization) - ) - }) + return { + codecFor: serialization.codecFor, + make: Effect.fnUntraced(function*(address) { + const socket = yield* Socket.makeWebSocket( + `ws${https ? "s" : ""}://${address.host}:${address.port}/${options.path}` + ).pipe( + Effect.provideService(Socket.WebSocketConstructor, constructor) + ) + return yield* RpcClient.makeProtocolSocket().pipe( + Effect.provideService(Socket.Socket, socket), + Effect.provideService(RpcSerialization.RpcSerialization, serialization) + ) + }) + } }) ) @@ -147,12 +153,20 @@ export const toHttpEffect: Effect.Effect< never, Scope | RpcSerialization.RpcSerialization | Sharding.Sharding | MessageStorage > = Effect.gen(function*() { - const handlers = yield* Layer.build(RunnerServer.layerHandlers) - return yield* RpcServer.toHttpEffect(Runners.Rpcs, { + const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffect() + const handlers = yield* Layer.build(RunnerServer.layerHandlers).pipe( + Effect.provideService(RpcServer.Protocol, protocol) + ) + yield* RpcServer.make(Runners.Rpcs, { spanPrefix: "RunnerServer", disableTracing: true, disableFatalDefects: true - }).pipe(Effect.provideContext(handlers)) + }).pipe( + Effect.provideContext(handlers), + Effect.provideService(RpcServer.Protocol, protocol), + Effect.forkScoped + ) + return httpEffect }) /** @@ -171,12 +185,20 @@ export const toHttpEffectWebsocket: Effect.Effect< never, Scope | RpcSerialization.RpcSerialization | Sharding.Sharding | MessageStorage > = Effect.gen(function*() { - const handlers = yield* Layer.build(RunnerServer.layerHandlers) - return yield* RpcServer.toHttpEffectWebsocket(Runners.Rpcs, { + const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket + const handlers = yield* Layer.build(RunnerServer.layerHandlers).pipe( + Effect.provideService(RpcServer.Protocol, protocol) + ) + yield* RpcServer.make(Runners.Rpcs, { spanPrefix: "RunnerServer", disableTracing: true, disableFatalDefects: true - }).pipe(Effect.provideContext(handlers)) + }).pipe( + Effect.provideContext(handlers), + Effect.provideService(RpcServer.Protocol, protocol), + Effect.forkScoped + ) + return httpEffect }) /** diff --git a/repos/effect/packages/effect/src/unstable/cluster/K8sHttpClient.ts b/repos/effect/packages/effect/src/unstable/cluster/K8sHttpClient.ts index 263be27c99..dc1e98c89f 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/K8sHttpClient.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/K8sHttpClient.ts @@ -6,7 +6,6 @@ * * @since 4.0.0 */ -import type * as v1 from "kubernetes-types/core/v1.d.ts" import * as Context from "../../Context.ts" import * as Effect from "../../Effect.ts" import * as FileSystem from "../../FileSystem.ts" @@ -20,6 +19,7 @@ import * as HttpClient from "../http/HttpClient.ts" import * as HttpClientError from "../http/HttpClientError.ts" import * as HttpClientRequest from "../http/HttpClientRequest.ts" import * as HttpClientResponse from "../http/HttpClientResponse.ts" +import type { Pod as K8sPod } from "./K8sTypes.ts" /** * Service tag for the HTTP client used to call the Kubernetes API. @@ -129,7 +129,7 @@ export const makeGetPods: ( export const makeCreatePod = Effect.gen(function*() { const client = yield* K8sHttpClient - return Effect.fnUntraced(function*(spec: v1.Pod) { + return Effect.fnUntraced(function*(spec: K8sPod) { spec = { apiVersion: "v1", kind: "Pod", diff --git a/repos/effect/packages/effect/src/unstable/cluster/K8sTypes.ts b/repos/effect/packages/effect/src/unstable/cluster/K8sTypes.ts new file mode 100644 index 0000000000..3593667b6f --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/cluster/K8sTypes.ts @@ -0,0 +1,2467 @@ +/** + * Kubernetes resource types used by the cluster helpers. + * + * @since 4.0.0 + */ +/* + * This file incorporates declarations from kubernetes-types 1.30.0, + * generated from the Kubernetes OpenAPI definitions. + * + * Source: https://github.com/silverlyra/kubernetes-types/tree/a46eb94629404af98a6758cd843b31816237d7d0 + * Modified: only the transitive closure of `Pod` is included, flattened from + * core/v1, meta/v1 and api/resource; JSDoc normalized for Effect and + * `@category` / `@since` tags added. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Represents a Persistent Disk resource in AWS. + * + * **Details** + * + * An AWS EBS disk must exist before mounting to a container. The disk must also be in the same AWS zone as the kubelet. An AWS EBS disk can only be mounted as read/write once. AWS EBS volumes support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface AWSElasticBlockStoreVolumeSource { + /** fsType is the filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore */ + fsType?: string + /** partition is the partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as "1". Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty). */ + partition?: number + /** readOnly value true will force the readOnly setting in VolumeMounts. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore */ + readOnly?: boolean + /** volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore */ + volumeID: string +} + +/** + * Affinity is a group of affinity scheduling rules. + * + * @category models + * @since 4.0.0 + */ +export interface Affinity { + /** Describes node affinity scheduling rules for the pod. */ + nodeAffinity?: NodeAffinity + /** Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)). */ + podAffinity?: PodAffinity + /** Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)). */ + podAntiAffinity?: PodAntiAffinity +} + +/** + * AppArmorProfile defines a pod or container's AppArmor settings. + * + * @category models + * @since 4.0.0 + */ +export interface AppArmorProfile { + /** localhostProfile indicates a profile loaded on the node that should be used. The profile must be preconfigured on the node to work. Must match the loaded name of the profile. Must be set if and only if type is "Localhost". */ + localhostProfile?: string + /** + * type indicates which kind of AppArmor profile will be applied. Valid options are: + * Localhost - a profile pre-loaded on the node. + * RuntimeDefault - the container runtime's default profile. + * Unconfined - no AppArmor enforcement. + */ + type: string +} + +/** + * AzureDisk represents an Azure Data Disk mount on the host and bind mount to the pod. + * + * @category models + * @since 4.0.0 + */ +export interface AzureDiskVolumeSource { + /** cachingMode is the Host Caching mode: None, Read Only, Read Write. */ + cachingMode?: string + /** diskName is the Name of the data disk in the blob storage */ + diskName: string + /** diskURI is the URI of data disk in the blob storage */ + diskURI: string + /** fsType is Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. */ + fsType?: string + /** kind expected values are Shared: multiple blob disks per storage account Dedicated: single blob disk per storage account Managed: azure managed data disk (only in managed availability set). defaults to shared */ + kind?: string + /** readOnly Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. */ + readOnly?: boolean +} + +/** + * AzureFile represents an Azure File Service mount on the host and bind mount to the pod. + * + * @category models + * @since 4.0.0 + */ +export interface AzureFileVolumeSource { + /** readOnly defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. */ + readOnly?: boolean + /** secretName is the name of secret that contains Azure Storage Account Name and Key */ + secretName: string + /** shareName is the azure share Name */ + shareName: string +} + +/** + * Represents a source location of a volume to mount, managed by an external CSI driver + * + * @category models + * @since 4.0.0 + */ +export interface CSIVolumeSource { + /** driver is the name of the CSI driver that handles this volume. Consult with your admin for the correct name as registered in the cluster. */ + driver: string + /** fsType to mount. Ex. "ext4", "xfs", "ntfs". If not provided, the empty value is passed to the associated CSI driver which will determine the default filesystem to apply. */ + fsType?: string + /** nodePublishSecretRef is a reference to the secret object containing sensitive information to pass to the CSI driver to complete the CSI NodePublishVolume and NodeUnpublishVolume calls. This field is optional, and may be empty if no secret is required. If the secret object contains more than one secret, all secret references are passed. */ + nodePublishSecretRef?: LocalObjectReference + /** readOnly specifies a read-only configuration for the volume. Defaults to false (read/write). */ + readOnly?: boolean + /** volumeAttributes stores driver-specific properties that are passed to the CSI driver. Consult your driver's documentation for supported values. */ + volumeAttributes?: { + [name: string]: string + } +} + +/** + * Adds and removes POSIX capabilities from running containers. + * + * @category models + * @since 4.0.0 + */ +export interface Capabilities { + /** Added capabilities */ + add?: Array + /** Removed capabilities */ + drop?: Array +} + +/** + * Represents a Ceph Filesystem mount that lasts the lifetime of a pod Cephfs volumes do not support ownership management or SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface CephFSVolumeSource { + /** monitors is Required: Monitors is a collection of Ceph monitors More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it */ + monitors: Array + /** path is Optional: Used as the mounted root, rather than the full Ceph tree, default is / */ + path?: string + /** readOnly is Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it */ + readOnly?: boolean + /** secretFile is Optional: SecretFile is the path to key ring for User, default is /etc/ceph/user.secret More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it */ + secretFile?: string + /** secretRef is Optional: SecretRef is reference to the authentication secret for User, default is empty. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it */ + secretRef?: LocalObjectReference + /** user is optional: User is the rados user name, default is admin More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it */ + user?: string +} + +/** + * Represents a cinder volume resource in Openstack. A Cinder volume must exist before mounting to a container. The volume must also be in the same region as the kubelet. Cinder volumes support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface CinderVolumeSource { + /** fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://examples.k8s.io/mysql-cinder-pd/README.md */ + fsType?: string + /** readOnly defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. More info: https://examples.k8s.io/mysql-cinder-pd/README.md */ + readOnly?: boolean + /** secretRef is optional: points to a secret object containing parameters used to connect to OpenStack. */ + secretRef?: LocalObjectReference + /** volumeID used to identify the volume in cinder. More info: https://examples.k8s.io/mysql-cinder-pd/README.md */ + volumeID: string +} + +/** + * ClaimSource describes a reference to a ResourceClaim. + * + * **Details** + * + * Exactly one of these fields should be set. Consumers of this type must treat an empty object as if it has an unknown value. + * + * @category models + * @since 4.0.0 + */ +export interface ClaimSource { + /** ResourceClaimName is the name of a ResourceClaim object in the same namespace as this pod. */ + resourceClaimName?: string + /** + * ResourceClaimTemplateName is the name of a ResourceClaimTemplate object in the same namespace as this pod. + * + * **Details** + * + * The template will be used to create a new ResourceClaim, which will be bound to this pod. When this pod is deleted, the ResourceClaim will also be deleted. The pod name and resource name, along with a generated component, will be used to form a unique name for the ResourceClaim, which will be recorded in pod.status.resourceClaimStatuses. + * + * This field is immutable and no changes will be made to the corresponding ResourceClaim by the control plane after creating the ResourceClaim. + */ + resourceClaimTemplateName?: string +} + +/** + * ClusterTrustBundleProjection describes how to select a set of ClusterTrustBundle objects and project their contents into the pod filesystem. + * + * @category models + * @since 4.0.0 + */ +export interface ClusterTrustBundleProjection { + /** Select all ClusterTrustBundles that match this label selector. Only has effect if signerName is set. Mutually-exclusive with name. If unset, interpreted as "match nothing". If set but empty, interpreted as "match everything". */ + labelSelector?: LabelSelector + /** Select a single ClusterTrustBundle by object name. Mutually-exclusive with signerName and labelSelector. */ + name?: string + /** If true, don't block pod startup if the referenced ClusterTrustBundle(s) aren't available. If using name, then the named ClusterTrustBundle is allowed not to exist. If using signerName, then the combination of signerName and labelSelector is allowed to match zero ClusterTrustBundles. */ + optional?: boolean + /** Relative path from the volume root to write the bundle. */ + path: string + /** Select all ClusterTrustBundles that match this signer name. Mutually-exclusive with name. The contents of all selected ClusterTrustBundles will be unified and deduplicated. */ + signerName?: string +} + +/** + * ConfigMapEnvSource selects a ConfigMap to populate the environment variables with. + * + * **Details** + * + * The contents of the target ConfigMap's Data field will represent the key-value pairs as environment variables. + * + * @category models + * @since 4.0.0 + */ +export interface ConfigMapEnvSource { + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ + name?: string + /** Specify whether the ConfigMap must be defined */ + optional?: boolean +} + +/** + * Selects a key from a ConfigMap. + * + * @category models + * @since 4.0.0 + */ +export interface ConfigMapKeySelector { + /** The key to select. */ + key: string + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ + name?: string + /** Specify whether the ConfigMap or its key must be defined */ + optional?: boolean +} + +/** + * Adapts a ConfigMap into a projected volume. + * + * **Details** + * + * The contents of the target ConfigMap's Data field will be presented in a projected volume as files using the keys in the Data field as the file names, unless the items element is populated with specific mappings of keys to paths. Note that this is identical to a configmap volume source without the default mode. + * + * @category models + * @since 4.0.0 + */ +export interface ConfigMapProjection { + /** items if unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. */ + items?: Array + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ + name?: string + /** optional specify whether the ConfigMap or its keys must be defined */ + optional?: boolean +} + +/** + * Adapts a ConfigMap into a volume. + * + * **Details** + * + * The contents of the target ConfigMap's Data field will be presented in a volume as files using the keys in the Data field as the file names, unless the items element is populated with specific mappings of keys to paths. ConfigMap volumes support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface ConfigMapVolumeSource { + /** defaultMode is optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set. */ + defaultMode?: number + /** items if unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. */ + items?: Array + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ + name?: string + /** optional specify whether the ConfigMap or its keys must be defined */ + optional?: boolean +} + +/** + * A single application container that you want to run within a pod. + * + * @category models + * @since 4.0.0 + */ +export interface Container { + /** Arguments to the entrypoint. The container image's CMD is used if this is not provided. Variable references $(VAR_NAME) are expanded using the container's environment. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Cannot be updated. More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell */ + args?: Array + /** Entrypoint array. Not executed within a shell. The container image's ENTRYPOINT is used if this is not provided. Variable references $(VAR_NAME) are expanded using the container's environment. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Cannot be updated. More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell */ + command?: Array + /** List of environment variables to set in the container. Cannot be updated. */ + env?: Array + /** List of sources to populate environment variables in the container. The keys defined within a source must be a C_IDENTIFIER. All invalid keys will be reported as an event when the container is starting. When a key exists in multiple sources, the value associated with the last source will take precedence. Values defined by an Env with a duplicate key will take precedence. Cannot be updated. */ + envFrom?: Array + /** Container image name. More info: https://kubernetes.io/docs/concepts/containers/images This field is optional to allow higher level config management to default or override container images in workload controllers like Deployments and StatefulSets. */ + image?: string + /** Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always if :latest tag is specified, or IfNotPresent otherwise. Cannot be updated. More info: https://kubernetes.io/docs/concepts/containers/images#updating-images */ + imagePullPolicy?: string + /** Actions that the management system should take in response to container lifecycle events. Cannot be updated. */ + lifecycle?: Lifecycle + /** Periodic probe of container liveness. Container will be restarted if the probe fails. Cannot be updated. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes */ + livenessProbe?: Probe + /** Name of the container specified as a DNS_LABEL. Each container in a pod must have a unique name (DNS_LABEL). Cannot be updated. */ + name: string + /** List of ports to expose from the container. Not specifying a port here DOES NOT prevent that port from being exposed. Any port which is listening on the default "0.0.0.0" address inside a container will be accessible from the network. Modifying this array with strategic merge patch may corrupt the data. For more information See https://github.com/kubernetes/kubernetes/issues/108255. Cannot be updated. */ + ports?: Array + /** Periodic probe of container service readiness. Container will be removed from service endpoints if the probe fails. Cannot be updated. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes */ + readinessProbe?: Probe + /** Resources resize policy for the container. */ + resizePolicy?: Array + /** Compute Resources required by this container. Cannot be updated. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ */ + resources?: ResourceRequirements + /** RestartPolicy defines the restart behavior of individual containers in a pod. This field may only be set for init containers, and the only allowed value is "Always". For non-init containers or when this field is not specified, the restart behavior is defined by the Pod's restart policy and the container type. Setting the RestartPolicy as "Always" for the init container will have the following effect: this init container will be continually restarted on exit until all regular containers have terminated. Once all regular containers have completed, all init containers with restartPolicy "Always" will be shut down. This lifecycle differs from normal init containers and is often referred to as a "sidecar" container. Although this init container still starts in the init container sequence, it does not wait for the container to complete before proceeding to the next init container. Instead, the next init container starts immediately after this init container is started, or after any startupProbe has successfully completed. */ + restartPolicy?: string + /** SecurityContext defines the security options the container should be run with. If set, the fields of SecurityContext override the equivalent fields of PodSecurityContext. More info: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ */ + securityContext?: SecurityContext + /** StartupProbe indicates that the Pod has successfully initialized. If specified, no other probes are executed until this completes successfully. If this probe fails, the Pod will be restarted, just as if the livenessProbe failed. This can be used to provide different probe parameters at the beginning of a Pod's lifecycle, when it might take a long time to load data or warm a cache, than during steady-state operation. This cannot be updated. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes */ + startupProbe?: Probe + /** Whether this container should allocate a buffer for stdin in the container runtime. If this is not set, reads from stdin in the container will always result in EOF. Default is false. */ + stdin?: boolean + /** Whether the container runtime should close the stdin channel after it has been opened by a single attach. When stdin is true the stdin stream will remain open across multiple attach sessions. If stdinOnce is set to true, stdin is opened on container start, is empty until the first client attaches to stdin, and then remains open and accepts data until the client disconnects, at which time stdin is closed and remains closed until the container is restarted. If this flag is false, a container processes that reads from stdin will never receive an EOF. Default is false */ + stdinOnce?: boolean + /** Optional: Path at which the file to which the container's termination message will be written is mounted into the container's filesystem. Message written is intended to be brief final status, such as an assertion failure message. Will be truncated by the node if greater than 4096 bytes. The total message length across all containers will be limited to 12kb. Defaults to /dev/termination-log. Cannot be updated. */ + terminationMessagePath?: string + /** Indicate how the termination message should be populated. File will use the contents of terminationMessagePath to populate the container status message on both success and failure. FallbackToLogsOnError will use the last chunk of container log output if the termination message file is empty and the container exited with an error. The log output is limited to 2048 bytes or 80 lines, whichever is smaller. Defaults to File. Cannot be updated. */ + terminationMessagePolicy?: string + /** Whether this container should allocate a TTY for itself, also requires 'stdin' to be true. Default is false. */ + tty?: boolean + /** volumeDevices is the list of block devices to be used by the container. */ + volumeDevices?: Array + /** Pod volumes to mount into the container's filesystem. Cannot be updated. */ + volumeMounts?: Array + /** Container's working directory. If not specified, the container runtime's default will be used, which might be configured in the container image. Cannot be updated. */ + workingDir?: string +} + +/** + * ContainerPort represents a network port in a single container. + * + * @category models + * @since 4.0.0 + */ +export interface ContainerPort { + /** Number of port to expose on the pod's IP address. This must be a valid port number, 0 < x < 65536. */ + containerPort: number + /** What host IP to bind the external port to. */ + hostIP?: string + /** Number of port to expose on the host. If specified, this must be a valid port number, 0 < x < 65536. If HostNetwork is specified, this must match ContainerPort. Most containers do not need this. */ + hostPort?: number + /** If specified, this must be an IANA_SVC_NAME and unique within the pod. Each named port in a pod must have a unique name. Name for the port that can be referred to by services. */ + name?: string + /** Protocol for port. Must be UDP, TCP, or SCTP. Defaults to "TCP". */ + protocol?: string +} + +/** + * ContainerResizePolicy represents resource resize policy for the container. + * + * @category models + * @since 4.0.0 + */ +export interface ContainerResizePolicy { + /** Name of the resource to which this resource resize policy applies. Supported values: cpu, memory. */ + resourceName: string + /** Restart policy to apply when specified resource is resized. If not specified, it defaults to NotRequired. */ + restartPolicy: string +} + +/** + * ContainerState holds a possible state of container. Only one of its members may be specified. If none of them is specified, the default one is ContainerStateWaiting. + * + * @category models + * @since 4.0.0 + */ +export interface ContainerState { + /** Details about a running container */ + running?: ContainerStateRunning + /** Details about a terminated container */ + terminated?: ContainerStateTerminated + /** Details about a waiting container */ + waiting?: ContainerStateWaiting +} + +/** + * ContainerStateRunning is a running state of a container. + * + * @category models + * @since 4.0.0 + */ +export interface ContainerStateRunning { + /** Time at which the container was last (re-)started */ + startedAt?: Time +} + +/** + * ContainerStateTerminated is a terminated state of a container. + * + * @category models + * @since 4.0.0 + */ +export interface ContainerStateTerminated { + /** Container's ID in the format '://' */ + containerID?: string + /** Exit status from the last termination of the container */ + exitCode: number + /** Time at which the container last terminated */ + finishedAt?: Time + /** Message regarding the last termination of the container */ + message?: string + /** (brief) reason from the last termination of the container */ + reason?: string + /** Signal from the last termination of the container */ + signal?: number + /** Time at which previous execution of the container started */ + startedAt?: Time +} + +/** + * ContainerStateWaiting is a waiting state of a container. + * + * @category models + * @since 4.0.0 + */ +export interface ContainerStateWaiting { + /** Message regarding why the container is not yet running. */ + message?: string + /** (brief) reason the container is not yet running. */ + reason?: string +} + +/** + * ContainerStatus contains details for the current status of this container. + * + * @category models + * @since 4.0.0 + */ +export interface ContainerStatus { + /** AllocatedResources represents the compute resources allocated for this container by the node. Kubelet sets this value to Container.Resources.Requests upon successful pod admission and after successfully admitting desired pod resize. */ + allocatedResources?: { + [name: string]: Quantity + } + /** ContainerID is the ID of the container in the format '://'. Where type is a container runtime identifier, returned from Version call of CRI API (for example "containerd"). */ + containerID?: string + /** Image is the name of container image that the container is running. The container image may not match the image used in the PodSpec, as it may have been resolved by the runtime. More info: https://kubernetes.io/docs/concepts/containers/images. */ + image: string + /** ImageID is the image ID of the container's image. The image ID may not match the image ID of the image used in the PodSpec, as it may have been resolved by the runtime. */ + imageID: string + /** LastTerminationState holds the last termination state of the container to help debug container crashes and restarts. This field is not populated if the container is still running and RestartCount is 0. */ + lastState?: ContainerState + /** Name is a DNS_LABEL representing the unique name of the container. Each container in a pod must have a unique name across all container types. Cannot be updated. */ + name: string + /** + * Ready specifies whether the container is currently passing its readiness check. The value will change as readiness probes keep executing. If no readiness probes are specified, this field defaults to true once the container is fully started (see Started field). + * + * **Details** + * + * The value is typically used to determine whether a container is ready to accept traffic. + */ + ready: boolean + /** Resources represents the compute resource requests and limits that have been successfully enacted on the running container after it has been started or has been successfully resized. */ + resources?: ResourceRequirements + /** RestartCount holds the number of times the container has been restarted. Kubelet makes an effort to always increment the value, but there are cases when the state may be lost due to node restarts and then the value may be reset to 0. The value is never negative. */ + restartCount: number + /** Started indicates whether the container has finished its postStart lifecycle hook and passed its startup probe. Initialized as false, becomes true after startupProbe is considered successful. Resets to false when the container is restarted, or if kubelet loses state temporarily. In both cases, startup probes will run again. Is always true when no startupProbe is defined and container is running and has passed the postStart lifecycle hook. The null value must be treated the same as false. */ + started?: boolean + /** State holds details about the container's current condition. */ + state?: ContainerState + /** Status of volume mounts. */ + volumeMounts?: Array +} + +/** + * Represents downward API info for projecting into a projected volume. Note that this is identical to a downwardAPI volume source without the default mode. + * + * @category models + * @since 4.0.0 + */ +export interface DownwardAPIProjection { + /** Items is a list of DownwardAPIVolume file */ + items?: Array +} + +/** + * DownwardAPIVolumeFile represents information to create the file containing the pod field + * + * @category models + * @since 4.0.0 + */ +export interface DownwardAPIVolumeFile { + /** Required: Selects a field of the pod: only annotations, labels, name, namespace and uid are supported. */ + fieldRef?: ObjectFieldSelector + /** Optional: mode bits used to set permissions on this file, must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set. */ + mode?: number + /** Required: Path is the relative path name of the file to be created. Must not be absolute or contain the '..' path. Must be utf-8 encoded. The first item of the relative path must not start with '..' */ + path: string + /** Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. */ + resourceFieldRef?: ResourceFieldSelector +} + +/** + * DownwardAPIVolumeSource represents a volume containing downward API info. Downward API volumes support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface DownwardAPIVolumeSource { + /** Optional: mode bits to use on created files by default. Must be a Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set. */ + defaultMode?: number + /** Items is a list of downward API volume file */ + items?: Array +} + +/** + * Represents an empty directory for a pod. Empty directory volumes support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface EmptyDirVolumeSource { + /** medium represents what type of storage medium should back this directory. The default is "" which means to use the node's default medium. Must be an empty string (default) or Memory. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir */ + medium?: string + /** sizeLimit is the total amount of local storage required for this EmptyDir volume. The size limit is also applicable for memory medium. The maximum usage on memory medium EmptyDir would be the minimum value between the SizeLimit specified here and the sum of memory limits of all containers in a pod. The default is nil which means that the limit is undefined. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir */ + sizeLimit?: Quantity +} + +/** + * EnvFromSource represents the source of a set of ConfigMaps + * + * @category models + * @since 4.0.0 + */ +export interface EnvFromSource { + /** The ConfigMap to select from */ + configMapRef?: ConfigMapEnvSource + /** An optional identifier to prepend to each key in the ConfigMap. Must be a C_IDENTIFIER. */ + prefix?: string + /** The Secret to select from */ + secretRef?: SecretEnvSource +} + +/** + * EnvVar represents an environment variable present in a Container. + * + * @category models + * @since 4.0.0 + */ +export interface EnvVar { + /** Name of the environment variable. Must be a C_IDENTIFIER. */ + name: string + /** Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and any service environment variables. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Defaults to "". */ + value?: string + /** Source for the environment variable's value. Cannot be used if value is not empty. */ + valueFrom?: EnvVarSource +} + +/** + * EnvVarSource represents a source for the value of an EnvVar. + * + * @category models + * @since 4.0.0 + */ +export interface EnvVarSource { + /** Selects a key of a ConfigMap. */ + configMapKeyRef?: ConfigMapKeySelector + /** Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. */ + fieldRef?: ObjectFieldSelector + /** Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. */ + resourceFieldRef?: ResourceFieldSelector + /** Selects a key of a secret in the pod's namespace */ + secretKeyRef?: SecretKeySelector +} + +/** + * An EphemeralContainer is a temporary container that you may add to an existing Pod for user-initiated activities such as debugging. Ephemeral containers have no resource or scheduling guarantees, and they will not be restarted when they exit or when a Pod is removed or restarted. The kubelet may evict a Pod if an ephemeral container causes the Pod to exceed its resource allocation. + * + * **Details** + * + * To add an ephemeral container, use the ephemeralcontainers subresource of an existing Pod. Ephemeral containers may not be removed or restarted. + * + * @category models + * @since 4.0.0 + */ +export interface EphemeralContainer { + /** Arguments to the entrypoint. The image's CMD is used if this is not provided. Variable references $(VAR_NAME) are expanded using the container's environment. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Cannot be updated. More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell */ + args?: Array + /** Entrypoint array. Not executed within a shell. The image's ENTRYPOINT is used if this is not provided. Variable references $(VAR_NAME) are expanded using the container's environment. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Cannot be updated. More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell */ + command?: Array + /** List of environment variables to set in the container. Cannot be updated. */ + env?: Array + /** List of sources to populate environment variables in the container. The keys defined within a source must be a C_IDENTIFIER. All invalid keys will be reported as an event when the container is starting. When a key exists in multiple sources, the value associated with the last source will take precedence. Values defined by an Env with a duplicate key will take precedence. Cannot be updated. */ + envFrom?: Array + /** Container image name. More info: https://kubernetes.io/docs/concepts/containers/images */ + image?: string + /** Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always if :latest tag is specified, or IfNotPresent otherwise. Cannot be updated. More info: https://kubernetes.io/docs/concepts/containers/images#updating-images */ + imagePullPolicy?: string + /** Lifecycle is not allowed for ephemeral containers. */ + lifecycle?: Lifecycle + /** Probes are not allowed for ephemeral containers. */ + livenessProbe?: Probe + /** Name of the ephemeral container specified as a DNS_LABEL. This name must be unique among all containers, init containers and ephemeral containers. */ + name: string + /** Ports are not allowed for ephemeral containers. */ + ports?: Array + /** Probes are not allowed for ephemeral containers. */ + readinessProbe?: Probe + /** Resources resize policy for the container. */ + resizePolicy?: Array + /** Resources are not allowed for ephemeral containers. Ephemeral containers use spare resources already allocated to the pod. */ + resources?: ResourceRequirements + /** Restart policy for the container to manage the restart behavior of each container within a pod. This may only be set for init containers. You cannot set this field on ephemeral containers. */ + restartPolicy?: string + /** Optional: SecurityContext defines the security options the ephemeral container should be run with. If set, the fields of SecurityContext override the equivalent fields of PodSecurityContext. */ + securityContext?: SecurityContext + /** Probes are not allowed for ephemeral containers. */ + startupProbe?: Probe + /** Whether this container should allocate a buffer for stdin in the container runtime. If this is not set, reads from stdin in the container will always result in EOF. Default is false. */ + stdin?: boolean + /** Whether the container runtime should close the stdin channel after it has been opened by a single attach. When stdin is true the stdin stream will remain open across multiple attach sessions. If stdinOnce is set to true, stdin is opened on container start, is empty until the first client attaches to stdin, and then remains open and accepts data until the client disconnects, at which time stdin is closed and remains closed until the container is restarted. If this flag is false, a container processes that reads from stdin will never receive an EOF. Default is false */ + stdinOnce?: boolean + /** + * If set, the name of the container from PodSpec that this ephemeral container targets. The ephemeral container will be run in the namespaces (IPC, PID, etc) of this container. If not set then the ephemeral container uses the namespaces configured in the Pod spec. + * + * **Details** + * + * The container runtime must implement support for this feature. If the runtime does not support namespace targeting then the result of setting this field is undefined. + */ + targetContainerName?: string + /** Optional: Path at which the file to which the container's termination message will be written is mounted into the container's filesystem. Message written is intended to be brief final status, such as an assertion failure message. Will be truncated by the node if greater than 4096 bytes. The total message length across all containers will be limited to 12kb. Defaults to /dev/termination-log. Cannot be updated. */ + terminationMessagePath?: string + /** Indicate how the termination message should be populated. File will use the contents of terminationMessagePath to populate the container status message on both success and failure. FallbackToLogsOnError will use the last chunk of container log output if the termination message file is empty and the container exited with an error. The log output is limited to 2048 bytes or 80 lines, whichever is smaller. Defaults to File. Cannot be updated. */ + terminationMessagePolicy?: string + /** Whether this container should allocate a TTY for itself, also requires 'stdin' to be true. Default is false. */ + tty?: boolean + /** volumeDevices is the list of block devices to be used by the container. */ + volumeDevices?: Array + /** Pod volumes to mount into the container's filesystem. Subpath mounts are not allowed for ephemeral containers. Cannot be updated. */ + volumeMounts?: Array + /** Container's working directory. If not specified, the container runtime's default will be used, which might be configured in the container image. Cannot be updated. */ + workingDir?: string +} + +/** + * Represents an ephemeral volume that is handled by a normal storage driver. + * + * @category models + * @since 4.0.0 + */ +export interface EphemeralVolumeSource { + /** + * Will be used to create a stand-alone PVC to provision the volume. The pod in which this EphemeralVolumeSource is embedded will be the owner of the PVC, i.e. the PVC will be deleted together with the pod. The name of the PVC will be `-` where `` is the name from the `PodSpec.Volumes` array entry. Pod validation will reject the pod if the concatenated name is not valid for a PVC (for example, too long). + * + * **Details** + * + * An existing PVC with that name that is not owned by the pod will *not* be used for the pod to avoid using an unrelated volume by mistake. Starting the pod is then blocked until the unrelated PVC is removed. If such a pre-created PVC is meant to be used by the pod, the PVC has to updated with an owner reference to the pod once the pod exists. Normally this should not be necessary, but it may be useful when manually reconstructing a broken cluster. + * + * This field is read-only and no changes will be made by Kubernetes to the PVC after it has been created. + * + * Required, must not be nil. + */ + volumeClaimTemplate?: PersistentVolumeClaimTemplate +} + +/** + * ExecAction describes a "run in container" action. + * + * @category models + * @since 4.0.0 + */ +export interface ExecAction { + /** Command is the command line to execute inside the container, the working directory for the command is root ('/') in the container's filesystem. The command is simply exec'd, it is not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use a shell, you need to explicitly call out to that shell. Exit status of 0 is treated as live/healthy and non-zero is unhealthy. */ + command?: Array +} + +/** + * Represents a Fibre Channel volume. Fibre Channel volumes can only be mounted as read/write once. Fibre Channel volumes support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface FCVolumeSource { + /** fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. */ + fsType?: string + /** lun is Optional: FC target lun number */ + lun?: number + /** readOnly is Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. */ + readOnly?: boolean + /** targetWWNs is Optional: FC target worldwide names (WWNs) */ + targetWWNs?: Array + /** wwids Optional: FC volume world wide identifiers (wwids) Either wwids or combination of targetWWNs and lun must be set, but not both simultaneously. */ + wwids?: Array +} + +/** + * FlexVolume represents a generic volume resource that is provisioned/attached using an exec based plugin. + * + * @category models + * @since 4.0.0 + */ +export interface FlexVolumeSource { + /** driver is the name of the driver to use for this volume. */ + driver: string + /** fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". The default filesystem depends on FlexVolume script. */ + fsType?: string + /** options is Optional: this field holds extra command options if any. */ + options?: { + [name: string]: string + } + /** readOnly is Optional: defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. */ + readOnly?: boolean + /** secretRef is Optional: secretRef is reference to the secret object containing sensitive information to pass to the plugin scripts. This may be empty if no secret object is specified. If the secret object contains more than one secret, all secrets are passed to the plugin scripts. */ + secretRef?: LocalObjectReference +} + +/** + * Represents a Flocker volume mounted by the Flocker agent. One and only one of datasetName and datasetUUID should be set. Flocker volumes do not support ownership management or SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface FlockerVolumeSource { + /** datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker should be considered as deprecated */ + datasetName?: string + /** datasetUUID is the UUID of the dataset. This is unique identifier of a Flocker dataset */ + datasetUUID?: string +} + +/** + * Represents a Persistent Disk resource in Google Compute Engine. + * + * **Details** + * + * A GCE PD must exist before mounting to a container. The disk must also be in the same GCE project and zone as the kubelet. A GCE PD can only be mounted as read/write once or read-only many times. GCE PDs support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface GCEPersistentDiskVolumeSource { + /** fsType is filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk */ + fsType?: string + /** partition is the partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as "1". Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty). More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk */ + partition?: number + /** pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk */ + pdName: string + /** readOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk */ + readOnly?: boolean +} + +/** + * GRPCAction specifies an action involving a gRPC service. + * + * @category models + * @since 4.0.0 + */ +export interface GRPCAction { + /** Port number of the gRPC service. Number must be in the range 1 to 65535. */ + port: number + /** + * Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + * + * **Details** + * + * If this is not specified, the default behavior is defined by gRPC. + */ + service?: string +} + +/** + * Represents a volume that is populated with the contents of a git repository. Git repo volumes do not support ownership management. Git repo volumes support SELinux relabeling. + * + * **Details** + * + * DEPRECATED: GitRepo is deprecated. To provision a container with a git repo, mount an EmptyDir into an InitContainer that clones the repo using git, then mount the EmptyDir into the Pod's container. + * + * @category models + * @since 4.0.0 + */ +export interface GitRepoVolumeSource { + /** directory is the target directory name. Must not contain or start with '..'. If '.' is supplied, the volume directory will be the git repository. Otherwise, if specified, the volume will contain the git repository in the subdirectory with the given name. */ + directory?: string + /** repository is the URL */ + repository: string + /** revision is the commit hash for the specified revision. */ + revision?: string +} + +/** + * Represents a Glusterfs mount that lasts the lifetime of a pod. Glusterfs volumes do not support ownership management or SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface GlusterfsVolumeSource { + /** endpoints is the endpoint name that details Glusterfs topology. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod */ + endpoints: string + /** path is the Glusterfs volume path. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod */ + path: string + /** readOnly here will force the Glusterfs volume to be mounted with read-only permissions. Defaults to false. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod */ + readOnly?: boolean +} + +/** + * HTTPGetAction describes an action based on HTTP Get requests. + * + * @category models + * @since 4.0.0 + */ +export interface HTTPGetAction { + /** Host name to connect to, defaults to the pod IP. You probably want to set "Host" in httpHeaders instead. */ + host?: string + /** Custom headers to set in the request. HTTP allows repeated headers. */ + httpHeaders?: Array + /** Path to access on the HTTP server. */ + path?: string + /** Name or number of the port to access on the container. Number must be in the range 1 to 65535. Name must be an IANA_SVC_NAME. */ + port: number | string + /** Scheme to use for connecting to the host. Defaults to HTTP. */ + scheme?: string +} + +/** + * HTTPHeader describes a custom header to be used in HTTP probes + * + * @category models + * @since 4.0.0 + */ +export interface HTTPHeader { + /** The header field name. This will be canonicalized upon output, so case-variant names will be understood as the same header. */ + name: string + /** The header field value */ + value: string +} + +/** + * HostAlias holds the mapping between IP and hostnames that will be injected as an entry in the pod's hosts file. + * + * @category models + * @since 4.0.0 + */ +export interface HostAlias { + /** Hostnames for the above IP address. */ + hostnames?: Array + /** IP address of the host file entry. */ + ip?: string +} + +/** + * HostIP represents a single IP address allocated to the host. + * + * @category models + * @since 4.0.0 + */ +export interface HostIP { + /** IP is the IP address assigned to the host */ + ip?: string +} + +/** + * Represents a host path mapped into a pod. Host path volumes do not support ownership management or SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface HostPathVolumeSource { + /** path of the directory on the host. If the path is a symlink, it will follow the link to the real path. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath */ + path: string + /** type for HostPath Volume Defaults to "" More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath */ + type?: string +} + +/** + * Represents an ISCSI disk. ISCSI volumes can only be mounted as read/write once. ISCSI volumes support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface ISCSIVolumeSource { + /** chapAuthDiscovery defines whether support iSCSI Discovery CHAP authentication */ + chapAuthDiscovery?: boolean + /** chapAuthSession defines whether support iSCSI Session CHAP authentication */ + chapAuthSession?: boolean + /** fsType is the filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#iscsi */ + fsType?: string + /** initiatorName is the custom iSCSI Initiator Name. If initiatorName is specified with iscsiInterface simultaneously, new iSCSI interface : will be created for the connection. */ + initiatorName?: string + /** iqn is the target iSCSI Qualified Name. */ + iqn: string + /** iscsiInterface is the interface Name that uses an iSCSI transport. Defaults to 'default' (tcp). */ + iscsiInterface?: string + /** lun represents iSCSI Target Lun number. */ + lun: number + /** portals is the iSCSI Target Portal List. The portal is either an IP or ip_addr:port if the port is other than default (typically TCP ports 860 and 3260). */ + portals?: Array + /** readOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. */ + readOnly?: boolean + /** secretRef is the CHAP Secret for iSCSI target and initiator authentication */ + secretRef?: LocalObjectReference + /** targetPortal is iSCSI Target Portal. The Portal is either an IP or ip_addr:port if the port is other than default (typically TCP ports 860 and 3260). */ + targetPortal: string +} + +/** + * Maps a string key to a path within a volume. + * + * @category models + * @since 4.0.0 + */ +export interface KeyToPath { + /** key is the key to project. */ + key: string + /** mode is Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set. */ + mode?: number + /** path is the relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. */ + path: string +} + +/** + * Lifecycle describes actions that the management system should take in response to container lifecycle events. For the PostStart and PreStop lifecycle handlers, management of the container blocks until the action is complete, unless the container process fails, in which case the handler is aborted. + * + * @category models + * @since 4.0.0 + */ +export interface Lifecycle { + /** PostStart is called immediately after a container is created. If the handler fails, the container is terminated and restarted according to its restart policy. Other management of the container blocks until the hook completes. More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks */ + postStart?: LifecycleHandler + /** PreStop is called immediately before a container is terminated due to an API request or management event such as liveness/startup probe failure, preemption, resource contention, etc. The handler is not called if the container crashes or exits. The Pod's termination grace period countdown begins before the PreStop hook is executed. Regardless of the outcome of the handler, the container will eventually terminate within the Pod's termination grace period (unless delayed by finalizers). Other management of the container blocks until the hook completes or until the termination grace period is reached. More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks */ + preStop?: LifecycleHandler +} + +/** + * LifecycleHandler defines a specific action that should be taken in a lifecycle hook. One and only one of the fields, except TCPSocket must be specified. + * + * @category models + * @since 4.0.0 + */ +export interface LifecycleHandler { + /** Exec specifies the action to take. */ + exec?: ExecAction + /** HTTPGet specifies the http request to perform. */ + httpGet?: HTTPGetAction + /** Sleep represents the duration that the container should sleep before being terminated. */ + sleep?: SleepAction + /** Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept for the backward compatibility. There are no validation of this field and lifecycle hooks will fail in runtime when tcp handler is specified. */ + tcpSocket?: TCPSocketAction +} + +/** + * LocalObjectReference contains enough information to let you locate the referenced object inside the same namespace. + * + * @category models + * @since 4.0.0 + */ +export interface LocalObjectReference { + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ + name?: string +} + +/** + * Represents an NFS mount that lasts the lifetime of a pod. NFS volumes do not support ownership management or SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface NFSVolumeSource { + /** path that is exported by the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs */ + path: string + /** readOnly here will force the NFS export to be mounted with read-only permissions. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs */ + readOnly?: boolean + /** server is the hostname or IP address of the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs */ + server: string +} + +/** + * Node affinity is a group of node affinity scheduling rules. + * + * @category models + * @since 4.0.0 + */ +export interface NodeAffinity { + /** The scheduler will prefer to schedule pods to nodes that satisfy the affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node matches the corresponding matchExpressions; the node(s) with the highest sum are the most preferred. */ + preferredDuringSchedulingIgnoredDuringExecution?: Array + /** If the affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to an update), the system may or may not try to eventually evict the pod from its node. */ + requiredDuringSchedulingIgnoredDuringExecution?: NodeSelector +} + +/** + * A node selector represents the union of the results of one or more label queries over a set of nodes; that is, it represents the OR of the selectors represented by the node selector terms. + * + * @category models + * @since 4.0.0 + */ +export interface NodeSelector { + /** Required. A list of node selector terms. The terms are ORed. */ + nodeSelectorTerms: Array +} + +/** + * A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + * + * @category models + * @since 4.0.0 + */ +export interface NodeSelectorRequirement { + /** The label key that the selector applies to. */ + key: string + /** Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. */ + operator: string + /** An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch. */ + values?: Array +} + +/** + * A null or empty node selector term matches no objects. The requirements of them are ANDed. The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + * + * @category models + * @since 4.0.0 + */ +export interface NodeSelectorTerm { + /** A list of node selector requirements by node's labels. */ + matchExpressions?: Array + /** A list of node selector requirements by node's fields. */ + matchFields?: Array +} + +/** + * ObjectFieldSelector selects an APIVersioned field of an object. + * + * @category models + * @since 4.0.0 + */ +export interface ObjectFieldSelector { + /** Version of the schema the FieldPath is written in terms of, defaults to "v1". */ + apiVersion?: string + /** Path of the field to select in the specified API version. */ + fieldPath: string +} + +/** + * PersistentVolumeClaimSpec describes the common attributes of storage devices and allows a Source for provider-specific attributes + * + * @category models + * @since 4.0.0 + */ +export interface PersistentVolumeClaimSpec { + /** accessModes contains the desired access modes the volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 */ + accessModes?: Array + /** dataSource field can be used to specify either: * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) * An existing PVC (PersistentVolumeClaim) If the provisioner or an external controller can support the specified data source, it will create a new volume based on the contents of the specified data source. When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. If the namespace is specified, then dataSourceRef will not be copied to dataSource. */ + dataSource?: TypedLocalObjectReference + /** + * dataSourceRef specifies the object from which to populate the volume with data, if a non-empty volume is desired. This may be any object from a non-empty API group (non core object) or a PersistentVolumeClaim object. When this field is specified, volume binding will only succeed if the type of the specified object matches some installed volume populator or dynamic provisioner. This field will replace the functionality of the dataSource field and as such if both fields are non-empty, they must have the same value. For backwards compatibility, when namespace isn't specified in dataSourceRef, both fields (dataSource and dataSourceRef) will be set to the same value automatically if one of them is empty and the other is non-empty. When namespace is specified in dataSourceRef, dataSource isn't set to the same value and must be empty. There are three important differences between dataSource and dataSourceRef: * While dataSource only allows two specific types of objects, dataSourceRef + * allows any non-core object, as well as PersistentVolumeClaim objects. + * * While dataSource ignores disallowed values (dropping them), dataSourceRef + * preserves all values, and generates an error if a disallowed value is + * specified. + * * While dataSource only allows local objects, dataSourceRef allows objects + * in any namespaces. + * (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + */ + dataSourceRef?: TypedObjectReference + /** resources represents the minimum resources the volume should have. If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements that are lower than previous value but must still be higher than capacity recorded in the status field of the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources */ + resources?: VolumeResourceRequirements + /** selector is a label query over volumes to consider for binding. */ + selector?: LabelSelector + /** storageClassName is the name of the StorageClass required by the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 */ + storageClassName?: string + /** volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. If specified, the CSI driver will create or update the volume with the attributes defined in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass will be applied to the claim but it's not allowed to reset this field to empty string once it is set. If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass will be set by the persistentvolume controller if it exists. If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ (Alpha) Using this field requires the VolumeAttributesClass feature gate to be enabled. */ + volumeAttributesClassName?: string + /** volumeMode defines what type of volume is required by the claim. Value of Filesystem is implied when not included in claim spec. */ + volumeMode?: string + /** volumeName is the binding reference to the PersistentVolume backing this claim. */ + volumeName?: string +} + +/** + * PersistentVolumeClaimTemplate is used to produce PersistentVolumeClaim objects as part of an EphemeralVolumeSource. + * + * @category models + * @since 4.0.0 + */ +export interface PersistentVolumeClaimTemplate { + /** May contain labels and annotations that will be copied into the PVC when creating it. No other fields are allowed and will be rejected during validation. */ + metadata?: ObjectMeta + /** The specification for the PersistentVolumeClaim. The entire content is copied unchanged into the PVC that gets created from this template. The same fields as in a PersistentVolumeClaim are also valid here. */ + spec: PersistentVolumeClaimSpec +} + +/** + * PersistentVolumeClaimVolumeSource references the user's PVC in the same namespace. This volume finds the bound PV and mounts that volume for the pod. A PersistentVolumeClaimVolumeSource is, essentially, a wrapper around another type of volume that is owned by someone else (the system). + * + * @category models + * @since 4.0.0 + */ +export interface PersistentVolumeClaimVolumeSource { + /** claimName is the name of a PersistentVolumeClaim in the same namespace as the pod using this volume. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims */ + claimName: string + /** readOnly Will force the ReadOnly setting in VolumeMounts. Default false. */ + readOnly?: boolean +} + +/** + * Represents a Photon Controller persistent disk resource. + * + * @category models + * @since 4.0.0 + */ +export interface PhotonPersistentDiskVolumeSource { + /** fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. */ + fsType?: string + /** pdID is the ID that identifies Photon Controller persistent disk */ + pdID: string +} + +/** + * Pod is a collection of containers that can run on a host. This resource is created by clients and scheduled onto hosts. + * + * @category models + * @since 4.0.0 + */ +export interface Pod { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: "v1" + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: "Pod" + /** Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + metadata?: ObjectMeta + /** Specification of the desired behavior of the pod. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + spec?: PodSpec + /** Most recently observed status of the pod. This data may not be up to date. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + readonly status?: PodStatus +} + +/** + * Pod affinity is a group of inter pod affinity scheduling rules. + * + * @category models + * @since 4.0.0 + */ +export interface PodAffinity { + /** The scheduler will prefer to schedule pods to nodes that satisfy the affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred. */ + preferredDuringSchedulingIgnoredDuringExecution?: Array + /** If the affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to a pod label update), the system may or may not try to eventually evict the pod from its node. When there are multiple elements, the lists of nodes corresponding to each podAffinityTerm are intersected, i.e. all terms must be satisfied. */ + requiredDuringSchedulingIgnoredDuringExecution?: Array +} + +/** + * Defines a set of pods (namely those matching the labelSelector relative to the given namespace(s)) that this pod should be co-located (affinity) or not co-located (anti-affinity) with, where co-located is defined as running on a node whose value of the label with key matches that of any node on which a pod of the set of pods is running + * + * @category models + * @since 4.0.0 + */ +export interface PodAffinityTerm { + /** A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods. */ + labelSelector?: LabelSelector + /** MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. */ + matchLabelKeys?: Array + /** MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. */ + mismatchLabelKeys?: Array + /** A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces. */ + namespaceSelector?: LabelSelector + /** namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace". */ + namespaces?: Array + /** This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed. */ + topologyKey: string +} + +/** + * Pod anti affinity is a group of inter pod anti affinity scheduling rules. + * + * @category models + * @since 4.0.0 + */ +export interface PodAntiAffinity { + /** The scheduler will prefer to schedule pods to nodes that satisfy the anti-affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling anti-affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred. */ + preferredDuringSchedulingIgnoredDuringExecution?: Array + /** If the anti-affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the anti-affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to a pod label update), the system may or may not try to eventually evict the pod from its node. When there are multiple elements, the lists of nodes corresponding to each podAffinityTerm are intersected, i.e. all terms must be satisfied. */ + requiredDuringSchedulingIgnoredDuringExecution?: Array +} + +/** + * PodCondition contains details for the current condition of this pod. + * + * @category models + * @since 4.0.0 + */ +export interface PodCondition { + /** Last time we probed the condition. */ + lastProbeTime?: Time + /** Last time the condition transitioned from one status to another. */ + lastTransitionTime?: Time + /** Human-readable message indicating details about last transition. */ + message?: string + /** Unique, one-word, CamelCase reason for the condition's last transition. */ + reason?: string + /** Status is the status of the condition. Can be True, False, Unknown. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#pod-conditions */ + status: string + /** Type is the type of the condition. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#pod-conditions */ + type: string +} + +/** + * PodDNSConfig defines the DNS parameters of a pod in addition to those generated from DNSPolicy. + * + * @category models + * @since 4.0.0 + */ +export interface PodDNSConfig { + /** A list of DNS name server IP addresses. This will be appended to the base nameservers generated from DNSPolicy. Duplicated nameservers will be removed. */ + nameservers?: Array + /** A list of DNS resolver options. This will be merged with the base options generated from DNSPolicy. Duplicated entries will be removed. Resolution options given in Options will override those that appear in the base DNSPolicy. */ + options?: Array + /** A list of DNS search domains for host-name lookup. This will be appended to the base search paths generated from DNSPolicy. Duplicated search paths will be removed. */ + searches?: Array +} + +/** + * PodDNSConfigOption defines DNS resolver options of a pod. + * + * @category models + * @since 4.0.0 + */ +export interface PodDNSConfigOption { + /** Required. */ + name?: string + value?: string +} + +/** + * PodIP represents a single IP address allocated to the pod. + * + * @category models + * @since 4.0.0 + */ +export interface PodIP { + /** IP is the IP address assigned to the pod */ + ip?: string +} + +/** + * PodOS defines the OS parameters of a pod. + * + * @category models + * @since 4.0.0 + */ +export interface PodOS { + /** Name is the name of the operating system. The currently supported values are linux and windows. Additional value may be defined in future and can be one of: https://github.com/opencontainers/runtime-spec/blob/master/config.md#platform-specific-configuration Clients should expect to handle additional values and treat unrecognized values in this field as os: null */ + name: string +} + +/** + * PodReadinessGate contains the reference to a pod condition + * + * @category models + * @since 4.0.0 + */ +export interface PodReadinessGate { + /** ConditionType refers to a condition in the pod's condition list with matching type. */ + conditionType: string +} + +/** + * PodResourceClaim references exactly one ResourceClaim through a ClaimSource. It adds a name to it that uniquely identifies the ResourceClaim inside the Pod. Containers that need access to the ResourceClaim reference it with this name. + * + * @category models + * @since 4.0.0 + */ +export interface PodResourceClaim { + /** Name uniquely identifies this resource claim inside the pod. This must be a DNS_LABEL. */ + name: string + /** Source describes where to find the ResourceClaim. */ + source?: ClaimSource +} + +/** + * PodResourceClaimStatus is stored in the PodStatus for each PodResourceClaim which references a ResourceClaimTemplate. It stores the generated name for the corresponding ResourceClaim. + * + * @category models + * @since 4.0.0 + */ +export interface PodResourceClaimStatus { + /** Name uniquely identifies this resource claim inside the pod. This must match the name of an entry in pod.spec.resourceClaims, which implies that the string must be a DNS_LABEL. */ + name: string + /** ResourceClaimName is the name of the ResourceClaim that was generated for the Pod in the namespace of the Pod. It this is unset, then generating a ResourceClaim was not necessary. The pod.spec.resourceClaims entry can be ignored in this case. */ + resourceClaimName?: string +} + +/** + * PodSchedulingGate is associated to a Pod to guard its scheduling. + * + * @category models + * @since 4.0.0 + */ +export interface PodSchedulingGate { + /** Name of the scheduling gate. Each scheduling gate must have a unique name field. */ + name: string +} + +/** + * PodSecurityContext holds pod-level security attributes and common container settings. Some fields are also present in container.securityContext. Field values of container.securityContext take precedence over field values of PodSecurityContext. + * + * @category models + * @since 4.0.0 + */ +export interface PodSecurityContext { + /** appArmorProfile is the AppArmor options to use by the containers in this pod. Note that this field cannot be set when spec.os.name is windows. */ + appArmorProfile?: AppArmorProfile + /** + * A special supplemental group that applies to all containers in a pod. Some volume types allow the Kubelet to change the ownership of that volume to be owned by the pod: + * + * **Details** + * + * 1. The owning GID will be the FSGroup 2. The setgid bit is set (new files created in the volume will be owned by FSGroup) 3. The permission bits are OR'd with rw-rw---- + * + * If unset, the Kubelet will not modify the ownership and permissions of any volume. Note that this field cannot be set when spec.os.name is windows. + */ + fsGroup?: number + /** fsGroupChangePolicy defines behavior of changing ownership and permission of the volume before being exposed inside Pod. This field will only apply to volume types which support fsGroup based ownership(and permissions). It will have no effect on ephemeral volume types such as: secret, configmaps and emptydir. Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used. Note that this field cannot be set when spec.os.name is windows. */ + fsGroupChangePolicy?: string + /** The GID to run the entrypoint of the container process. Uses runtime default if unset. May also be set in SecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence for that container. Note that this field cannot be set when spec.os.name is windows. */ + runAsGroup?: number + /** Indicates that the container must run as a non-root user. If true, the Kubelet will validate the image at runtime to ensure that it does not run as UID 0 (root) and fail to start the container if it does. If unset or false, no such validation will be performed. May also be set in SecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. */ + runAsNonRoot?: boolean + /** The UID to run the entrypoint of the container process. Defaults to user specified in image metadata if unspecified. May also be set in SecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence for that container. Note that this field cannot be set when spec.os.name is windows. */ + runAsUser?: number + /** The SELinux context to be applied to all containers. If unspecified, the container runtime will allocate a random SELinux context for each container. May also be set in SecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence for that container. Note that this field cannot be set when spec.os.name is windows. */ + seLinuxOptions?: SELinuxOptions + /** The seccomp options to use by the containers in this pod. Note that this field cannot be set when spec.os.name is windows. */ + seccompProfile?: SeccompProfile + /** A list of groups applied to the first process run in each container, in addition to the container's primary GID, the fsGroup (if specified), and group memberships defined in the container image for the uid of the container process. If unspecified, no additional groups are added to any container. Note that group memberships defined in the container image for the uid of the container process are still effective, even if they are not included in this list. Note that this field cannot be set when spec.os.name is windows. */ + supplementalGroups?: Array + /** Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported sysctls (by the container runtime) might fail to launch. Note that this field cannot be set when spec.os.name is windows. */ + sysctls?: Array + /** The Windows specific settings applied to all containers. If unspecified, the options within a container's SecurityContext will be used. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. Note that this field cannot be set when spec.os.name is linux. */ + windowsOptions?: WindowsSecurityContextOptions +} + +/** + * PodSpec is a description of a pod. + * + * @category models + * @since 4.0.0 + */ +export interface PodSpec { + /** Optional duration in seconds the pod may be active on the node relative to StartTime before the system will actively try to mark it failed and kill associated containers. Value must be a positive integer. */ + activeDeadlineSeconds?: number + /** If specified, the pod's scheduling constraints */ + affinity?: Affinity + /** AutomountServiceAccountToken indicates whether a service account token should be automatically mounted. */ + automountServiceAccountToken?: boolean + /** List of containers belonging to the pod. Containers cannot currently be added or removed. There must be at least one container in a Pod. Cannot be updated. */ + containers: Array + /** Specifies the DNS parameters of a pod. Parameters specified here will be merged to the generated DNS configuration based on DNSPolicy. */ + dnsConfig?: PodDNSConfig + /** Set DNS policy for the pod. Defaults to "ClusterFirst". Valid values are 'ClusterFirstWithHostNet', 'ClusterFirst', 'Default' or 'None'. DNS parameters given in DNSConfig will be merged with the policy selected with DNSPolicy. To have DNS options set along with hostNetwork, you have to specify DNS policy explicitly to 'ClusterFirstWithHostNet'. */ + dnsPolicy?: string + /** EnableServiceLinks indicates whether information about services should be injected into pod's environment variables, matching the syntax of Docker links. Optional: Defaults to true. */ + enableServiceLinks?: boolean + /** List of ephemeral containers run in this pod. Ephemeral containers may be run in an existing pod to perform user-initiated actions such as debugging. This list cannot be specified when creating a pod, and it cannot be modified by updating the pod spec. In order to add an ephemeral container to an existing pod, use the pod's ephemeralcontainers subresource. */ + ephemeralContainers?: Array + /** HostAliases is an optional list of hosts and IPs that will be injected into the pod's hosts file if specified. */ + hostAliases?: Array + /** Use the host's ipc namespace. Optional: Default to false. */ + hostIPC?: boolean + /** Host networking requested for this pod. Use the host's network namespace. If this option is set, the ports that will be used must be specified. Default to false. */ + hostNetwork?: boolean + /** Use the host's pid namespace. Optional: Default to false. */ + hostPID?: boolean + /** Use the host's user namespace. Optional: Default to true. If set to true or not present, the pod will be run in the host user namespace, useful for when the pod needs a feature only available to the host user namespace, such as loading a kernel module with CAP_SYS_MODULE. When set to false, a new userns is created for the pod. Setting false is useful for mitigating container breakout vulnerabilities even allowing users to run their containers as root without actually having root privileges on the host. This field is alpha-level and is only honored by servers that enable the UserNamespacesSupport feature. */ + hostUsers?: boolean + /** Specifies the hostname of the Pod If not specified, the pod's hostname will be set to a system-defined value. */ + hostname?: string + /** ImagePullSecrets is an optional list of references to secrets in the same namespace to use for pulling any of the images used by this PodSpec. If specified, these secrets will be passed to individual puller implementations for them to use. More info: https://kubernetes.io/docs/concepts/containers/images#specifying-imagepullsecrets-on-a-pod */ + imagePullSecrets?: Array + /** List of initialization containers belonging to the pod. Init containers are executed in order prior to containers being started. If any init container fails, the pod is considered to have failed and is handled according to its restartPolicy. The name for an init container or normal container must be unique among all containers. Init containers may not have Lifecycle actions, Readiness probes, Liveness probes, or Startup probes. The resourceRequirements of an init container are taken into account during scheduling by finding the highest request/limit for each resource type, and then using the max of of that value or the sum of the normal containers. Limits are applied to init containers in a similar fashion. Init containers cannot currently be added or removed. Cannot be updated. More info: https://kubernetes.io/docs/concepts/workloads/pods/init-containers/ */ + initContainers?: Array + /** NodeName is a request to schedule this pod onto a specific node. If it is non-empty, the scheduler simply schedules this pod onto that node, assuming that it fits resource requirements. */ + nodeName?: string + /** NodeSelector is a selector which must be true for the pod to fit on a node. Selector which must match a node's labels for the pod to be scheduled on that node. More info: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/ */ + nodeSelector?: { + [name: string]: string + } + /** + * Specifies the OS of the containers in the pod. Some pod and container fields are restricted if this is set. + * + * **Details** + * + * If the OS field is set to linux, the following fields must be unset: -securityContext.windowsOptions + * + * If the OS field is set to windows, following fields must be unset: - spec.hostPID - spec.hostIPC - spec.hostUsers - spec.securityContext.appArmorProfile - spec.securityContext.seLinuxOptions - spec.securityContext.seccompProfile - spec.securityContext.fsGroup - spec.securityContext.fsGroupChangePolicy - spec.securityContext.sysctls - spec.shareProcessNamespace - spec.securityContext.runAsUser - spec.securityContext.runAsGroup - spec.securityContext.supplementalGroups - spec.containers[*].securityContext.appArmorProfile - spec.containers[*].securityContext.seLinuxOptions - spec.containers[*].securityContext.seccompProfile - spec.containers[*].securityContext.capabilities - spec.containers[*].securityContext.readOnlyRootFilesystem - spec.containers[*].securityContext.privileged - spec.containers[*].securityContext.allowPrivilegeEscalation - spec.containers[*].securityContext.procMount - spec.containers[*].securityContext.runAsUser - spec.containers[*].securityContext.runAsGroup + */ + os?: PodOS + /** Overhead represents the resource overhead associated with running a pod for a given RuntimeClass. This field will be autopopulated at admission time by the RuntimeClass admission controller. If the RuntimeClass admission controller is enabled, overhead must not be set in Pod create requests. The RuntimeClass admission controller will reject Pod create requests which have the overhead already set. If RuntimeClass is configured and selected in the PodSpec, Overhead will be set to the value defined in the corresponding RuntimeClass, otherwise it will remain unset and treated as zero. More info: https://git.k8s.io/enhancements/keps/sig-node/688-pod-overhead/README.md */ + overhead?: { + [name: string]: Quantity + } + /** PreemptionPolicy is the Policy for preempting pods with lower priority. One of Never, PreemptLowerPriority. Defaults to PreemptLowerPriority if unset. */ + preemptionPolicy?: string + /** The priority value. Various system components use this field to find the priority of the pod. When Priority Admission Controller is enabled, it prevents users from setting this field. The admission controller populates this field from PriorityClassName. The higher the value, the higher the priority. */ + priority?: number + /** If specified, indicates the pod's priority. "system-node-critical" and "system-cluster-critical" are two special keywords which indicate the highest priorities with the former being the highest priority. Any other name must be defined by creating a PriorityClass object with that name. If not specified, the pod priority will be default or zero if there is no default. */ + priorityClassName?: string + /** If specified, all readiness gates will be evaluated for pod readiness. A pod is ready when all its containers are ready AND all conditions specified in the readiness gates have status equal to "True" More info: https://git.k8s.io/enhancements/keps/sig-network/580-pod-readiness-gates */ + readinessGates?: Array + /** + * ResourceClaims defines which ResourceClaims must be allocated and reserved before the Pod is allowed to start. The resources will be made available to those containers which consume them by name. + * + * **Details** + * + * This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. + * + * This field is immutable. + */ + resourceClaims?: Array + /** Restart policy for all containers within the pod. One of Always, OnFailure, Never. In some contexts, only a subset of those values may be permitted. Default to Always. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#restart-policy */ + restartPolicy?: string + /** RuntimeClassName refers to a RuntimeClass object in the node.k8s.io group, which should be used to run this pod. If no RuntimeClass resource matches the named class, the pod will not be run. If unset or empty, the "legacy" RuntimeClass will be used, which is an implicit class with an empty definition that uses the default runtime handler. More info: https://git.k8s.io/enhancements/keps/sig-node/585-runtime-class */ + runtimeClassName?: string + /** If specified, the pod will be dispatched by specified scheduler. If not specified, the pod will be dispatched by default scheduler. */ + schedulerName?: string + /** + * SchedulingGates is an opaque list of values that if specified will block scheduling the pod. If schedulingGates is not empty, the pod will stay in the SchedulingGated state and the scheduler will not attempt to schedule the pod. + * + * **Details** + * + * SchedulingGates can only be set at pod creation time, and be removed only afterwards. + */ + schedulingGates?: Array + /** SecurityContext holds pod-level security attributes and common container settings. Optional: Defaults to empty. See type description for default values of each field. */ + securityContext?: PodSecurityContext + /** DeprecatedServiceAccount is a deprecated alias for ServiceAccountName. Deprecated: Use serviceAccountName instead. */ + serviceAccount?: string + /** ServiceAccountName is the name of the ServiceAccount to use to run this pod. More info: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/ */ + serviceAccountName?: string + /** If true the pod's hostname will be configured as the pod's FQDN, rather than the leaf name (the default). In Linux containers, this means setting the FQDN in the hostname field of the kernel (the nodename field of struct utsname). In Windows containers, this means setting the registry value of hostname for the registry key HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters to FQDN. If a pod does not have FQDN, this has no effect. Default to false. */ + setHostnameAsFQDN?: boolean + /** Share a single process namespace between all of the containers in a pod. When this is set containers will be able to view and signal processes from other containers in the same pod, and the first process in each container will not be assigned PID 1. HostPID and ShareProcessNamespace cannot both be set. Optional: Default to false. */ + shareProcessNamespace?: boolean + /** If specified, the fully qualified Pod hostname will be "...svc.". If not specified, the pod will not have a domainname at all. */ + subdomain?: string + /** Optional duration in seconds the pod needs to terminate gracefully. May be decreased in delete request. Value must be non-negative integer. The value zero indicates stop immediately via the kill signal (no opportunity to shut down). If this value is nil, the default grace period will be used instead. The grace period is the duration in seconds after the processes running in the pod are sent a termination signal and the time when the processes are forcibly halted with a kill signal. Set this value longer than the expected cleanup time for your process. Defaults to 30 seconds. */ + terminationGracePeriodSeconds?: number + /** If specified, the pod's tolerations. */ + tolerations?: Array + /** TopologySpreadConstraints describes how a group of pods ought to spread across topology domains. Scheduler will schedule pods in a way which abides by the constraints. All topologySpreadConstraints are ANDed. */ + topologySpreadConstraints?: Array + /** List of volumes that can be mounted by containers belonging to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes */ + volumes?: Array +} + +/** + * PodStatus represents information about the status of a pod. Status may trail the actual state of a system, especially if the node that hosts the pod cannot contact the control plane. + * + * @category models + * @since 4.0.0 + */ +export interface PodStatus { + /** Current service state of pod. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#pod-conditions */ + conditions?: Array + /** The list has one entry per container in the manifest. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#pod-and-container-status */ + containerStatuses?: Array + /** Status for any ephemeral containers that have run in this pod. */ + ephemeralContainerStatuses?: Array + /** hostIP holds the IP address of the host to which the pod is assigned. Empty if the pod has not started yet. A pod can be assigned to a node that has a problem in kubelet which in turns mean that HostIP will not be updated even if there is a node is assigned to pod */ + hostIP?: string + /** hostIPs holds the IP addresses allocated to the host. If this field is specified, the first entry must match the hostIP field. This list is empty if the pod has not started yet. A pod can be assigned to a node that has a problem in kubelet which in turns means that HostIPs will not be updated even if there is a node is assigned to this pod. */ + hostIPs?: Array + /** The list has one entry per init container in the manifest. The most recent successful init container will have ready = true, the most recently started container will have startTime set. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#pod-and-container-status */ + initContainerStatuses?: Array + /** A human readable message indicating details about why the pod is in this condition. */ + message?: string + /** nominatedNodeName is set only when this pod preempts other pods on the node, but it cannot be scheduled right away as preemption victims receive their graceful termination periods. This field does not guarantee that the pod will be scheduled on this node. Scheduler may decide to place the pod elsewhere if other nodes become available sooner. Scheduler may also decide to give the resources on this node to a higher priority pod that is created after preemption. As a result, this field may be different than PodSpec.nodeName when the pod is scheduled. */ + nominatedNodeName?: string + /** + * The phase of a Pod is a simple, high-level summary of where the Pod is in its lifecycle. The conditions array, the reason and message fields, and the individual container status arrays contain more detail about the pod's status. There are five possible phase values: + * + * **Details** + * + * Pending: The pod has been accepted by the Kubernetes system, but one or more of the container images has not been created. This includes time before being scheduled as well as time spent downloading images over the network, which could take a while. Running: The pod has been bound to a node, and all of the containers have been created. At least one container is still running, or is in the process of starting or restarting. Succeeded: All containers in the pod have terminated in success, and will not be restarted. Failed: All containers in the pod have terminated, and at least one container has terminated in failure. The container either exited with non-zero status or was terminated by the system. Unknown: For some reason the state of the pod could not be obtained, typically due to an error in communicating with the host of the pod. + * + * More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#pod-phase + */ + phase?: string + /** podIP address allocated to the pod. Routable at least within the cluster. Empty if not yet allocated. */ + podIP?: string + /** podIPs holds the IP addresses allocated to the pod. If this field is specified, the 0th entry must match the podIP field. Pods may be allocated at most 1 value for each of IPv4 and IPv6. This list is empty if no IPs have been allocated yet. */ + podIPs?: Array + /** The Quality of Service (QOS) classification assigned to the pod based on resource requirements See PodQOSClass type for available QOS classes More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/#quality-of-service-classes */ + qosClass?: string + /** A brief CamelCase message indicating details about why the pod is in this state. e.g. 'Evicted' */ + reason?: string + /** Status of resources resize desired for pod's containers. It is empty if no resources resize is pending. Any changes to container resources will automatically set this to "Proposed" */ + resize?: string + /** Status of resource claims. */ + resourceClaimStatuses?: Array + /** RFC 3339 date and time at which the object was acknowledged by the Kubelet. This is before the Kubelet pulled the container image(s) for the pod. */ + startTime?: Time +} + +/** + * PortworxVolumeSource represents a Portworx volume resource. + * + * @category models + * @since 4.0.0 + */ +export interface PortworxVolumeSource { + /** fSType represents the filesystem type to mount Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs". Implicitly inferred to be "ext4" if unspecified. */ + fsType?: string + /** readOnly defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. */ + readOnly?: boolean + /** volumeID uniquely identifies a Portworx volume */ + volumeID: string +} + +/** + * An empty preferred scheduling term matches all objects with implicit weight 0 (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + * + * @category models + * @since 4.0.0 + */ +export interface PreferredSchedulingTerm { + /** A node selector term, associated with the corresponding weight. */ + preference: NodeSelectorTerm + /** Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100. */ + weight: number +} + +/** + * Probe describes a health check to be performed against a container to determine whether it is alive or ready to receive traffic. + * + * @category models + * @since 4.0.0 + */ +export interface Probe { + /** Exec specifies the action to take. */ + exec?: ExecAction + /** Minimum consecutive failures for the probe to be considered failed after having succeeded. Defaults to 3. Minimum value is 1. */ + failureThreshold?: number + /** GRPC specifies an action involving a GRPC port. */ + grpc?: GRPCAction + /** HTTPGet specifies the http request to perform. */ + httpGet?: HTTPGetAction + /** Number of seconds after the container has started before liveness probes are initiated. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes */ + initialDelaySeconds?: number + /** How often (in seconds) to perform the probe. Default to 10 seconds. Minimum value is 1. */ + periodSeconds?: number + /** Minimum consecutive successes for the probe to be considered successful after having failed. Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. */ + successThreshold?: number + /** TCPSocket specifies an action involving a TCP port. */ + tcpSocket?: TCPSocketAction + /** Optional duration in seconds the pod needs to terminate gracefully upon probe failure. The grace period is the duration in seconds after the processes running in the pod are sent a termination signal and the time when the processes are forcibly halted with a kill signal. Set this value longer than the expected cleanup time for your process. If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this value overrides the value provided by the pod spec. Value must be non-negative integer. The value zero indicates stop immediately via the kill signal (no opportunity to shut down). This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. */ + terminationGracePeriodSeconds?: number + /** Number of seconds after which the probe times out. Defaults to 1 second. Minimum value is 1. More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes */ + timeoutSeconds?: number +} + +/** + * Represents a projected volume source + * + * @category models + * @since 4.0.0 + */ +export interface ProjectedVolumeSource { + /** defaultMode are the mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set. */ + defaultMode?: number + /** sources is the list of volume projections */ + sources?: Array +} + +/** + * Represents a Quobyte mount that lasts the lifetime of a pod. Quobyte volumes do not support ownership management or SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface QuobyteVolumeSource { + /** group to map volume access to Default is no group */ + group?: string + /** readOnly here will force the Quobyte volume to be mounted with read-only permissions. Defaults to false. */ + readOnly?: boolean + /** registry represents a single or multiple Quobyte Registry services specified as a string as host:port pair (multiple entries are separated with commas) which acts as the central registry for volumes */ + registry: string + /** tenant owning the given Quobyte volume in the Backend Used with dynamically provisioned Quobyte volumes, value is set by the plugin */ + tenant?: string + /** user to map volume access to Defaults to serivceaccount user */ + user?: string + /** volume is a string that references an already created Quobyte volume by name. */ + volume: string +} + +/** + * Represents a Rados Block Device mount that lasts the lifetime of a pod. RBD volumes support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface RBDVolumeSource { + /** fsType is the filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#rbd */ + fsType?: string + /** image is the rados image name. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it */ + image: string + /** keyring is the path to key ring for RBDUser. Default is /etc/ceph/keyring. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it */ + keyring?: string + /** monitors is a collection of Ceph monitors. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it */ + monitors: Array + /** pool is the rados pool name. Default is rbd. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it */ + pool?: string + /** readOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it */ + readOnly?: boolean + /** secretRef is name of the authentication secret for RBDUser. If provided overrides keyring. Default is nil. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it */ + secretRef?: LocalObjectReference + /** user is the rados user name. Default is admin. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it */ + user?: string +} + +/** + * ResourceClaim references one entry in PodSpec.ResourceClaims. + * + * @category models + * @since 4.0.0 + */ +export interface ResourceClaim { + /** Name must match the name of one entry in pod.spec.resourceClaims of the Pod where this field is used. It makes that resource available inside a container. */ + name: string +} + +/** + * ResourceFieldSelector represents container resources (cpu, memory) and their output format + * + * @category models + * @since 4.0.0 + */ +export interface ResourceFieldSelector { + /** Container name: required for volumes, optional for env vars */ + containerName?: string + /** Specifies the output format of the exposed resources, defaults to "1" */ + divisor?: Quantity + /** Required: resource to select */ + resource: string +} + +/** + * ResourceRequirements describes the compute resource requirements. + * + * @category models + * @since 4.0.0 + */ +export interface ResourceRequirements { + /** + * Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. + * + * **Details** + * + * This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. + * + * This field is immutable. It can only be set for containers. + */ + claims?: Array + /** Limits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ */ + limits?: { + [name: string]: Quantity + } + /** Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. Requests cannot exceed Limits. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ */ + requests?: { + [name: string]: Quantity + } +} + +/** + * SELinuxOptions are the labels to be applied to the container + * + * @category models + * @since 4.0.0 + */ +export interface SELinuxOptions { + /** Level is SELinux level label that applies to the container. */ + level?: string + /** Role is a SELinux role label that applies to the container. */ + role?: string + /** Type is a SELinux type label that applies to the container. */ + type?: string + /** User is a SELinux user label that applies to the container. */ + user?: string +} + +/** + * ScaleIOVolumeSource represents a persistent ScaleIO volume + * + * @category models + * @since 4.0.0 + */ +export interface ScaleIOVolumeSource { + /** fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Default is "xfs". */ + fsType?: string + /** gateway is the host address of the ScaleIO API Gateway. */ + gateway: string + /** protectionDomain is the name of the ScaleIO Protection Domain for the configured storage. */ + protectionDomain?: string + /** readOnly Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. */ + readOnly?: boolean + /** secretRef references to the secret for ScaleIO user and other sensitive information. If this is not provided, Login operation will fail. */ + secretRef: LocalObjectReference + /** sslEnabled Flag enable/disable SSL communication with Gateway, default false */ + sslEnabled?: boolean + /** storageMode indicates whether the storage for a volume should be ThickProvisioned or ThinProvisioned. Default is ThinProvisioned. */ + storageMode?: string + /** storagePool is the ScaleIO Storage Pool associated with the protection domain. */ + storagePool?: string + /** system is the name of the storage system as configured in ScaleIO. */ + system: string + /** volumeName is the name of a volume already created in the ScaleIO system that is associated with this volume source. */ + volumeName?: string +} + +/** + * SeccompProfile defines a pod/container's seccomp profile settings. Only one profile source may be set. + * + * @category models + * @since 4.0.0 + */ +export interface SeccompProfile { + /** localhostProfile indicates a profile defined in a file on the node should be used. The profile must be preconfigured on the node to work. Must be a descending path, relative to the kubelet's configured seccomp profile location. Must be set if type is "Localhost". Must NOT be set for any other type. */ + localhostProfile?: string + /** + * type indicates which kind of seccomp profile will be applied. Valid options are: + * + * **Details** + * + * Localhost - a profile defined in a file on the node should be used. RuntimeDefault - the container runtime default profile should be used. Unconfined - no profile should be applied. + */ + type: string +} + +/** + * SecretEnvSource selects a Secret to populate the environment variables with. + * + * **Details** + * + * The contents of the target Secret's Data field will represent the key-value pairs as environment variables. + * + * @category models + * @since 4.0.0 + */ +export interface SecretEnvSource { + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ + name?: string + /** Specify whether the Secret must be defined */ + optional?: boolean +} + +/** + * SecretKeySelector selects a key of a Secret. + * + * @category models + * @since 4.0.0 + */ +export interface SecretKeySelector { + /** The key of the secret to select from. Must be a valid secret key. */ + key: string + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ + name?: string + /** Specify whether the Secret or its key must be defined */ + optional?: boolean +} + +/** + * Adapts a secret into a projected volume. + * + * **Details** + * + * The contents of the target Secret's Data field will be presented in a projected volume as files using the keys in the Data field as the file names. Note that this is identical to a secret volume source without the default mode. + * + * @category models + * @since 4.0.0 + */ +export interface SecretProjection { + /** items if unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. */ + items?: Array + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ + name?: string + /** optional field specify whether the Secret or its key must be defined */ + optional?: boolean +} + +/** + * Adapts a Secret into a volume. + * + * **Details** + * + * The contents of the target Secret's Data field will be presented in a volume as files using the keys in the Data field as the file names. Secret volumes support ownership management and SELinux relabeling. + * + * @category models + * @since 4.0.0 + */ +export interface SecretVolumeSource { + /** defaultMode is Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set. */ + defaultMode?: number + /** items If unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. */ + items?: Array + /** optional field specify whether the Secret or its keys must be defined */ + optional?: boolean + /** secretName is the name of the secret in the pod's namespace to use. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret */ + secretName?: string +} + +/** + * SecurityContext holds security configuration that will be applied to a container. Some fields are present in both SecurityContext and PodSecurityContext. When both are set, the values in SecurityContext take precedence. + * + * @category models + * @since 4.0.0 + */ +export interface SecurityContext { + /** AllowPrivilegeEscalation controls whether a process can gain more privileges than its parent process. This bool directly controls if the no_new_privs flag will be set on the container process. AllowPrivilegeEscalation is true always when the container is: 1) run as Privileged 2) has CAP_SYS_ADMIN Note that this field cannot be set when spec.os.name is windows. */ + allowPrivilegeEscalation?: boolean + /** appArmorProfile is the AppArmor options to use by this container. If set, this profile overrides the pod's appArmorProfile. Note that this field cannot be set when spec.os.name is windows. */ + appArmorProfile?: AppArmorProfile + /** The capabilities to add/drop when running containers. Defaults to the default set of capabilities granted by the container runtime. Note that this field cannot be set when spec.os.name is windows. */ + capabilities?: Capabilities + /** Run container in privileged mode. Processes in privileged containers are essentially equivalent to root on the host. Defaults to false. Note that this field cannot be set when spec.os.name is windows. */ + privileged?: boolean + /** procMount denotes the type of proc mount to use for the containers. The default is DefaultProcMount which uses the container runtime defaults for readonly paths and masked paths. This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. */ + procMount?: string + /** Whether this container has a read-only root filesystem. Default is false. Note that this field cannot be set when spec.os.name is windows. */ + readOnlyRootFilesystem?: boolean + /** The GID to run the entrypoint of the container process. Uses runtime default if unset. May also be set in PodSecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. Note that this field cannot be set when spec.os.name is windows. */ + runAsGroup?: number + /** Indicates that the container must run as a non-root user. If true, the Kubelet will validate the image at runtime to ensure that it does not run as UID 0 (root) and fail to start the container if it does. If unset or false, no such validation will be performed. May also be set in PodSecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. */ + runAsNonRoot?: boolean + /** The UID to run the entrypoint of the container process. Defaults to user specified in image metadata if unspecified. May also be set in PodSecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. Note that this field cannot be set when spec.os.name is windows. */ + runAsUser?: number + /** The SELinux context to be applied to the container. If unspecified, the container runtime will allocate a random SELinux context for each container. May also be set in PodSecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. Note that this field cannot be set when spec.os.name is windows. */ + seLinuxOptions?: SELinuxOptions + /** The seccomp options to use by this container. If seccomp options are provided at both the pod & container level, the container options override the pod options. Note that this field cannot be set when spec.os.name is windows. */ + seccompProfile?: SeccompProfile + /** The Windows specific settings applied to all containers. If unspecified, the options from the PodSecurityContext will be used. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. Note that this field cannot be set when spec.os.name is linux. */ + windowsOptions?: WindowsSecurityContextOptions +} + +/** + * ServiceAccountTokenProjection represents a projected service account token volume. This projection can be used to insert a service account token into the pods runtime filesystem for use against APIs (Kubernetes API Server or otherwise). + * + * @category models + * @since 4.0.0 + */ +export interface ServiceAccountTokenProjection { + /** audience is the intended audience of the token. A recipient of a token must identify itself with an identifier specified in the audience of the token, and otherwise should reject the token. The audience defaults to the identifier of the apiserver. */ + audience?: string + /** expirationSeconds is the requested duration of validity of the service account token. As the token approaches expiration, the kubelet volume plugin will proactively rotate the service account token. The kubelet will start trying to rotate the token if the token is older than 80 percent of its time to live or if the token is older than 24 hours.Defaults to 1 hour and must be at least 10 minutes. */ + expirationSeconds?: number + /** path is the path relative to the mount point of the file to project the token into. */ + path: string +} + +/** + * SleepAction describes a "sleep" action. + * + * @category models + * @since 4.0.0 + */ +export interface SleepAction { + /** Seconds is the number of seconds to sleep. */ + seconds: number +} + +/** + * Represents a StorageOS persistent volume resource. + * + * @category models + * @since 4.0.0 + */ +export interface StorageOSVolumeSource { + /** fsType is the filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. */ + fsType?: string + /** readOnly defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. */ + readOnly?: boolean + /** secretRef specifies the secret to use for obtaining the StorageOS API credentials. If not specified, default values will be attempted. */ + secretRef?: LocalObjectReference + /** volumeName is the human-readable name of the StorageOS volume. Volume names are only unique within a namespace. */ + volumeName?: string + /** volumeNamespace specifies the scope of the volume within StorageOS. If no namespace is specified then the Pod's namespace will be used. This allows the Kubernetes name scoping to be mirrored within StorageOS for tighter integration. Set VolumeName to any name to override the default behaviour. Set to "default" if you are not using namespaces within StorageOS. Namespaces that do not pre-exist within StorageOS will be created. */ + volumeNamespace?: string +} + +/** + * Sysctl defines a kernel parameter to be set + * + * @category models + * @since 4.0.0 + */ +export interface Sysctl { + /** Name of a property to set */ + name: string + /** Value of a property to set */ + value: string +} + +/** + * TCPSocketAction describes an action based on opening a socket + * + * @category models + * @since 4.0.0 + */ +export interface TCPSocketAction { + /** Optional: Host name to connect to, defaults to the pod IP. */ + host?: string + /** Number or name of the port to access on the container. Number must be in the range 1 to 65535. Name must be an IANA_SVC_NAME. */ + port: number | string +} + +/** + * The pod this Toleration is attached to tolerates any taint that matches the triple using the matching operator . + * + * @category models + * @since 4.0.0 + */ +export interface Toleration { + /** Effect indicates the taint effect to match. Empty means match all taint effects. When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. */ + effect?: string + /** Key is the taint key that the toleration applies to. Empty means match all taint keys. If the key is empty, operator must be Exists; this combination means to match all values and all keys. */ + key?: string + /** Operator represents a key's relationship to the value. Valid operators are Exists and Equal. Defaults to Equal. Exists is equivalent to wildcard for value, so that a pod can tolerate all taints of a particular category. */ + operator?: string + /** TolerationSeconds represents the period of time the toleration (which must be of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, it is not set, which means tolerate the taint forever (do not evict). Zero and negative values will be treated as 0 (evict immediately) by the system. */ + tolerationSeconds?: number + /** Value is the taint value the toleration matches to. If the operator is Exists, the value should be empty, otherwise just a regular string. */ + value?: string +} + +/** + * TopologySpreadConstraint specifies how to spread matching pods among the given topology. + * + * @category models + * @since 4.0.0 + */ +export interface TopologySpreadConstraint { + /** LabelSelector is used to find matching pods. Pods that match this label selector are counted to determine the number of pods in their corresponding topology domain. */ + labelSelector?: LabelSelector + /** + * MatchLabelKeys is a set of pod label keys to select the pods over which spreading will be calculated. The keys are used to lookup values from the incoming pod labels, those key-value labels are ANDed with labelSelector to select the group of existing pods over which spreading will be calculated for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. MatchLabelKeys cannot be set when LabelSelector isn't set. Keys that don't exist in the incoming pod labels will be ignored. A null or empty list means only match against labelSelector. + * + * **Details** + * + * This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + */ + matchLabelKeys?: Array + /** MaxSkew describes the degree to which pods may be unevenly distributed. When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference between the number of matching pods in the target topology and the global minimum. The global minimum is the minimum number of matching pods in an eligible domain or zero if the number of eligible domains is less than MinDomains. For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same labelSelector spread as 2/2/1: In this case, the global minimum is 1. | zone1 | zone2 | zone3 | | P P | P P | P | - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) violate MaxSkew(1). - if MaxSkew is 2, incoming pod can be scheduled onto any zone. When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence to topologies that satisfy it. It's a required field. Default value is 1 and 0 is not allowed. */ + maxSkew: number + /** + * MinDomains indicates a minimum number of eligible domains. When the number of eligible domains with matching topology keys is less than minDomains, Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. And when the number of eligible domains with matching topology keys equals or greater than minDomains, this value has no effect on scheduling. As a result, when the number of eligible domains is less than minDomains, scheduler won't schedule more than maxSkew Pods to those domains. If value is nil, the constraint behaves as if MinDomains is equal to 1. Valid values are integers greater than 0. When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + * + * **Details** + * + * For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same labelSelector spread as 2/2/2: | zone1 | zone2 | zone3 | | P P | P P | P P | The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. In this situation, new pod with the same labelSelector cannot be scheduled, because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, it will violate MaxSkew. + */ + minDomains?: number + /** + * NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector when calculating pod topology spread skew. Options are: - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + * + * **Details** + * + * If this value is nil, the behavior is equivalent to the Honor policy. This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. + */ + nodeAffinityPolicy?: string + /** + * NodeTaintsPolicy indicates how we will treat node taints when calculating pod topology spread skew. Options are: - Honor: nodes without taints, along with tainted nodes for which the incoming pod has a toleration, are included. - Ignore: node taints are ignored. All nodes are included. + * + * **Details** + * + * If this value is nil, the behavior is equivalent to the Ignore policy. This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. + */ + nodeTaintsPolicy?: string + /** TopologyKey is the key of node labels. Nodes that have a label with this key and identical values are considered to be in the same topology. We consider each as a "bucket", and try to put balanced number of pods into each bucket. We define a domain as a particular instance of a topology. Also, we define an eligible domain as a domain whose nodes meet the requirements of nodeAffinityPolicy and nodeTaintsPolicy. e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. It's a required field. */ + topologyKey: string + /** + * WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy the spread constraint. - DoNotSchedule (default) tells the scheduler not to schedule it. - ScheduleAnyway tells the scheduler to schedule the pod in any location, + * but giving higher precedence to topologies that would help reduce the + * skew. + * A constraint is considered "Unsatisfiable" for an incoming pod if and only if every possible node assignment for that pod would violate "MaxSkew" on some topology. For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same labelSelector spread as 3/1/1: | zone1 | zone2 | zone3 | | P P P | P | P | If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler won't make it *more* imbalanced. It's a required field. + */ + whenUnsatisfiable: string +} + +/** + * TypedLocalObjectReference contains enough information to let you locate the typed referenced object inside the same namespace. + * + * @category models + * @since 4.0.0 + */ +export interface TypedLocalObjectReference { + /** APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required. */ + apiGroup?: string + /** Kind is the type of resource being referenced */ + kind: string + /** Name is the name of resource being referenced */ + name: string +} + +/** + * TypedObjectReference contains enough information to locate a typed referenced object. + * + * @category models + * @since 4.0.0 + */ +export interface TypedObjectReference { + /** APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required. */ + apiGroup?: string + /** Kind is the type of resource being referenced */ + kind: string + /** Name is the name of resource being referenced */ + name: string + /** Namespace is the namespace of resource being referenced Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. */ + namespace?: string +} + +/** + * Volume represents a named volume in a pod that may be accessed by any container in the pod. + * + * @category models + * @since 4.0.0 + */ +export interface Volume { + /** awsElasticBlockStore represents an AWS Disk resource that is attached to a kubelet's host machine and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore */ + awsElasticBlockStore?: AWSElasticBlockStoreVolumeSource + /** azureDisk represents an Azure Data Disk mount on the host and bind mount to the pod. */ + azureDisk?: AzureDiskVolumeSource + /** azureFile represents an Azure File Service mount on the host and bind mount to the pod. */ + azureFile?: AzureFileVolumeSource + /** cephFS represents a Ceph FS mount on the host that shares a pod's lifetime */ + cephfs?: CephFSVolumeSource + /** cinder represents a cinder volume attached and mounted on kubelets host machine. More info: https://examples.k8s.io/mysql-cinder-pd/README.md */ + cinder?: CinderVolumeSource + /** configMap represents a configMap that should populate this volume */ + configMap?: ConfigMapVolumeSource + /** csi (Container Storage Interface) represents ephemeral storage that is handled by certain external CSI drivers (Beta feature). */ + csi?: CSIVolumeSource + /** downwardAPI represents downward API about the pod that should populate this volume */ + downwardAPI?: DownwardAPIVolumeSource + /** emptyDir represents a temporary directory that shares a pod's lifetime. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir */ + emptyDir?: EmptyDirVolumeSource + /** + * ephemeral represents a volume that is handled by a cluster storage driver. The volume's lifecycle is tied to the pod that defines it - it will be created before the pod starts, and deleted when the pod is removed. + * + * **Details** + * + * Use this if: a) the volume is only needed while the pod runs, b) features of normal volumes like restoring from snapshot or capacity + * tracking are needed, + * c) the storage driver is specified through a storage class, and d) the storage driver supports dynamic volume provisioning through + * a PersistentVolumeClaim (see EphemeralVolumeSource for more + * information on the connection between this volume type + * and PersistentVolumeClaim). + * + * Use PersistentVolumeClaim or one of the vendor-specific APIs for volumes that persist for longer than the lifecycle of an individual pod. + * + * Use CSI for light-weight local ephemeral volumes if the CSI driver is meant to be used that way - see the documentation of the driver for more information. + * + * A pod can use both types of ephemeral volumes and persistent volumes at the same time. + */ + ephemeral?: EphemeralVolumeSource + /** fc represents a Fibre Channel resource that is attached to a kubelet's host machine and then exposed to the pod. */ + fc?: FCVolumeSource + /** flexVolume represents a generic volume resource that is provisioned/attached using an exec based plugin. */ + flexVolume?: FlexVolumeSource + /** flocker represents a Flocker volume attached to a kubelet's host machine. This depends on the Flocker control service being running */ + flocker?: FlockerVolumeSource + /** gcePersistentDisk represents a GCE Disk resource that is attached to a kubelet's host machine and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk */ + gcePersistentDisk?: GCEPersistentDiskVolumeSource + /** gitRepo represents a git repository at a particular revision. DEPRECATED: GitRepo is deprecated. To provision a container with a git repo, mount an EmptyDir into an InitContainer that clones the repo using git, then mount the EmptyDir into the Pod's container. */ + gitRepo?: GitRepoVolumeSource + /** glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. More info: https://examples.k8s.io/volumes/glusterfs/README.md */ + glusterfs?: GlusterfsVolumeSource + /** hostPath represents a pre-existing file or directory on the host machine that is directly exposed to the container. This is generally used for system agents or other privileged things that are allowed to see the host machine. Most containers will NOT need this. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath */ + hostPath?: HostPathVolumeSource + /** iscsi represents an ISCSI Disk resource that is attached to a kubelet's host machine and then exposed to the pod. More info: https://examples.k8s.io/volumes/iscsi/README.md */ + iscsi?: ISCSIVolumeSource + /** name of the volume. Must be a DNS_LABEL and unique within the pod. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names */ + name: string + /** nfs represents an NFS mount on the host that shares a pod's lifetime More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs */ + nfs?: NFSVolumeSource + /** persistentVolumeClaimVolumeSource represents a reference to a PersistentVolumeClaim in the same namespace. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims */ + persistentVolumeClaim?: PersistentVolumeClaimVolumeSource + /** photonPersistentDisk represents a PhotonController persistent disk attached and mounted on kubelets host machine */ + photonPersistentDisk?: PhotonPersistentDiskVolumeSource + /** portworxVolume represents a portworx volume attached and mounted on kubelets host machine */ + portworxVolume?: PortworxVolumeSource + /** projected items for all in one resources secrets, configmaps, and downward API */ + projected?: ProjectedVolumeSource + /** quobyte represents a Quobyte mount on the host that shares a pod's lifetime */ + quobyte?: QuobyteVolumeSource + /** rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. More info: https://examples.k8s.io/volumes/rbd/README.md */ + rbd?: RBDVolumeSource + /** scaleIO represents a ScaleIO persistent volume attached and mounted on Kubernetes nodes. */ + scaleIO?: ScaleIOVolumeSource + /** secret represents a secret that should populate this volume. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret */ + secret?: SecretVolumeSource + /** storageOS represents a StorageOS volume attached and mounted on Kubernetes nodes. */ + storageos?: StorageOSVolumeSource + /** vsphereVolume represents a vSphere volume attached and mounted on kubelets host machine */ + vsphereVolume?: VsphereVirtualDiskVolumeSource +} + +/** + * volumeDevice describes a mapping of a raw block device within a container. + * + * @category models + * @since 4.0.0 + */ +export interface VolumeDevice { + /** devicePath is the path inside of the container that the device will be mapped to. */ + devicePath: string + /** name must match the name of a persistentVolumeClaim in the pod */ + name: string +} + +/** + * VolumeMount describes a mounting of a Volume within a container. + * + * @category models + * @since 4.0.0 + */ +export interface VolumeMount { + /** Path within the container at which the volume should be mounted. Must not contain ':'. */ + mountPath: string + /** mountPropagation determines how mounts are propagated from the host to container and the other way around. When not set, MountPropagationNone is used. This field is beta in 1.10. When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified (which defaults to None). */ + mountPropagation?: string + /** This must match the Name of a Volume. */ + name: string + /** Mounted read-only if true, read-write otherwise (false or unspecified). Defaults to false. */ + readOnly?: boolean + /** + * RecursiveReadOnly specifies whether read-only mounts should be handled recursively. + * + * **Details** + * + * If ReadOnly is false, this field has no meaning and must be unspecified. + * + * If ReadOnly is true, and this field is set to Disabled, the mount is not made recursively read-only. If this field is set to IfPossible, the mount is made recursively read-only, if it is supported by the container runtime. If this field is set to Enabled, the mount is made recursively read-only if it is supported by the container runtime, otherwise the pod will not be started and an error will be generated to indicate the reason. + * + * If this field is set to IfPossible or Enabled, MountPropagation must be set to None (or be unspecified, which defaults to None). + * + * If this field is not specified, it is treated as an equivalent of Disabled. + */ + recursiveReadOnly?: string + /** Path within the volume from which the container's volume should be mounted. Defaults to "" (volume's root). */ + subPath?: string + /** Expanded path within the volume from which the container's volume should be mounted. Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. Defaults to "" (volume's root). SubPathExpr and SubPath are mutually exclusive. */ + subPathExpr?: string +} + +/** + * VolumeMountStatus shows status of volume mounts. + * + * @category models + * @since 4.0.0 + */ +export interface VolumeMountStatus { + /** MountPath corresponds to the original VolumeMount. */ + mountPath: string + /** Name corresponds to the name of the original VolumeMount. */ + name: string + /** ReadOnly corresponds to the original VolumeMount. */ + readOnly?: boolean + /** RecursiveReadOnly must be set to Disabled, Enabled, or unspecified (for non-readonly mounts). An IfPossible value in the original VolumeMount must be translated to Disabled or Enabled, depending on the mount result. */ + recursiveReadOnly?: string +} + +/** + * Projection that may be projected along with other supported volume types + * + * @category models + * @since 4.0.0 + */ +export interface VolumeProjection { + /** + * ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field of ClusterTrustBundle objects in an auto-updating file. + * + * **Details** + * + * Alpha, gated by the ClusterTrustBundleProjection feature gate. + * + * ClusterTrustBundle objects can either be selected by name, or by the combination of signer name and a label selector. + * + * Kubelet performs aggressive normalization of the PEM contents written into the pod filesystem. Esoteric PEM features such as inter-block comments and block headers are stripped. Certificates are deduplicated. The ordering of certificates within the file is arbitrary, and Kubelet may change the order over time. + */ + clusterTrustBundle?: ClusterTrustBundleProjection + /** configMap information about the configMap data to project */ + configMap?: ConfigMapProjection + /** downwardAPI information about the downwardAPI data to project */ + downwardAPI?: DownwardAPIProjection + /** secret information about the secret data to project */ + secret?: SecretProjection + /** serviceAccountToken is information about the serviceAccountToken data to project */ + serviceAccountToken?: ServiceAccountTokenProjection +} + +/** + * VolumeResourceRequirements describes the storage resource requirements for a volume. + * + * @category models + * @since 4.0.0 + */ +export interface VolumeResourceRequirements { + /** Limits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ */ + limits?: { + [name: string]: Quantity + } + /** Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. Requests cannot exceed Limits. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ */ + requests?: { + [name: string]: Quantity + } +} + +/** + * Represents a vSphere volume resource. + * + * @category models + * @since 4.0.0 + */ +export interface VsphereVirtualDiskVolumeSource { + /** fsType is filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. */ + fsType?: string + /** storagePolicyID is the storage Policy Based Management (SPBM) profile ID associated with the StoragePolicyName. */ + storagePolicyID?: string + /** storagePolicyName is the storage Policy Based Management (SPBM) profile name. */ + storagePolicyName?: string + /** volumePath is the path that identifies vSphere volume vmdk */ + volumePath: string +} + +/** + * The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) + * + * @category models + * @since 4.0.0 + */ +export interface WeightedPodAffinityTerm { + /** Required. A pod affinity term, associated with the corresponding weight. */ + podAffinityTerm: PodAffinityTerm + /** weight associated with matching the corresponding podAffinityTerm, in the range 1-100. */ + weight: number +} + +/** + * WindowsSecurityContextOptions contain Windows-specific options and credentials. + * + * @category models + * @since 4.0.0 + */ +export interface WindowsSecurityContextOptions { + /** GMSACredentialSpec is where the GMSA admission webhook (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the GMSA credential spec named by the GMSACredentialSpecName field. */ + gmsaCredentialSpec?: string + /** GMSACredentialSpecName is the name of the GMSA credential spec to use. */ + gmsaCredentialSpecName?: string + /** HostProcess determines if a container should be run as a 'Host Process' container. All of a Pod's containers must have the same effective HostProcess value (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). In addition, if HostProcess is true then HostNetwork must also be set to true. */ + hostProcess?: boolean + /** The UserName in Windows to run the entrypoint of the container process. Defaults to the user specified in image metadata if unspecified. May also be set in PodSecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. */ + runAsUserName?: string +} + +/** + * FieldsV1 stores a set of fields in a data structure like a Trie, in JSON format. + * + * **Details** + * + * Each key is either a '.' representing the field itself, and will always map to an empty set, or a string representing a sub-field or item. The string will follow one of these four formats: 'f:', where is the name of a field in a struct, or key in a map 'v:', where is the exact json formatted value of a list item 'i:', where is position of a item in a list 'k:', where is a map of a list item's key fields to their unique values If a key maps to an empty Fields value, the field that key represents is part of the set. + * + * The exact format is defined in sigs.k8s.io/structured-merge-diff + * + * @category models + * @since 4.0.0 + */ +export interface FieldsV1 { +} + +/** + * A label selector is a label query over a set of resources. The result of matchLabels and matchExpressions are ANDed. An empty label selector matches all objects. A null label selector matches no objects. + * + * @category models + * @since 4.0.0 + */ +export interface LabelSelector { + /** matchExpressions is a list of label selector requirements. The requirements are ANDed. */ + matchExpressions?: Array + /** matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. */ + matchLabels?: { + [name: string]: string + } +} + +/** + * A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + * + * @category models + * @since 4.0.0 + */ +export interface LabelSelectorRequirement { + /** key is the label key that the selector applies to. */ + key: string + /** operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. */ + operator: string + /** values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. */ + values?: Array +} + +/** + * ManagedFieldsEntry is a workflow-id, a FieldSet and the group version of the resource that the fieldset applies to. + * + * @category models + * @since 4.0.0 + */ +export interface ManagedFieldsEntry { + /** APIVersion defines the version of this resource that this field set applies to. The format is "group/version" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted. */ + apiVersion?: string + /** FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: "FieldsV1" */ + fieldsType?: string + /** FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. */ + fieldsV1?: FieldsV1 + /** Manager is an identifier of the workflow managing these fields. */ + manager?: string + /** Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'. */ + operation?: string + /** Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource. */ + subresource?: string + /** Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over. */ + time?: Time +} + +/** + * ObjectMeta is metadata that all persisted resources must have, which includes all objects users must create. + * + * @category models + * @since 4.0.0 + */ +export interface ObjectMeta { + /** Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations */ + annotations?: { + [name: string]: string + } + /** + * CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. + * + * **Details** + * + * Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + */ + readonly creationTimestamp?: Time + /** Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. */ + readonly deletionGracePeriodSeconds?: number + /** + * DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. + * + * **Details** + * + * Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + */ + readonly deletionTimestamp?: Time + /** Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. */ + finalizers?: Array + /** + * GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. + * + * **Details** + * + * If this field is specified and the generated name exists, the server will return a 409. + * + * Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency + */ + generateName?: string + /** A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. */ + readonly generation?: number + /** Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels */ + labels?: { + [name: string]: string + } + /** ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like "ci-cd". The set of fields is always in the version that the workflow used when modifying the object. */ + managedFields?: Array + /** Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name?: string + /** + * Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. + * + * **Details** + * + * Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces + */ + namespace?: string + /** List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. */ + ownerReferences?: Array + /** + * An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. + * + * **Details** + * + * Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + */ + readonly resourceVersion?: string + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string + /** + * UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. + * + * **Details** + * + * Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids + */ + readonly uid?: string +} + +/** + * OwnerReference contains enough information to let you identify an owning object. An owning object must be in the same namespace as the dependent, or be cluster-scoped, so there is no namespace field. + * + * @category models + * @since 4.0.0 + */ +export interface OwnerReference { + /** API version of the referent. */ + apiVersion: string + /** If true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs "delete" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned. */ + blockOwnerDeletion?: boolean + /** If true, this reference points to the managing controller. */ + controller?: boolean + /** Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name: string + /** UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid: string +} + +/** + * Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers. + * + * @category models + * @since 4.0.0 + */ +export type Time = string + +/** + * Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors. + * + * @category models + * @since 4.0.0 + */ +export type Quantity = string diff --git a/repos/effect/packages/effect/src/unstable/cluster/Message.ts b/repos/effect/packages/effect/src/unstable/cluster/Message.ts index 2bac1f4ca5..05c98186ff 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/Message.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/Message.ts @@ -15,6 +15,7 @@ import * as Effect from "../../Effect.ts" import * as Option from "../../Option.ts" import * as Schema from "../../Schema.ts" import * as Rpc from "../rpc/Rpc.ts" +import type * as RpcSerialization from "../rpc/RpcSerialization.ts" import type { PersistenceError } from "./ClusterError.ts" import { MalformedMessage } from "./ClusterError.ts" import * as ClusterSchema from "./ClusterSchema.ts" @@ -90,6 +91,13 @@ export class IncomingRequest extends Data.TaggedClass("Incomi readonly envelope: Envelope.PartialRequest readonly lastSentReply: Option.Option readonly respond: (reply: Reply.ReplyWithContext) => Effect.Effect + /** + * Codec that filled the payload and reply holes of this message. + * + * Messages read from `MessageStorage` use the JSON codec, while the runner + * server sets it to the codec of its transport. + */ + readonly codecFor: RpcSerialization.CodecFor }> {} /** @@ -153,11 +161,15 @@ export class OutgoingRequest extends Data.TaggedClass("Outgoi readonly annotations: Context.Context }> { /** - * Cached encoded envelope payload reused when sending the request. + * Cached encoded envelope payload and the codec that produced it. The cache + * is reused only when the requested codec is the same function. * * @since 4.0.0 */ - public encodedCache?: Envelope.PartialRequest + public encodedCache?: { + readonly codecFor: RpcSerialization.CodecFor + readonly envelope: Envelope.PartialRequest + } } /** @@ -192,6 +204,8 @@ export class OutgoingEnvelope extends Data.TaggedClass("OutgoingEnvelope")<{ } } +const codecForJson = Schema.toCodecJson as RpcSerialization.CodecFor + const neverRpc = Rpc.make("Never", { success: Schema.Never as any, error: Schema.Never, @@ -210,16 +224,22 @@ const neverRpc = Rpc.make("Never", { * @since 4.0.0 */ export const serialize = ( - message: Outgoing + message: Outgoing, + codecFor: RpcSerialization.CodecFor ): Effect.Effect => { if (message._tag !== "OutgoingRequest") { return Effect.succeed(message.envelope) } - return Effect.suspend(() => - message.encodedCache - ? Effect.succeed(message.encodedCache) - : serializeRequest(message) - ) + return Effect.suspend(() => { + const cached = message.encodedCache + if (cached?.codecFor === codecFor) { + return Effect.succeed(cached.envelope) + } + return Effect.tap(serializeRequest(message, codecFor), (envelope) => + Effect.sync(() => { + message.encodedCache = { codecFor, envelope } + })) + }) } /** @@ -236,7 +256,7 @@ export const serializeEnvelope = ( message: Outgoing ): Effect.Effect => Effect.flatMap( - serialize(message), + serialize(message, codecForJson), (envelope) => MalformedMessage.refail(Schema.encodeEffect(Envelope.PartialJson)(envelope)) ) @@ -252,10 +272,11 @@ export const serializeEnvelope = ( * @since 4.0.0 */ export const serializeRequest = ( - self: OutgoingRequest + self: OutgoingRequest, + codecFor: RpcSerialization.CodecFor ): Effect.Effect => { const rpc = self.rpc as any as Rpc.AnyWithProps - return Schema.encodeEffect(Schema.toCodecJson(rpc.payloadSchema))(self.envelope.payload).pipe( + return Schema.encodeEffect(codecFor(rpc.payloadSchema))(self.envelope.payload).pipe( Effect.provideContext(self.context), MalformedMessage.refail, Effect.map((payload) => ({ @@ -279,7 +300,8 @@ export const serializeRequest = ( */ export const deserializeLocal = ( self: Outgoing, - encoded: Envelope.Partial + encoded: Envelope.Partial, + codecFor: RpcSerialization.CodecFor ): Effect.Effect< IncomingLocal, MalformedMessage @@ -292,7 +314,7 @@ export const deserializeLocal = ( ) } const rpc = self.rpc as any as Rpc.AnyWithProps - return Schema.decodeEffect(Schema.toCodecJson(rpc.payloadSchema))(encoded.payload).pipe( + return Schema.decodeEffect(codecFor(rpc.payloadSchema))(encoded.payload).pipe( Effect.provideContext(self.context), MalformedMessage.refail, Effect.map((payload) => { diff --git a/repos/effect/packages/effect/src/unstable/cluster/MessageStorage.ts b/repos/effect/packages/effect/src/unstable/cluster/MessageStorage.ts index 8d33454a50..53084dcc2e 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/MessageStorage.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/MessageStorage.ts @@ -23,6 +23,7 @@ import * as Option from "../../Option.ts" import type { Predicate } from "../../Predicate.ts" import * as Schema from "../../Schema.ts" import type * as Rpc from "../rpc/Rpc.ts" +import type * as RpcSerialization from "../rpc/RpcSerialization.ts" import { EntityNotAssignedToRunner, MalformedMessage, type PersistenceError } from "./ClusterError.ts" import * as DeliverAt from "./DeliverAt.ts" import type { EntityAddress } from "./EntityAddress.ts" @@ -33,6 +34,8 @@ import * as ShardId from "./ShardId.ts" import type { ShardingConfig } from "./ShardingConfig.ts" import * as Snowflake from "./Snowflake.ts" +const codecForJson = Schema.toCodecJson as RpcSerialization.CodecFor + /** * Service for cluster mailbox persistence and reply delivery. * @@ -131,9 +134,18 @@ export class MessageStorage extends Context.Service + shardIds: Iterable, + options?: { + readonly limit?: number | undefined + readonly addresses?: ReadonlyArray | undefined + } | undefined ) => Effect.Effect>, PersistenceError> /** @@ -157,6 +169,13 @@ export class MessageStorage extends Context.Service Effect.Effect + /** + * Reset the mailbox state for the provided addresses. + */ + readonly resetAddresses: ( + addresses: ReadonlyArray + ) => Effect.Effect + /** * Clear all messages and replies for the provided address. */ @@ -349,10 +368,19 @@ export type Encoded = { * - Requests that have no `WithExit` replies or no unacknowledged chunk replies * - The latest `AckChunk` envelope * - All `Interrupt` envelopes for unprocessed requests + * + * The `limit` option bounds the number of rows returned by a single read, + * and the `addresses` option restricts the read to the provided entity + * addresses. Only the returned rows are claimed; other rows stay eligible + * for later reads. */ readonly unprocessedMessages: ( shardIds: Arr.NonEmptyArray, - now: number + now: number, + options?: { + readonly limit?: number | undefined + readonly addresses?: ReadonlyArray | undefined + } | undefined ) => Effect.Effect< Array<{ readonly envelope: Envelope.Encoded @@ -376,10 +404,10 @@ export type Encoded = { > /** - * Reset the mailbox state for the provided address. + * Reset the mailbox state for the provided addresses. */ - readonly resetAddress: ( - address: EntityAddress + readonly resetAddresses: ( + addresses: ReadonlyArray ) => Effect.Effect /** @@ -582,7 +610,7 @@ export const makeEncoded: (encoded: Encoded) => Effect.Effect< return Effect.succeed(result as SaveResult) } const duplicate = result - const schema = Reply.Reply(message.rpc) + const schema = Reply.Reply(message.rpc, codecForJson) return Schema.decodeEffect(schema)(result.lastReceivedReply.value).pipe( Effect.provideContext(message.context), MalformedMessage.refail, @@ -606,7 +634,7 @@ export const makeEncoded: (encoded: Encoded) => Effect.Effect< ), Effect.asVoid ), - saveReply: (reply) => Effect.flatMap(Reply.serializeOrDefect(reply), encoded.saveReply), + saveReply: (reply) => Effect.flatMap(Reply.serializeOrDefect(reply, codecForJson), encoded.saveReply), clearReplies: encoded.clearReplies, repliesFor: Effect.fnUntraced(function*(messages) { const requestIds = Arr.empty() @@ -629,12 +657,13 @@ export const makeEncoded: (encoded: Encoded) => Effect.Effect< const primaryKey = Envelope.primaryKeyByAddress(options) return encoded.requestIdForPrimaryKey(primaryKey) }, - unprocessedMessages(shardIds) { + unprocessedMessages(shardIds, options) { const storage = this as MessageStorage["Service"] const shards = Array.from(shardIds, (id) => id.toString()) if (!Arr.isArrayNonEmpty(shards)) return Effect.succeed([]) + if (options?.addresses !== undefined && options.addresses.length === 0) return Effect.succeed([]) return Effect.flatMap( - Effect.suspend(() => encoded.unprocessedMessages(shards, clock.currentTimeMillisUnsafe())), + Effect.suspend(() => encoded.unprocessedMessages(shards, clock.currentTimeMillisUnsafe(), options)), (messages) => decodeMessages(storage, messages) ) }, @@ -647,7 +676,8 @@ export const makeEncoded: (encoded: Encoded) => Effect.Effect< (messages) => decodeMessages(storage, messages) ) }, - resetAddress: encoded.resetAddress, + resetAddress: (address) => encoded.resetAddresses([address]), + resetAddresses: (addresses) => addresses.length === 0 ? Effect.void : encoded.resetAddresses(addresses), clearAddress: encoded.clearAddress, resetShards: (shardIds) => { const shards = Array.from(shardIds, (id) => id.toString()) @@ -699,7 +729,8 @@ export const makeEncoded: (encoded: Encoded) => Effect.Effect< ? new Message.IncomingRequest({ envelope: message.envelope, lastSentReply: envelope.lastSentReply, - respond: storage.saveReply + respond: storage.saveReply, + codecFor: codecForJson }) : new Message.IncomingEnvelope({ envelope: message.envelope @@ -725,7 +756,7 @@ export const makeEncoded: (encoded: Encoded) => Effect.Effect< if (ignoredRequests.has(reply.requestId)) return Effect.void const message = messages.get(reply.requestId) if (!message) return Effect.void - const schema = Reply.Reply(message.rpc) + const schema = Reply.Reply(message.rpc, codecForJson) return Schema.decodeEffect(schema)(reply).pipe( Effect.provideContext(message.context) ) as Effect.Effect, Schema.SchemaError> @@ -776,6 +807,7 @@ export const noop: MessageStorage["Service"] = Effect.runSync(make({ unprocessedMessages: () => Effect.succeed([]), unprocessedMessagesById: () => Effect.succeed([]), resetAddress: () => Effect.void, + resetAddresses: () => Effect.void, clearAddress: () => Effect.void, resetShards: () => Effect.void, withTransaction: identity @@ -809,6 +841,9 @@ export const MemoryTransaction = Context.Reference("effect/cluster/Mess defaultValue: constFalse }) +// the same claim window the SQL driver uses (`last_read < ten minutes ago`) +const claimExpirationMillis = 10 * 60 * 1000 + /** * Service that provides an in-memory message storage driver with inspectable backing state. * @@ -828,9 +863,25 @@ export class MemoryDriver extends Context.Service()("effect/cluste const requestsByPrimaryKey = new Map() const unprocessed = new Set() const replyIds = new Set() + const lastRead = new Map() const journal: Array = [] + const addressKey = (address: Envelope.Encoded["address"] | EntityAddress) => + `${address.shardId.group}/${address.shardId.id}/${address.entityType}/${address.entityId}` + + const resetAddresses = (addresses: ReadonlyArray) => + addresses.length === 0 + ? Effect.void + : Effect.sync(() => { + const keys = new Set(addresses.map(addressKey)) + for (const envelope of journal) { + if (keys.has(addressKey(envelope.address))) { + lastRead.delete(envelope) + } + } + }) + const cursors = new WeakMap<{}, number>() const unprocessedWith = (predicate: Predicate) => { @@ -926,6 +977,7 @@ export class MemoryDriver extends Context.Service()("effect/cluste if (!entry || replyIds.has(reply.id)) return if (reply._tag === "WithExit") { unprocessed.delete(entry.envelope) + lastRead.delete(entry.envelope) } entry.replies.push(reply) replyIds.add(reply.id) @@ -938,6 +990,7 @@ export class MemoryDriver extends Context.Service()("effect/cluste entry.replies = [] entry.lastReceivedChunk = undefined unprocessed.add(entry.envelope) + lastRead.delete(entry.envelope) }), requestIdForPrimaryKey: (primaryKey) => Effect.sync(() => { @@ -947,29 +1000,39 @@ export class MemoryDriver extends Context.Service()("effect/cluste repliesFor: (requestIds) => Effect.sync(() => repliesFor(requestIds)), repliesForUnfiltered: (requestIds) => Effect.sync(() => requestIds.flatMap((id) => requests.get(String(id))?.replies ?? [])), - unprocessedMessages: (shardIds) => - Effect.sync(() => { + unprocessedMessages: (shardIds, now, options) => + options?.addresses?.length === 0 ? Effect.succeed([]) : Effect.sync(() => { if (unprocessed.size === 0) return [] - const now = clock.currentTimeMillisUnsafe() + const limit = options?.limit ?? Infinity + const addressFilter = options?.addresses && new Set(options.addresses.map(addressKey)) const messages = Arr.empty<{ envelope: Envelope.Encoded lastSentReply: Option.Option }>() for (let index = 0; index < journal.length; index++) { + if (messages.length >= limit) break const envelope = journal[index] const shardId = ShardId.make(envelope.address.shardId.group, envelope.address.shardId.id) if (!unprocessed.has(envelope as any) || !shardIds.includes(shardId.toString())) { continue } + if (addressFilter && !addressFilter.has(addressKey(envelope.address))) { + continue + } if (envelope._tag === "Request") { const entry = requests.get(envelope.requestId)! if (entry.deliverAt && entry.deliverAt > now) { continue } + const claimedAt = lastRead.get(envelope) + if (claimedAt !== undefined && claimedAt > now - claimExpirationMillis) { + continue + } messages.push({ envelope, lastSentReply: Option.fromNullishOr(entry.replies[entry.replies.length - 1]) }) + lastRead.set(envelope, now) } else { messages.push({ envelope, @@ -988,7 +1051,7 @@ export class MemoryDriver extends Context.Service()("effect/cluste } return unprocessedWith((envelope) => envelopeIds.has(envelope.requestId)) }), - resetAddress: () => Effect.void, + resetAddresses, clearAddress: (address) => Effect.sync(() => { for (const [primaryKey, entry] of requestsByPrimaryKey) { @@ -1007,11 +1070,21 @@ export class MemoryDriver extends Context.Service()("effect/cluste continue } unprocessed.delete(envelope) + lastRead.delete(envelope) requests.delete(envelope.requestId) journal.splice(i, 1) } }), - resetShards: () => Effect.void, + resetShards: (shardIds) => + Effect.sync(() => { + const shards = new Set(shardIds) + for (const envelope of journal) { + const shardId = ShardId.make(envelope.address.shardId.group, envelope.address.shardId.id) + if (shards.has(shardId.toString())) { + lastRead.delete(envelope) + } + } + }), withTransaction: Effect.provideService(MemoryTransaction, true) } diff --git a/repos/effect/packages/effect/src/unstable/cluster/Reply.ts b/repos/effect/packages/effect/src/unstable/cluster/Reply.ts index 2f8b310e63..0df5ab949d 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/Reply.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/Reply.ts @@ -24,7 +24,9 @@ import * as SchemaTransformation from "../../SchemaTransformation.ts" import * as Rpc from "../rpc/Rpc.ts" import type * as RpcMessage from "../rpc/RpcMessage.ts" import type * as RpcSchema from "../rpc/RpcSchema.ts" +import type * as RpcSerialization from "../rpc/RpcSerialization.ts" import { MalformedMessage } from "./ClusterError.ts" +import * as Envelope from "./Envelope.ts" import type { OutgoingRequest } from "./Message.ts" import { Snowflake, SnowflakeFromBigInt } from "./Snowflake.ts" @@ -66,7 +68,7 @@ export type Encoded = WithExitEncoded | ChunkEncoded * @category schemas * @since 4.0.0 */ -export const Encoded: Schema.Codec = Schema.Any as any +export const Encoded: Schema.Codec = Envelope.OpaqueHole as any /** * Represents a cluster reply paired with the RPC definition and service context required to @@ -161,7 +163,9 @@ export interface ChunkEncoded { readonly values: NonEmptyReadonlyArray } -const schemaCache = new WeakMap() +// Keyed by codec first: the storage path and the transport path can compile +// different codecs for the same RPC. +const schemaCaches = new WeakMap>() /** * Represents a streaming RPC reply chunk for a request, carrying a non-empty @@ -398,17 +402,24 @@ export class WithExit extends Data.TaggedClass("WithExit")<{ * @since 4.0.0 */ export const Reply = ( - rpc: R + rpc: R, + codecFor: RpcSerialization.CodecFor ): Schema.Codec< WithExit | Chunk, Encoded, Rpc.ServicesServer, Rpc.ServicesClient > => { - if (schemaCache.has(rpc)) { - return schemaCache.get(rpc) as any + let schemaCache = schemaCaches.get(codecFor) + if (schemaCache === undefined) { + schemaCache = new WeakMap() + schemaCaches.set(codecFor, schemaCache) } - const schema = Schema.toCodecJson(Schema.Union([WithExit.schema(rpc), Chunk.schema(rpc)])) + const cached = schemaCache.get(rpc) + if (cached !== undefined) { + return cached as any + } + const schema = codecFor(Schema.Union([WithExit.schema(rpc), Chunk.schema(rpc)])) schemaCache.set(rpc, schema) return schema as any } @@ -422,9 +433,10 @@ export const Reply = ( * @since 4.0.0 */ export const serialize = ( - self: ReplyWithContext + self: ReplyWithContext, + codecFor: RpcSerialization.CodecFor ): Effect.Effect => { - const schema = Reply(self.rpc) + const schema = Reply(self.rpc, codecFor) return MalformedMessage.refail( Effect.provideContext( Schema.encodeEffect(schema)(self.reply), @@ -441,17 +453,21 @@ export const serialize = ( * @since 4.0.0 */ export const serializeOrDefect = ( - self: ReplyWithContext + self: ReplyWithContext, + codecFor: RpcSerialization.CodecFor ): Effect.Effect => Effect.catchTag( - serialize(self), + serialize(self, codecFor), "MalformedMessage", (error) => - Effect.orDie(serialize(ReplyWithContext.fromDefect({ - id: self.reply.id, - requestId: self.reply.requestId, - defect: error - }))) + Effect.orDie(serialize( + ReplyWithContext.fromDefect({ + id: self.reply.id, + requestId: self.reply.requestId, + defect: error + }), + codecFor + )) ) /** @@ -463,13 +479,14 @@ export const serializeOrDefect = ( * @since 4.0.0 */ export const serializeLastReceived = ( - self: OutgoingRequest + self: OutgoingRequest, + codecFor: RpcSerialization.CodecFor ): Effect.Effect, MalformedMessage> => { const lastReceivedReply = self.lastReceivedReply if (lastReceivedReply._tag === "None") { return Effect.succeedNone } - const schema = Reply(self.rpc) + const schema = Reply(self.rpc, codecFor) return MalformedMessage.refail( Effect.provideContext(Schema.encodeEffect(schema)(lastReceivedReply.value), self.context) ).pipe( diff --git a/repos/effect/packages/effect/src/unstable/cluster/RunnerServer.ts b/repos/effect/packages/effect/src/unstable/cluster/RunnerServer.ts index 4ac30093a4..3b57906992 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/RunnerServer.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/RunnerServer.ts @@ -34,13 +34,17 @@ const constVoid = constant(Effect.void) const serializeDefectReply = ( reply: Reply.ReplyWithContext, - defect: unknown + defect: unknown, + codecFor: RpcServer.Protocol["Service"]["codecFor"] ): Effect.Effect => - Effect.orDie(Reply.serialize(Reply.ReplyWithContext.fromDefect({ - id: reply.reply.id, - requestId: reply.reply.requestId, - defect - }))) + Effect.orDie(Reply.serialize( + Reply.ReplyWithContext.fromDefect({ + id: reply.reply.id, + requestId: reply.reply.requestId, + defect + }), + codecFor + )) /** * Layer that handles runner protocol RPCs by forwarding requests to `Sharding` @@ -52,19 +56,21 @@ const serializeDefectReply = ( export const layerHandlers = Runners.Rpcs.toLayer(Effect.gen(function*() { const sharding = yield* Sharding.Sharding const storage = yield* MessageStorage.MessageStorage + const { codecFor } = yield* RpcServer.Protocol return { Ping: () => Effect.void, - Notify: ({ envelope }) => - sharding.notify( - envelope._tag === "Request" - ? new Message.IncomingRequest({ - envelope, - respond: constVoid, - lastSentReply: Option.none() - }) - : new Message.IncomingEnvelope({ envelope }) - ), + Notify: ({ envelope, persisted }) => { + const message = envelope._tag === "Request" + ? new Message.IncomingRequest({ + envelope, + respond: constVoid, + lastSentReply: Option.none(), + codecFor + }) + : new Message.IncomingEnvelope({ envelope }) + return persisted ? sharding.notify(message) : sharding.send(message) + }, Effect: ({ persisted, request }) => { let replyEncoded: Option.Option> = Option .none() @@ -74,8 +80,9 @@ export const layerHandlers = Runners.Rpcs.toLayer(Effect.gen(function*() { const message = new Message.IncomingRequest({ envelope: request, lastSentReply: Option.none(), + codecFor, respond(reply) { - resume(Reply.serializeOrDefect(reply)) + resume(Reply.serializeOrDefect(reply, codecFor)) return Effect.void } }) @@ -125,8 +132,9 @@ export const layerHandlers = Runners.Rpcs.toLayer(Effect.gen(function*() { const message = new Message.IncomingRequest({ envelope: request, lastSentReply: Option.none(), + codecFor, respond(reply) { - return Reply.serialize(reply).pipe( + return Reply.serialize(reply, codecFor).pipe( Effect.flatMap((reply) => { Queue.offerUnsafe(queue, reply) if (reply._tag === "WithExit") { @@ -135,7 +143,7 @@ export const layerHandlers = Runners.Rpcs.toLayer(Effect.gen(function*() { return Effect.void }), Effect.catchTag("MalformedMessage", (error) => - Effect.flatMap(serializeDefectReply(reply, error), (reply) => { + Effect.flatMap(serializeDefectReply(reply, error, codecFor), (reply) => { // the fallback defect reply is terminal, so end the stream Queue.offerUnsafe(queue, reply) Queue.endUnsafe(queue) @@ -191,7 +199,9 @@ const constWaitUntilRead = { waitUntilRead: true } as const export const layer: Layer.Layer< never, never, - RpcServer.Protocol | Sharding.Sharding | MessageStorage.MessageStorage + | RpcServer.Protocol + | Sharding.Sharding + | MessageStorage.MessageStorage > = RpcServer.layer(Runners.Rpcs, { spanPrefix: "RunnerServer", disableTracing: true, diff --git a/repos/effect/packages/effect/src/unstable/cluster/Runners.ts b/repos/effect/packages/effect/src/unstable/cluster/Runners.ts index 5881cf0ab0..227bcbdb71 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/Runners.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/Runners.ts @@ -25,7 +25,8 @@ import * as RpcClient_ from "../rpc/RpcClient.ts" import type { RpcClientError } from "../rpc/RpcClientError.ts" import * as RpcGroup from "../rpc/RpcGroup.ts" import * as RpcSchema from "../rpc/RpcSchema.ts" -import type { PersistenceError } from "./ClusterError.ts" +import type * as RpcSerialization from "../rpc/RpcSerialization.ts" +import type { MalformedMessage, PersistenceError } from "./ClusterError.ts" import { AlreadyProcessingMessage, EntityNotAssignedToRunner, MailboxFull, RunnerUnavailable } from "./ClusterError.ts" import { Persisted } from "./ClusterSchema.ts" import * as Envelope from "./Envelope.ts" @@ -90,7 +91,8 @@ export class Runners extends Context.Service /** - * Notify a Runner that a message is available, then read replies from storage. + * Notify a Runner that a message is available. Persisted messages recover + * replies from storage, while volatile messages complete after delivery. */ readonly notify: ( options: { @@ -98,7 +100,14 @@ export class Runners extends Context.Service readonly discard: boolean } - ) => Effect.Effect + ) => Effect.Effect< + void, + | EntityNotAssignedToRunner + | RunnerUnavailable + | MailboxFull + | AlreadyProcessingMessage + | PersistenceError + > /** * Notify the current Runner that a message is available, then read replies from @@ -139,32 +148,31 @@ export class Runners extends Context.Service) => Effect.Effect< +export const make: ( + options: Omit & { + readonly codecFor: RpcSerialization.CodecFor + } +) => Effect.Effect< Runners["Service"], never, MessageStorage.MessageStorage | Snowflake.Generator | ShardingConfig | Scope -> = Effect.fnUntraced(function*(options: Omit) { +> = Effect.fnUntraced(function*(options) { const storage = yield* MessageStorage.MessageStorage const runnersScope = yield* Effect.scope const snowflakeGen = yield* Snowflake.Generator const config = yield* ShardingConfig + const { codecFor, ...serviceOptions } = options const requestIdRewrites = new Map() @@ -175,7 +183,7 @@ export const make: (options: Omit Message.deserializeLocal(message, encoded)), + return Message.serialize(message, codecFor).pipe( + Effect.flatMap((encoded) => Message.deserializeLocal(message, encoded, codecFor)), Effect.flatMap(options.send), Effect.catchTag("MalformedMessage", (error) => { if (message._tag === "OutgoingEnvelope") { @@ -433,6 +441,7 @@ export const makeNoop: Effect.Effect< never, MessageStorage.MessageStorage | Snowflake.Generator | ShardingConfig | Scope > = make({ + codecFor: Schema.toCodecJson as RpcSerialization.CodecFor, send: ({ message }) => Effect.fail(new EntityNotAssignedToRunner({ address: message.envelope.address })), notify: () => Effect.void, ping: () => Effect.void, @@ -473,10 +482,11 @@ export class Rpcs extends RpcGroup.make( Rpc.make("Ping"), Rpc.make("Notify", { payload: { - envelope: Envelope.Partial + envelope: Envelope.Partial, + persisted: Schema.Boolean }, success: Schema.Void, - error: Schema.Union([EntityNotAssignedToRunner, AlreadyProcessingMessage]) + error: rpcErrors }), Rpc.make("Effect", { payload: { @@ -538,22 +548,27 @@ export const makeRpc: Effect.Effect< never, Scope | RpcClientProtocol | MessageStorage.MessageStorage | Snowflake.Generator | ShardingConfig > = Effect.gen(function*() { - const makeClientProtocol = yield* RpcClientProtocol + const clientProtocol = yield* RpcClientProtocol const snowflakeGen = yield* Snowflake.Generator const clients = yield* RcMap.make({ lookup: (address: RunnerAddress) => Effect.flatMap( - makeClientProtocol(address), - (protocol) => Effect.provideService(makeRpcClient, RpcClient_.Protocol, protocol) + clientProtocol.make(address), + (protocol) => + Effect.map( + Effect.provideService(makeRpcClient, RpcClient_.Protocol, protocol), + (client) => ({ client, codecFor: protocol.codecFor }) + ) ), idleTimeToLive: "3 minutes" }) return yield* make({ + codecFor: clientProtocol.codecFor, ping(address) { return RcMap.get(clients, address).pipe( - Effect.flatMap((client) => client.Ping()), + Effect.flatMap(({ client }) => client.Ping()), Effect.catchCause(() => Effect.andThen( RcMap.invalidate(clients, address), @@ -568,7 +583,7 @@ export const makeRpc: Effect.Effect< const isPersisted = Context.get(rpc.annotations, Persisted) if (message._tag === "OutgoingEnvelope") { return RcMap.get(clients, address).pipe( - Effect.flatMap((client) => + Effect.flatMap(({ client }) => client.Envelope({ envelope: message.envelope, persisted: isPersisted @@ -590,84 +605,90 @@ export const makeRpc: Effect.Effect< exit: Exit.die(defect) }) ) + const respondMalformed = (error: MalformedMessage) => + message.respond( + new Reply.WithExit({ + id: snowflakeGen.nextUnsafe(), + requestId: message.envelope.requestId, + exit: Exit.die(error) + }) + ) const isStream = RpcSchema.isStreamSchema(rpc.successSchema) if (!isStream) { - return Effect.matchEffect(Message.serializeRequest(message), { - onSuccess: (request) => - RcMap.get(clients, address).pipe( - Effect.flatMap((client) => + return RcMap.get(clients, address).pipe( + Effect.flatMap(({ client, codecFor }) => + Effect.matchEffect(Message.serializeRequest(message, codecFor), { + onSuccess: (request) => client.Effect({ request, persisted: isPersisted - }) - ), - Effect.flatMap((reply) => - Schema.decodeEffect(Reply.Reply(message.rpc))(reply).pipe( - Effect.provideContext(message.context), - Effect.orDie - ) - ), - Effect.flatMap(message.respond), - Effect.scoped, - Effect.catchTag("RpcClientError", () => Effect.fail(new RunnerUnavailable({ address }))), - Effect.catchDefect(respondDefect) - ), - onFailure: (error) => - message.respond( - new Reply.WithExit({ - id: snowflakeGen.nextUnsafe(), - requestId: message.envelope.requestId, - exit: Exit.die(error) - }) - ) - }) + }).pipe( + Effect.flatMap((reply) => + Schema.decodeEffect(Reply.Reply(message.rpc, codecFor))(reply).pipe( + Effect.provideContext(message.context), + Effect.orDie + ) + ), + Effect.flatMap(message.respond), + Effect.catchTag("RpcClientError", () => Effect.fail(new RunnerUnavailable({ address }))), + Effect.catchDefect(respondDefect) + ), + onFailure: respondMalformed + }) + ), + Effect.scoped + ) } - return Effect.matchEffect(Message.serializeRequest(message), { - onSuccess: (request) => - RcMap.get(clients, address).pipe( - Effect.flatMap((client) => + return RcMap.get(clients, address).pipe( + Effect.flatMap(({ client, codecFor }) => + Effect.matchEffect(Message.serializeRequest(message, codecFor), { + onSuccess: (request) => client.Stream({ request, persisted: isPersisted - }, { asQueue: true }) - ), - Effect.flatMap((queue) => { - const decode = Schema.decodeEffect(Reply.Reply(message.rpc)) - return Queue.take(queue).pipe( - Effect.flatMap((reply) => Effect.orDie(decode(reply))), - Effect.flatMap(message.respond), - Effect.forever, - Effect.provideContext(message.context), - Effect.catchTag("Done", (_) => Effect.void), - Effect.catchTag("RpcClientError", () => Effect.fail(new RunnerUnavailable({ address }))), - Effect.catchDefect(respondDefect) - ) - }), - Effect.scoped - ), - onFailure: (error) => - message.respond( - new Reply.WithExit({ - id: snowflakeGen.nextUnsafe(), - requestId: message.envelope.requestId, - exit: Exit.die(error) - }) - ) - }) + }, { asQueue: true }).pipe( + Effect.flatMap((queue) => { + const decode = Schema.decodeEffect(Reply.Reply(message.rpc, codecFor)) + return Queue.take(queue).pipe( + Effect.flatMap((reply) => Effect.orDie(decode(reply))), + Effect.flatMap(message.respond), + Effect.forever, + Effect.provideContext(message.context), + Effect.catchTag("Done", (_) => Effect.void), + Effect.catchTag("RpcClientError", () => Effect.fail(new RunnerUnavailable({ address }))), + Effect.catchDefect(respondDefect) + ) + }) + ), + onFailure: respondMalformed + }) + ), + Effect.scoped + ) }, notify({ address, message }) { if (Option.isNone(address)) { return Effect.void } + const rpc = message.rpc as any as Rpc.AnyWithProps + const isPersisted = Context.get(rpc.annotations, Persisted) const envelope = message.envelope - const encode: Effect.Effect = - message._tag === "OutgoingRequest" ? Effect.orDie(Message.serializeRequest(message)) : Effect.succeed(envelope) - return Effect.flatMap(encode, (envelope) => - RcMap.get(clients, address.value).pipe( - Effect.flatMap((client) => client.Notify({ envelope })), - Effect.scoped, - Effect.ignore - )) + const notify = RcMap.get(clients, address.value).pipe( + Effect.flatMap(({ client, codecFor }) => { + const encode: Effect.Effect = + message._tag === "OutgoingRequest" + ? Effect.orDie(Message.serializeRequest(message, codecFor)) + : Effect.succeed(envelope) + return Effect.flatMap(encode, (envelope) => + client.Notify({ + envelope, + persisted: isPersisted + })) + }), + Effect.scoped, + Effect.catchTag("RpcClientError", () => Effect.fail(new RunnerUnavailable({ address: address.value }))) + ) + return isPersisted ? Effect.ignore(notify) : notify }, onRunnerUnavailable: (address) => RcMap.invalidate(clients, address) }) @@ -689,13 +710,16 @@ export const layerRpc: Layer.Layer< ) /** - * Service that creates an RPC client protocol for communicating with a runner at a - * given address. + * Service that creates RPC client protocols for runner addresses and exposes + * the codec shared by those protocols. * * @category services * @since 4.0.0 */ export class RpcClientProtocol extends Context.Service< RpcClientProtocol, - (address: RunnerAddress) => Effect.Effect + { + readonly make: (address: RunnerAddress) => Effect.Effect + readonly codecFor: RpcSerialization.CodecFor + } >()("effect/cluster/Runners/RpcClientProtocol") {} diff --git a/repos/effect/packages/effect/src/unstable/cluster/Sharding.ts b/repos/effect/packages/effect/src/unstable/cluster/Sharding.ts index e5a0891268..3348615239 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/Sharding.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/Sharding.ts @@ -588,6 +588,33 @@ const make = Effect.gen(function*() { const storageReadLock = Semaphore.makeUnsafe(1) const withStorageReadLock = storageReadLock.withPermits(1) + // --- Entity residency --- + // + // A runner-wide counter that bounds how many entities can be resident at + // the same time. Entity managers reserve a slot before spawning an entity, + // so the sequential storage read loop and concurrent volatile sends share + // the same cap. + + const maxResidentEntities = config.maxResidentEntities + let residentEntityCount = 0 + const residencyAtCapacityUnsafe = () => + maxResidentEntities !== "unbounded" && residentEntityCount >= maxResidentEntities + const residency: EntityManager.Residency = { + admitUnsafe() { + if (residencyAtCapacityUnsafe()) return false + residentEntityCount++ + return true + }, + releaseUnsafe() { + residentEntityCount-- + // a slot has freed up, so the storage read loop can admit messages for + // entities it previously had to skip + if (maxResidentEntities !== "unbounded") { + storageReadLatch.openUnsafe() + } + } + } + if (storageEnabled && initialRunnerAddress) { const selfAddress = initialRunnerAddress const entityRegistrationTimeoutMillis = Duration.toMillis( @@ -600,8 +627,14 @@ const make = Effect.gen(function*() { let index = 0 let messages: Array> = [] + let deliveredThisRead = false const removableNotifications = new Set() const resetAddresses = MutableHashSet.empty() + const cappedAddresses = MutableHashSet.empty() + + const markDelivered = Effect.sync(() => { + deliveredThisRead = true + }) const processMessages = Effect.whileLoop({ while: () => index < messages.length, @@ -609,6 +642,16 @@ const make = Effect.gen(function*() { body: () => send }) + const readAndProcess = Effect.fnUntraced(function*(options?: { + readonly limit?: number | undefined + readonly addresses?: ReadonlyArray | undefined + }) { + messages = yield* storage.unprocessedMessages(acquiredShards, options) + index = 0 + yield* processMessages + return messages.length + }) + const send = Effect.catchCause( Effect.suspend(() => { const message = messages[index] @@ -642,7 +685,7 @@ const make = Effect.gen(function*() { if (message._tag === "IncomingEnvelope" && isProcessing) { // If the message might affect a currently processing request, we // send it to the entity manager to be processed. - return state.manager.send(message) + return Effect.tap(state.manager.send(message), markDelivered) } else if (isProcessing || state.status === "closing") { // If the request is already processing, we skip it. // Or if the entity is closing, we skip all incoming messages. @@ -654,6 +697,14 @@ const make = Effect.gen(function*() { entry.resume(Effect.void) } + // The runner is at entity capacity, so messages for entities that + // are not already resident are skipped. Their claims are released + // after the read, so they stay eligible for once a slot frees up. + if (residencyAtCapacityUnsafe() && !state.manager.isResidentUnsafe(address)) { + MutableHashSet.add(cappedAddresses, address) + return Effect.void + } + // If the entity was resuming in another fiber, we add the message // id to the unprocessed set. const resumptionState = MutableHashMap.get(entityResumptionState, address) @@ -664,7 +715,7 @@ const make = Effect.gen(function*() { } return Effect.void } - return state.manager.send(message) + return Effect.tap(state.manager.send(message), markDelivered) }), (cause) => { const message = messages[index] @@ -681,13 +732,25 @@ const make = Effect.gen(function*() { })) } if (error.success._tag === "MailboxFull") { + const address = message.envelope.address + const state = entityManagers.get(address.entityType) + // A resident entity has a full per-entity mailbox; its messages + // are resumed from storage once there is capacity again. // MailboxFull can only happen for requests, so this cast is safe - return resumeEntityFromStorage(message as Message.IncomingRequest) + if (message._tag === "IncomingRequest" && state?.manager.isResidentUnsafe(address)) { + return resumeEntityFromStorage(message as Message.IncomingRequest) + } + // Otherwise the runner ran out of entity slots while the message + // was in flight; leave it in storage. + MutableHashSet.add(cappedAddresses, address) + return Effect.void } return Effect.void } ) + const batchSize = Math.max(1, config.unprocessedMessageBatchSize) + while (true) { // wait for the next poll interval, or if we get notified of a change yield* storageReadLatch.await @@ -706,15 +769,58 @@ const make = Effect.gen(function*() { pendingNotifications.forEach((entry) => removableNotifications.add(entry)) } - messages = yield* storage.unprocessedMessages(acquiredShards) - index = 0 - yield* processMessages + deliveredThisRead = false + let readCount = 0 + let fullBatch = false + let exhaustive = false + + // First deliver messages for entities that are already resident, so + // they keep making progress even when the runner is at entity + // capacity. + if (residencyAtCapacityUnsafe()) { + const residentAddresses: Array = [] + for (const state of entityManagers.values()) { + if (state.status === "closed") continue + for (const address of state.manager.residentAddressesUnsafe()) { + residentAddresses.push(address) + } + } + if (residentAddresses.length > 0) { + readCount = yield* readAndProcess({ limit: batchSize, addresses: residentAddresses }) + fullBatch = readCount >= batchSize + } + } + + // Then walk the remaining messages, spawning new entities while the + // runner has entity slots left. + if (!residencyAtCapacityUnsafe() && readCount < batchSize) { + const limit = batchSize - readCount + const read = yield* readAndProcess({ limit }) + if (read >= limit) { + fullBatch = true + } else if (MutableHashSet.size(cappedAddresses) === 0) { + exhaustive = true + } + } if (removableNotifications.size > 0) { - removableNotifications.forEach(({ message, resume }) => { - pendingNotifications.delete(message.envelope.requestId) - resume(Effect.fail(new EntityNotAssignedToRunner({ address: message.envelope.address }))) - }) + // On an exhaustive read, a message that was not seen is no longer + // processable by this runner. At the entity cap, the messages are + // safely persisted and delivered once a slot frees up, so persisted + // senders succeed instead of failing. Otherwise the read was + // truncated by the batch size and the notifications stay registered + // for the immediately following read. + const capped = MutableHashSet.size(cappedAddresses) > 0 || residencyAtCapacityUnsafe() + if (exhaustive || capped) { + removableNotifications.forEach(({ message, resume }) => { + pendingNotifications.delete(message.envelope.requestId) + resume( + exhaustive + ? Effect.fail(new EntityNotAssignedToRunner({ address: message.envelope.address })) + : Effect.void + ) + }) + } removableNotifications.clear() } if (MutableHashSet.size(resetAddresses) > 0) { @@ -726,9 +832,29 @@ const make = Effect.gen(function*() { } MutableHashSet.clear(resetAddresses) } + // Capture claims skipped because of the entity cap. They are reset in + // one storage operation after releasing the read lock. + let cappedAddressesToReset: Array | undefined + if (MutableHashSet.size(cappedAddresses) > 0) { + cappedAddressesToReset = Arr.fromIterable(cappedAddresses) + MutableHashSet.clear(cappedAddresses) + } // let the resuming entities check if they are done yield* storageReadLock.release(1) + + if (cappedAddressesToReset !== undefined) { + yield* Effect.ignore(storage.resetAddresses(cappedAddressesToReset)) + } + + // A full batch means more messages could be waiting; start the next + // read immediately, as long as this read made progress (a full batch + // of skipped messages must not spin the loop). When the runner is at + // capacity, the next read waits for an entity to be removed or the + // next poll interval instead. + if (fullBatch && deliveredThisRead) { + storageReadLatch.openUnsafe() + } } }).pipe( Effect.scoped, @@ -1012,6 +1138,8 @@ const make = Effect.gen(function*() { } return runnerIsLocal ? sendLocal(message) + : discard + ? runnersService.notify({ address: maybeRunner, message, discard }) : runnersService.send({ address: maybeRunner.value, message }) }), (error) => @@ -1473,6 +1601,7 @@ const make = Effect.gen(function*() { ...options, storage, runnerAddress, + residency, sharding }).pipe( Effect.provideContext(services.pipe( diff --git a/repos/effect/packages/effect/src/unstable/cluster/ShardingConfig.ts b/repos/effect/packages/effect/src/unstable/cluster/ShardingConfig.ts index e9a465d637..1c35f200b3 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/ShardingConfig.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/ShardingConfig.ts @@ -95,6 +95,28 @@ export class ShardingConfig extends Context.Service = Config.all({ Config.withDefault(defaults.entityMailboxCapacity) // Config.withDescription("The default capacity of the mailbox for entities.") ), + maxResidentEntities: Config.schema( + Schema.Int.check(Schema.isGreaterThan(0)), + "maxResidentEntities" + ).pipe( + Config.withDefault(defaults.maxResidentEntities) + // Config.withDescription("The maximum number of entities that can be resident on this runner at the same time.") + ), + unprocessedMessageBatchSize: Config.schema( + Schema.Int.check(Schema.isGreaterThan(0)), + "unprocessedMessageBatchSize" + ).pipe( + Config.withDefault(defaults.unprocessedMessageBatchSize) + // Config.withDescription("The maximum number of unprocessed messages read from storage in a single poll.") + ), entityMaxIdleTime: Config.duration("entityMaxIdleTime").pipe( Config.withDefault(defaults.entityMaxIdleTime) // Config.withDescription( diff --git a/repos/effect/packages/effect/src/unstable/cluster/SqlMessageStorage.ts b/repos/effect/packages/effect/src/unstable/cluster/SqlMessageStorage.ts index e8c2ed4c0d..cdcef650b0 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/SqlMessageStorage.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/SqlMessageStorage.ts @@ -29,6 +29,7 @@ import * as SqlClient from "../sql/SqlClient.ts" import type { Row } from "../sql/SqlConnection.ts" import { isSqlError, type SqlError } from "../sql/SqlError.ts" import { PersistenceError } from "./ClusterError.ts" +import type * as EntityAddress from "./EntityAddress.ts" import type * as Envelope from "./Envelope.ts" import * as MessageStorage from "./MessageStorage.ts" import { SaveResultEncoded } from "./MessageStorage.ts" @@ -40,13 +41,13 @@ import * as Snowflake from "./Snowflake.ts" const withTracerDisabled = Effect.withTracerEnabled(false) /** - * Creates a SQL-backed `MessageStorage` implementation, running its migrations + * Creates a SQL-backed encoded message storage driver, running its migrations * and using the optional table prefix. * * **When to use** * - * Use when you need the SQL-backed `MessageStorage` service directly, such as - * when composing a custom layer or providing your own `Snowflake.Generator`. + * Use when you need the SQL-backed encoded driver directly, such as when + * composing a custom message storage adapter. * * **Details** * @@ -58,18 +59,17 @@ const withTracerDisabled = Effect.withTracerEnabled(false) * Changing `prefix` after deployment points the runtime at a different set of * tables, including the migration history table. * - * @see {@link layer} for a ready-made layer using the default prefix and generator - * @see {@link layerWith} for a ready-made layer with a custom table prefix + * @see {@link make} for the decoded `MessageStorage` constructor * * @category constructors * @since 4.0.0 */ -export const make: (options?: { +export const makeEncoded: (options?: { readonly prefix?: string | undefined }) => Effect.Effect< - MessageStorage.MessageStorage["Service"], + MessageStorage.Encoded, never, - SqlClient.SqlClient | Snowflake.Generator | Crypto.Crypto + SqlClient.SqlClient | Crypto.Crypto > = Effect.fnUntraced(function*(options) { const sql = (yield* SqlClient.SqlClient).withoutTransforms() const crypto = yield* Crypto.Crypto @@ -372,8 +372,58 @@ export const make: (options?: { orElse: () => sql.literal("FOR UPDATE") }) - const getUnprocessedMessages = sql.onDialectOrElse({ - pg: () => (shardIds: ReadonlyArray, now: number) => + const emptyFragment = sql.literal("") + // mssql only limits with OFFSET/FETCH, which requires the ORDER BY that + // both queries already have + const limitFragment = sql.onDialectOrElse({ + mssql: () => (limit: number) => sql.literal(`OFFSET 0 ROWS FETCH NEXT ${Math.floor(limit)} ROWS ONLY`), + orElse: () => (limit: number) => sql.literal(`LIMIT ${Math.floor(limit)}`) + }) + const groupAddresses = (addresses: ReadonlyArray) => { + const byShard = new Map>>() + for (const address of addresses) { + const shardId = address.shardId.toString() + let byEntityType = byShard.get(shardId) + if (byEntityType === undefined) { + byShard.set(shardId, byEntityType = new Map()) + } + const group = byEntityType.get(address.entityType) + if (group === undefined) { + byEntityType.set(address.entityType, [address]) + } else { + group.push(address) + } + } + return Array.from( + byShard, + ([shardId, byEntityType]) => + Array.from(byEntityType, ([entityType, addresses]) => ({ shardId, entityType, addresses })) + ).flat() + } + type UnprocessedOptions = { + readonly limit?: number | undefined + readonly addresses?: ReadonlyArray | undefined + } + const unprocessedFilters = (options?: UnprocessedOptions | undefined) => ({ + addressFilter: options?.addresses !== undefined + ? sql`AND (${ + sql.or( + groupAddresses(options.addresses).map((group) => + sql.and([ + sql`m.shard_id = ${group.shardId}`, + sql`m.entity_type = ${group.entityType}`, + sql.in("m.entity_id", group.addresses.map((address) => address.entityId)) + ]) + ) + ) + })` + : emptyFragment, + limit: options?.limit !== undefined ? limitFragment(options.limit) : emptyFragment + }) + type UnprocessedFilters = ReturnType + + const getUnprocessedMessagesForDialect = sql.onDialectOrElse({ + pg: () => (shardIds: ReadonlyArray, now: number, filters: UnprocessedFilters) => sql` WITH messages AS ( UPDATE ${messagesTableSql} m @@ -382,6 +432,7 @@ export const make: (options?: { SELECT m.* FROM ${messagesTableSql} m WHERE m.shard_id IN (${sql.literal(shardIds.map(wrapString).join(","))}) + ${filters.addressFilter} AND NOT EXISTS ( SELECT 1 FROM ${repliesTableSql} WHERE request_id = m.request_id @@ -390,6 +441,8 @@ export const make: (options?: { AND m.processed = ${sqlFalse} AND (m.last_read IS NULL OR m.last_read < ${tenMinutesAgo}) AND (m.deliver_at IS NULL OR m.deliver_at <= ${sql.literal(String(now))}) + ORDER BY m.rowid ASC + ${filters.limit} FOR UPDATE ) AS ids LEFT JOIN ${repliesTableSql} r ON r.id = ids.last_reply_id @@ -398,12 +451,13 @@ export const make: (options?: { ) SELECT * FROM messages ORDER BY rowid ASC `, - orElse: () => (shardIds: ReadonlyArray, now: number) => + orElse: () => (shardIds: ReadonlyArray, now: number, filters: UnprocessedFilters) => sql` SELECT m.*, r.id as reply_reply_id, r.kind as reply_kind, r.payload as reply_payload, r.sequence as reply_sequence FROM ${messagesTableSql} m LEFT JOIN ${repliesTableSql} r ON r.id = m.last_reply_id WHERE m.shard_id IN (${sql.literal(shardIds.map(wrapString).join(","))}) + ${filters.addressFilter} AND NOT EXISTS ( SELECT 1 FROM ${repliesTableSql} WHERE request_id = m.request_id @@ -413,6 +467,7 @@ export const make: (options?: { AND (m.last_read IS NULL OR m.last_read < ${tenMinutesAgo}) AND (m.deliver_at IS NULL OR m.deliver_at <= ${sql.literal(String(now))}) ORDER BY m.rowid ASC + ${filters.limit} ${forUpdate} `.unprepared.pipe( Effect.tap((rows) => { @@ -428,8 +483,16 @@ export const make: (options?: { sql.withTransaction ) }) + const getUnprocessedMessages = ( + shardIds: ReadonlyArray, + now: number, + options?: UnprocessedOptions | undefined + ) => + options?.addresses?.length === 0 + ? Effect.succeed([]) + : getUnprocessedMessagesForDialect(shardIds, now, unprocessedFilters(options)) - return yield* MessageStorage.makeEncoded({ + const encoded: MessageStorage.Encoded = { saveEnvelope: ({ deliverAt, envelope, primaryKey }) => Effect.suspend(() => { let insert: Effect.Effect, SqlError | PlatformError.PlatformError> @@ -579,8 +642,8 @@ export const make: (options?: { ), unprocessedMessages: Effect.fnUntraced( - function*(shardIds, now) { - const rows = yield* getUnprocessedMessages(shardIds, now) + function*(shardIds, now, options) { + const rows = yield* getUnprocessedMessages(shardIds, now, options) if (rows.length === 0) { return [] } @@ -623,19 +686,30 @@ export const make: (options?: { ) }, - resetAddress: (address) => - sql` + resetAddresses: (addresses) => + addresses.length === 0 + ? Effect.void + : sql` UPDATE ${messagesTableSql} SET last_read = NULL WHERE processed = ${sqlFalse} - AND shard_id = ${address.shardId.toString()} - AND entity_type = ${address.entityType} - AND entity_id = ${address.entityId} - `.pipe( - Effect.asVoid, - PersistenceError.refail, - withTracerDisabled - ), + AND (${ + sql.or( + groupAddresses(addresses).map( + (group) => + sql.and([ + sql`shard_id = ${group.shardId}`, + sql`entity_type = ${group.entityType}`, + sql.in("entity_id", group.addresses.map((address) => address.entityId)) + ]) + ) + ) + }) + `.pipe( + Effect.asVoid, + PersistenceError.refail, + withTracerDisabled + ), clearAddress: (address) => sql` @@ -675,9 +749,25 @@ export const make: (options?: { sql.withTransaction(effect).pipe( Effect.catchIf(isSqlError, Effect.die) ) - }) + } + return encoded }, withTracerDisabled) +/** + * Creates a SQL-backed `MessageStorage` implementation, running its migrations + * and using the optional table prefix. + * + * @category constructors + * @since 4.0.0 + */ +export const make: (options?: { + readonly prefix?: string | undefined +}) => Effect.Effect< + MessageStorage.MessageStorage["Service"], + never, + SqlClient.SqlClient | Snowflake.Generator | Crypto.Crypto +> = (options) => Effect.flatMap(makeEncoded(options), MessageStorage.makeEncoded) + /** * Layer that provides SQL-backed `MessageStorage` using the default table prefix * and the default snowflake generator. @@ -1025,6 +1115,20 @@ const migrations = (options?: { // sqlite Effect.void }) + }), + "0003_pg_messages_rowid_index": Effect.gen(function*() { + const sql = (yield* SqlClient.SqlClient).withoutTransforms() + const messagesTableSql = sql(messagesTable) + const rowIdIndex = `${messagesTable}_rowid_idx` + + yield* sql.onDialectOrElse({ + pg: () => + sql` + CREATE INDEX IF NOT EXISTS ${sql(rowIdIndex)} + ON ${messagesTableSql} (rowid) + `, + orElse: () => Effect.void + }) }) }) } diff --git a/repos/effect/packages/effect/src/unstable/cluster/index.ts b/repos/effect/packages/effect/src/unstable/cluster/index.ts index 3ffc13b9bc..bd0331fe98 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/index.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/index.ts @@ -84,6 +84,11 @@ export * as HttpRunner from "./HttpRunner.ts" */ export * as K8sHttpClient from "./K8sHttpClient.ts" +/** + * @since 4.0.0 + */ +export * as K8sTypes from "./K8sTypes.ts" + /** * @since 4.0.0 */ diff --git a/repos/effect/packages/effect/src/unstable/cluster/internal/entityManager.ts b/repos/effect/packages/effect/src/unstable/cluster/internal/entityManager.ts index e568d3d07e..11384fa211 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/internal/entityManager.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/internal/entityManager.ts @@ -56,6 +56,9 @@ export interface EntityManager { }) => boolean readonly clearProcessed: () => void + readonly isResidentUnsafe: (address: EntityAddress) => boolean + readonly residentAddressesUnsafe: () => Array + readonly interruptShard: (shardId: ShardId, options?: { readonly force?: boolean }) => Effect.Effect @@ -63,6 +66,19 @@ export interface EntityManager { readonly activeEntityCount: Effect.Effect } +// Tracks how many entities are resident on the runner across all entity +// managers, so the spawn of new entities can be gated by +// `ShardingConfig.maxResidentEntities`. +/** @internal */ +export interface Residency { + /** + * Reserve a slot for a new entity. Returns `false` when the runner is at + * capacity. + */ + readonly admitUnsafe: () => boolean + readonly releaseUnsafe: () => void +} + // Represents the entities managed by this entity manager /** @internal */ export type EntityState = { @@ -94,6 +110,7 @@ export const make = Effect.fnUntraced(function*< readonly sharding: Sharding["Service"] readonly storage: MessageStorage.MessageStorage["Service"] readonly runnerAddress: RunnerAddress + readonly residency: Residency readonly maxIdleTime?: Input | undefined readonly concurrency?: number | "unbounded" | undefined readonly mailboxCapacity?: number | "unbounded" | undefined @@ -128,13 +145,22 @@ export const make = Effect.fnUntraced(function*< const entities: ResourceMap< EntityAddress, EntityState, - EntityNotAssignedToRunner + EntityNotAssignedToRunner | MailboxFull > = yield* ResourceMap.make(Effect.fnUntraced(function*(address: EntityAddress) { if (!options.sharding.hasShardId(address.shardId)) { return yield* new EntityNotAssignedToRunner({ address }) } const scope = yield* Effect.scope + + // Gate the spawn on the runner-wide entity cap. Registering the release + // must be atomic with taking the slot, otherwise an interrupt in between + // would leak it. + yield* Effect.uninterruptible(Effect.suspend(() => + options.residency.admitUnsafe() + ? Scope.addFinalizer(scope, Effect.sync(options.residency.releaseUnsafe)) + : Effect.fail(new MailboxFull({ address })) + )) const endLatch = Latch.makeUnsafe() const keepAliveLatch = Latch.makeUnsafe() const closeLatches = { @@ -590,6 +616,8 @@ export const make = Effect.fnUntraced(function*< clearProcessed() { processedRequestIds.clear() }, + isResidentUnsafe: (address) => entities.hasUnsafe(address), + residentAddressesUnsafe: () => entities.keysUnsafe(), sendLocal, send: (message) => decodeMessage(message).pipe( @@ -654,10 +682,11 @@ const makeMessageDecode = (entityRpcs: Map) message: Message.IncomingRequest, rpc: Rpc.AnyWithProps ) { - const payload = yield* Schema.decodeEffect(Schema.toCodecJson(rpc.payloadSchema))(message.envelope.payload) + const codecFor = message.codecFor + const payload = yield* Schema.decodeEffect(codecFor(rpc.payloadSchema))(message.envelope.payload) const lastSentReply = Option.isNone(message.lastSentReply) ? message.lastSentReply : - Option.some(yield* Schema.decodeEffect(Reply.Reply(rpc))(message.lastSentReply.value)) + Option.some(yield* Schema.decodeEffect(Reply.Reply(rpc, codecFor))(message.lastSentReply.value)) return { _tag: "IncomingRequest", envelope: { diff --git a/repos/effect/packages/effect/src/unstable/cluster/internal/entityReaper.ts b/repos/effect/packages/effect/src/unstable/cluster/internal/entityReaper.ts index 0c3c974b81..d50c602fc2 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/internal/entityReaper.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/internal/entityReaper.ts @@ -3,7 +3,6 @@ import * as Context from "../../../Context.ts" import * as Effect from "../../../Effect.ts" import * as Latch from "../../../Latch.ts" import * as Layer from "../../../Layer.ts" -import type { EntityNotAssignedToRunner } from "../ClusterError.ts" import type { EntityAddress } from "../EntityAddress.ts" import type { EntityId } from "../EntityId.ts" import type { EntityState } from "./entityManager.ts" @@ -16,14 +15,14 @@ export class EntityReaper extends Context.Service()("effect/cluste const registered: Array<{ readonly maxIdleTime: number readonly servers: Map - readonly entities: ResourceMap + readonly entities: ResourceMap }> = [] const latch = yield* Latch.make() const register = (options: { readonly maxIdleTime: number readonly servers: Map - readonly entities: ResourceMap + readonly entities: ResourceMap }) => Effect.suspend(() => { currentResolution = Math.max(Math.min(currentResolution, options.maxIdleTime), 5000) diff --git a/repos/effect/packages/effect/src/unstable/cluster/internal/resourceMap.ts b/repos/effect/packages/effect/src/unstable/cluster/internal/resourceMap.ts index 13377199b6..e09e1f67b4 100644 --- a/repos/effect/packages/effect/src/unstable/cluster/internal/resourceMap.ts +++ b/repos/effect/packages/effect/src/unstable/cluster/internal/resourceMap.ts @@ -56,6 +56,14 @@ export class ResourceMap { ) }) + hasUnsafe(key: K): boolean { + return backingGet(this.entries, key) !== undefined + } + + keysUnsafe(): Array { + return Array.from(this.entries.map, ([key]) => key) + } + get(key: K): Effect.Effect { return Effect.suspend(() => { if (MutableRef.get(this.isClosed)) { diff --git a/repos/effect/packages/effect/src/unstable/encoding/SchemaBinary.ts b/repos/effect/packages/effect/src/unstable/encoding/SchemaBinary.ts new file mode 100644 index 0000000000..f530e1e0a6 --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/encoding/SchemaBinary.ts @@ -0,0 +1,5154 @@ +/** + * A compact binary codec derived from the encoded side of a Schema. + * + * The default wire format supports compatible schema evolution. An array of + * structs is written as a row run: rows declare their shape once and + * back-reference repeated strings, so both stay off the wire on later rows. + * Fingerprint mode uses positional layouts and rejects mismatches for smaller + * frames; its row shapes are presence masks instead of field id lists. Encoded + * results are arena-backed views; see {@link toCodec} for ownership details. + * + * @since 4.0.0 + */ +import * as Arr from "../../Array.ts" +import * as BigDecimal from "../../BigDecimal.ts" +import * as Cause from "../../Cause.ts" +import * as Channel from "../../Channel.ts" +import * as Chunk from "../../Chunk.ts" +import * as DateTime from "../../DateTime.ts" +import * as Duration from "../../Duration.ts" +import * as Effect from "../../Effect.ts" +import * as Exit from "../../Exit.ts" +import { dual, memoize } from "../../Function.ts" +import * as HashMap from "../../HashMap.ts" +import * as HashSet from "../../HashSet.ts" +import { assignProperty } from "../../internal/record.ts" +import * as InternalParser from "../../internal/schema/parser.ts" +import * as Option from "../../Option.ts" +import * as Predicate from "../../Predicate.ts" +import * as Pull from "../../Pull.ts" +import * as Redacted from "../../Redacted.ts" +import * as Result from "../../Result.ts" +import * as Schema from "../../Schema.ts" +import * as SchemaAST from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import * as SchemaParser from "../../SchemaParser.ts" +import * as SchemaTransformation from "../../SchemaTransformation.ts" + +/** + * Selects the wire mode. + * + * The default mode supports compatible schema evolution. `fingerprint: true` + * uses positional layouts and an 8-byte layout hash for smaller frames, but + * requires peers to use the same schema definition. + * + * @category models + * @since 4.0.0 + */ +export interface Options { + /** + * @since 4.0.0 + */ + readonly fingerprint?: boolean | undefined +} + +/** + * The codec type returned by {@link toCodec}. + * + * @category models + * @since 4.0.0 + */ +export interface toCodec extends + Schema.Codec< + S["Type"], + Uint8Array, + S["DecodingServices"], + S["EncodingServices"] + > +{} + +/** + * Derives a compact binary codec from a schema. + * + * The wire layout is compiled from the encoded side of the schema. Each + * encode/decode handles exactly one frame; use {@link parser} for streams. + * + * Encoded results are arena-backed views and may share a larger buffer. Use + * `bytes.slice()` when independent ownership is required. + * + * **Example** + * + * ```ts + * import { Schema } from "effect" + * import { SchemaBinary } from "effect/unstable/encoding" + * + * const Person = Schema.Struct({ name: Schema.String, age: Schema.Number }) + * const codec = SchemaBinary.toCodec(Person) + * + * const bytes = Schema.encodeUnknownSync(codec)({ name: "Ada", age: 36 }) + * const person = Schema.decodeUnknownSync(codec)(bytes) + * ``` + * + * @category constructors + * @since 4.0.0 + */ +export function toCodec(schema: S, options?: Options): toCodec { + const { exact, layout, recursive, target } = compileTarget(schema) + const mode = compileMode(layout, options?.fingerprint) + const trusted: Trusted | undefined = exact ? { value: undefined } : undefined + return assembleCodec( + trusted === undefined ? target : withTrustedDecode(target, trusted, !recursive), + layout, + mode, + trusted + ) +} + +/** + * Derives the {@link toCodec} codec without the schema pass around the binary + * layer. + * + * Only schemas the binary layer already validates on its own take the direct + * path; anything else falls back to {@link toCodec}. A direct codec encodes and + * decodes the same values as {@link toCodec} but is not a sound `Schema.is` + * guard, so it is only for callers that never guard on it. + * + * @internal + */ +export function toCodecDirect(schema: S, options?: Options): toCodec { + const { exact, exitSuccess, layout, target } = compileTarget(schema) + if (!exact) { + if (!exitSuccess) return toCodec(schema, options) + const trusted: Trusted = { value: undefined } + return assembleCodec( + withExitSuccessDecode(target, trusted), + layout, + compileMode(layout, options?.fingerprint), + trusted, + true + ) + } + return assembleCodec(passThrough(target), layout, compileMode(layout, options?.fingerprint), undefined) +} + +function assembleCodec( + target: Schema.Constraint, + layout: Layout, + mode: Mode, + trusted: Trusted | undefined, + successOnly = false +): toCodec { + return (Schema.Uint8Array as Schema.instanceOf>).pipe( + Schema.decodeTo(target, makeTransformation(layout, mode, trusted, successOnly)) + ) as unknown as toCodec +} + +/** + * Encodes one frame without the schema pass {@link toCodec} wraps around it. + * + * Only schemas the binary layer already validates on its own take the direct + * path; anything else falls back to the codec, so checks and transformations + * still run. + * + * @internal + */ +export function encodeUnknownSync( + schema: S, + options?: SchemaAST.ParseOptions & Options +): (value: unknown) => Uint8Array { + const { exact, exitSuccess, layout } = compileTarget(schema) + const mode = compileMode(layout, options?.fingerprint) + const parseOptions: SchemaAST.ParseOptions = options ?? SchemaAST.defaultParseOptions + if (!exact) { + const fallback = Schema.encodeUnknownSync( + toCodec(schema, options) as unknown as Schema.ConstraintEncoder, + options + ) as (value: unknown) => Uint8Array + // The binary layer drops excess keys instead of reporting them. + if (!exitSuccess || parseOptions.onExcessProperty === "error") return fallback + return (value) => { + if (!isSuccessExit(value)) return fallback(value) + try { + return encodeFrame(layout, value, parseOptions, mode) + } catch (e) { + throw e instanceof IssueError ? new Schema.SchemaError(e.issue) : e + } + } + } + return (value) => { + try { + return encodeFrame(layout, value, parseOptions, mode) + } catch (e) { + throw e instanceof IssueError ? new Schema.SchemaError(e.issue) : e + } + } +} + +/** + * Encodes concatenated frames in one writer pass, so a batch costs no more + * allocations than a single frame. + * + * Shares {@link encodeUnknownSync}'s direct path and its fallback. + * + * @internal + */ +export function encodeManyUnknownSync( + schema: S, + options?: SchemaAST.ParseOptions & Options +): (values: ReadonlyArray) => Uint8Array { + const { exact, layout } = compileTarget(schema) + if (!exact) { + const encode = encodeUnknownSync(schema, options) + return (values) => concatFrames(values.map(encode)) + } + const mode = compileMode(layout, options?.fingerprint) + const parseOptions: SchemaAST.ParseOptions = options ?? SchemaAST.defaultParseOptions + return (values) => { + try { + return encodeFrames(layout, values, parseOptions, mode) + } catch (e) { + throw e instanceof IssueError ? new Schema.SchemaError(e.issue) : e + } + } +} + +function concatFrames(frames: ReadonlyArray>): Uint8Array { + if (frames.length === 1) return frames[0] + let length = 0 + for (const frame of frames) length += frame.length + const out = new Uint8Array(length) + let offset = 0 + for (const frame of frames) { + out.set(frame, offset) + offset += frame.length + } + return out +} + +/** + * A stateful frame parser for concatenated {@link toCodec} outputs. + * + * @category models + * @since 4.0.0 + */ +export interface Parser { + /** + * @since 4.0.0 + */ + feed(chunk: Uint8Array): Effect.Effect, Schema.SchemaError> + /** + * @since 4.0.0 + */ + feedSync(chunk: Uint8Array): ReadonlyArray + /** + * @since 4.0.0 + */ + end: Effect.Effect + /** + * @since 4.0.0 + */ + endSync(): void +} + +/** + * Creates a stateful parser for a stream of concatenated frames. + * + * Values completed before a failure remain observable. After a failure, the + * parser rejects further calls. Use `maxFrameSize` to limit buffered frames. + * + * @category constructors + * @since 4.0.0 + */ +export function parser( + schema: S, + options?: SchemaAST.ParseOptions & Options & StreamOptions & { readonly maxFrameSize?: number | undefined } +): Parser { + const { exact, exitSuccess, layout, target } = compileTarget(schema) + const parseOptions: SchemaAST.ParseOptions = options ?? SchemaAST.defaultParseOptions + const decodeTarget = exact + ? undefined + : Schema.decodeUnknownSync(target as Schema.ConstraintDecoder, parseOptions) + const decodeEncoded = decodeTarget !== undefined && exitSuccess + ? (value: unknown) => isSuccessExit(value) ? value : decodeTarget(value) + : decodeTarget + return makeParser( + layout, + compileMode(layout, options?.fingerprint), + parseOptions, + options?.maxFrameSize, + decodeEncoded, + requireDictionary(exact, options) + ) +} + +/** + * Options for the connection-scoped pair, {@link encoder} and {@link parser}. + * + * @category models + * @since 4.0.0 + */ +export interface StreamOptions { + /** + * Share one string dictionary across every frame on the connection, so a + * string that repeats costs a reference after the first frame that carries + * it. Frames stop standing alone: they only decode through the parser that + * saw the frames before them, in order. + * + * Both ends have to set it. A schema the binary layer does not fully + * validate on its own cannot use it, and asking for it throws. + * + * @since 4.0.0 + */ + readonly dictionary?: boolean | undefined +} + +function requireDictionary(exact: boolean, options: StreamOptions | undefined): boolean { + if (options?.dictionary !== true) return false + if (!exact) { + throw new Error("Binary layout: dictionary needs a schema the binary layer validates on its own") + } + return true +} + +/** + * A writer for one connection. Every frame it produces shares the string + * dictionary that the matching {@link parser} rebuilds as it reads them, so the + * two have to be created from the same schema and the same options. + * + * @category constructors + * @since 4.0.0 + */ +export function encoder( + schema: S, + options?: SchemaAST.ParseOptions & Options & StreamOptions +): Encoder { + const { exact, layout } = compileTarget(schema) + const dictionary = requireDictionary(exact, options) + const encode = encodeUnknownSync(schema, options) + const encodeMany = encodeManyUnknownSync(schema, options) + if (!dictionary) return { encode, encodeMany } + const mode = compileMode(layout, options?.fingerprint) + const parseOptions: SchemaAST.ParseOptions = options ?? SchemaAST.defaultParseOptions + const dict = makeDictWrite() + const run = (f: () => A): A => { + try { + return f() + } catch (e) { + throw e instanceof IssueError ? new Schema.SchemaError(e.issue) : e + } + } + return { + encode: (value) => run(() => encodeFrame(layout, value, parseOptions, mode, dict)), + encodeMany: (values) => run(() => encodeFrames(layout, values, parseOptions, mode, dict)) + } +} + +/** + * The writer returned by {@link encoder}. + * + * @category models + * @since 4.0.0 + */ +export interface Encoder { + encode(value: unknown): Uint8Array + encodeMany(values: ReadonlyArray): Uint8Array +} + +// Frame parser over a compiled layout. `decodeEncoded` runs the target schema +// pass on each framed value; `undefined` yields the raw binary-decoded values. +function makeParser( + layout: Layout, + mode: Mode, + parseOptions: SchemaAST.ParseOptions, + maxFrameSize: number | undefined, + decodeEncoded: ((value: unknown) => unknown) | undefined, + dictionary: boolean +): Parser { + const dict: DictRead | undefined = dictionary ? [] : undefined + let buffer = new Uint8Array(0) + let bufferStart = 0 + let bufferEnd = 0 + let stashed: Schema.SchemaError | undefined + let spent = false + const body = new Reader() + const indexSignatures = new IndexSignatureCache(parseOptions, PARSER_INDEX_SIGNATURE_CACHE_SIZE) + + const release = () => { + body.release() + indexSignatures.clear() + buffer = EMPTY_READER_BUFFER + bufferStart = bufferEnd = 0 + } + + const failSync = (expected: string, input?: unknown): never => { + throw new Schema.SchemaError(new SchemaIssue.InvalidValue({ expected }, input, parseOptions)) + } + + const takeStashed = (): never => { + const error = stashed! + stashed = undefined + throw error + } + + // Copy the unconsumed tail of a directly parsed chunk into the buffer. + const stashTail = (chunk: Uint8Array, start: number) => { + const remaining = chunk.length - start + if (remaining > buffer.length) { + buffer = new Uint8Array(Math.max(256, remaining)) + } + buffer.set(start === 0 ? chunk : chunk.subarray(start), 0) + bufferStart = 0 + bufferEnd = remaining + } + + const self: Parser = { + feedSync(chunk) { + if (stashed !== undefined) return takeStashed() + if (spent) return failSync("parser is spent") + // Complete frames in a fresh chunk parse in place; only partial frames + // are ever copied into the buffer. + const direct = bufferEnd === bufferStart + let buf: Uint8Array + let pos: number + let end: number + if (direct) { + buf = chunk + pos = 0 + end = chunk.length + } else { + if (chunk.length > 0) { + const remaining = bufferEnd - bufferStart + const required = remaining + chunk.length + if (required > buffer.length) { + const next = new Uint8Array(Math.max(256, buffer.length * 2, required)) + next.set(buffer.subarray(bufferStart, bufferEnd)) + buffer = next + bufferStart = 0 + bufferEnd = remaining + } else if (bufferStart > 0) { + buffer.copyWithin(0, bufferStart, bufferEnd) + bufferStart = 0 + bufferEnd = remaining + } + buffer.set(chunk, bufferEnd) + bufferEnd += chunk.length + } + buf = buffer + pos = bufferStart + end = bufferEnd + } + const out: Array = [] + const done = (): Array => { + if (direct) { + if (pos < end) stashTail(chunk, pos) + } else { + bufferStart = pos + if (bufferStart === bufferEnd) bufferStart = bufferEnd = 0 + } + return out + } + const fail = (expected: string, input?: unknown): Array => { + spent = true + const error = new Schema.SchemaError(new SchemaIssue.InvalidValue({ expected }, input, parseOptions)) + release() + if (out.length === 0) throw error + stashed = error + return out + } + while (true) { + // Use bigint only for frame lengths wider than six varint groups. + let frameLen = 0 + let headerLen = -1 + const buffered = end - pos + let scale = 1 + for (let i = 0; i < Math.min(6, buffered); i++) { + const b = buf[pos + i] + frameLen += (b & 0x7F) * scale + if ((b & 0x80) === 0) { + headerLen = i + 1 + break + } + scale *= 128 + } + if (headerLen === -1) { + if (buffered < 6) return done() + let n = BigInt(frameLen) + for (let i = 6; i < Math.min(10, buffered); i++) { + const b = buf[pos + i] + n |= BigInt(b & 0x7F) << BigInt(i * 7) + if ((b & 0x80) === 0) { + headerLen = i + 1 + if (n > MAX_SAFE_BIGINT) return fail("safe integer length", n) + frameLen = Number(n) + break + } + } + if (headerLen === -1) { + if (buffered >= 10) return fail("uvarint", buf.subarray(pos, pos + 10)) + return done() + } + } + if (frameLen === 0) return fail("nonzero frame length", frameLen) + if (maxFrameSize !== undefined && frameLen > maxFrameSize) { + return fail("frame within maxFrameSize", frameLen) + } + if (headerLen + frameLen > buffered) return done() + const savedPathLen = issuePathLen + try { + issuePathLen = 0 + const bodyStart = pos + headerLen + indexSignatures.beginFrame() + body.reset(buf, bodyStart, bodyStart + frameLen, parseOptions, indexSignatures, mode.positional, dict) + const value = decodeFrameBody(layout, body, mode) + out.push((decodeEncoded === undefined ? value : decodeEncoded(value)) as T) + } catch (e) { + spent = true + release() + const error = e instanceof IssueError + ? new Schema.SchemaError(e.issue) + : Schema.isSchemaError(e) + ? e + : (() => { + throw e + })() + if (out.length === 0) throw error + stashed = error + return out + } finally { + issuePathLen = savedPathLen + } + pos += headerLen + frameLen + } + }, + endSync() { + if (stashed !== undefined) return takeStashed() + if (spent) return failSync("parser is spent") + spent = true + const input = bufferStart < bufferEnd ? buffer.subarray(bufferStart, bufferEnd) : undefined + release() + if (input !== undefined) return failSync("complete value", input) + }, + feed: (chunk) => + Effect.suspend(() => { + try { + return Effect.succeed(self.feedSync(chunk)) + } catch (e) { + if (Schema.isSchemaError(e)) return Effect.fail(e) + throw e + } + }), + end: Effect.suspend(() => { + try { + self.endSync() + return Effect.void + } catch (e) { + if (Schema.isSchemaError(e)) return Effect.fail(e) + throw e + } + }) + } + return self +} + +/** + * Creates a channel that encodes chunks of schema values into binary frames. + * + * **Details** + * + * Each input chunk is emitted as one byte element holding concatenated frames + * written in one writer pass, so a batch costs no more allocations than a + * single frame. Schemas with transformations first run one schema pass per + * chunk to the binary-adjusted encoded side — supporting async transformations + * and encoding services — before that writer pass. Failures are + * `Schema.SchemaError`, matching {@link toCodec}. `maxFrameSize` only applies + * to {@link decode} and is ignored here. + * + * @category channels + * @since 4.0.0 + */ +export const encode = ( + schema: S, + options?: SchemaAST.ParseOptions & Options & { readonly maxFrameSize?: number | undefined } +) => +(): Channel.Channel< + Arr.NonEmptyReadonlyArray>, + Schema.SchemaError | IE, + Done, + Arr.NonEmptyReadonlyArray, + IE, + Done, + S["EncodingServices"] +> => { + // Everything here depends only on schema and options, so it is derived once + // per channel rather than once per run. + const { exact, layout, target } = compileTarget(schema) + const parseOptions: SchemaAST.ParseOptions = options ?? SchemaAST.defaultParseOptions + const mode = compileMode(layout, options?.fingerprint) + const write = ( + values: ReadonlyArray + ): Effect.Effect>, Schema.SchemaError> => { + try { + return Effect.succeed(Arr.of(encodeFrames(layout, values, parseOptions, mode))) + } catch (e) { + if (e instanceof IssueError) return Effect.fail(new Schema.SchemaError(e.issue)) + return Effect.die(e) + } + } + if (exact) { + return Channel.fromTransform((upstream, _scope) => Effect.sync(() => Effect.flatMap(upstream, write))) + } + // One schema pass per chunk brings the values to the binary-adjusted encoded + // side the writer expects, so the writer pass stays one sync batch. + const encodeValues = Schema.encodeUnknownEffect( + Schema.NonEmptyArray(target) as unknown as Schema.ConstraintEncoder, + options + ) as (values: ReadonlyArray) => Effect.Effect, Schema.SchemaError> + return Channel.fromTransform((upstream, _scope) => + Effect.sync(() => Effect.flatMap(upstream, (chunk) => Effect.flatMap(encodeValues(chunk), write))) + ) +} + +/** + * Creates a channel that decodes chunks of binary frames into schema values. + * + * **Details** + * + * The channel keeps one frame parser for its lifetime: frames may be + * fragmented across chunks or concatenated within one, values completed before + * a failure remain observable, and a leftover incomplete frame fails when the + * upstream is done. Schemas with transformations run the schema pass per + * framed value, which supports async transformations and decoding services. + * Failures are `Schema.SchemaError`, matching {@link toCodec}. + * + * @category channels + * @since 4.0.0 + */ +export const decode = ( + schema: S, + options?: SchemaAST.ParseOptions & Options & { readonly maxFrameSize?: number | undefined } +) => +(): Channel.Channel< + Arr.NonEmptyReadonlyArray, + Schema.SchemaError | IE, + Done, + Arr.NonEmptyReadonlyArray>, + IE, + Done, + S["DecodingServices"] +> => { + // Everything here depends only on schema and options, so it is derived once + // per channel; the frame parser and failure stash below stay per run. + const { exact, exitSuccess, layout, target } = compileTarget(schema) + const parseOptions: SchemaAST.ParseOptions = options ?? SchemaAST.defaultParseOptions + const mode = compileMode(layout, options?.fingerprint) + const decodeTarget = exact + ? undefined + : Schema.decodeUnknownEffect(target as Schema.ConstraintDecoder, parseOptions) + const decodeEncoded = decodeTarget !== undefined && exitSuccess + ? (value: unknown) => isSuccessExit(value) ? Effect.succeed(value) : decodeTarget(value) + : decodeTarget + return Channel.fromTransform((upstream, _scope) => + Effect.sync(() => { + const frames = makeParser(layout, mode, parseOptions, options?.maxFrameSize, undefined, false) + let stashed: Schema.SchemaError | undefined + + // Decodes framed values in order. On a failure, values decoded before it + // are emitted first and the error fails the next pull. + const decodeAll = ( + raw: ReadonlyArray + ): Pull.Pull, Schema.SchemaError | IE, Done> => { + const out: Array = [] + const loop: Effect.Effect, Schema.SchemaError> = Effect.suspend(() => + out.length === raw.length + ? Effect.succeed(out as unknown as Arr.NonEmptyReadonlyArray) + : Effect.flatMap(decodeEncoded!(raw[out.length]), (value) => { + out.push(value as S["Type"]) + return loop + }) + ) + return Effect.catch(loop, (error) => { + if (out.length === 0) { + stashed = undefined + return Effect.fail(error) + } + stashed = error + return Effect.succeed(out as unknown as Arr.NonEmptyReadonlyArray) + }) + } + + const feed = ( + chunk: Arr.NonEmptyReadonlyArray> + ): Pull.Pull, Schema.SchemaError | IE, Done> => { + const raw: Array = [] + try { + for (let i = 0; i < chunk.length; i++) { + const values = frames.feedSync(chunk[i]) + for (let j = 0; j < values.length; j++) raw.push(values[j]) + } + } catch (e) { + if (!Schema.isSchemaError(e)) return Effect.die(e) + if (raw.length === 0) return Effect.fail(e) + // Emit the values framed before the failure, then fail on the next + // pull. + stashed = e + } + if (!Arr.isReadonlyArrayNonEmpty(raw)) return pull + return decodeEncoded === undefined + ? Effect.succeed(raw as Arr.NonEmptyReadonlyArray) + : decodeAll(raw) + } + + const pull: Pull.Pull, Schema.SchemaError | IE, Done> = Effect.suspend(() => + stashed !== undefined ? Effect.fail(stashed) : Pull.matchEffect(upstream, { + onSuccess: feed, + onFailure: Effect.failCause, + onDone: (done): Pull.Pull => { + try { + frames.endSync() + return Cause.done(done) + } catch (e) { + if (Schema.isSchemaError(e)) return Effect.fail(e) + throw e + } + } + }) + ) + + return pull + }) + ) +} + +/** + * Wraps a bidirectional byte channel with schema-driven binary encoding and + * decoding. + * + * **Details** + * + * Values sent to the wrapped channel are encoded with `inputSchema` as binary + * frames; bytes received from it are decoded with `outputSchema`. + * + * @category channels + * @since 4.0.0 + */ +export const duplex: { + ( + options: SchemaAST.ParseOptions & Options & { + readonly inputSchema: In + readonly outputSchema: Out + readonly maxFrameSize?: number | undefined + } + ): ( + self: Channel.Channel< + Arr.NonEmptyReadonlyArray>, + OutErr, + OutDone, + Arr.NonEmptyReadonlyArray>, + Schema.SchemaError | InErr, + InDone, + R + > + ) => Channel.Channel< + Arr.NonEmptyReadonlyArray, + Schema.SchemaError | OutErr, + OutDone, + Arr.NonEmptyReadonlyArray, + InErr, + InDone, + R | In["EncodingServices"] | Out["DecodingServices"] + > + ( + self: Channel.Channel< + Arr.NonEmptyReadonlyArray>, + OutErr, + OutDone, + Arr.NonEmptyReadonlyArray>, + Schema.SchemaError | InErr, + InDone, + R + >, + options: SchemaAST.ParseOptions & Options & { + readonly inputSchema: In + readonly outputSchema: Out + readonly maxFrameSize?: number | undefined + } + ): Channel.Channel< + Arr.NonEmptyReadonlyArray, + Schema.SchemaError | OutErr, + OutDone, + Arr.NonEmptyReadonlyArray, + InErr, + InDone, + R | In["EncodingServices"] | Out["DecodingServices"] + > +} = dual(2, ( + self: Channel.Channel< + Arr.NonEmptyReadonlyArray>, + OutErr, + OutDone, + Arr.NonEmptyReadonlyArray>, + Schema.SchemaError | InErr, + InDone, + R + >, + options: SchemaAST.ParseOptions & Options & { + readonly inputSchema: In + readonly outputSchema: Out + readonly maxFrameSize?: number | undefined + } +): Channel.Channel< + Arr.NonEmptyReadonlyArray, + Schema.SchemaError | OutErr, + OutDone, + Arr.NonEmptyReadonlyArray, + InErr, + InDone, + R | In["EncodingServices"] | Out["DecodingServices"] +> => + encode(options.inputSchema, options)().pipe( + Channel.pipeTo(self), + Channel.pipeTo(decode(options.outputSchema, options)()) + )) + +/** + * Assigns an explicit wire field id to a struct property. + * + * Use this to preserve the wire id across a rename or resolve a hash collision. + * Valid ids are integers from 1 through 4294967295. + * + * **Example** + * + * ```ts + * import { Schema } from "effect" + * import { SchemaBinary } from "effect/unstable/encoding" + * + * const Person = Schema.Struct({ + * id: Schema.String.pipe(SchemaBinary.fieldId(1)) + * }) + * ``` + * + * @category annotations + * @since 4.0.0 + */ +export function fieldId(id: number) { + if (!Number.isInteger(id) || id <= 0 || id > 0xFFFFFFFF) { + throw new Error(`Binary layout field id must be an integer in [1, 4294967295], got ${id}`) + } + const annotations = { [FIELD_ID_ANNOTATION_KEY]: id } + const annotateLastLink = SchemaAST.applyToLastLink((ast) => SchemaAST.annotateKey(ast, annotations)) + return (self: S): S["Rebuild"] => + self.rebuild(annotateLastLink(SchemaAST.annotateKey(self.ast, annotations))) +} + +const FIELD_ID_ANNOTATION_KEY = "~effect/encoding/SchemaBinary/fieldId" + +// Bit 0 selects fingerprint mode; all other flag bits are reserved. +const ENVELOPE = 0x20 // version nibble 2, flags 0 +const ENVELOPE_FINGERPRINT = 0x21 // version nibble 2, flag bit 0 + +const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER) +const BIGINT_ZERO = BigInt(0) +const BIGINT_ONE = BigInt(1) +const BIGINT_TWO = BigInt(2) +const BIGINT_SEVEN = BigInt(7) +const BIGINT_VARINT_MASK = BigInt(0x7F) +const BIGINT_NANOS_PER_MILLI = BigInt(1_000_000) +const BIGINT_BYTE_MASK = BigInt(0xFF) +const BIGINT_U32_MASK = BigInt(0xFFFFFFFF) +const BIGINT_THIRTY_TWO = BigInt(32) + +const utf8Encode = new TextEncoder() +const utf8DecodeFatal = new TextDecoder("utf-8", { fatal: true }) + +// General numbers use up to seven varint bytes, a varint mantissa with a +// decimal scale byte, or an eight-byte f64. +const NUMBER_VARINT_MAX_BYTES = 7 + +const NUMBER_VARINT_MAX_MAGNITUDE = 281_474_976_710_655 // 2 ** 48 - 1 + +// Above this magnitude, build the sign-magnitude code with bigint. +const EXACT_MAGNITUDE_MAX = 4_503_599_627_370_495 // 2 ** 52 - 1 + +const NUMBER_RUN_F64 = 0 +const NUMBER_RUN_VARINT = 1 +const NUMBER_RUN_DECIMAL = 2 + +// Struct field tags pack the field id with enough information to skip the +// payload without consulting the schema. Decimal is a sign-magnitude mantissa +// varint followed by an unsigned scale varint. +const FIELD_WIRE_SIZED = 0 +const FIELD_WIRE_VARINT = 1 +const FIELD_WIRE_FIXED64 = 2 +const FIELD_WIRE_FALSE = 3 +const FIELD_WIRE_TRUE = 4 +const FIELD_WIRE_DECIMAL = 5 +const FIELD_WIRE_FIXED32 = 6 +const FIELD_WIRE_EMPTY = 7 +const FIELD_WIRE_FACTOR = 8 + +function isVarintNumber(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && + value >= -NUMBER_VARINT_MAX_MAGNITUDE && value <= NUMBER_VARINT_MAX_MAGNITUDE +} + +// Short decimals ride the varint path as mantissa + one scale byte. The +// mantissa is capped at six varint bytes so the total stays under the +// eight-byte f64 discriminator. +const DECIMAL_SCALE_MAX = 8 +const DECIMAL_MANTISSA_MAX = 2_199_023_255_551 // 2 ** 41 - 1 + +const POW10 = [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000] + +// The scale that makes `value` an exact short-decimal mantissa, or 0. +function decimalScale(value: number): number { + for (let scale = 1; scale <= DECIMAL_SCALE_MAX; scale++) { + const mantissa = Math.round(value * POW10[scale]) + // The mantissa only grows with the scale, so once it leaves the range no + // later scale can fit; this also rejects NaN and the infinities in one + // comparison. + if (!(mantissa >= -DECIMAL_MANTISSA_MAX && mantissa <= DECIMAL_MANTISSA_MAX)) return 0 + if (mantissa / POW10[scale] === value) return scale + } + return 0 +} + +// Sign-magnitude preserves `-0`, unlike zigzag. +function decodeSignMagnitude(code: number): number { + const magnitude = Math.floor(code / 2) + return code % 2 === 1 ? -magnitude : magnitude +} + +const K = { + bool: 1, + null: 2, + undefined: 3, + number: 4, + string: 5, + bytes: 6, + bigint: 7, + int64: 8, + struct: 9, + variant: 10, + array: 11, + option: 12, + result: 13, + duration: 14, + bigDecimal: 15, + dateTimeZoned: 16, + json: 17, + exit: 18, + cause: 19, + causeReason: 20 +} as const + +function fnv32(bytes: ArrayLike): number { + let hash = 0x811C9DC5 + for (let i = 0; i < bytes.length; i++) { + hash = Math.imul(hash ^ bytes[i], 0x01000193) + } + return hash >>> 0 +} + +const FNV64_OFFSET_BASIS = BigInt("14695981039346656037") +const FNV64_PRIME = BigInt("1099511628211") +const FNV64_MASK = BigInt("18446744073709551615") + +function fnv64(bytes: ArrayLike): bigint { + let hash = FNV64_OFFSET_BASIS + for (let i = 0; i < bytes.length; i++) { + hash = ((hash ^ BigInt(bytes[i])) * FNV64_PRIME) & FNV64_MASK + } + return hash +} + +function pushBytes(out: Array, bytes: ArrayLike) { + for (let i = 0; i < bytes.length; i++) out.push(bytes[i]) +} + +function pushUvarint(out: Array, n: number) { + while (n > 0x7F) { + out.push((n & 0x7F) | 0x80) + n = Math.floor(n / 128) + } + out.push(n) +} + +function pushU32(out: Array, n: number) { + out.push(n & 0xFF, (n >>> 8) & 0xFF, (n >>> 16) & 0xFF, (n >>> 24) & 0xFF) +} + +function pushU64(out: Array, n: bigint) { + for (let i = 0; i < 8; i++) { + out.push(Number((n >> BigInt(i * 8)) & BIGINT_BYTE_MASK)) + } +} + +function compareBytes(a: Uint8Array, b: Uint8Array): number { + const len = Math.min(a.length, b.length) + for (let i = 0; i < len; i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return a.length - b.length +} + +class IssueError extends Error { + readonly issue: SchemaIssue.Issue + constructor(issue: SchemaIssue.Issue) { + super("SchemaBinary failure") + this.issue = issue + } +} + +function invalid(expected: string, input?: unknown, options?: SchemaAST.ParseOptions): never { + throw issueError(new SchemaIssue.InvalidValue({ expected }, input, options)) +} + +// Materialize the ambient path only when raising an issue. +const issuePath: Array = [] +let issuePathLen = 0 + +function issueError(issue: SchemaIssue.Issue): IssueError { + return new IssueError( + issuePathLen === 0 ? issue : new SchemaIssue.Pointer(issuePath.slice(0, issuePathLen), issue) + ) +} + +function uvarintBytes(n: number): Uint8Array { + const out = new Uint8Array(uvarintSize(n)) + let p = 0 + while (n > 0x7F) { + out[p++] = (n & 0x7F) | 0x80 + n = Math.floor(n / 128) + } + out[p] = n + return out +} + +function uvarintSize(n: number): number { + let size = 1 + while (n > 0x7F) { + n = Math.floor(n / 128) + size++ + } + return size +} + +// Avoid TextDecoder's fixed cost for short strings. +const UTF8_INLINE_LIMIT = 32 + +function decodeUtf8( + buf: Uint8Array, + start: number, + end: number, + options: SchemaAST.ParseOptions | undefined +): string { + const len = end - start + if (len === 0) return "" + if (len <= UTF8_INLINE_LIMIT) { + const ascii = decodeAscii(buf, start, end) + if (ascii !== undefined) return ascii + } + try { + return utf8DecodeFatal.decode(buf.subarray(start, end)) + } catch { + invalid("utf-8", undefined, options) + } +} + +// Sixteen and eight code unit blocks, checking their high bits together and +// giving up on the first non-ASCII byte, then one call for the 0-7 tail. +function decodeAscii(buf: Uint8Array, start: number, end: number): string | undefined { + let out = "" + let i = start + for (; i + 16 <= end; i += 16) { + const a = buf[i], b = buf[i + 1], c = buf[i + 2], d = buf[i + 3] + const e = buf[i + 4], f = buf[i + 5], g = buf[i + 6], h = buf[i + 7] + const a2 = buf[i + 8], b2 = buf[i + 9], c2 = buf[i + 10], d2 = buf[i + 11] + const e2 = buf[i + 12], f2 = buf[i + 13], g2 = buf[i + 14], h2 = buf[i + 15] + if ((a | b | c | d | e | f | g | h | a2 | b2 | c2 | d2 | e2 | f2 | g2 | h2) > 0x7F) return undefined + out += String.fromCharCode(a, b, c, d, e, f, g, h, a2, b2, c2, d2, e2, f2, g2, h2) + } + if (i + 8 <= end) { + const a = buf[i], b = buf[i + 1], c = buf[i + 2], d = buf[i + 3] + const e = buf[i + 4], f = buf[i + 5], g = buf[i + 6], h = buf[i + 7] + if ((a | b | c | d | e | f | g | h) > 0x7F) return undefined + out += String.fromCharCode(a, b, c, d, e, f, g, h) + i += 8 + } + switch (end - i) { + case 0: + return out + case 1: { + const a = buf[i] + if (a > 0x7F) return undefined + return out + String.fromCharCode(a) + } + case 2: { + const a = buf[i], b = buf[i + 1] + if ((a | b) > 0x7F) return undefined + return out + String.fromCharCode(a, b) + } + case 3: { + const a = buf[i], b = buf[i + 1], c = buf[i + 2] + if ((a | b | c) > 0x7F) return undefined + return out + String.fromCharCode(a, b, c) + } + case 4: { + const a = buf[i], b = buf[i + 1], c = buf[i + 2], d = buf[i + 3] + if ((a | b | c | d) > 0x7F) return undefined + return out + String.fromCharCode(a, b, c, d) + } + case 5: { + const a = buf[i], b = buf[i + 1], c = buf[i + 2], d = buf[i + 3], e = buf[i + 4] + if ((a | b | c | d | e) > 0x7F) return undefined + return out + String.fromCharCode(a, b, c, d, e) + } + case 6: { + const a = buf[i], b = buf[i + 1], c = buf[i + 2], d = buf[i + 3] + const e = buf[i + 4], f = buf[i + 5] + if ((a | b | c | d | e | f) > 0x7F) return undefined + return out + String.fromCharCode(a, b, c, d, e, f) + } + default: { + const a = buf[i], b = buf[i + 1], c = buf[i + 2], d = buf[i + 3] + const e = buf[i + 4], f = buf[i + 5], g = buf[i + 6] + if ((a | b | c | d | e | f | g) > 0x7F) return undefined + return out + String.fromCharCode(a, b, c, d, e, f, g) + } + } +} + +const OUTPUT_ARENA_SIZE = 64 * 1024 + +// Bound attacker-controlled keys retained between parser feeds. +const PARSER_INDEX_SIGNATURE_CACHE_SIZE = 256 + +// Bound the last universal record shape retained by a compiled layout. +const ENCODE_INDEX_SIGNATURE_CACHE_KEYS = 512 +const ENCODE_INDEX_SIGNATURE_CACHE_CODE_UNITS = 16 * 1024 + +interface OutputArena { + readonly buf: Uint8Array + readonly view: DataView + offset: number + writing: boolean +} + +function makeOutputArena(size: number): OutputArena { + const buf: Uint8Array = new Uint8Array(size) + return { buf, view: new DataView(buf.buffer), offset: 0, writing: false } +} + +let outputArena = makeOutputArena(OUTPUT_ARENA_SIZE) + +class Writer { + buf: Uint8Array = new Uint8Array(0) + view = new DataView(this.buf.buffer) + arena: OutputArena | undefined + start = 0 + limit = 0 + len = 0 + reset() { + let arena = outputArena + // Nested codecs cannot share the active arena tail. + if (arena.writing || arena.offset >= arena.buf.length) { + arena = outputArena = makeOutputArena(OUTPUT_ARENA_SIZE) + } + arena.writing = true + this.arena = arena + this.buf = arena.buf + this.view = arena.view + this.start = arena.offset + this.limit = arena.buf.length - arena.offset + this.len = 0 + } + ensure(n: number) { + if (this.len + n > this.limit) { + const previous = this.arena! + const required = this.len + n + let size = OUTPUT_ARENA_SIZE + while (size < required) size *= 2 + const next = makeOutputArena(size) + next.writing = true + next.buf.set(this.buf.subarray(this.start, this.start + this.len)) + previous.writing = false + this.arena = outputArena = next + this.buf = next.buf + this.view = next.view + this.start = 0 + this.limit = next.buf.length + } + } + byte(b: number) { + this.ensure(1) + this.buf[this.start + this.len++] = b + } + bytes(b: Uint8Array) { + this.ensure(b.length) + this.buf.set(b, this.start + this.len) + this.len += b.length + } + uvarint(n: number) { + this.ensure(10) + const buf = this.buf + let p = this.start + this.len + if (n < 0x80) { + buf[p++] = n + } else { + buf[p++] = (n & 0x7F) | 0x80 + if (n < 0x4000) { + buf[p++] = n >>> 7 + } else { + buf[p++] = ((n >>> 7) & 0x7F) | 0x80 + n = Math.floor(n / 0x4000) + while (n > 0x7F) { + buf[p++] = (n & 0x7F) | 0x80 + n = Math.floor(n / 128) + } + buf[p++] = n + } + } + this.len = p - this.start + } + uvarintBig(n: bigint) { + while (n > BIGINT_VARINT_MASK) { + this.byte(Number(n & BIGINT_VARINT_MASK) | 0x80) + n >>= BIGINT_SEVEN + } + this.byte(Number(n)) + } + zigzag(n: bigint) { + this.uvarintBig(n >= BIGINT_ZERO ? n << BIGINT_ONE : (-n << BIGINT_ONE) - BIGINT_ONE) + } + numberVarint(n: number) { + const negative = n < 0 || (n === 0 && 1 / n < 0) + const magnitude = negative ? -n : n + if (magnitude <= EXACT_MAGNITUDE_MAX) { + this.uvarint(magnitude * 2 + (negative ? 1 : 0)) + } else { + this.uvarintBig(BigInt(magnitude) * BIGINT_TWO + (negative ? BIGINT_ONE : BIGINT_ZERO)) + } + } + f64(n: number) { + this.ensure(8) + this.view.setFloat64(this.start + this.len, n, true) + this.len += 8 + } + i64(n: bigint) { + this.ensure(8) + this.view.setBigInt64(this.start + this.len, n, true) + this.len += 8 + } + u32le(n: number) { + this.ensure(4) + this.view.setUint32(this.start + this.len, n >>> 0, true) + this.len += 4 + } + i32le(n: number) { + this.ensure(4) + this.view.setInt32(this.start + this.len, n | 0, true) + this.len += 4 + } + // Writes a field id and reserves its length byte under one bounds check. + idAndMark(idBytes: Uint8Array): number { + const n = idBytes.length + this.ensure(n + 1) + const buf = this.buf + let p = this.start + this.len + for (let i = 0; i < n; i++) buf[p++] = idBytes[i] + const mark = p - this.start + this.len = mark + 1 + return mark + } + // Reserve one byte, then expand and backfill the length prefix if needed. + beginSized(): number { + this.ensure(1) + return this.len++ + } + endSized(mark: number) { + const code = this.len - mark - 1 + if (code < 0x80) this.buf[this.start + mark] = code + else this.growSized(mark, code) + } + // Row-run regions prefix `length * 2`; odd codes are back-references. + endSizedRun(mark: number) { + const code = (this.len - mark - 1) * 2 + if (code < 0x80) this.buf[this.start + mark] = code + else this.growSized(mark, code) + } + private growSized(mark: number, code: number) { + const size = uvarintSize(code) + const extra = size - 1 + this.ensure(extra) + const absoluteMark = this.start + mark + this.buf.copyWithin(absoluteMark + size, absoluteMark + 1, this.start + this.len) + this.len += extra + let n = code + let p = absoluteMark + while (n > 0x7F) { + this.buf[p++] = (n & 0x7F) | 0x80 + n = Math.floor(n / 128) + } + this.buf[p] = n + } + string(s: string) { + const n = s.length + if (n === 0) return + this.ensure(n * 3) + const buf = this.buf + let p = this.start + this.len + let i = 0 + for (; i + 4 <= n; i += 4) { + const a = s.charCodeAt(i) + const b = s.charCodeAt(i + 1) + const c = s.charCodeAt(i + 2) + const d = s.charCodeAt(i + 3) + if ((a | b | c | d) > 0x7F) { + this.len += utf8Encode.encodeInto(s, buf.subarray(this.start + this.len)).written + return + } + buf[p++] = a + buf[p++] = b + buf[p++] = c + buf[p++] = d + } + for (; i < n; i++) { + const c = s.charCodeAt(i) + if (c > 0x7F) { + this.len += utf8Encode.encodeInto(s, buf.subarray(this.start + this.len)).written + return + } + buf[p++] = c + } + this.len = p - this.start + } + out(): Uint8Array { + const arena = this.arena! + const out = new Uint8Array(arena.buf.buffer, this.start, this.len) + if (arena === outputArena) arena.offset = this.start + this.len + arena.writing = false + this.arena = undefined + return out + } + abort() { + const arena = this.arena + if (arena === undefined) return + if (arena === outputArena) arena.offset = this.start + arena.writing = false + this.arena = undefined + } +} + +// Index-signature predicates are not part of the fingerprint, so unmatched +// keys are dropped in both modes. +// A plain string parameter accepts every key without running the matcher. +function matchesEveryKey(parameter: SchemaAST.AST): boolean { + const ast = SchemaAST.toEncoded(parameter) + return ast._tag === "String" && ast.checks === undefined +} + +function matchIndexSignature( + layout: StructLayout, + key: string, + options: SchemaAST.ParseOptions +): ExtraSignature | undefined { + return layout.extra.find((s) => SchemaAST.getIndexSignatureKeys({ [key]: null }, s.parameter, options).length > 0) +} + +class IndexSignatureCache { + entries = new WeakMap>() + readonly orderLayouts: Array | undefined + readonly orderKeys: Array | undefined + readonly options: SchemaAST.ParseOptions + size = 0 + next = 0 + replace = false + // Only the parser cache outlives one operation, and it is always bounded. + constructor(options: SchemaAST.ParseOptions, capacity?: number) { + this.options = options + this.orderLayouts = capacity === undefined ? undefined : new Array(capacity) + this.orderKeys = capacity === undefined ? undefined : new Array(capacity) + } + beginFrame() { + this.replace = true + } + find(layout: StructLayout, key: string): ExtraSignature | undefined { + let entries = this.entries.get(layout) + if (entries === undefined) { + entries = new Map() + this.entries.set(layout, entries) + } + if (entries.has(key)) return entries.get(key) + const signature = matchIndexSignature(layout, key, this.options) + const orderLayouts = this.orderLayouts + if (orderLayouts === undefined) { + entries.set(key, signature) + return signature + } + const orderKeys = this.orderKeys! + if (this.size < orderLayouts.length) { + orderLayouts[this.size] = layout + orderKeys[this.size] = key + this.size++ + entries.set(key, signature) + } else if (this.replace) { + this.replace = false + const evictedLayout = orderLayouts[this.next]! + const evictedEntries = evictedLayout === layout ? entries : this.entries.get(evictedLayout) + evictedEntries?.delete(orderKeys[this.next]!) + orderLayouts[this.next] = layout + orderKeys[this.next] = key + this.next = (this.next + 1) % orderLayouts.length + entries.set(key, signature) + } + return signature + } + clear() { + this.entries = new WeakMap() + this.orderLayouts?.fill(undefined) + this.orderKeys?.fill(undefined) + this.size = this.next = 0 + this.replace = false + } +} + +// A named buffer preserves the build's pure annotation for these placeholders. +const EMPTY_READER_ARRAY_BUFFER = new ArrayBuffer(0) +const EMPTY_READER_BUFFER = new Uint8Array(EMPTY_READER_ARRAY_BUFFER) +const EMPTY_READER_VIEW = new DataView(EMPTY_READER_ARRAY_BUFFER) + +class Reader { + pos = 0 + buf: Uint8Array = EMPTY_READER_BUFFER + // Frames of varints and strings never need a view, so it is built on demand + // and reused while the reader stays on one buffer. + view: DataView | undefined + viewCache: DataView = EMPTY_READER_VIEW + viewBuffer: ArrayBufferLike = EMPTY_READER_ARRAY_BUFFER + viewOffset = 0 + viewLength = 0 + end = 0 + options: SchemaAST.ParseOptions = SchemaAST.defaultParseOptions + indexSignatures: IndexSignatureCache | undefined + positional = false + // Handed to the row-run field's immediate consumer, then cleared. + intern: Array | undefined + // String tables shared by every frame on one connection; `undefined` for a + // one-shot decode, where each frame stands alone. + dict: DictRead | undefined + reset( + buf: Uint8Array, + start: number, + end: number, + options: SchemaAST.ParseOptions, + indexSignatures: IndexSignatureCache | undefined, + positional: boolean, + dict: DictRead | undefined + ) { + this.view = undefined + this.buf = buf + this.pos = start + this.end = end + this.options = options + this.indexSignatures = indexSignatures + this.positional = positional + this.intern = undefined + this.dict = dict + } + release() { + this.pos = this.end = 0 + this.buf = EMPTY_READER_BUFFER + this.view = undefined + this.viewCache = EMPTY_READER_VIEW + this.viewBuffer = EMPTY_READER_ARRAY_BUFFER + this.viewOffset = this.viewLength = 0 + this.options = SchemaAST.defaultParseOptions + this.indexSignatures = undefined + this.positional = false + this.intern = undefined + this.dict = undefined + } + get remaining(): number { + return this.end - this.pos + } + dataView(): DataView { + const buf = this.buf + if ( + buf.buffer !== this.viewBuffer || buf.byteOffset !== this.viewOffset || buf.byteLength !== this.viewLength + ) { + this.viewBuffer = buf.buffer + this.viewOffset = buf.byteOffset + this.viewLength = buf.byteLength + this.viewCache = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) + } + return this.view = this.viewCache + } + byte(): number { + if (this.pos >= this.end) invalid("complete value", undefined, this.options) + return this.buf[this.pos++] + } + take(n: number): Uint8Array { + if (this.pos + n > this.end) invalid("complete value", undefined, this.options) + const out = this.buf.subarray(this.pos, this.pos + n) + this.pos += n + return out + } + // Decoded bytes outlive the parser's buffer, so they are copied, not viewed. + takeCopy(n: number): Uint8Array { + if (this.pos + n > this.end) invalid("complete value", undefined, this.options) + const out = this.buf.slice(this.pos, this.pos + n) as Uint8Array + this.pos += n + return out + } + // Decode a bounded child value without allocating another reader. + enter(len: number): number { + if (this.pos + len > this.end) invalid("complete value", undefined, this.options) + const saved = this.end + this.end = this.pos + len + return saved + } + exit(saved: number) { + this.pos = this.end + this.end = saved + } + readUtf8(n: number): string { + if (this.pos + n > this.end) invalid("complete value", undefined, this.options) + const start = this.pos + this.pos += n + return decodeUtf8(this.buf, start, start + n, this.options) + } + // Use bitwise arithmetic for the first four varint groups. + uvarint(): number { + const buf = this.buf + const end = this.end + let pos = this.pos + if (pos >= end) invalid("complete value", undefined, this.options) + let b = buf[pos++] + if (b < 0x80) { + this.pos = pos + return b + } + let value = b & 0x7F + if (pos >= end) invalid("complete value", undefined, this.options) + b = buf[pos++] + value |= (b & 0x7F) << 7 + if (b < 0x80) { + this.pos = pos + return value + } + if (pos >= end) invalid("complete value", undefined, this.options) + b = buf[pos++] + value |= (b & 0x7F) << 14 + if (b < 0x80) { + this.pos = pos + return value + } + if (pos >= end) invalid("complete value", undefined, this.options) + b = buf[pos++] + value |= (b & 0x7F) << 21 + if (b < 0x80) { + this.pos = pos + return value + } + let scale = 268435456 // 2 ** 28 + for (let i = 4; i < 10; i++) { + if (pos >= end) { + this.pos = pos + invalid("complete value", undefined, this.options) + } + b = buf[pos++] + const chunk = (b & 0x7F) * scale + if (chunk > Number.MAX_SAFE_INTEGER - value) { + this.pos = pos + invalid("safe integer length", undefined, this.options) + } + value += chunk + if (b < 0x80) { + this.pos = pos + return value + } + scale *= 128 + } + this.pos = pos + invalid("uvarint", undefined, this.options) + } + // Field tags are capped at 35 bits, so their common five-byte form can skip + // the general safe-integer loop in uvarint. + fieldTag(): number { + const buf = this.buf + const end = this.end + let pos = this.pos + if (pos >= end) invalid("complete value", undefined, this.options) + let b = buf[pos++] + if (b < 0x80) { + this.pos = pos + return b + } + let value = b & 0x7F + if (pos >= end) invalid("complete value", undefined, this.options) + b = buf[pos++] + value |= (b & 0x7F) << 7 + if (b < 0x80) { + this.pos = pos + return value + } + if (pos >= end) invalid("complete value", undefined, this.options) + b = buf[pos++] + value |= (b & 0x7F) << 14 + if (b < 0x80) { + this.pos = pos + return value + } + if (pos >= end) invalid("complete value", undefined, this.options) + b = buf[pos++] + value |= (b & 0x7F) << 21 + if (b < 0x80) { + this.pos = pos + return value + } + if (pos >= end) invalid("complete value", undefined, this.options) + b = buf[pos++] + this.pos = pos + if (b >= 0x80) invalid("uvarint", undefined, this.options) + return value + b * 268435456 + } + uvarintBig(): bigint { + let value = BIGINT_ZERO + let shift = BIGINT_ZERO + while (true) { + const b = this.byte() + value |= BigInt(b & 0x7F) << shift + if ((b & 0x80) === 0) return value + shift += BIGINT_SEVEN + } + } + // Wider schema-proven integers switch to bigint arithmetic. + numberVarint(): number { + const buf = this.buf + const end = this.end + let pos = this.pos + let value = 0 + let scale = 1 + for (let i = 0; i < NUMBER_VARINT_MAX_BYTES; i++) { + if (pos >= end) { + this.pos = pos + invalid("complete value", undefined, this.options) + } + const b = buf[pos++] + value += (b & 0x7F) * scale + if (b < 0x80) { + this.pos = pos + return decodeSignMagnitude(value) + } + scale *= 128 + } + const code = this.uvarintBig() + const magnitude = Number(code >> BIGINT_ONE) + if (!Number.isFinite(magnitude)) invalid("safe integer length", undefined, this.options) + return (code & BIGINT_ONE) === BIGINT_ONE ? -magnitude : magnitude + } + zigzag(): bigint { + const u = this.uvarintBig() + return (u & BIGINT_ONE) === BIGINT_ONE + ? -((u + BIGINT_ONE) >> BIGINT_ONE) + : u >> BIGINT_ONE + } + f64(): number { + if (this.pos + 8 > this.end) invalid("complete value", undefined, this.options) + const value = (this.view ?? this.dataView()).getFloat64(this.pos, true) + this.pos += 8 + return value + } + i64(): bigint { + if (this.pos + 8 > this.end) invalid("complete value", undefined, this.options) + const value = (this.view ?? this.dataView()).getBigInt64(this.pos, true) + this.pos += 8 + return value + } + u32le(): number { + if (this.pos + 4 > this.end) invalid("complete value", undefined, this.options) + const value = (this.view ?? this.dataView()).getUint32(this.pos, true) + this.pos += 4 + return value + } + i32le(): number { + if (this.pos + 4 > this.end) invalid("complete value", undefined, this.options) + const value = (this.view ?? this.dataView()).getInt32(this.pos, true) + this.pos += 4 + return value + } +} + +type LeafKind = + | "bool" + | "null" + | "undefined" + | "number" + | "int" + | "string" + | "symbol" + | "bytes" + | "bigint" + | "json" + | "duration" + | "bigDecimal" + | "dateTimeZoned" + +type Layout = + | LeafLayout + | LiteralLayout + | { readonly _: "int64"; readonly flavor: "date" | "utc" } + | { readonly _: "never"; readonly ast: SchemaAST.AST } + | StructLayout + | ArrayLayout + | UnionLayout + | { readonly _: "option"; value: Layout } + | { readonly _: "result"; success: Layout; failure: Layout } + | { readonly _: "exit"; value: Layout; cause: ReasonLayout } + | ReasonLayout + +// Every leaf carries a `slot`, so they all share one shape and the layout +// switch stays monomorphic on them. Only strings use it: it indexes their table +// in a connection dictionary, and both ends compile the same schema in the same +// order, so they number the leaves the same way. +interface LeafLayout { + readonly _: LeafKind + readonly slot: number +} + +// Literal values are validated here rather than by a downstream schema pass. +interface LiteralLayout { + readonly _: "literal" + readonly leaf: Layout + readonly values: ReadonlyArray + // Larger literal sets (enums) check membership in a set instead of a scan. + readonly valueSet: ReadonlySet | undefined +} + +function literalLayout(leaf: Layout, values: ReadonlyArray): LiteralLayout { + return { _: "literal", leaf, values, valueSet: values.length > 4 ? new Set(values) : undefined } +} + +function hasLiteral(layout: LiteralLayout, value: unknown): boolean { + const values = layout.values + return layout.valueSet !== undefined + ? layout.valueSet.has(value as SchemaAST.LiteralValue) + : values.length === 1 + ? value === values[0] + : values.includes(value as SchemaAST.LiteralValue) +} + +function literalExpected(layout: LiteralLayout): string { + return layout.values.map((value) => typeof value === "string" ? JSON.stringify(value) : String(value)).join(" | ") +} + +interface ReasonLayout { + readonly _: "cause" | "causeReason" + error: Layout + defect: Layout +} + +interface Field { + readonly name: string + readonly id: number + readonly tagBytes: Uint8Array + index: number + readonly optional: boolean + readonly annotations: Schema.Annotations.Key | undefined + layout: Layout + wireMask: number + inline: boolean +} + +interface ExtraSignature { + readonly parameter: SchemaAST.IndexSignature["parameter"] + layout: Layout + wireMask: number +} + +interface StructLayout { + readonly _: "struct" + readonly ast: SchemaAST.AST + readonly fields: Array + readonly byId: Map + readonly extra: Array + // The lone signature every key matches, so lookups skip the cache. + readonly extraAll: ExtraSignature | undefined + readonly names: Set + // Reuse the last universal index-signature shape across encode calls. + extraShape: ExtraShape | undefined + optionalCount: number + // Required field indices below 32 as one comparison; wider structs with + // required fields past that keep the per-field check. + requiredMask: number + requiredWide: boolean +} + +interface Slot { + readonly optional: boolean + layout: Layout +} + +interface ArrayLayout { + readonly _: "array" + readonly ast: SchemaAST.AST + readonly elements: Array + readonly rest: Array + readonly hasCount: boolean + readonly minCount: number + // Shared layout for `Schema.Array(S)`. + uniform: Layout | undefined + uniformInline: boolean + uniformPacked: number | undefined + uniformNumbers: boolean + // Lazily compiled row-run plan; `null` once known to be unavailable. + run: RunPlan | null | undefined +} + +// Struct rows repeated in one array share their field ids and repeated strings. +interface RunPlan { + readonly struct: StructLayout + // Per field index: INTERN_NONE / SELF / ELEMENTS / KEYS. + readonly intern: Array + readonly shapes: boolean + readonly byName: Map | undefined +} + +const INTERN_NONE = 0 +const INTERN_SELF = 1 +const INTERN_ELEMENTS = 2 +const INTERN_KEYS = 3 + +// Rows carry a presence mask over fields plus one bit for the extra block. +const RUN_MAX_FIELDS = 30 +const RUN_EXTRA_BIT = 1 << RUN_MAX_FIELDS + +interface VariantRow { + readonly tag: number + readonly sentinels: ReadonlyArray + readonly tuple: boolean + payload: Layout + position: number +} + +interface UnionMember { + readonly kind: number + readonly layout: Layout + position: number +} + +interface UnionPosition { + readonly variant: VariantRow | undefined + readonly layout: Layout +} + +// A key every variant pins to its own literal, so encoding picks the variant +// with one lookup instead of scanning. +interface Discriminator { + readonly key: PropertyKey + readonly rows: Map +} + +interface UnionLayout { + readonly _: "union" + readonly ast: SchemaAST.AST + readonly variants: Array + readonly byTag: Map + discriminator: Discriminator | undefined + readonly others: Array + readonly byKind: Map + // Canonical order used by fingerprint mode. + readonly byPos: Array +} + +function isSelfDelimiting(layout: Layout): boolean { + return layout._ === "literal" ? isSelfDelimiting(layout.leaf) : layout._ === "int" +} + +function packedSize(layout: Layout): number | undefined { + switch (layout._) { + case "literal": + return packedSize(layout.leaf) + case "bool": + return 1 + case "int64": + return 8 + default: + return undefined + } +} + +function isInlineSlot(layout: Layout): boolean { + return packedSize(layout) !== undefined || isSelfDelimiting(layout) || + layout._ === "null" || layout._ === "undefined" +} + +// One registry row per natively supported declaration: its wire kind and +// whether the binary layer validates it exactly on its own (`true`) or exactly +// when its type parameters are ("parameters"). `compileDeclaration` holds the +// matching layout constructors; its fallback asserts the two stay in sync. +const natives: Record = { + "effect/schema/Date": { kind: K.int64, exact: true }, + "effect/schema/DateTimeUtc": { kind: K.int64, exact: true }, + "effect/schema/DateTimeZoned": { kind: K.dateTimeZoned, exact: true }, + "effect/schema/Duration": { kind: K.duration, exact: true }, + "effect/schema/BigDecimal": { kind: K.bigDecimal, exact: true }, + "effect/schema/Uint8Array": { kind: K.bytes, exact: true }, + "effect/schema/Option": { kind: K.option, exact: "parameters" }, + "effect/schema/Result": { kind: K.result, exact: "parameters" }, + "effect/schema/Exit": { kind: K.exit, exact: "parameters" }, + "effect/schema/Cause": { kind: K.cause, exact: "parameters" }, + "effect/schema/CauseReason": { kind: K.causeReason, exact: "parameters" } +} + +function representationId(ast: SchemaAST.AST): string | undefined { + const representation = ast.annotations?.representation + return Predicate.isObject(representation) && typeof (representation as { id?: unknown }).id === "string" + ? (representation as { id: string }).id + : undefined +} + +const toBinaryAST = SchemaAST.applyToSelfOrLastLinkEncodingIdempotent((ast) => { + const out = toBinaryASTStep(ast) + const context = ast.context + if (out === ast || context === undefined) return out + return SchemaAST.replaceContextLastLink( + out, + new SchemaAST.Context(context.isOptional, context.isMutable, undefined, context.annotations) + ) +}) + +function toBinaryASTStep(ast: SchemaAST.AST): SchemaAST.AST { + switch (ast._tag) { + case "Declaration": { + const id = representationId(ast) + if ( + id !== undefined && (id in natives || id === "effect/schema/Json" || id === "effect/schema/MutableJson") + ) { + return ast.recur(toBinaryAST) + } + const getJson = ast.annotations?.toCodecJson + const getCodec = ast.annotations?.toCodec + if (!Predicate.isFunction(getJson) && !Predicate.isFunction(getCodec)) { + return ast + } + const typeParameters = ast.typeParameters.map((tp) => Schema.make(SchemaAST.toEncoded(tp))) + const link = (Predicate.isFunction(getJson) ? getJson(typeParameters) : undefined) ?? + (Predicate.isFunction(getCodec) ? getCodec(typeParameters) : undefined) + return link === undefined ? ast : SchemaAST.replaceEncoding(ast, [SchemaAST.mapLink(link, toBinaryAST)]) + } + case "Arrays": + case "Objects": + case "Union": + case "Suspend": + return ast.recur(toBinaryAST) + default: + return ast + } +} + +function sentinelSetHash(sentinels: ReadonlyArray): number { + const sorted = [...sentinels].sort((a, b) => { + const an = typeof a.key === "number" + const bn = typeof b.key === "number" + if (an !== bn) return an ? -1 : 1 + if (an) return (a.key as number) - (b.key as number) + return compareBytes(utf8Encode.encode(a.key as string), utf8Encode.encode(b.key as string)) + }) + const out: Array = [] + for (const sentinel of sorted) { + const keyBytes = utf8Encode.encode(String(sentinel.key)) + out.push(typeof sentinel.key === "number" ? 0 : 1) + pushU32(out, keyBytes.length) + pushBytes(out, keyBytes) + const literal = sentinel.literal + const valueBytes = utf8Encode.encode(sentinelLiteralString(literal)) + out.push(sentinelLiteralKind(literal)) + pushU32(out, valueBytes.length) + pushBytes(out, valueBytes) + } + return fnv32(out) +} + +function sentinelLiteralKind(literal: SchemaAST.LiteralValue | symbol): number { + switch (typeof literal) { + case "string": + return 1 + case "number": + return 2 + case "boolean": + return 3 + case "bigint": + return 4 + default: + return 5 + } +} + +function sentinelLiteralString(literal: SchemaAST.LiteralValue | symbol): string { + if (typeof literal === "symbol") { + const key = globalThis.Symbol.keyFor(literal) + if (key === undefined) { + throw new Error("Binary layout: unregistered unique symbol (Symbol.keyFor)") + } + return key + } + return String(literal) +} + +function parameterHasSymbol(parameter: SchemaAST.AST): boolean { + switch (parameter._tag) { + case "Symbol": + return true + case "Union": + return parameter.types.some(parameterHasSymbol) + default: + return false + } +} + +function isJsonDeclaration(ast: SchemaAST.Declaration): boolean { + const id = representationId(ast) + if (id === "effect/schema/Json" || id === "effect/schema/MutableJson") return true + return Predicate.isFunction(ast.annotations?.toCodecJson) +} + +// Detect integer checks nested inside filter groups. +function provesInteger(ast: SchemaAST.AST): boolean { + const checks = ast.checks + if (checks === undefined) return false + const go = (check: SchemaAST.Check): boolean => { + const id = check.annotations?.representation?.id + if (id === "effect/schema/isInt") return true + return check._tag === "FilterGroup" && check.checks.some(go) + } + return checks.some(go) +} + +function resolveSuspend(ast: SchemaAST.AST): SchemaAST.AST { + while (ast._tag === "Suspend") { + ast = ast.thunk() + } + return ast +} + +function flattenMembers(union: SchemaAST.Union): Array { + const out: Array = [] + const seen = new Set() + const go = (ast: SchemaAST.AST) => { + const resolved = resolveSuspend(ast) + if (seen.has(resolved)) return + seen.add(resolved) + switch (resolved._tag) { + case "Never": + return + case "Union": + resolved.types.forEach(go) + return + case "Enum": + SchemaAST.enumsToLiterals(resolved).types.forEach(go) + return + default: + out.push(resolved) + } + } + union.types.forEach(go) + return out +} + +function literalKind(literal: SchemaAST.LiteralValue): LeafKind { + switch (typeof literal) { + case "string": + return "string" + case "number": + return "number" + case "boolean": + return "bool" + default: + return "bigint" + } +} + +// Classify union members without eagerly compiling recursive members. +function astKind(ast: SchemaAST.AST): number { + switch (ast._tag) { + case "String": + case "TemplateLiteral": + case "Symbol": + return K.string + case "UniqueSymbol": + if (globalThis.Symbol.keyFor(ast.symbol) === undefined) { + throw new Error("Binary layout: unregistered unique symbol (Symbol.keyFor)") + } + return K.string + case "Boolean": + return K.bool + case "Null": + return K.null + case "Undefined": + case "Void": + return K.undefined + case "Number": + return K.number + case "BigInt": + return K.bigint + case "Literal": + switch (typeof ast.literal) { + case "string": + return K.string + case "number": + return K.number + case "boolean": + return K.bool + default: + return K.bigint + } + case "Unknown": + case "Any": + case "ObjectKeyword": + return K.json + case "Objects": + return K.struct + case "Arrays": + return K.array + case "Declaration": { + const id = representationId(ast) + if (id !== undefined && id in natives) return natives[id].kind + if (isJsonDeclaration(ast)) return K.json + throw new Error(`Binary layout: declaration ${id ?? ""} has no toCodecJson or toCodec`) + } + case "Suspend": + return astKind(resolveSuspend(ast)) + default: + throw new Error("Binary layout: union members are not uniquely identifiable") + } +} + +function computeRequired(layout: StructLayout) { + for (const field of layout.fields) { + if (field.optional) continue + if (field.index < 32) layout.requiredMask |= 1 << field.index + else layout.requiredWide = true + } +} + +interface CompiledLayout { + readonly layout: Layout + readonly recursive: boolean + readonly exact: boolean + readonly exitSuccess: boolean +} + +function compileLayout(root: SchemaAST.AST): CompiledLayout { + const exact = isExact(root) + const exitSuccess = !exact && isExitWithExactSuccess(root) + root = SchemaAST.toEncoded(root) + const memo = new Map() + let recursive = false + let stringSlots = 0 + + const leaf = (kind: LeafKind): LeafLayout => ({ _: kind, slot: kind === "string" ? stringSlots++ : 0 }) + + function compile(ast: SchemaAST.AST): Layout { + const hit = memo.get(ast) + if (hit !== undefined) return hit + const layout = go(ast) + memo.set(ast, layout) + return layout + } + + function go(ast: SchemaAST.AST): Layout { + switch (ast._tag) { + case "String": + case "TemplateLiteral": + return leaf("string") + case "Symbol": + case "UniqueSymbol": + return leaf("symbol") + case "Boolean": + return leaf("bool") + case "Null": + return leaf("null") + case "Undefined": + case "Void": + return leaf("undefined") + case "Number": + return leaf(provesInteger(ast) ? "int" : "number") + case "BigInt": + return leaf("bigint") + case "Literal": + return literalLayout(leaf(literalKind(ast.literal)), [ast.literal]) + case "Unknown": + case "Any": + case "ObjectKeyword": + return leaf("json") + case "Never": + return { _: "never", ast } + case "Enum": + return compileUnion(ast, flattenMembers(SchemaAST.enumsToLiterals(ast))) + case "Suspend": { + recursive = true + const layout = compile(ast.thunk()) + memo.set(ast, layout) + return layout + } + case "Objects": + return compileStruct(ast) + case "Arrays": + return compileArray(ast) + case "Union": + return compileUnion(ast, flattenMembers(ast)) + case "Declaration": + return compileDeclaration(ast) + } + } + + function compileStruct(ast: SchemaAST.Objects): StructLayout { + const fields: Array = [] + const types: Array = [] + const idNames = new Map>() + for (const ps of ast.propertySignatures) { + if (typeof ps.name === "symbol") { + throw new Error("Binary layout: symbol property names are illegal") + } + const name = String(ps.name) + const annotations = ps.type.context?.annotations + const explicit = annotations?.[FIELD_ID_ANNOTATION_KEY] + const id = typeof explicit === "number" ? explicit : fnv32(utf8Encode.encode(name)) + if (!Number.isInteger(id) || id < 0 || id > 0xFFFFFFFF) { + throw new Error(`Binary layout: illegal field id for ${name}`) + } + const names = idNames.get(id) + if (names === undefined) idNames.set(id, [name]) + else names.push(name) + fields.push({ + name, + id, + tagBytes: uvarintBytes(id * FIELD_WIRE_FACTOR + FIELD_WIRE_SIZED), + index: 0, + optional: ps.type.context?.isOptional === true, + annotations, + layout: undefined as unknown as Layout, + wireMask: 0, + inline: false + }) + types.push(ps.type) + } + for (const [id, names] of idNames) { + if (id === 0 || names.length > 1) { + throw new Error(`Binary layout field id collision: ${id} (${names.join(", ")})`) + } + } + const extra: Array = [] + for (const is of ast.indexSignatures) { + if (parameterHasSymbol(is.parameter)) { + throw new Error("Binary layout: symbol property names are illegal") + } + extra.push({ parameter: is.parameter, layout: undefined as unknown as Layout, wireMask: 0 }) + } + const layout: StructLayout = { + _: "struct", + ast, + fields, + byId: new Map(), + extra, + extraAll: extra.length === 1 && matchesEveryKey(extra[0].parameter) ? extra[0] : undefined, + names: new Set(fields.map((f) => f.name)), + extraShape: undefined, + optionalCount: 0, + requiredMask: 0, + requiredWide: false + } + memo.set(ast, layout) + for (let i = 0; i < fields.length; i++) { + const field = fields[i] + field.layout = compile(types[i]) + field.wireMask = fieldWireMask(field.layout) + // Recursive placeholders already have the discriminant used here. + field.inline = isInlineSlot(field.layout) + if (field.optional) layout.optionalCount++ + layout.byId.set(field.id, field) + } + fields.sort((a, b) => a.id - b.id) + for (let i = 0; i < fields.length; i++) fields[i].index = i + computeRequired(layout) + for (let i = 0; i < extra.length; i++) { + extra[i].layout = compile(ast.indexSignatures[i].type) + extra[i].wireMask = fieldWireMask(extra[i].layout) + } + return layout + } + + function compileArray(ast: SchemaAST.Arrays): ArrayLayout { + const hasCount = ast.rest.length > 0 || ast.elements.some((e) => e.context?.isOptional === true) + const requiredElements = ast.elements.filter((e) => e.context?.isOptional !== true).length + const tailLen = Math.max(0, ast.rest.length - 1) + const layout: ArrayLayout = { + _: "array", + ast, + elements: [], + rest: [], + hasCount, + minCount: requiredElements + tailLen, + uniform: undefined, + uniformInline: false, + uniformPacked: undefined, + uniformNumbers: false, + run: undefined + } + memo.set(ast, layout) + for (const element of ast.elements) { + layout.elements.push({ + optional: element.context?.isOptional === true, + layout: compile(element) + }) + } + for (const rest of ast.rest) { + layout.rest.push(compile(rest)) + } + if (layout.elements.length === 0 && layout.rest.length === 1) { + const slot = layout.rest[0] + layout.uniform = slot + layout.uniformPacked = packedSize(slot) + layout.uniformNumbers = slot._ === "number" + layout.uniformInline = isInlineSlot(slot) + } + return layout + } + + function compileDeclaration(ast: SchemaAST.Declaration): Layout { + const id = representationId(ast) + switch (id) { + case "effect/schema/Date": + return { _: "int64", flavor: "date" } + case "effect/schema/DateTimeUtc": + return { _: "int64", flavor: "utc" } + case "effect/schema/DateTimeZoned": + return leaf("dateTimeZoned") + case "effect/schema/Duration": + return leaf("duration") + case "effect/schema/BigDecimal": + return leaf("bigDecimal") + case "effect/schema/Uint8Array": + return leaf("bytes") + } + const tps = ast.typeParameters + switch (id) { + case "effect/schema/Option": { + const layout = { _: "option" as const, value: undefined as unknown as Layout } + memo.set(ast, layout) + layout.value = compile(tps[0]) + return layout + } + case "effect/schema/Result": { + const layout = { + _: "result" as const, + success: undefined as unknown as Layout, + failure: undefined as unknown as Layout + } + memo.set(ast, layout) + layout.success = compile(tps[0]) + layout.failure = compile(tps[1]) + return layout + } + case "effect/schema/Exit": { + const cause: ReasonLayout = { + _: "cause", + error: undefined as unknown as Layout, + defect: undefined as unknown as Layout + } + const layout = { _: "exit" as const, value: undefined as unknown as Layout, cause } + memo.set(ast, layout) + layout.value = compile(tps[0]) + cause.error = compile(tps[1]) + cause.defect = compile(tps[2]) + return layout + } + case "effect/schema/Cause": + case "effect/schema/CauseReason": { + const layout: ReasonLayout = { + _: id === "effect/schema/Cause" ? "cause" : "causeReason", + error: undefined as unknown as Layout, + defect: undefined as unknown as Layout + } + memo.set(ast, layout) + layout.error = compile(tps[0]) + layout.defect = compile(tps[1]) + return layout + } + } + if (id !== undefined && id in natives) { + throw new Error(`Binary layout: native declaration ${id} has no layout constructor`) + } + if (isJsonDeclaration(ast)) return leaf("json") + throw new Error(`Binary layout: declaration ${id ?? ""} has no toCodecJson or toCodec`) + } + + function compileUnion(ast: SchemaAST.AST, members: Array): Layout { + if (members.length === 0) return { _: "never", ast } + const variantMembers: Array<{ member: SchemaAST.AST; sentinels: ReadonlyArray }> = [] + const rowMembers: Array<{ member: SchemaAST.AST; kind: number }> = [] + const literalRows = new Map() + const literalValues = new Map>() + const addLiteralRow = (kind: number, row: Layout) => { + const existing = literalRows.get(kind) + if (existing !== undefined && existing._ !== row._) { + throw new Error("Binary layout: union members are not uniquely identifiable") + } + literalRows.set(kind, row) + } + for (const member of members) { + if (member._tag === "Literal") { + const kind = astKind(member) + const values = literalValues.get(kind) + if (values === undefined) { + literalValues.set(kind, [member.literal]) + } else if (!values.includes(member.literal)) { + values.push(member.literal) + } + continue + } + if (member._tag === "UniqueSymbol") { + astKind(member) // validates the symbol is registered + addLiteralRow(K.string, leaf("symbol")) + continue + } + if (member._tag === "Objects" || member._tag === "Arrays") { + const sentinels = SchemaAST.collectSentinels(member) + if (sentinels !== undefined && sentinels.length > 0) { + for (const sentinel of sentinels) { + if (typeof sentinel.key === "symbol") { + throw new Error("Binary layout: symbol property names are illegal") + } + } + variantMembers.push({ member, sentinels }) + continue + } + } + rowMembers.push({ member, kind: astKind(member) }) + } + for (const [kind, values] of literalValues) { + addLiteralRow(kind, literalLayout(leaf(literalKind(values[0])), values)) + } + if (variantMembers.length === 0 && rowMembers.length === 0 && literalRows.size === 1) { + return literalRows.values().next().value! + } + const kinds = new Set(literalRows.keys()) + for (const { kind } of rowMembers) { + if (kinds.has(kind)) { + throw new Error("Binary layout: union members are not uniquely identifiable") + } + kinds.add(kind) + } + if (variantMembers.length === 0 && literalRows.size === 0 && rowMembers.length === 1) { + const layout = compile(rowMembers[0].member) + memo.set(ast, layout) + return layout + } + const tags = new Map>() + const variantTags: Array = [] + for (const { sentinels } of variantMembers) { + const tag = sentinelSetHash(sentinels) + if (tags.has(tag)) { + throw new Error(`Binary layout sentinel collision: ${tag}`) + } + tags.set(tag, sentinels) + variantTags.push(tag) + } + const layout: UnionLayout = { + _: "union", + ast, + variants: [], + byTag: new Map(), + discriminator: undefined, + others: [], + byKind: new Map(), + byPos: [] + } + memo.set(ast, layout) + for (let v = 0; v < variantMembers.length; v++) { + const { member, sentinels } = variantMembers[v] + const tag = variantTags[v] + const full = compile(member) + let payload: Layout + let tuple: boolean + if (member._tag === "Objects") { + const struct = full as StructLayout + const sentinelNames = new Set(sentinels.map((s) => String(s.key))) + const fields = struct.fields.filter((f) => !sentinelNames.has(f.name)) + const payloadStruct: StructLayout = { + _: "struct", + ast: struct.ast, + fields, + byId: new Map(fields.map((f) => [f.id, f])), + extra: struct.extra, + extraAll: struct.extraAll, + names: struct.names, + extraShape: undefined, + optionalCount: fields.reduce((count, f) => f.optional ? count + 1 : count, 0), + requiredMask: 0, + requiredWide: false + } + computeRequired(payloadStruct) + payload = payloadStruct + tuple = false + } else { + payload = full + tuple = true + } + const row: VariantRow = { tag, sentinels, tuple, payload, position: 0 } + layout.variants.push(row) + layout.byTag.set(tag, row) + } + for (const [kind, row] of literalRows) { + layout.others.push({ kind, layout: row, position: 0 }) + layout.byKind.set(kind, row) + } + for (const { kind, member } of rowMembers) { + const row = compile(member) + layout.others.push({ kind, layout: row, position: 0 }) + layout.byKind.set(kind, row) + } + // Fingerprint positions are independent of declaration order. + for (const row of [...layout.variants].sort((a, b) => a.tag - b.tag)) { + row.position = layout.byPos.length + layout.byPos.push({ variant: row, layout: row.payload }) + } + for (const member of [...layout.others].sort((a, b) => a.kind - b.kind)) { + member.position = layout.byPos.length + layout.byPos.push({ variant: undefined, layout: member.layout }) + } + // Probe specific runtime guards before `json`, which matches anything. + layout.others.sort((a, b) => matchRank(a.layout) - matchRank(b.layout)) + layout.discriminator = findDiscriminator(layout.variants) + return layout + } + + const layout = compile(root) + return { layout, recursive, exact, exitSuccess } +} + +// The parser may return the binary decoder's value directly only when that +// decoder proves every predicate and no Schema parser behavior can change it. +// Schemas whose decoded values the binary layer already produces and validates +// on its own, so the schema pass around it would only repeat work. Constructor +// defaults are ignored because they only run during construction. +function isExact(root: SchemaAST.AST): boolean { + // A suspend that is still being walked counts as exact: if nothing else in + // the cycle disqualifies it, the whole cycle is exact. + const walked = new Map() + const exact = (ast: SchemaAST.AST): boolean => { + if ( + ast.encoding !== undefined || ast.checks !== undefined || + (ast as { readonly encodingChecks?: SchemaAST.Checks }).encodingChecks !== undefined || + ast.annotations?.parseOptions !== undefined + ) { + return false + } + switch (ast._tag) { + case "String": + case "Symbol": + case "Boolean": + case "Null": + case "Undefined": + case "Void": + case "Number": + case "BigInt": + case "Literal": + return true + // The binary layer rejects every value for a never layout, in both + // directions, so the schema pass has nothing left to say about it. + // Inferred schemas hit this through the element type of an empty array. + case "Never": + return true + // Every decoded JSON value is a valid `unknown`, and the encoder already + // reports the values it cannot represent, so the pass adds nothing. + // `ObjectKeyword` is not here: the JSON layout can hand it a string. + case "Unknown": + case "Any": + return true + case "Arrays": + return ast.elements.every(exact) && ast.rest.every(exact) + case "Objects": + return ast.propertySignatures.every((property) => exact(property.type)) && + ast.indexSignatures.every((signature) => + signature.parameter._tag === "String" && exact(signature.parameter) && exact(signature.type) + ) + case "Union": + return ast.mode === "anyOf" && ast.types.every(exact) + // The layout compiles straight through a suspend, so the binary layer + // validates whatever the thunk returns. Only decoding gets to act on + // this: encoding a recursive schema still needs the cycle walk, which is + // why {@link toCodec} keeps the schema pass on that side. + case "Suspend": { + const seen = walked.get(ast) + if (seen !== undefined) return seen + walked.set(ast, true) + const result = exact(ast.thunk()) + walked.set(ast, result) + return result + } + case "Declaration": { + const id = representationId(ast) + const native = id !== undefined ? natives[id] : undefined + if (native === undefined) return false + return native.exact === true || ast.typeParameters.every(exact) + } + default: + return false + } + } + return exact(root) +} + +// A clean Exit whose success schema is exact: the binary layer fully produces +// and validates success exits, so only failure exits need the schema pass for +// their cause's error and defect encodings. +function isExitWithExactSuccess(root: SchemaAST.AST): boolean { + return root._tag === "Declaration" && + root.encoding === undefined && root.checks === undefined && + (root as { readonly encodingChecks?: SchemaAST.Checks }).encodingChecks === undefined && + root.annotations?.parseOptions === undefined && + representationId(root) === "effect/schema/Exit" && + isExact(root.typeParameters[0]) +} + +// Layouts sharing a wire kind can still require different fingerprints. +const F = { + backEdge: 0, + bool: 1, + null: 2, + undefined: 3, + number: 4, + int: 5, + string: 6, + symbol: 7, + bytes: 8, + bigint: 9, + json: 10, + duration: 11, + bigDecimal: 12, + dateTimeZoned: 13, + date: 14, + dateTimeUtc: 15, + never: 16, + struct: 17, + array: 18, + union: 19, + option: 20, + result: 21, + exit: 22, + cause: 23, + causeReason: 24, + literal: 25 +} as const + +// Hash the compiled layout graph, including cycle back-edge distances. +function layoutFingerprint(root: Layout): bigint { + const cache = new Map() + const stack: Array = [] + // Cache only subtrees that do not escape above their own root. + let escape = Number.MAX_SAFE_INTEGER + + function go(layout: Layout): bigint { + const at = stack.lastIndexOf(layout) + if (at >= 0) { + if (at < escape) escape = at + const out: Array = [F.backEdge] + pushUvarint(out, stack.length - at) + return fnv64(out) + } + const cached = cache.get(layout) + if (cached !== undefined) return cached + const self = stack.length + const outerEscape = escape + escape = Number.MAX_SAFE_INTEGER + stack.push(layout) + const hash = fnv64(structure(layout)) + stack.pop() + const closed = escape >= self + if (closed) cache.set(layout, hash) + escape = closed ? outerEscape : Math.min(outerEscape, escape) + return hash + } + + function structure(layout: Layout): Array { + const out: Array = [] + switch (layout._) { + case "bool": + case "null": + case "undefined": + case "number": + case "int": + case "string": + case "symbol": + case "bytes": + case "bigint": + case "json": + case "duration": + case "bigDecimal": + case "dateTimeZoned": + out.push(F[layout._]) + return out + case "literal": { + out.push(F.literal) + pushBytes(out, structure(layout.leaf)) + pushUvarint(out, layout.values.length) + // Sorted, so a union hashes the same whatever order it declares. + const sorted = layout.values + .map((value) => [sentinelLiteralKind(value), sentinelLiteralString(value)] as const) + .sort((a, b) => a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : a[0] - b[0]) + for (const [kind, text] of sorted) { + out.push(kind) + const bytes = utf8Encode.encode(text) + pushUvarint(out, bytes.length) + pushBytes(out, bytes) + } + return out + } + case "int64": + out.push(layout.flavor === "date" ? F.date : F.dateTimeUtc) + return out + case "never": + out.push(F.never) + return out + case "struct": { + out.push(F.struct) + pushUvarint(out, layout.fields.length) + for (const field of layout.fields) { + pushU32(out, field.id) + out.push(field.optional ? 1 : 0) + pushU64(out, go(field.layout)) + } + pushUvarint(out, layout.extra.length) + for (const signature of layout.extra) pushU64(out, go(signature.layout)) + return out + } + case "array": { + out.push(F.array) + pushUvarint(out, layout.elements.length) + for (const slot of layout.elements) { + out.push(slot.optional ? 1 : 0) + pushU64(out, go(slot.layout)) + } + pushUvarint(out, layout.rest.length) + for (const rest of layout.rest) pushU64(out, go(rest)) + return out + } + case "union": { + out.push(F.union) + pushUvarint(out, layout.byPos.length) + for (const position of layout.byPos) { + const variant = position.variant + if (variant === undefined) out.push(0) + else { + out.push(1, variant.tuple ? 1 : 0) + pushU32(out, variant.tag) + } + pushU64(out, go(position.layout)) + } + return out + } + case "option": + out.push(F.option) + pushU64(out, go(layout.value)) + return out + case "result": + out.push(F.result) + pushU64(out, go(layout.success)) + pushU64(out, go(layout.failure)) + return out + case "exit": + out.push(F.exit) + pushU64(out, go(layout.value)) + pushU64(out, go(layout.cause.error)) + pushU64(out, go(layout.cause.defect)) + return out + case "cause": + case "causeReason": + out.push(layout._ === "cause" ? F.cause : F.causeReason) + pushU64(out, go(layout.error)) + pushU64(out, go(layout.defect)) + return out + } + } + + return go(root) +} + +// A frame header the decoder can compare byte by byte, so frames that hold no +// wide numbers never build a DataView. +function fingerprintBytes(lo: number, hi: number): Uint8Array { + const out = new Uint8Array(8) + for (let i = 0; i < 4; i++) { + out[i] = (lo >>> (i * 8)) & 0xFF + out[i + 4] = (hi >>> (i * 8)) & 0xFF + } + return out +} + +interface Mode { + readonly positional: boolean + readonly envelope: number + readonly expectedEnvelope: string + readonly fingerprint: Uint8Array +} + +const defaultMode: Mode = { + positional: false, + envelope: ENVELOPE, + expectedEnvelope: "version 2 envelope, flags 0", + fingerprint: new Uint8Array(8) +} + +function fingerprintMode(layout: Layout): Mode { + const fingerprint = layoutFingerprint(layout) + return { + positional: true, + envelope: ENVELOPE_FINGERPRINT, + expectedEnvelope: "version 2 envelope, flags 1", + fingerprint: fingerprintBytes( + Number(fingerprint & BIGINT_U32_MASK), + Number((fingerprint >> BIGINT_THIRTY_TWO) & BIGINT_U32_MASK) + ) + } +} + +function matchRank(layout: Layout): number { + switch (layout._) { + case "json": + return 2 + case "struct": + return 1 + default: + return 0 + } +} + +function matchesLayout(layout: Layout, value: unknown): boolean { + switch (layout._) { + case "literal": + return hasLiteral(layout, value) + case "bool": + return typeof value === "boolean" + case "null": + return value === null + case "undefined": + return value === undefined + case "number": + case "int": + return typeof value === "number" + case "string": + return typeof value === "string" + case "symbol": + return typeof value === "symbol" + case "bigint": + return typeof value === "bigint" + case "bytes": + return value instanceof Uint8Array + case "int64": + return layout.flavor === "date" + ? value instanceof Date + : DateTime.isDateTime(value) && value._tag === "Utc" + case "dateTimeZoned": + return DateTime.isDateTime(value) && value._tag === "Zoned" + case "duration": + return Duration.isDuration(value) + case "bigDecimal": + return BigDecimal.isBigDecimal(value) + case "option": + return Option.isOption(value) + case "result": + return Result.isResult(value) + case "exit": + return Exit.isExit(value) + case "cause": + return Cause.isCause(value) + case "causeReason": + return Cause.isReason(value) + case "array": + return Array.isArray(value) + case "struct": + return Predicate.isObject(value) && !Array.isArray(value) + case "json": + return true + case "union": + case "never": + return false + } +} + +interface EncodeContext { + readonly options: SchemaAST.ParseOptions + readonly positional: boolean + indexSignatures: IndexSignatureCache | undefined + // Records of one shape repeat within a frame, so the last one is reused. + extraShape: ExtraShape | undefined + // Handed to the row-run field's immediate consumer, then cleared. + intern: InternWrite | undefined + // String tables shared by every frame on one connection; `undefined` for a + // one-shot encode, where each frame stands alone. + readonly dict: DictWrite | undefined +} + +function encodeFail(expected: string, input: unknown, options: SchemaAST.ParseOptions): never { + throw issueError(new SchemaIssue.InvalidValue({ expected }, input, options)) +} + +function isCyclic(value: unknown, stack = new Set()): boolean { + if (!Predicate.isObjectOrArray(value)) return false + const isArray = Array.isArray(value) + const prototype = Object.getPrototypeOf(value) + if (isArray || prototype === Object.prototype || prototype === null) { + if (stack.has(value)) return true + stack.add(value) + try { + if (isArray) { + for (let i = 0; i < value.length; i++) { + if (isCyclic(value[i], stack)) return true + } + } else { + for (const key of Object.keys(value)) { + if (isCyclic((value as Record)[key], stack)) return true + } + } + } finally { + stack.delete(value) + } + return false + } + if ( + value instanceof Date || + value instanceof Uint8Array || + DateTime.isDateTime(value) || + Duration.isDuration(value) || + BigDecimal.isBigDecimal(value) + ) return false + if (stack.has(value)) return true + stack.add(value) + try { + if (Option.isOption(value)) { + return Option.isSome(value) && isCyclic(value.value, stack) + } + if (Result.isResult(value)) { + return isCyclic(Result.isSuccess(value) ? value.success : value.failure, stack) + } + if (Exit.isExit(value)) { + return isCyclic(Exit.isSuccess(value) ? value.value : value.cause, stack) + } + if (Cause.isCause(value)) { + return value.reasons.some((reason) => isCyclic(reason, stack)) + } + if (Cause.isReason(value)) { + switch (value._tag) { + case "Fail": + return isCyclic(value.error, stack) + case "Die": + return isCyclic(value.defect, stack) + case "Interrupt": + return false + } + } + if (Chunk.isChunk(value)) { + for (const item of value) { + if (isCyclic(item, stack)) return true + } + return false + } + if (HashMap.isHashMap(value)) { + for (const [key, item] of value) { + if (isCyclic(key, stack) || isCyclic(item, stack)) return true + } + return false + } + if (HashSet.isHashSet(value)) { + for (const item of value) { + if (isCyclic(item, stack)) return true + } + return false + } + if (Redacted.isRedacted(value)) { + return isCyclic(Redacted.value(value), stack) + } + for (const key of Object.keys(value)) { + if (isCyclic((value as Record)[key], stack)) return true + } + } finally { + stack.delete(value) + } + return false +} + +function encodeSized(ctx: EncodeContext, layout: Layout, value: unknown, w: Writer) { + const mark = w.beginSized() + encodeValue(ctx, layout, value, w) + w.endSized(mark) +} + +function encodeSymbol(ctx: EncodeContext, value: unknown, w: Writer) { + const key = globalThis.Symbol.keyFor(value as symbol) + if (key === undefined) encodeFail("registered symbol", value, ctx.options) + w.string(key) +} + +function encodeReason(ctx: EncodeContext, layout: ReasonLayout, value: unknown, w: Writer) { + const reason = value as Cause.Reason + switch (reason._tag) { + case "Fail": + w.byte(0) + encodeValue(ctx, layout.error, reason.error, w) + return + case "Die": + w.byte(1) + encodeValue(ctx, layout.defect, reason.defect, w) + return + case "Interrupt": + if (reason.fiberId === undefined) { + w.byte(2) + } else { + w.byte(3) + w.f64(reason.fiberId) + } + } +} + +// `keyBytes` stays undefined while every key is ASCII, where code unit order +// and length already match raw UTF-8. +type ExtraPair = [key: string, signature: ExtraSignature, keyBytes: Uint8Array | undefined] + +function isAscii(key: string): boolean { + for (let i = 0; i < key.length; i++) { + if (key.charCodeAt(i) > 0x7F) return false + } + return true +} + +interface ExtraShape { + readonly layout: StructLayout + readonly keys: ReadonlyArray + readonly pairs: Array +} + +function sameShape(shape: ExtraShape, layout: StructLayout, keys: ReadonlyArray): boolean { + if (shape.layout !== layout || shape.keys.length !== keys.length) return false + for (let i = 0; i < keys.length; i++) { + if (shape.keys[i] !== keys[i]) return false + } + return true +} + +function canCacheExtraShape(layout: StructLayout, keys: ReadonlyArray): boolean { + if (layout.extraAll === undefined || keys.length > ENCODE_INDEX_SIGNATURE_CACHE_KEYS) return false + let codeUnits = 0 + for (let i = 0; i < keys.length; i++) { + codeUnits += keys[i].length + if (codeUnits > ENCODE_INDEX_SIGNATURE_CACHE_CODE_UNITS) return false + } + return true +} + +// Sort extra keys by raw UTF-8 for deterministic output. +function extraPairs(ctx: EncodeContext, layout: StructLayout, obj: Record): Array { + const keys = Object.keys(obj) + const cached = layout.extraShape + if (cached !== undefined && sameShape(cached, layout, keys)) return cached.pairs + const persistent = canCacheExtraShape(layout, keys) + const shape = persistent ? undefined : ctx.extraShape + if (shape !== undefined && sameShape(shape, layout, keys)) return shape.pairs + const named = layout.names + const every = layout.extraAll + const pairs: Array = [] + let ascii = true + for (const key of keys) { + if (named.has(key)) continue + const signature = every ?? (ctx.indexSignatures ??= new IndexSignatureCache(ctx.options)).find(layout, key) + if (signature === undefined) continue + if (ascii && !isAscii(key)) ascii = false + pairs.push([key, signature, undefined]) + } + if (ascii) { + pairs.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0) + } else { + for (const pair of pairs) pair[2] = utf8Encode.encode(pair[0]) + pairs.sort((a, b) => compareBytes(a[2]!, b[2]!)) + } + if (persistent) { + for (const pair of pairs) pair[2] ??= utf8Encode.encode(pair[0]) + } + const next = { layout, keys, pairs } + if (persistent) layout.extraShape = next + else ctx.extraShape = next + return pairs +} + +function encodeExtraPairs( + ctx: EncodeContext, + pairs: Array, + obj: Record, + w: Writer, + keys?: InternWrite | undefined +) { + for (const [key, signature, keyBytes] of pairs) { + const keyLength = keyBytes === undefined ? key.length : keyBytes.length + const value = obj[key] + const wire = valueWireCode(signature.layout, value, ctx.options) + const kind = wire % FIELD_WIRE_FACTOR + if (keys === undefined) { + w.uvarint(keyLength * FIELD_WIRE_FACTOR + kind) + } else { + if (!keys.disabled) { + const ref = internRef(keys, key) + if (ref !== undefined) { + w.uvarint(ref * 16 + kind * 2 + 1) + issuePath[issuePathLen++] = key + encodeWirePayload(ctx, signature.layout, value, wire, w) + issuePathLen-- + continue + } + internAdd(keys, key) + } + w.uvarint(keyLength * 16 + kind * 2) + } + if (keyBytes === undefined) w.string(key) + else w.bytes(keyBytes) + issuePath[issuePathLen++] = key + encodeWirePayload(ctx, signature.layout, value, wire, w) + issuePathLen-- + } +} + +// Decimal scale rides above the low three kind bits internally, avoiding a +// second decimalScale scan when the payload is written. +function valueWireCode(layout: Layout, value: unknown, options: SchemaAST.ParseOptions): number { + if (layout._ === "literal") { + if (!hasLiteral(layout, value)) encodeFail(literalExpected(layout), value, options) + return valueWireCode(layout.leaf, value, options) + } + switch (layout._) { + case "bool": + if (typeof value !== "boolean") encodeFail("a boolean", value, options) + return value ? FIELD_WIRE_TRUE : FIELD_WIRE_FALSE + case "number": + if (typeof value !== "number") encodeFail("a number", value, options) + if (isVarintNumber(value)) return FIELD_WIRE_VARINT + { + const scale = decimalScale(value) + return scale === 0 ? FIELD_WIRE_FIXED64 : scale * FIELD_WIRE_FACTOR + FIELD_WIRE_DECIMAL + } + case "int": + if (!Number.isSafeInteger(value)) encodeFail("an integer", value, options) + return FIELD_WIRE_VARINT + default: + return FIELD_WIRE_SIZED + } +} + +function encodeWirePayload(ctx: EncodeContext, layout: Layout, value: unknown, wire: number, w: Writer) { + const kind = wire % FIELD_WIRE_FACTOR + switch (kind) { + // EMPTY and FIXED32 are decoder-side skip support only; `valueWireCode` + // never produces them. + case FIELD_WIRE_FALSE: + case FIELD_WIRE_TRUE: + return + case FIELD_WIRE_VARINT: + w.numberVarint(value as number) + return + case FIELD_WIRE_FIXED64: + w.f64(value as number) + return + case FIELD_WIRE_DECIMAL: { + const scale = Math.floor(wire / FIELD_WIRE_FACTOR) + w.numberVarint(Math.round((value as number) * POW10[scale])) + w.uvarint(scale) + return + } + default: + encodeSized(ctx, layout, value, w) + } +} + +function encodeStructField(ctx: EncodeContext, field: Field, value: unknown, w: Writer) { + const wire = valueWireCode(field.layout, value, ctx.options) + const kind = wire % FIELD_WIRE_FACTOR + if (kind === FIELD_WIRE_SIZED) { + const mark = w.idAndMark(field.tagBytes) + encodeValue(ctx, field.layout, value, w) + w.endSized(mark) + return + } + w.uvarint(field.id * FIELD_WIRE_FACTOR + kind) + encodeWirePayload(ctx, field.layout, value, wire, w) +} + +function runPresenceMask(plan: RunPlan, obj: Record): number | undefined { + const byName = plan.byName + if (byName === undefined) return undefined + const keys = Object.getOwnPropertyNames(obj) + let mask = 0 + for (let i = 0; i < keys.length; i++) { + const index = byName.get(keys[i]) + if (index !== undefined) mask |= 1 << index + } + const struct = plan.struct + if ((mask & struct.requiredMask) !== struct.requiredMask) { + const fields = struct.fields + for (let i = 0; i < fields.length; i++) { + const field = fields[i] + if (!field.optional && (mask & (1 << i)) === 0) { + issuePath[issuePathLen++] = field.name + throw issueError(new SchemaIssue.MissingKey(field.annotations)) + } + } + } + return mask +} + +function encodeStructFields(ctx: EncodeContext, layout: StructLayout, value: object, w: Writer) { + const obj = value as Record + if (layout.extra.length > 0) { + // Only a run field with index signatures hands over an intern table. + const keys = ctx.intern + ctx.intern = undefined + const pairs = extraPairs(ctx, layout, obj) + if (pairs.length > 0) { + w.uvarint(0) + const mark = w.beginSized() + encodeExtraPairs(ctx, pairs, obj, w, keys) + w.endSized(mark) + } + } + const fields = layout.fields + for (let i = 0; i < fields.length; i++) { + const field = fields[i] + const name = field.name + if (!Object.hasOwn(obj, name)) { + if (field.optional) continue + issuePath[issuePathLen++] = name + throw issueError(new SchemaIssue.MissingKey(field.annotations)) + } + issuePath[issuePathLen++] = name + encodeStructField(ctx, field, obj[name], w) + issuePathLen-- + } +} + +// Fingerprint structs use a presence bitmap followed by positional fields. +function encodeStructPositional(ctx: EncodeContext, layout: StructLayout, value: object, w: Writer) { + const obj = value as Record + // Only a run field with index signatures hands over an intern table. Extras + // encode after the fields here, so claim it before a nested field can. + const keys = ctx.intern + ctx.intern = undefined + const bitmapBytes = (layout.optionalCount + 7) >> 3 + let bitmap = 0 + if (bitmapBytes > 0) { + bitmap = w.len + for (let i = 0; i < bitmapBytes; i++) w.byte(0) + } + const fields = layout.fields + let optionalIndex = 0 + for (let i = 0; i < fields.length; i++) { + const field = fields[i] + const name = field.name + const present = Object.hasOwn(obj, name) + if (field.optional) { + // Resolve the bitmap address again after any arena growth. + if (present) w.buf[w.start + bitmap + (optionalIndex >> 3)] |= 1 << (optionalIndex & 7) + optionalIndex++ + if (!present) continue + } else if (!present) { + issuePath[issuePathLen++] = name + throw issueError(new SchemaIssue.MissingKey(field.annotations)) + } + issuePath[issuePathLen++] = name + if (field.inline) encodeValue(ctx, field.layout, obj[name], w) + else encodeSized(ctx, field.layout, obj[name], w) + issuePathLen-- + } + if (layout.extra.length > 0) { + const pairs = extraPairs(ctx, layout, obj) + w.uvarint(pairs.length) + encodeExtraPairs(ctx, pairs, obj, w, keys) + } +} + +function internKind(layout: Layout): number { + switch (layout._) { + case "string": + return INTERN_SELF + case "array": + return layout.uniform?._ === "string" ? INTERN_ELEMENTS : INTERN_NONE + case "struct": + return layout.extra.length > 0 ? INTERN_KEYS : INTERN_NONE + default: + return INTERN_NONE + } +} + +// `Schema.Array(S)` and `Schema.NonEmptyArray(S)` both give every slot one layout. +function runStruct(layout: ArrayLayout): StructLayout | undefined { + let uniform = layout.uniform + if (uniform === undefined) { + if (layout.rest.length !== 1) return undefined + uniform = layout.rest[0] + for (const slot of layout.elements) { + if (slot.optional || slot.layout !== uniform) return undefined + } + } + return uniform._ === "struct" ? uniform : undefined +} + +function runPlan(layout: ArrayLayout): RunPlan | null { + let plan = layout.run + if (plan !== undefined) return plan + const struct = runStruct(layout) + if (struct === undefined) { + layout.run = plan = null + return plan + } + const intern = struct.fields.map((field) => internKind(field.layout)) + const shapes = struct.fields.length <= RUN_MAX_FIELDS + plan = { + struct, + intern, + shapes, + byName: shapes && struct.extra.length === 0 + ? new Map(struct.fields.map((field, index) => [field.name, index])) + : undefined + } + layout.run = plan + return plan +} + +// One table per field, so a field the reader skips can never shift another +// field's references. Holds the values written literally so far, in reference +// order. Short runs scan the array and longer ones build a map, while a table +// whose first `INTERN_DISABLE_AT` values never repeated stops tracking +// entirely: references are optional for writers, and the reader keeps indexing +// literal values either way, so only repeats with more than that many distinct +// values between them lose their back-references. +interface InternWrite { + readonly values: Array + refs: Map | undefined + hits: number + disabled: boolean +} + +const INTERN_MAP_AT = 16 +const INTERN_DISABLE_AT = 64 + +function internWrite(tables: Array, index: number): InternWrite { + return tables[index] ??= { values: [], refs: undefined, hits: 0, disabled: false } +} + +function internRef(table: InternWrite, value: unknown): number | undefined { + const refs = table.refs + if (refs !== undefined) { + const ref = refs.get(value) + if (ref !== undefined) table.hits++ + return ref + } + const values = table.values + for (let i = 0; i < values.length; i++) { + if (values[i] === value) { + table.hits++ + return i + } + } + return undefined +} + +function internAdd(table: InternWrite, value: unknown) { + const values = table.values + if (table.refs !== undefined) { + if (table.hits === 0 && values.length >= INTERN_DISABLE_AT) { + table.disabled = true + table.refs = undefined + return + } + table.refs.set(value, values.length) + } else if (values.length >= INTERN_MAP_AT) { + const refs = table.refs = new Map() + for (let i = 0; i < values.length; i++) refs.set(values[i], i) + refs.set(value, values.length) + } + values.push(value) +} + +// A connection dictionary: string tables that outlive a single frame. The +// writer and the reader build the same tables from the same frames, so a +// reference means the same string on both ends. Fields that share a schema +// object share a table, which is why `Schema.String` fields all land in one: +// splitting them costs every layout a few percent and buys the RPC envelope, +// where every string repeats, nothing. +// +// A leaf starts out only watching. It writes plain strings and remembers them +// until one comes back, and only then starts writing references. A leaf whose +// strings never repeat gives up and goes back to writing plain strings for +// good, so it never costs a byte more than no dictionary at all. +const DICT_WATCHING = 0 +const DICT_REFERENCING = 1 +const DICT_GAVE_UP = 2 + +// How many distinct strings a leaf watches before giving up on it. +const DICT_WATCH_LIMIT = 64 +// How many strings one leaf keeps once it is referencing. +const DICT_MAX_ENTRIES = 512 + +interface DictSlot { + readonly values: Array + readonly refs: Map + state: number + // Frame that last touched this slot, so a frame records one mark per slot. + stamp: number +} + +type DictRead = Array + +interface DictWrite { + readonly slots: DictRead + // A frame that fails is never sent, so its additions have to come back out or + // the reader's tables drift from the writer's. + readonly marks: Array + frame: number +} + +interface DictMark { + readonly slot: DictSlot + readonly length: number + readonly state: number +} + +function makeDictWrite(): DictWrite { + return { slots: [], marks: [], frame: 0 } +} + +function dictSlot(slots: DictRead, index: number): DictSlot { + return slots[index] ??= { values: [], refs: new Map(), state: DICT_WATCHING, stamp: -1 } +} + +function dictWatch(slot: DictSlot, value: string) { + if (slot.refs.has(value)) { + slot.state = DICT_REFERENCING + return + } + slot.refs.set(value, slot.values.length) + slot.values.push(value) + if (slot.values.length >= DICT_WATCH_LIMIT) slot.state = DICT_GAVE_UP +} + +function dictAdd(slot: DictSlot, value: string) { + const values = slot.values + if (values.length >= DICT_MAX_ENTRIES) return + slot.refs.set(value, values.length) + values.push(value) +} + +function dictMark(dict: DictWrite, slot: DictSlot) { + if (slot.stamp === dict.frame) return + slot.stamp = dict.frame + dict.marks.push({ slot, length: slot.values.length, state: slot.state }) +} + +function dictCommit(dict: DictWrite) { + dict.marks.length = 0 + dict.frame++ +} + +function dictRollback(dict: DictWrite) { + const marks = dict.marks + for (let i = marks.length - 1; i >= 0; i--) { + const { length, slot, state } = marks[i] + const values = slot.values + for (let j = length; j < values.length; j++) slot.refs.delete(values[j]) + values.length = length + slot.state = state + } + marks.length = 0 + dict.frame++ +} + +// A referencing leaf writes either a reference or a zero marker and the string. +// A watching leaf writes the string alone, so watching costs nothing on the +// wire. +function encodeString(dict: DictWrite, layout: LeafLayout, value: string, w: Writer) { + const slot = dictSlot(dict.slots, layout.slot) + const state = slot.state + if (state === DICT_GAVE_UP) { + w.string(value) + return + } + dictMark(dict, slot) + if (state === DICT_WATCHING) { + w.string(value) + dictWatch(slot, value) + return + } + const ref = slot.refs.get(value) + if (ref !== undefined) { + w.uvarint(ref * 2 + 1) + return + } + w.byte(0) + dictAdd(slot, value) + w.string(value) +} + +function decodeString(dict: DictRead, layout: LeafLayout, r: Reader): string { + const slot = dictSlot(dict, layout.slot) + const state = slot.state + if (state === DICT_GAVE_UP) return r.readUtf8(r.end - r.pos) + if (state === DICT_WATCHING) { + const value = r.readUtf8(r.end - r.pos) + dictWatch(slot, value) + return value + } + const marker = r.uvarint() + if ((marker & 1) === 1) { + const ref = (marker - 1) / 2 + const values = slot.values + if (ref >= values.length) invalid("a dictionary reference", marker, r.options) + return values[ref] + } + if (marker !== 0) invalid("a dictionary marker", marker, r.options) + const value = r.readUtf8(r.end - r.pos) + dictAdd(slot, value) + return value +} + +function encodeInterned(table: InternWrite, ctx: EncodeContext, layout: Layout, value: unknown, w: Writer) { + if (!table.disabled) { + const ref = internRef(table, value) + if (ref !== undefined) { + w.uvarint(ref * 2 + 1) + return + } + internAdd(table, value) + } + const mark = w.beginSized() + encodeValue(ctx, layout, value, w) + w.endSizedRun(mark) +} + +function encodeStructRun(ctx: EncodeContext, plan: RunPlan, arr: ReadonlyArray, count: number, w: Writer) { + const shapes: Array = [] + const tables: Array = [] + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + const mark = w.beginSized() + encodeRunRow(ctx, plan, shapes, tables, arr[i], w) + w.endSized(mark) + issuePathLen-- + } +} + +function encodeRunRow( + ctx: EncodeContext, + plan: RunPlan, + shapes: Array, + tables: Array, + value: unknown, + w: Writer +) { + const struct = plan.struct + const obj = value as Record + const fields = struct.fields + let pairs: Array | undefined + if (struct.extra.length > 0) { + const found = extraPairs(ctx, struct, obj) + if (found.length > 0) pairs = found + } + const wide = !plan.shapes + let mask = pairs === undefined ? 0 : RUN_EXTRA_BIT + const presenceMask = runPresenceMask(plan, obj) + if (presenceMask !== undefined) { + mask = presenceMask + } else { + for (let i = 0; i < fields.length; i++) { + const field = fields[i] + if (Object.hasOwn(obj, field.name)) { + if (!wide) mask |= 1 << i + } else if (!field.optional) { + issuePath[issuePathLen++] = field.name + throw issueError(new SchemaIssue.MissingKey(field.annotations)) + } + } + } + let shape = -1 + for (let i = 0; i < shapes.length; i++) { + if (shapes[i] === mask) { + shape = i + break + } + } + if (shape >= 0) { + w.uvarint(shape + 1) + } else { + w.uvarint(0) + if (!wide) shapes.push(mask) + } + const declare = shape < 0 + // Fingerprint mode proves both layouts match, so a declared shape is the + // presence mask instead of a field id list. + const positionalShape = ctx.positional && !wide + if (declare && positionalShape) w.uvarint(mask) + if (pairs !== undefined) { + if (declare && !positionalShape) w.uvarint(0) + const mark = w.beginSized() + encodeExtraPairs(ctx, pairs, obj, w) + w.endSizedRun(mark) + } + for (let i = 0; i < fields.length; i++) { + const field = fields[i] + if (wide ? !Object.hasOwn(obj, field.name) : (mask & (1 << i)) === 0) continue + if (declare && !positionalShape) w.uvarint(field.id) + issuePath[issuePathLen++] = field.name + const kind = plan.intern[i] + if (kind === INTERN_SELF) { + encodeInterned(internWrite(tables, i), ctx, field.layout, obj[field.name], w) + } else { + if (kind !== INTERN_NONE) ctx.intern = internWrite(tables, i) + const mark = w.beginSized() + encodeValue(ctx, field.layout, obj[field.name], w) + w.endSizedRun(mark) + ctx.intern = undefined + } + issuePathLen-- + } +} + +function arraySlot(layout: ArrayLayout, index: number, count: number): Layout { + const elementLen = layout.elements.length + if (index < elementLen) return layout.elements[index].layout + const tailLen = Math.max(0, layout.rest.length - 1) + const tailThreshold = Math.max(elementLen, count - tailLen) + return index >= tailThreshold ? layout.rest[index - tailThreshold + 1] : layout.rest[0] +} + +function encodeArray(ctx: EncodeContext, layout: ArrayLayout, arr: ReadonlyArray, w: Writer) { + const count = arr.length + if (layout.rest.length === 0 && count > layout.elements.length) { + issuePath[issuePathLen++] = layout.elements.length + throw issueError( + new SchemaIssue.UnexpectedKey(layout.ast, arr[layout.elements.length], ctx.options) + ) + } + if (count < layout.minCount) { + issuePath[issuePathLen++] = count + throw issueError(new SchemaIssue.MissingKey(undefined)) + } + if (layout.hasCount) w.uvarint(count) + if (count > 0) { + const plan = runPlan(layout) + if (plan !== null) { + encodeStructRun(ctx, plan, arr, count, w) + return + } + } + const uniform = layout.uniform + if (uniform !== undefined) { + if (layout.uniformNumbers) { + encodeNumberRun(arr, count, w) + return + } + // Only a run field holding an array of strings hands over an intern table. + const table = ctx.intern + ctx.intern = undefined + if (table !== undefined) { + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + encodeInterned(table, ctx, uniform, arr[i], w) + issuePathLen-- + } + return + } + const inline = layout.uniformInline + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + if (inline) encodeValue(ctx, uniform, arr[i], w) + else encodeSized(ctx, uniform, arr[i], w) + issuePathLen-- + } + return + } + for (let i = 0; i < count; i++) { + const slot = arraySlot(layout, i, count) + issuePath[issuePathLen++] = i + if (isInlineSlot(slot)) { + encodeValue(ctx, slot, arr[i], w) + } else { + encodeSized(ctx, slot, arr[i], w) + } + issuePathLen-- + } +} + +// Uniform number arrays share one mode byte: varint, packed decimal, or f64. +// Scales found by the classification pass, reused by the decimal write pass so +// each non-integer scans `decimalScale` once. Only written and read within one +// synchronous `encodeNumberRun` call. +const numberRunScales: Array = [] + +function encodeNumberRun(arr: ReadonlyArray, count: number, w: Writer) { + let varint = true + let decimal = true + for (let i = 0; i < count; i++) { + const value = arr[i] + if (isVarintNumber(value)) { + const magnitude = (value as number) < 0 ? -(value as number) : value as number + if (magnitude > DECIMAL_MANTISSA_MAX) decimal = false + } else { + varint = false + if (typeof value !== "number") decimal = false + else { + const scale = decimalScale(value) + if (scale === 0) decimal = false + else numberRunScales[i] = scale + } + } + if (!varint && !decimal) break + } + if (varint) { + w.byte(NUMBER_RUN_VARINT) + for (let i = 0; i < count; i++) w.numberVarint(arr[i] as number) + } else if (decimal) { + w.byte(NUMBER_RUN_DECIMAL) + for (let i = 0; i < count; i++) { + const value = arr[i] as number + // Integers take scale 0 even when a scale would fit, matching the + // classification pass writing scales only for non-integers. + const scale = Number.isInteger(value) ? 0 : numberRunScales[i] + const mantissa = scale === 0 ? value : Math.round(value * POW10[scale]) + const negative = mantissa < 0 || (mantissa === 0 && 1 / mantissa < 0) + const magnitude = negative ? -mantissa : mantissa + w.uvarint((magnitude * 2 + (negative ? 1 : 0)) * 16 + scale) + } + } else { + w.byte(NUMBER_RUN_F64) + for (let i = 0; i < count; i++) w.f64(arr[i] as number) + } +} + +function findDiscriminator(variants: ReadonlyArray): Discriminator | undefined { + if (variants.length < 2 || variants.some((variant) => variant.tuple)) return undefined + for (const candidate of variants[0].sentinels) { + const rows = new Map() + for (const variant of variants) { + const sentinel = variant.sentinels.find((s) => s.key === candidate.key) + // `NaN` matches under `Map`'s key equality but not under `===`. + if (sentinel === undefined || rows.has(sentinel.literal) || Number.isNaN(sentinel.literal)) break + rows.set(sentinel.literal, variant) + } + if (rows.size === variants.length) return { key: candidate.key, rows } + } + return undefined +} + +// An inherited sentinel does not select a variant, matching the schema pass. +function hasSentinels(variant: VariantRow, value: Record): boolean { + const sentinels = variant.sentinels + for (let i = 0; i < sentinels.length; i++) { + const { key, literal } = sentinels[i] + if (value[key] !== literal || !Object.hasOwn(value, key)) return false + } + return true +} + +function matchesVariant(variant: VariantRow, value: unknown): boolean { + return variant.tuple + ? Array.isArray(value) && hasSentinels(variant, value as unknown as Record) + : Predicate.isObject(value) && !Array.isArray(value) && + hasSentinels(variant, value as Record) +} + +function encodeVariant(ctx: EncodeContext, variant: VariantRow, value: unknown, w: Writer) { + if (ctx.positional) { + w.uvarint(variant.position) + } else { + w.byte(K.variant) + w.u32le(variant.tag) + } + encodeValue(ctx, variant.payload, value, w) +} + +function encodeUnion(ctx: EncodeContext, layout: UnionLayout, value: unknown, w: Writer) { + const discriminator = layout.discriminator + if (discriminator !== undefined) { + // A miss rules out every variant, because each one pins this key to a + // literal no other variant uses. + if (Predicate.isObject(value) && !Array.isArray(value)) { + const record = value as Record + const variant = discriminator.rows.get(record[discriminator.key]) + if (variant !== undefined && hasSentinels(variant, record)) { + encodeVariant(ctx, variant, value, w) + return + } + } + } else { + for (const variant of layout.variants) { + if (matchesVariant(variant, value)) { + encodeVariant(ctx, variant, value, w) + return + } + } + } + for (const member of layout.others) { + if (matchesLayout(member.layout, value)) { + if (ctx.positional) w.uvarint(member.position) + else w.byte(member.kind) + encodeValue(ctx, member.layout, value, w) + return + } + } + throw issueError(new SchemaIssue.InvalidType(layout.ast, value, ctx.options)) +} + +function encodeValue(ctx: EncodeContext, layout: Layout, value: unknown, w: Writer): void { + switch (layout._) { + case "literal": + if (!hasLiteral(layout, value)) encodeFail(literalExpected(layout), value, ctx.options) + encodeValue(ctx, layout.leaf, value, w) + return + case "bool": + if (typeof value !== "boolean") encodeFail("a boolean", value, ctx.options) + w.byte(value === true ? 1 : 0) + return + case "null": + if (value !== null) encodeFail("null", value, ctx.options) + return + case "undefined": + if (value !== undefined) encodeFail("undefined", value, ctx.options) + return + case "number": { + if (typeof value !== "number") encodeFail("a number", value, ctx.options) + if (isVarintNumber(value)) { + w.numberVarint(value) + return + } + const scale = decimalScale(value) + if (scale > 0) { + w.numberVarint(Math.round(value * POW10[scale])) + w.byte(scale) + } else { + w.f64(value) + } + return + } + case "int": { + // `disableChecks` can bypass the integer check used to select this layout. + if (!Number.isSafeInteger(value)) encodeFail("an integer", value, ctx.options) + w.numberVarint(value as number) + return + } + case "string": { + if (typeof value !== "string") encodeFail("a string", value, ctx.options) + const dict = ctx.dict + if (dict === undefined) w.string(value) + else encodeString(dict, layout, value, w) + return + } + case "symbol": + if (typeof value !== "symbol") encodeFail("a symbol", value, ctx.options) + encodeSymbol(ctx, value, w) + return + case "bytes": + if (!(value instanceof Uint8Array)) encodeFail("a Uint8Array", value, ctx.options) + w.bytes(value) + return + case "bigint": + if (typeof value !== "bigint") encodeFail("a bigint", value, ctx.options) + w.zigzag(value) + return + case "int64": { + if (!matchesLayout(layout, value)) { + encodeFail(layout.flavor === "date" ? "a Date" : "a DateTime.Utc", value, ctx.options) + } + const millis = layout.flavor === "date" ? (value as Date).getTime() : (value as DateTime.Utc).epochMilliseconds + if (Number.isNaN(millis)) encodeFail("a valid Date", value, ctx.options) + w.i64(BigInt(millis)) + return + } + case "dateTimeZoned": { + if (!matchesLayout(layout, value)) encodeFail("a DateTime.Zoned", value, ctx.options) + const zoned = value as DateTime.Zoned + w.i64(BigInt(zoned.epochMilliseconds)) + if (zoned.zone._tag === "Offset") { + w.byte(0) + w.i32le(zoned.zone.offset) + } else { + w.byte(1) + w.string(zoned.zone.id) + } + return + } + case "duration": { + if (!Duration.isDuration(value)) encodeFail("a Duration", value, ctx.options) + const duration = value.value + switch (duration._tag) { + case "Infinity": + w.byte(1) + return + case "NegativeInfinity": + w.byte(2) + return + case "Nanos": + w.byte(0) + w.zigzag(duration.nanos) + return + case "Millis": + w.byte(0) + w.zigzag(BigInt(duration.millis) * BIGINT_NANOS_PER_MILLI) + return + } + } + case "bigDecimal": { + if (!BigDecimal.isBigDecimal(value)) encodeFail("a BigDecimal", value, ctx.options) + const normalized = BigDecimal.normalize(value) + w.zigzag(normalized.value) + w.zigzag(BigInt(normalized.scale)) + return + } + case "json": { + let text: string | undefined + try { + text = JSON.stringify(value) + } catch { + text = undefined + } + if (text === undefined) { + if (isCyclic(value)) encodeFail("acyclic value", value, ctx.options) + encodeFail("a JSON-serializable value", value, ctx.options) + } + w.string(text) + return + } + case "option": { + if (!Option.isOption(value)) encodeFail("an Option", value, ctx.options) + const option = value + if (option._tag === "None") { + w.byte(0) + } else { + w.byte(1) + encodeValue(ctx, layout.value, option.value, w) + } + return + } + case "result": { + if (!Result.isResult(value)) encodeFail("a Result", value, ctx.options) + const result = value + if (result._tag === "Success") { + w.byte(0) + encodeValue(ctx, layout.success, result.success, w) + } else { + w.byte(1) + encodeValue(ctx, layout.failure, result.failure, w) + } + return + } + case "exit": { + if (!Exit.isExit(value)) encodeFail("an Exit", value, ctx.options) + const exit = value + if (exit._tag === "Success") { + w.byte(0) + encodeValue(ctx, layout.value, exit.value, w) + } else { + w.byte(1) + encodeValue(ctx, layout.cause, exit.cause, w) + } + return + } + case "cause": { + if (!Cause.isCause(value)) encodeFail("a Cause", value, ctx.options) + const reasons = value.reasons + w.uvarint(reasons.length) + for (const reason of reasons) { + const mark = w.beginSized() + encodeReason(ctx, layout, reason, w) + w.endSized(mark) + } + return + } + case "causeReason": { + if (!Cause.isReason(value)) encodeFail("a Cause.Reason", value, ctx.options) + encodeReason(ctx, layout, value, w) + return + } + case "struct": { + if (!Predicate.isObject(value) || Array.isArray(value)) encodeFail("an object", value, ctx.options) + if (ctx.positional) encodeStructPositional(ctx, layout, value, w) + else encodeStructFields(ctx, layout, value, w) + return + } + case "array": { + if (!Array.isArray(value)) encodeFail("an array", value, ctx.options) + encodeArray(ctx, layout, value, w) + return + } + case "union": + encodeUnion(ctx, layout, value, w) + return + case "never": + throw issueError(new SchemaIssue.InvalidType(layout.ast, value, ctx.options)) + } +} + +// Reuse one top-level writer while allowing nested codecs to allocate their own. +let pooledWriter: Writer | undefined = new Writer() + +function writeFrame(ctx: EncodeContext, layout: Layout, value: unknown, mode: Mode, w: Writer) { + const mark = w.beginSized() + w.byte(mode.envelope) + if (mode.positional) w.bytes(mode.fingerprint) + encodeValue(ctx, layout, value, w) + w.endSized(mark) +} + +// One context and one writer across a batch, so the caches built for the +// first frame serve the rest and the frames need no second pass to join. The +// single-value and batch entry points share this wrapper: the pooled-writer +// protocol (checkout, reset, abort, restore) must not drift between them. +function runFrames( + layout: Layout, + value: unknown, + values: ReadonlyArray | undefined, + options: SchemaAST.ParseOptions, + mode: Mode, + dict: DictWrite | undefined +): Uint8Array { + const ctx: EncodeContext = { + options, + positional: mode.positional, + indexSignatures: undefined, + extraShape: undefined, + intern: undefined, + dict + } + const w = pooledWriter ?? new Writer() + const pooled = w === pooledWriter + if (pooled) pooledWriter = undefined + w.reset() + const savedPathLen = issuePathLen + // A batch is one output: a failure anywhere discards every frame in it, so + // the dictionary commits or rolls back once for the whole call. + let sent = false + try { + if (values === undefined) { + issuePathLen = 0 + writeFrame(ctx, layout, value, mode, w) + } else { + for (let i = 0; i < values.length; i++) { + issuePathLen = 0 + writeFrame(ctx, layout, values[i], mode, w) + } + } + const out = w.out() + sent = true + return out + } finally { + if (dict !== undefined) { + if (sent) dictCommit(dict) + else dictRollback(dict) + } + w.abort() + issuePathLen = savedPathLen + if (pooled) pooledWriter = w + } +} + +function encodeFrames( + layout: Layout, + values: ReadonlyArray, + options: SchemaAST.ParseOptions, + mode: Mode, + dict?: DictWrite | undefined +): Uint8Array { + return runFrames(layout, undefined, values, options, mode, dict) +} + +function encodeFrame( + layout: Layout, + value: unknown, + options: SchemaAST.ParseOptions, + mode: Mode, + dict?: DictWrite | undefined +): Uint8Array { + return runFrames(layout, value, undefined, options, mode, dict) +} + +// The direct paths bypass the schema pass for values the binary layer fully +// validates on its own; success exits are that case for `exitSuccess` targets. +function isSuccessExit(value: unknown): boolean { + return Exit.isExit(value) && Exit.isSuccess(value) +} + +// Unknown union members resolve to this sentinel. +const ABSENT = globalThis.Symbol.for("~effect/encoding/SchemaBinary/absent") + +function decodeChecked(layout: Layout, r: Reader): unknown { + const value = decodeValue(layout, r) + if (value !== ABSENT && r.pos !== r.end) invalid("no leftover bytes", undefined, r.options) + return value +} + +function decodeSized(layout: Layout, r: Reader): unknown { + const saved = r.enter(r.uvarint()) + const value = decodeChecked(layout, r) + r.exit(saved) + return value +} + +function decodeSlot(layout: Layout, r: Reader): unknown { + if (isSelfDelimiting(layout)) return decodeValue(layout, r) + const size = packedSize(layout) + const saved = r.enter( + size !== undefined ? size : layout._ === "null" || layout._ === "undefined" ? 0 : r.uvarint() + ) + const value = decodeChecked(layout, r) + r.exit(saved) + return value +} + +// Records are usually small, so duplicate keys scan a list and only wide +// records spill into a set. +const SEEN_LIST_MAX = 8 + +interface SeenKeys { + readonly list: Array + set: Set | undefined +} + +function seenKey(seen: SeenKeys, key: string): boolean { + const set = seen.set + if (set !== undefined) { + if (set.has(key)) return true + set.add(key) + return false + } + const list = seen.list + if (list.includes(key)) return true + if (list.length >= SEEN_LIST_MAX) { + const set = seen.set = new Set() + for (let i = 0; i < list.length; i++) set.add(list[i]) + set.add(key) + } else { + list.push(key) + } + return false +} + +function decodeExtraPair( + layout: StructLayout, + r: Reader, + out: Record, + seen: SeenKeys, + keys?: Array | undefined +) { + const code = r.uvarint() + const keyCode = keys === undefined ? Math.floor(code / FIELD_WIRE_FACTOR) : Math.floor(code / 16) + const kind = keys === undefined ? code - keyCode * FIELD_WIRE_FACTOR : Math.floor((code - keyCode * 16) / 2) + const key = keys === undefined + ? r.readUtf8(keyCode) + : decodeExtraKey(keys, code, keyCode, r) + if (seenKey(seen, key)) invalid("unique extra keys", undefined, r.options) + const signature = layout.extraAll ?? + (r.indexSignatures ??= new IndexSignatureCache(r.options)).find(layout, key) + if (signature === undefined) { + skipFieldPayload(kind, r) + return + } + issuePath[issuePathLen++] = key + const value = decodeFieldPayload(signature.layout, signature.wireMask, kind, r) + issuePathLen-- + if (value !== ABSENT) assignProperty(out, key, value) +} + +function decodeExtraKey(keys: Array, code: number, keyCode: number, r: Reader): string { + if ((code & 1) === 1) { + if (keyCode >= keys.length) invalid("a known back-reference", undefined, r.options) + return keys[keyCode] as string + } + const key = r.readUtf8(keyCode) + keys.push(key) + return key +} + +function missingKeyIssue(field: Field): SchemaIssue.Issue { + return new SchemaIssue.Pointer([field.name], new SchemaIssue.MissingKey(field.annotations)) +} + +function throwMissingKeys(layout: StructLayout, issues: Array): never { + throw issueError( + new SchemaIssue.Composite(layout.ast, issues as [SchemaIssue.Issue, ...Array]) + ) +} + +function skipFieldVarint(r: Reader) { + while ((r.byte() & 0x80) !== 0) { + // Scan without accumulating an attacker-controlled bigint. + } +} + +function skipFieldPayload(kind: number, r: Reader) { + switch (kind) { + case FIELD_WIRE_SIZED: + r.take(r.uvarint()) + return + case FIELD_WIRE_VARINT: + skipFieldVarint(r) + return + case FIELD_WIRE_FIXED64: + r.take(8) + return + case FIELD_WIRE_FALSE: + case FIELD_WIRE_TRUE: + case FIELD_WIRE_EMPTY: + return + case FIELD_WIRE_DECIMAL: + skipFieldVarint(r) + skipFieldVarint(r) + return + case FIELD_WIRE_FIXED32: + r.take(4) + return + } +} + +function fieldWireMask(layout: Layout): number { + if (layout._ === "literal") return fieldWireMask(layout.leaf) + switch (layout._) { + case "bool": + return (1 << FIELD_WIRE_FALSE) | (1 << FIELD_WIRE_TRUE) + case "number": + return (1 << FIELD_WIRE_VARINT) | (1 << FIELD_WIRE_FIXED64) | (1 << FIELD_WIRE_DECIMAL) + case "int": + return 1 << FIELD_WIRE_VARINT + default: + return 1 << FIELD_WIRE_SIZED + } +} + +function decodeScalarField(kind: number, r: Reader): unknown { + switch (kind) { + case FIELD_WIRE_FALSE: + return false + case FIELD_WIRE_TRUE: + return true + case FIELD_WIRE_VARINT: + return r.numberVarint() + case FIELD_WIRE_FIXED64: + return r.f64() + case FIELD_WIRE_DECIMAL: { + const mantissa = r.numberVarint() + const scale = r.uvarint() + if (scale === 0 || scale > DECIMAL_SCALE_MAX) invalid("a decimal scale in [1, 8]", scale, r.options) + return mantissa / POW10[scale] + } + } + return invalid("matching field wire kind", undefined, r.options) +} + +function decodeFieldPayload(layout: Layout, wireMask: number, kind: number, r: Reader): unknown { + if ((wireMask & (1 << kind)) === 0) { + skipFieldPayload(kind, r) + return ABSENT + } + if (kind !== FIELD_WIRE_SIZED) { + const value = decodeScalarField(kind, r) + if (layout._ === "literal" && !hasLiteral(layout, value)) { + invalid(literalExpected(layout), value, r.options) + } + return value + } + const saved = r.enter(r.uvarint()) + const value = decodeChecked(layout, r) + r.exit(saved) + return value +} + +function decodeStruct(layout: StructLayout, r: Reader): unknown { + // Only a run field with index signatures hands over an intern table. + let keys: Array | undefined + if (layout.extra.length > 0) { + keys = r.intern + r.intern = undefined + } + const out: Record = {} + // Use bitmasks for common structs and allocate sets only when needed. + let seenMask = 0 + let presentMask = 0 + let seenWide: Set | undefined + let presentWide: Set | undefined + let seenUnknown: Set | undefined + let seenExtra = false + // Fast-path fields in encoder order and fall back to the id map. + const fields = layout.fields + let cursor = 0 + while (r.pos < r.end) { + const tag = r.fieldTag() + const id = Math.floor(tag / FIELD_WIRE_FACTOR) + const kind = tag - id * FIELD_WIRE_FACTOR + if (id === 0) { + if (seenExtra) invalid("unique field ids", undefined, r.options) + seenExtra = true + if (kind !== FIELD_WIRE_SIZED) invalid("extra field map", undefined, r.options) + const saved = r.enter(r.uvarint()) + decodeExtraPairs(layout, r, out, keys) + r.exit(saved) + continue + } + let field: Field | undefined + if (cursor < fields.length && fields[cursor].id === id) { + field = fields[cursor] + cursor++ + } else { + field = layout.byId.get(id) + if (field !== undefined) cursor = field.index + 1 + } + if (field === undefined) { + if (seenUnknown === undefined) seenUnknown = new Set() + else if (seenUnknown.has(id)) invalid("unique field ids", undefined, r.options) + seenUnknown.add(id) + skipFieldPayload(kind, r) + continue + } + const index = field.index + if (index < 32) { + const bit = 1 << index + if ((seenMask & bit) !== 0) invalid("unique field ids", undefined, r.options) + seenMask |= bit + } else if (seenWide === undefined) { + seenWide = new Set([index]) + } else { + if (seenWide.has(index)) invalid("unique field ids", undefined, r.options) + seenWide.add(index) + } + issuePath[issuePathLen++] = field.name + const value = decodeFieldPayload(field.layout, field.wireMask, kind, r) + issuePathLen-- + if (value !== ABSENT) { + assignProperty(out, field.name, value) + if (index < 32) presentMask |= 1 << index + else (presentWide ??= new Set()).add(index) + } + } + checkRequiredFields(layout, presentMask, presentWide, r) + return out +} + +// Fails when a required field is absent, honoring `errors: "all"`. +function checkRequiredFields( + struct: StructLayout, + presentMask: number, + presentWide: Set | undefined, + r: Reader +): void { + if ((presentMask & struct.requiredMask) === struct.requiredMask && !struct.requiredWide) return + const fields = struct.fields + let issues: Array | undefined + for (let i = 0; i < fields.length; i++) { + const field = fields[i] + const index = field.index + const present = index < 32 ? (presentMask & (1 << index)) !== 0 : presentWide?.has(index) === true + if (!field.optional && !present) { + ;(issues ??= []).push(missingKeyIssue(field)) + if (r.options.errors !== "all") break + } + } + if (issues !== undefined) throwMissingKeys(struct, issues) +} + +function decodeStructPositional(layout: StructLayout, r: Reader): unknown { + // Only a run field with index signatures hands over an intern table. Extras + // decode after the fields here, so claim it before a nested field can. + let keys: Array | undefined + if (layout.extra.length > 0) { + keys = r.intern + r.intern = undefined + } + const out: Record = {} + const bitmapBytes = (layout.optionalCount + 7) >> 3 + let bitmap = 0 + if (bitmapBytes > 0) { + if (r.pos + bitmapBytes > r.end) invalid("complete value", undefined, r.options) + bitmap = r.pos + r.pos += bitmapBytes + } + const buf = r.buf + const fields = layout.fields + let optionalIndex = 0 + let issues: Array | undefined + for (let i = 0; i < fields.length; i++) { + const field = fields[i] + if (field.optional) { + const present = (buf[bitmap + (optionalIndex >> 3)] & (1 << (optionalIndex & 7))) !== 0 + optionalIndex++ + if (!present) continue + } + issuePath[issuePathLen++] = field.name + const value = field.inline ? decodeSlot(field.layout, r) : decodeSized(field.layout, r) + issuePathLen-- + if (value !== ABSENT) assignProperty(out, field.name, value) + else if (!field.optional) { + ;(issues ??= []).push(missingKeyIssue(field)) + if (r.options.errors !== "all") break + } + } + if (issues !== undefined) throwMissingKeys(layout, issues) + if (layout.extra.length > 0) { + const count = r.uvarint() + if (count > r.remaining) invalid("complete value", undefined, r.options) + if (count > 0) { + const seen: SeenKeys = { list: [], set: undefined } + for (let i = 0; i < count; i++) decodeExtraPair(layout, r, out, seen, keys) + } + } + return out +} + +function decodeExtraPairs( + layout: StructLayout, + r: Reader, + out: Record, + keys?: Array | undefined +) { + const seen: SeenKeys = { list: [], set: undefined } + while (r.pos < r.end) decodeExtraPair(layout, r, out, seen, keys) +} + +// A row shape is the writer's tag order: a known field, `null` for the extra +// block, or `undefined` for a field this reader does not have. +// A future shape version can retain scalar wire kinds here so reused rows can +// omit their per-value extents too. +type RunSlot = Field | null | undefined + +function decodeInterned(table: Array, layout: Layout, r: Reader): unknown { + const code = r.uvarint() + if ((code & 1) === 1) { + const ref = (code - 1) / 2 + if (ref >= table.length) invalid("a known back-reference", undefined, r.options) + return table[ref] + } + // Strings consume their whole region, so they skip the reader window. + if (layout._ === "string") { + const value = r.readUtf8(code / 2) + table.push(value) + return value + } + const saved = r.enter(code / 2) + const value = decodeChecked(layout, r) + r.exit(saved) + table.push(value) + return value +} + +function decodeStructRun(plan: RunPlan, count: number, r: Reader): Array { + const out: Array = new Array(count) + const shapes: Array> = [] + const tables: Array | undefined> = [] + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + const saved = r.enter(r.uvarint()) + out[i] = decodeRunRow(plan, shapes, tables, r) + r.exit(saved) + issuePathLen-- + } + return out +} + +// A positional shape is the presence mask over layout field order. +function positionalRunShape(struct: StructLayout, r: Reader): Array { + const mask = r.uvarint() + const fields = struct.fields + const fieldBits = mask & ~RUN_EXTRA_BIT + if (mask > RUN_EXTRA_BIT * 2 - 1 || (fieldBits >>> fields.length) !== 0) { + invalid("a known row shape", undefined, r.options) + } + const shape: Array = [] + if ((mask & RUN_EXTRA_BIT) !== 0) shape.push(null) + for (let i = 0; i < fields.length; i++) { + if ((fieldBits & (1 << i)) !== 0) shape.push(fields[i]) + } + return shape +} + +function decodeRunRow( + plan: RunPlan, + shapes: Array>, + tables: Array | undefined>, + r: Reader +): unknown { + const struct = plan.struct + const out: Record = {} + let presentMask = 0 + let presentWide: Set | undefined + const code = r.uvarint() + let shape: Array | undefined + if (code === 0) { + if (r.positional && plan.shapes) { + shape = positionalRunShape(struct, r) + shapes.push(shape) + } + } else { + shape = shapes[code - 1] + if (shape === undefined) invalid("a known row shape", undefined, r.options) + } + if (shape !== undefined) { + for (let i = 0; i < shape.length; i++) { + const filled = decodeRunSlot(plan, tables, shape[i], out, r) + if (filled >= 0) { + if (filled < 32) presentMask |= 1 << filled + else (presentWide ??= new Set()).add(filled) + } + } + if (r.pos !== r.end) invalid("no leftover bytes", undefined, r.options) + } else { + const declared: Array | undefined = plan.shapes ? [] : undefined + let seenMask = 0 + let seenWide: Set | undefined + let seenIds: Set | undefined + let seenExtra = false + while (r.pos < r.end) { + const id = r.uvarint() + let slot: RunSlot + if (id === 0) { + if (seenExtra) invalid("unique field ids", undefined, r.options) + seenExtra = true + slot = null + } else { + slot = struct.byId.get(id) + if (slot === undefined) { + if (seenIds === undefined) seenIds = new Set([id]) + else if (seenIds.has(id)) invalid("unique field ids", undefined, r.options) + else seenIds.add(id) + } else if (slot.index < 32) { + if ((seenMask & (1 << slot.index)) !== 0) invalid("unique field ids", undefined, r.options) + seenMask |= 1 << slot.index + } else if (seenWide === undefined) { + seenWide = new Set([slot.index]) + } else { + if (seenWide.has(slot.index)) invalid("unique field ids", undefined, r.options) + seenWide.add(slot.index) + } + } + declared?.push(slot) + const filled = decodeRunSlot(plan, tables, slot, out, r) + if (filled >= 0) { + if (filled < 32) presentMask |= 1 << filled + else (presentWide ??= new Set()).add(filled) + } + } + if (declared !== undefined) shapes.push(declared) + } + checkRequiredFields(struct, presentMask, presentWide, r) + return out +} + +// Returns the field index this slot filled, or -1. +function decodeRunSlot( + plan: RunPlan, + tables: Array | undefined>, + slot: RunSlot, + out: Record, + r: Reader +): number { + const code = r.uvarint() + if ((code & 1) === 1) { + if (slot === null || slot === undefined) return -1 + const table = tables[slot.index] + const ref = (code - 1) / 2 + if (table === undefined || ref >= table.length) { + invalid("a known back-reference", undefined, r.options) + } + const value = table[ref] + if (value === ABSENT) return -1 + assignProperty(out, slot.name, value) + return slot.index + } + const saved = r.enter(code / 2) + if (slot === null) { + decodeExtraPairs(plan.struct, r, out) + r.exit(saved) + return -1 + } + if (slot === undefined) { + r.exit(saved) + return -1 + } + const kind = plan.intern[slot.index] + if (kind === INTERN_ELEMENTS || kind === INTERN_KEYS) { + r.intern = tables[slot.index] ??= [] + } + issuePath[issuePathLen++] = slot.name + const value = decodeChecked(slot.layout, r) + issuePathLen-- + r.intern = undefined + r.exit(saved) + if (kind === INTERN_SELF) (tables[slot.index] ??= []).push(value) + if (value === ABSENT) return -1 + assignProperty(out, slot.name, value) + return slot.index +} + +function decodeArray(layout: ArrayLayout, r: Reader): unknown { + const elementLen = layout.elements.length + const count = layout.hasCount ? r.uvarint() : elementLen + // Cap allocation amplification from zero-width slots. + if (layout.hasCount && count > r.remaining + 1_048_576) { + invalid("array count within allocation limit", count, r.options) + } + if (layout.rest.length === 0 && count > elementLen) { + issuePath[issuePathLen++] = elementLen + throw issueError(new SchemaIssue.UnexpectedKey(layout.ast, undefined, r.options)) + } + if (count < layout.minCount) { + issuePath[issuePathLen++] = count + throw issueError(new SchemaIssue.MissingKey(undefined)) + } + if (count > 0) { + const plan = runPlan(layout) + if (plan !== null) return decodeStructRun(plan, count, r) + } + const out: Array = new Array(count) + const uniform = layout.uniform + if (uniform !== undefined) { + if (layout.uniformNumbers) return decodeNumberRun(out, count, r) + // Only a run field holding an array of strings hands over an intern table. + const table = r.intern + r.intern = undefined + if (table !== undefined) { + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + const value = decodeInterned(table, uniform, r) + if (value === ABSENT) throw issueError(new SchemaIssue.MissingKey(undefined)) + issuePathLen-- + out[i] = value + } + return out + } + if (isSelfDelimiting(uniform)) { + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + out[i] = decodeValue(uniform, r) + issuePathLen-- + } + return out + } + // Strings consume their whole region, so they skip the reader window. + if (uniform._ === "string") { + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + out[i] = r.readUtf8(r.uvarint()) + issuePathLen-- + } + return out + } + const packed = layout.uniformPacked + const inline = layout.uniformInline + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + const saved = r.enter(inline ? packed ?? 0 : r.uvarint()) + const value = decodeChecked(uniform, r) + r.exit(saved) + if (value === ABSENT) throw issueError(new SchemaIssue.MissingKey(undefined)) + issuePathLen-- + out[i] = value + } + return out + } + for (let i = 0; i < count; i++) { + const slot = arraySlot(layout, i, count) + const optional = i < elementLen && layout.elements[i].optional + if (!layout.hasCount && r.remaining === 0 && !optional && slot._ !== "null" && slot._ !== "undefined") { + issuePath[issuePathLen++] = i + throw issueError(new SchemaIssue.MissingKey(undefined)) + } + issuePath[issuePathLen++] = i + const value = decodeSlot(slot, r) + if (value === ABSENT) { + if (optional) invalid("known union member", undefined, r.options) + throw issueError(new SchemaIssue.MissingKey(undefined)) + } + issuePathLen-- + out[i] = value + } + if (!layout.hasCount && r.pos < r.end) { + issuePath[issuePathLen++] = elementLen + throw issueError(new SchemaIssue.UnexpectedKey(layout.ast, undefined, r.options)) + } + return out +} + +function decodeNumberRun(out: Array, count: number, r: Reader): Array { + const mode = r.byte() + if (mode === NUMBER_RUN_F64) { + if (r.remaining !== count * 8) invalid("f64", undefined, r.options) + for (let i = 0; i < count; i++) out[i] = r.f64() + return out + } + if (mode === NUMBER_RUN_DECIMAL) { + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + const code = r.uvarint() + const scale = code % 16 + if (scale > DECIMAL_SCALE_MAX) invalid("decimal", undefined, r.options) + const value = decodeSignMagnitude((code - scale) / 16) + out[i] = scale === 0 ? value : value / POW10[scale] + issuePathLen-- + } + return out + } + if (mode !== NUMBER_RUN_VARINT) invalid("f64", undefined, r.options) + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + out[i] = r.numberVarint() + issuePathLen-- + } + return out +} + +function decodeUnion(layout: UnionLayout, r: Reader): unknown { + const kind = r.byte() + if (kind === K.variant) { + const tag = r.u32le() + const variant = layout.byTag.get(tag) + if (variant === undefined) { + r.take(r.remaining) + return ABSENT + } + const payload = decodeChecked(variant.payload, r) + if (payload === ABSENT) return ABSENT + if (!variant.tuple) { + for (const sentinel of variant.sentinels) { + assignProperty(payload as object, sentinel.key, sentinel.literal) + } + } + return payload + } + const member = layout.byKind.get(kind) + if (member === undefined) { + r.take(r.remaining) + return ABSENT + } + return decodeChecked(member, r) +} + +// Fingerprint mode rejects selectors outside its canonical member table. +function decodeUnionPositional(layout: UnionLayout, r: Reader): unknown { + const position = layout.byPos[r.uvarint()] + if (position === undefined) invalid("known union member", undefined, r.options) + const payload = decodeChecked(position.layout, r) + const variant = position.variant + if (variant !== undefined && !variant.tuple && payload !== ABSENT) { + for (const sentinel of variant.sentinels) { + assignProperty(payload as object, sentinel.key, sentinel.literal) + } + } + return payload +} + +function decodeReason(layout: ReasonLayout, r: Reader): unknown { + const tag = r.byte() + switch (tag) { + case 0: + return Cause.makeFailReason(requirePresent(decodeChecked(layout.error, r))) + case 1: + return Cause.makeDieReason(requirePresent(decodeChecked(layout.defect, r))) + case 2: + if (r.remaining !== 0) invalid("empty", undefined, r.options) + return Cause.makeInterruptReason() + case 3: { + if (r.remaining !== 8) invalid("f64", undefined, r.options) + return Cause.makeInterruptReason(r.f64()) + } + default: + r.take(r.remaining) + return ABSENT + } +} + +function requirePresent(value: unknown): unknown { + if (value === ABSENT) throw issueError(new SchemaIssue.MissingKey(undefined)) + return value +} + +function decodeValue(layout: Layout, r: Reader): unknown { + switch (layout._) { + case "literal": { + const value = decodeValue(layout.leaf, r) + if (!hasLiteral(layout, value)) invalid(literalExpected(layout), value, r.options) + return value + } + case "bool": { + if (r.remaining !== 1) invalid("bool", undefined, r.options) + const b = r.byte() + if (b > 1) invalid("bool", undefined, r.options) + return b === 1 + } + case "null": + if (r.remaining !== 0) invalid("empty", undefined, r.options) + return null + case "undefined": + if (r.remaining !== 0) invalid("empty", undefined, r.options) + return undefined + case "number": { + const len = r.remaining + if (len === 8) return r.f64() + if (len === 0 || len > NUMBER_VARINT_MAX_BYTES) invalid("f64", undefined, r.options) + const value = r.numberVarint() + if (r.remaining === 0) return value + const scale = r.byte() + if (r.remaining !== 0 || scale === 0 || scale > DECIMAL_SCALE_MAX) { + invalid("decimal", undefined, r.options) + } + return value / POW10[scale] + } + case "int": + return r.numberVarint() + case "string": { + const dict = r.dict + return dict === undefined ? r.readUtf8(r.end - r.pos) : decodeString(dict, layout, r) + } + case "symbol": + return globalThis.Symbol.for(r.readUtf8(r.end - r.pos)) + case "bytes": + return r.takeCopy(r.end - r.pos) + case "bigint": + return r.zigzag() + case "int64": { + if (r.remaining !== 8) invalid("int64", undefined, r.options) + const millis = Number(r.i64()) + if (layout.flavor !== "date") return DateTime.makeUnsafe(millis) + const date = new Date(millis) + if (Number.isNaN(date.getTime())) invalid("a valid Date", millis, r.options) + return date + } + case "dateTimeZoned": { + const millis = Number(r.i64()) + const tag = r.byte() + let timeZone: number | string + if (tag === 0) { + if (r.remaining !== 4) invalid("time zone", undefined, r.options) + timeZone = r.i32le() + } else if (tag === 1) { + timeZone = r.readUtf8(r.end - r.pos) + } else { + return invalid("time zone", undefined, r.options) + } + try { + return DateTime.makeZonedUnsafe(millis, { timeZone }) + } catch { + return invalid("time zone", undefined, r.options) + } + } + case "duration": { + const tag = r.byte() + switch (tag) { + case 0: + return Duration.nanos(r.zigzag()) + case 1: + return Duration.infinity + case 2: + return Duration.negativeInfinity + default: + return invalid("duration", undefined, r.options) + } + } + case "bigDecimal": { + const value = r.zigzag() + const scale = r.zigzag() + if (scale > MAX_SAFE_BIGINT || -scale > MAX_SAFE_BIGINT) invalid("safe integer length", undefined, r.options) + return BigDecimal.make(value, Number(scale)) + } + case "json": { + const text = r.readUtf8(r.end - r.pos) + try { + return JSON.parse(text) + } catch { + return invalid("json", undefined, r.options) + } + } + case "option": { + const tag = r.byte() + if (tag === 0) { + if (r.remaining !== 0) invalid("empty", undefined, r.options) + return Option.none() + } + if (tag !== 1) invalid("bool", undefined, r.options) + return Option.some(requirePresent(decodeChecked(layout.value, r))) + } + case "result": { + const tag = r.byte() + if (tag === 0) return Result.succeed(requirePresent(decodeChecked(layout.success, r))) + if (tag !== 1) invalid("bool", undefined, r.options) + return Result.fail(requirePresent(decodeChecked(layout.failure, r))) + } + case "exit": { + const tag = r.byte() + if (tag === 0) return Exit.succeed(requirePresent(decodeChecked(layout.value, r))) + if (tag !== 1) invalid("bool", undefined, r.options) + const cause = decodeValue(layout.cause, r) + return Exit.failCause(cause as Cause.Cause) + } + case "cause": { + const count = r.uvarint() + const reasons: Array> = [] + for (let i = 0; i < count; i++) { + issuePath[issuePathLen++] = i + const saved = r.enter(r.uvarint()) + const reason = decodeReason(layout, r) + r.exit(saved) + issuePathLen-- + if (reason !== ABSENT) reasons.push(reason as Cause.Reason) + } + if (r.pos !== r.end) invalid("no leftover bytes", undefined, r.options) + return Cause.fromReasons(reasons) + } + case "causeReason": + return decodeReason(layout, r) + case "struct": + return r.positional ? decodeStructPositional(layout, r) : decodeStruct(layout, r) + case "array": + return decodeArray(layout, r) + case "union": + return r.positional ? decodeUnionPositional(layout, r) : decodeUnion(layout, r) + case "never": + throw issueError(new SchemaIssue.InvalidType(layout.ast, undefined, r.options)) + } +} + +function decodeFrameBody(layout: Layout, r: Reader, mode: Mode): unknown { + const envelope = r.byte() + if (envelope !== mode.envelope) invalid(mode.expectedEnvelope, envelope, r.options) + if (mode.positional) { + if (r.remaining < 8) invalid("complete value", undefined, r.options) + const buf = r.buf + const expected = mode.fingerprint + const pos = r.pos + for (let i = 0; i < 8; i++) { + if (buf[pos + i] !== expected[i]) invalid("matching layout fingerprint", undefined, r.options) + } + r.pos = pos + 8 + } + const value = decodeChecked(layout, r) + if (value === ABSENT) throw issueError(new SchemaIssue.MissingKey(undefined)) + return value +} + +// Reuse one top-level reader while allowing nested codecs to allocate their own. +let pooledReader: Reader | undefined = new Reader() + +function decodeOneShot( + layout: Layout, + bytes: Uint8Array, + options: SchemaAST.ParseOptions, + mode: Mode +): unknown { + const r = pooledReader ?? new Reader() + const pooled = r === pooledReader + if (pooled) pooledReader = undefined + r.reset(bytes, 0, bytes.length, options, undefined, mode.positional, undefined) + const savedPathLen = issuePathLen + issuePathLen = 0 + try { + const n = r.uvarint() + if (n === 0) invalid("nonzero frame length", undefined, options) + const saved = r.enter(n) + const value = decodeFrameBody(layout, r, mode) + r.exit(saved) + if (r.pos !== bytes.length) invalid("no leftover bytes", undefined, options) + return value + } finally { + issuePathLen = savedPathLen + r.release() + if (pooled) pooledReader = r + } +} + +function makeTransformation( + layout: Layout, + mode: Mode, + trusted: Trusted | undefined, + successOnly = false +): SchemaTransformation.Transformation> { + return SchemaTransformation.transformOrFail({ + decode: (bytes: Uint8Array, options) => { + try { + const value = decodeOneShot(layout, bytes, options, mode) + if ( + trusted !== undefined && Predicate.isObjectOrArray(value) && + (!successOnly || isSuccessExit(value)) + ) trusted.value = value + return Effect.succeed(value) + } catch (e) { + return e instanceof IssueError ? Effect.fail(e.issue) : Effect.die(e) + } + }, + encode: (value: unknown, options) => { + try { + return Effect.succeed(encodeFrame(layout, value, options, mode)) + } catch (e) { + return e instanceof IssueError ? Effect.fail(e.issue) : Effect.die(e) + } + } + }) +} + +const fingerprintModeMemo = memoize(fingerprintMode) + +function compileMode(layout: Layout, fingerprint: boolean | undefined): Mode { + return fingerprint === true ? fingerprintModeMemo(layout) : defaultMode +} + +interface CompiledTarget { + readonly target: Schema.Constraint + readonly layout: Layout + readonly exact: boolean + readonly exitSuccess: boolean + // A recursive schema can hold a cyclic value, which only the schema pass + // detects, so encoding one keeps that pass even when the layout is exact. + readonly recursive: boolean +} + +const compileTargetFromAst = memoize((ast: SchemaAST.AST): CompiledTarget => { + const raw = Schema.make(toBinaryAST(ast)) + const { exact, exitSuccess, layout, recursive } = compileLayout(raw.ast) + // Only recursive schemas need the cycle walk. + return { target: recursive ? withCycleGuard(raw) : raw, layout, exact, exitSuccess, recursive } +}) + +function compileTarget(schema: Schema.Constraint): CompiledTarget { + return compileTargetFromAst(schema.ast) +} + +// The value the binary decoder produced most recently. The schema pass around +// the decode runs straight after it, so one slot is all the handoff needs, and +// a value that misses the slot just pays the real check. The slot holds that +// one value until the next decode replaces it. +interface Trusted { + value: object | undefined +} + +function takeTrusted(trusted: Trusted, input: unknown): boolean { + if (trusted.value === undefined || input !== trusted.value) return false + trusted.value = undefined + return true +} + +// A target that bypasses the schema pass when `accept` takes the input, and +// otherwise runs the target's type schema. The two directions get their own +// predicate: `flip` swaps the declaration's encoding run in when encoding, the +// way it already swaps `encodingChecks`. +function bypassPass( + target: Schema.Constraint, + decodeAccept: (input: unknown, options: SchemaAST.ParseOptions) => boolean, + encodeAccept: (input: unknown, options: SchemaAST.ParseOptions) => boolean +): Schema.Constraint { + const type = Schema.make(SchemaAST.toType(target.ast)) + const parse = SchemaParser.decodeUnknownEffect(type as Schema.ConstraintDecoder) + // A bypassed input comes back unchanged, which the shared "same value" exit + // says without allocating one per call. + const run = ( + accept: (input: unknown, options: SchemaAST.ParseOptions) => boolean + ): SchemaAST.DeclarationRun => + () => + (input, _ast, options) => accept(input, options) ? InternalParser.sameExit : parse(input, options) + return Schema.make( + new SchemaAST.Declaration( + [type.ast], + run(decodeAccept), + { identifier: "binary value" }, + undefined, + undefined, + undefined, + undefined, + run(encodeAccept) + ) + ) +} + +// The binary layer drops unknown keys instead of reporting them, so only +// `onExcessProperty: "error"` needs the schema pass to see the input. +function reportsExcess(_input: unknown, options: SchemaAST.ParseOptions): boolean { + return options.onExcessProperty !== "error" +} + +// The binary layer already validates an exact schema in both directions, so the +// schema pass around it repeats that work. Encoding skips it outright. Decoding +// only lets values the binary layer just produced through, so every other +// input, `Schema.is` included, still runs the real check. +function withTrustedDecode( + target: Schema.Constraint, + trusted: Trusted, + bypassEncode: boolean +): Schema.Constraint { + return bypassPass( + target, + (input) => takeTrusted(trusted, input), + // A recursive schema keeps the pass on encode: decoding cannot build a + // cycle, but a caller can hand one in, and the cycle walk lives there. + bypassEncode ? reportsExcess : constFalse + ) +} + +function constFalse(): boolean { + return false +} + +// Success exits with an exact success schema are fully validated by the binary +// layer, so only failure exits pay the schema pass for their cause's error and +// defect encodings. The type parameter flips with the direction, so the same +// fallback decodes binary failure exits and encodes failure exits back to the +// wire representation. Under `onExcessProperty: "error"` only decoder-produced +// values bypass the pass: the binary layer drops unknown keys instead of +// reporting them, so other success exits keep the schema pass. It is not a +// sound `Schema.is` guard, which is why only {@link toCodecDirect} uses it. +function withExitSuccessDecode(target: Schema.Constraint, trusted: Trusted): Schema.Constraint { + return Schema.declareConstructor()( + [target], + ([codec]) => { + const parse = SchemaParser.decodeUnknownEffect(codec as Schema.ConstraintDecoder) + return (input, _ast, options) => + takeTrusted(trusted, input) || + (isSuccessExit(input) && options.onExcessProperty !== "error") + ? Effect.succeed(input) + : parse(input, options) + }, + { identifier: "binary exit value" } + ) +} + +// An exact schema is fully validated by the binary layer in both directions, so +// this target adds nothing, except under `onExcessProperty: "error"`. It is not +// a sound `Schema.is` guard, which is why only {@link toCodecDirect} uses it. +function passThrough(target: Schema.Constraint): Schema.Constraint { + return bypassPass(target, reportsExcess, reportsExcess) +} + +// Skip the cycle walk once for structurally decoded values. +function withCycleGuard(target: Schema.Constraint): Schema.Constraint { + const type = Schema.make(SchemaAST.toType(target.ast)) + const decoded = new WeakSet() + const guard = Schema.declareConstructor()( + [type], + ([type]) => (input, ast, options) => { + if (Predicate.isObjectOrArray(input) && decoded.delete(input)) { + return Effect.succeed(input) + } + return isCyclic(input) + ? Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + : SchemaParser.decodeUnknownEffect(type)(input, options) + }, + { identifier: "acyclic value" } + ) + return Schema.decodeTo( + guard, + SchemaTransformation.transform({ + decode: (value) => { + if (Predicate.isObjectOrArray(value)) decoded.add(value) + return value + }, + encode: (value) => value + }) + )(target) +} diff --git a/repos/effect/packages/effect/src/unstable/encoding/index.ts b/repos/effect/packages/effect/src/unstable/encoding/index.ts index da43977da5..7c5a50a3fb 100644 --- a/repos/effect/packages/effect/src/unstable/encoding/index.ts +++ b/repos/effect/packages/effect/src/unstable/encoding/index.ts @@ -19,6 +19,11 @@ export * as Msgpack from "./Msgpack.ts" */ export * as Ndjson from "./Ndjson.ts" +/** + * @since 4.0.0 + */ +export * as SchemaBinary from "./SchemaBinary.ts" + /** * @since 4.0.0 */ diff --git a/repos/effect/packages/effect/src/unstable/eventlog/EventJournal.ts b/repos/effect/packages/effect/src/unstable/eventlog/EventJournal.ts index c80bf95bc2..03e2b1144d 100644 --- a/repos/effect/packages/effect/src/unstable/eventlog/EventJournal.ts +++ b/repos/effect/packages/effect/src/unstable/eventlog/EventJournal.ts @@ -11,13 +11,15 @@ * * @since 4.0.0 */ -import * as Uuid from "uuid" +import * as Arr from "../../Array.ts" import type { Brand } from "../../Brand.ts" import * as Context from "../../Context.ts" import * as Data from "../../Data.ts" import * as DateTime from "../../DateTime.ts" import * as Effect from "../../Effect.ts" +import * as Uuid from "../../internal/uuid.ts" import * as Layer from "../../Layer.ts" +import type { Option } from "../../Option.ts" import * as Order from "../../Order.ts" import * as PubSub from "../../PubSub.ts" import * as Schema from "../../Schema.ts" @@ -78,12 +80,16 @@ export class EventJournal extends Context.Service /** - * Return the uncommitted entries for a remote source. + * Run an effect with the uncommitted entries for a remote source. + * + * The effect is not run when there are no uncommitted entries, in which case + * `Option.none()` is returned. Otherwise, its result is wrapped in + * `Option.some()`. */ readonly withRemoteUncommited: ( remoteId: RemoteId, - f: (entries: ReadonlyArray) => Effect.Effect - ) => Effect.Effect + f: (entries: Arr.NonEmptyReadonlyArray) => Effect.Effect + ) => Effect.Effect, EventJournalError | E, R> /** * Retrieve the first unused sequence number for a remote source. @@ -178,7 +184,7 @@ export const RemoteId = Schema.Uint8Array.pipe(Schema.brand(RemoteIdTypeId)) * @category unsafe * @since 4.0.0 */ -export const makeRemoteIdUnsafe = (): RemoteId => Uuid.v4({}, new globalThis.Uint8Array(16)) as RemoteId +export const makeRemoteIdUnsafe = (): RemoteId => Uuid.v4Bytes() as RemoteId /** * Runtime brand identifier used for `EntryId` values. @@ -247,7 +253,7 @@ export const EntryIdOrder = Order.make((a, b) => { * @since 4.0.0 */ export const makeEntryIdUnsafe = (options: { msecs?: number } = {}): EntryId => - Uuid.v7(options, new globalThis.Uint8Array(16)) as EntryId + Uuid.v7Bytes(options.msecs ?? DateTime.nowUnsafe().epochMilliseconds) as EntryId /** * Extracts the millisecond timestamp encoded in a UUID v7 `EntryId`. @@ -471,7 +477,7 @@ export const makeMemory: Effect.Effect = Effect.gen(fun withRemoteUncommited: (remoteId, f) => Effect.acquireUseRelease( Effect.sync(() => ensureRemote(remoteId).missing.slice()), - f, + (entries) => Arr.isReadonlyArrayNonEmpty(entries) ? Effect.asSome(f(entries)) : Effect.succeedNone, (entries, exit) => Effect.sync(() => { if (exit._tag === "Failure") return @@ -705,7 +711,7 @@ export const makeIndexedDb = (options?: { return Effect.sync(() => tx.abort()) }).pipe( Effect.flatMap((entries) => { - if (entries.length === 0) return f(entries) + if (!Arr.isReadonlyArrayNonEmpty(entries)) return Effect.succeedNone const entryId = entries[entries.length - 1].id return Effect.uninterruptibleMask((restore) => restore(f(entries)).pipe( @@ -715,7 +721,8 @@ export const makeIndexedDb = (options?: { remoteId, entryId })) - ) + ), + Effect.asSome ) ) }) diff --git a/repos/effect/packages/effect/src/unstable/eventlog/EventLog.ts b/repos/effect/packages/effect/src/unstable/eventlog/EventLog.ts index faa5a3b0c1..27c2fad9a5 100644 --- a/repos/effect/packages/effect/src/unstable/eventlog/EventLog.ts +++ b/repos/effect/packages/effect/src/unstable/eventlog/EventLog.ts @@ -763,6 +763,11 @@ export const makeReplayFromRemote = (options: { }) ) +const remoteRetrySchedule = Schedule.min([ + Schedule.exponential(200, 1.5), + Schedule.spaced({ seconds: 10 }) +]) + const make = Effect.gen(function*() { const storeId = yield* CurrentStoreId const identity = yield* Identity @@ -860,12 +865,7 @@ const make = Effect.gen(function*() { }).pipe( Effect.scoped, Effect.catchCause(Effect.logError), - Effect.repeat( - Schedule.min([ - Schedule.exponential(200, 1.5), - Schedule.spaced({ seconds: 10 }) - ]) - ), + Effect.repeat(remoteRetrySchedule), Effect.annotateLogs({ service: "EventLog", effect: "runRemote consume" @@ -874,11 +874,21 @@ const make = Effect.gen(function*() { ) const write = journal.withRemoteUncommited(remote.id, (entries) => remote.write({ identity, entries, storeId })) + const writeUntilSuccess = write.pipe( + Effect.tapCause(Effect.logDebug), + Effect.retry(remoteRetrySchedule) + ) yield* Effect.addFinalizer(() => Effect.ignore(write)) - yield* write const changesSub = yield* journal.changes - return yield* PubSub.takeAll(changesSub).pipe( - Effect.andThen(write), + const changes = yield* Queue.dropping(1) + yield* PubSub.takeAll(changesSub).pipe( + Effect.andThen(Queue.offer(changes, undefined)), + Effect.forever, + Effect.forkScoped + ) + yield* writeUntilSuccess + return yield* Queue.take(changes).pipe( + Effect.andThen(writeUntilSuccess), Effect.catchCause(Effect.logError), Effect.forever ) diff --git a/repos/effect/packages/effect/src/unstable/eventlog/EventLogServerEncrypted.ts b/repos/effect/packages/effect/src/unstable/eventlog/EventLogServerEncrypted.ts index 642306b4ca..5445265bfe 100644 --- a/repos/effect/packages/effect/src/unstable/eventlog/EventLogServerEncrypted.ts +++ b/repos/effect/packages/effect/src/unstable/eventlog/EventLogServerEncrypted.ts @@ -10,10 +10,10 @@ * * @since 4.0.0 */ -import * as Uuid from "uuid" import * as Arr from "../../Array.ts" import * as Context from "../../Context.ts" import * as Effect from "../../Effect.ts" +import * as Uuid from "../../internal/uuid.ts" import * as Layer from "../../Layer.ts" import * as PubSub from "../../PubSub.ts" import * as RcMap from "../../RcMap.ts" diff --git a/repos/effect/packages/effect/src/unstable/eventlog/SqlEventJournal.ts b/repos/effect/packages/effect/src/unstable/eventlog/SqlEventJournal.ts index 23beb8a379..319c36d370 100644 --- a/repos/effect/packages/effect/src/unstable/eventlog/SqlEventJournal.ts +++ b/repos/effect/packages/effect/src/unstable/eventlog/SqlEventJournal.ts @@ -8,8 +8,9 @@ * * @since 4.0.0 */ -import * as Uuid from "uuid" +import * as Arr from "../../Array.ts" import * as Effect from "../../Effect.ts" +import * as Uuid from "../../internal/uuid.ts" import * as Layer from "../../Layer.ts" import * as PubSub from "../../PubSub.ts" import * as Schema from "../../Schema.ts" @@ -249,7 +250,8 @@ export const make = (options?: { Effect.flatMap(decodeEntryRows), Effect.map(toEntries) ) - return yield* f(entries) + if (!Arr.isReadonlyArrayNonEmpty(entries)) return yield* Effect.succeedNone + return yield* Effect.asSome(f(entries)) }, withTracerDisabled, Effect.mapError((cause) => new EventJournal.EventJournalError({ cause, method: "withRemoteUncommited" })) diff --git a/repos/effect/packages/effect/src/unstable/http/FetchHttpClient.ts b/repos/effect/packages/effect/src/unstable/http/FetchHttpClient.ts index f4019dd72f..2176499eba 100644 --- a/repos/effect/packages/effect/src/unstable/http/FetchHttpClient.ts +++ b/repos/effect/packages/effect/src/unstable/http/FetchHttpClient.ts @@ -52,7 +52,7 @@ export class RequestInit extends Context.Service { const fetch = fiber.getRef(Fetch) - const options: globalThis.RequestInit = fiber.context.mapUnsafe.get(RequestInit.key) ?? {} + const options: globalThis.RequestInit = Context.getOrUndefined(fiber.context, RequestInit) ?? {} let headers = options.headers ? Headers.merge(Headers.fromInput(options.headers as Headers.Input), request.headers) : request.headers diff --git a/repos/effect/packages/effect/src/unstable/http/Headers.ts b/repos/effect/packages/effect/src/unstable/http/Headers.ts index d46772524e..3005bdd3a0 100644 --- a/repos/effect/packages/effect/src/unstable/http/Headers.ts +++ b/repos/effect/packages/effect/src/unstable/http/Headers.ts @@ -416,6 +416,35 @@ export const redact: { } ) +/** + * Checks whether a header name matches one of the redaction patterns. + * + * **Details** + * + * String patterns are compared case-insensitively against the header name; + * regular expressions are tested against it. Use to avoid the record copy of + * `redact` when only a membership check is needed. + * + * @category combinators + * @since 4.0.0 + */ +export const isRedactedName = ( + name: string, + patterns: ReadonlyArray +): boolean => { + for (let i = 0; i < patterns.length; i++) { + const pattern = patterns[i] + if (typeof pattern === "string") { + if (pattern.toLowerCase() === name) { + return true + } + } else if (pattern.test(name)) { + return true + } + } + return false +} + /** * Context reference listing header names or patterns that should be redacted when `Headers` are inspected or rendered. * diff --git a/repos/effect/packages/effect/src/unstable/http/HttpClient.ts b/repos/effect/packages/effect/src/unstable/http/HttpClient.ts index c3a1a37c1f..fb736c201b 100644 --- a/repos/effect/packages/effect/src/unstable/http/HttpClient.ts +++ b/repos/effect/packages/effect/src/unstable/http/HttpClient.ts @@ -688,10 +688,12 @@ export const make = ( } const redactedHeaderNames = fiber.getRef(Headers.CurrentRedactedNames) const headerFilter = fiber.getRef(TracerHeaderFilter) - const redactedHeaders = Headers.redact(request.headers, redactedHeaderNames) - for (const name in redactedHeaders) { + for (const name in request.headers) { if (!headerFilter(name, "request")) continue - span.attribute(`http.request.header.${name}`, String(redactedHeaders[name])) + span.attribute( + `http.request.header.${name}`, + Headers.isRedactedName(name, redactedHeaderNames) ? "" : request.headers[name] + ) } request = fiber.getRef(TracerPropagationEnabled) ? HttpClientRequest.setHeaders(request, TraceContext.toHeaders(span)) @@ -702,10 +704,12 @@ export const make = ( Effect.matchCauseEffect({ onSuccess: (response) => { span.attribute("http.response.status_code", response.status) - const redactedHeaders = Headers.redact(response.headers, redactedHeaderNames) - for (const name in redactedHeaders) { + for (const name in response.headers) { if (!headerFilter(name, "response")) continue - span.attribute(`http.response.header.${name}`, String(redactedHeaders[name])) + span.attribute( + `http.response.header.${name}`, + Headers.isRedactedName(name, redactedHeaderNames) ? "" : response.headers[name] + ) } if (scopedController) return Effect.succeed(response) diff --git a/repos/effect/packages/effect/src/unstable/http/HttpClientResponse.ts b/repos/effect/packages/effect/src/unstable/http/HttpClientResponse.ts index f070fe645d..5bd5aed55b 100644 --- a/repos/effect/packages/effect/src/unstable/http/HttpClientResponse.ts +++ b/repos/effect/packages/effect/src/unstable/http/HttpClientResponse.ts @@ -96,17 +96,18 @@ export const schemaJson = < RD >( schema: Schema.ConstraintCodec, - options?: ParseOptions | undefined + options?: (ParseOptions & HttpIncomingMessage.JsonOptions) | undefined ) => { const decode = Schema.decodeEffect(Schema.toCodecJson(schema).annotate({ options })) + const decodeBody = HttpIncomingMessage.schemaBodyJson(Schema.Unknown, options) return ( self: HttpClientResponse ): Effect.Effect => - Effect.flatMap(self.json, (body) => + Effect.flatMap(decodeBody(self), (body) => decode({ status: self.status, headers: self.headers, - body + body: body as Schema.Json })) } diff --git a/repos/effect/packages/effect/src/unstable/http/HttpEffect.ts b/repos/effect/packages/effect/src/unstable/http/HttpEffect.ts index 872493ea5f..013e7d3f76 100644 --- a/repos/effect/packages/effect/src/unstable/http/HttpEffect.ts +++ b/repos/effect/packages/effect/src/unstable/http/HttpEffect.ts @@ -247,18 +247,18 @@ export const toWebHandlerWith = | undefined): Promise => + return (request: Request, _context?: Context.Context | undefined): Promise => new Promise((resolve) => { - const contextMap = new Map(context.mapUnsafe) - if (Context.isContext(reqContext)) { - for (const [key, value] of reqContext.mapUnsafe) { - contextMap.set(key, value) + let reqContext = context + if (Context.isContext(_context)) { + for (const [key, value] of _context.mapUnsafe) { + reqContext = Context.addUnsafe(reqContext, key, value) } } const httpServerRequest = Request.fromWeb(request) - contextMap.set(HttpServerRequest.key, httpServerRequest) + reqContext = Context.add(reqContext, HttpServerRequest, httpServerRequest) ;(httpServerRequest as any)[resolveSymbol] = resolve - const fiber = Effect.runForkWith(Context.makeUnsafe(contextMap))(httpApp as any) + const fiber = Effect.runForkWith(reqContext)(httpApp as any) request.signal?.addEventListener("abort", () => { fiber.interruptUnsafe(undefined, ClientAbort.annotation) }, { once: true }) diff --git a/repos/effect/packages/effect/src/unstable/http/HttpIncomingMessage.ts b/repos/effect/packages/effect/src/unstable/http/HttpIncomingMessage.ts index c1b2f5ec37..1b915334fa 100644 --- a/repos/effect/packages/effect/src/unstable/http/HttpIncomingMessage.ts +++ b/repos/effect/packages/effect/src/unstable/http/HttpIncomingMessage.ts @@ -38,6 +38,16 @@ export const TypeId = "~effect/http/HttpIncomingMessage" */ export const isHttpIncomingMessage = (u: unknown): u is HttpIncomingMessage => hasProperty(u, TypeId) +/** + * Options for parsing an incoming HTTP message body as JSON. + * + * @category models + * @since 4.0.0 + */ +export interface JsonOptions { + readonly reviver?: Parameters[1] | undefined +} + /** * Common model for incoming HTTP messages, with headers, remote address, and effectful body accessors. * @@ -61,12 +71,21 @@ export interface HttpIncomingMessage extends Inspectable.Inspectabl * @category schemas * @since 4.0.0 */ -export const schemaBodyJson = (schema: S, options?: ParseOptions | undefined) => { +export const schemaBodyJson = ( + schema: S, + options?: (ParseOptions & JsonOptions) | undefined +) => { const decode = Schema.decodeEffect(Schema.toCodecJson(schema)) + const decodeJson = options?.reviver === undefined + ? undefined + : Schema.decodeEffect(Schema.fromJsonString(Schema.toCodecJson(schema), options)) return ( self: HttpIncomingMessage ): Effect.Effect => - Effect.flatMap(self.json, (u) => decode(u, options)) + decodeJson === undefined + ? Effect.flatMap(self.json, (u) => decode(u, options)) + : Effect.flatMap(self.text, (body) => + body === "" ? Effect.flatMap(self.json, (u) => decode(u, options)) : decodeJson(body, options)) } /** diff --git a/repos/effect/packages/effect/src/unstable/http/HttpMiddleware.ts b/repos/effect/packages/effect/src/unstable/http/HttpMiddleware.ts index e30ef3f1c9..8133b047f0 100644 --- a/repos/effect/packages/effect/src/unstable/http/HttpMiddleware.ts +++ b/repos/effect/packages/effect/src/unstable/http/HttpMiddleware.ts @@ -206,36 +206,56 @@ export const tracer: ( response = exit.value } if (span.sampled) { - const url = Request.toURL(request) - if (Option.isSome(url) && (url.value.username !== "" || url.value.password !== "")) { - url.value.username = "REDACTED" - url.value.password = "REDACTED" - } const redactedHeaderNames = fiber.getRef(Headers.CurrentRedactedNames) - const requestHeaders = Headers.redact(request.headers, redactedHeaderNames) span.attribute("http.request.method", request.method) - if (Option.isSome(url)) { - span.attribute("url.full", url.value.toString()) - span.attribute("url.path", url.value.pathname) - const query = url.value.search.slice(1) - if (query !== "") { - span.attribute("url.query", url.value.search.slice(1)) + if (request.url.startsWith("/")) { + const host = request.headers.host ?? "localhost" + const protocol = request.headers["x-forwarded-proto"] === "https" ? "https" : "http" + span.attribute("url.full", `${protocol}://${host}${request.url}`) + const queryIndex = request.url.indexOf("?") + if (queryIndex === -1) { + span.attribute("url.path", request.url) + } else { + span.attribute("url.path", request.url.slice(0, queryIndex)) + if (queryIndex < request.url.length - 1) { + span.attribute("url.query", request.url.slice(queryIndex + 1)) + } + } + span.attribute("url.scheme", protocol) + } else { + const url = Request.toURL(request) + if (Option.isSome(url)) { + if (url.value.username !== "" || url.value.password !== "") { + url.value.username = "REDACTED" + url.value.password = "REDACTED" + } + span.attribute("url.full", url.value.toString()) + span.attribute("url.path", url.value.pathname) + const query = url.value.search.slice(1) + if (query !== "") { + span.attribute("url.query", query) + } + span.attribute("url.scheme", url.value.protocol.slice(0, -1)) } - span.attribute("url.scheme", url.value.protocol.slice(0, -1)) } if (request.headers["user-agent"] !== undefined) { span.attribute("user_agent.original", request.headers["user-agent"]) } - for (const name in requestHeaders) { - span.attribute(`http.request.header.${name}`, String(requestHeaders[name])) + for (const name in request.headers) { + span.attribute( + `http.request.header.${name}`, + Headers.isRedactedName(name, redactedHeaderNames) ? "" : request.headers[name] + ) } if (Option.isSome(request.remoteAddress)) { span.attribute("client.address", request.remoteAddress.value) } span.attribute("http.response.status_code", response.status) - const responseHeaders = Headers.redact(response.headers, redactedHeaderNames) - for (const name in responseHeaders) { - span.attribute(`http.response.header.${name}`, String(responseHeaders[name])) + for (const name in response.headers) { + span.attribute( + `http.response.header.${name}`, + Headers.isRedactedName(name, redactedHeaderNames) ? "" : response.headers[name] + ) } } span.end(endTime, spanExit) diff --git a/repos/effect/packages/effect/src/unstable/http/HttpRouter.ts b/repos/effect/packages/effect/src/unstable/http/HttpRouter.ts index 742d5fd404..9d976ed274 100644 --- a/repos/effect/packages/effect/src/unstable/http/HttpRouter.ts +++ b/repos/effect/packages/effect/src/unstable/http/HttpRouter.ts @@ -23,6 +23,7 @@ import * as Tracer from "../../Tracer.ts" import type * as Types from "../../Types.ts" import * as FindMyWay from "./FindMyWay.ts" import * as HttpEffect from "./HttpEffect.ts" +import * as HttpIncomingMessage from "./HttpIncomingMessage.ts" import type * as HttpMethod from "./HttpMethod.ts" import * as HttpMiddleware from "./HttpMiddleware.ts" import * as HttpServer from "./HttpServer.ts" @@ -320,13 +321,14 @@ export const schemaJson = < RD >( schema: Schema.ConstraintCodec, - options?: ParseOptions | undefined + options?: (ParseOptions & HttpIncomingMessage.JsonOptions) | undefined ): Effect.Effect< A, HttpServerError.HttpServerError | Schema.SchemaError, HttpServerRequest.HttpServerRequest | HttpServerRequest.ParsedSearchParams | RouteContext | RD > => { const parse = Schema.decodeUnknownEffect(schema) + const parseBody = HttpIncomingMessage.schemaBodyJson(Schema.Unknown, options) return Effect.contextWith( ( context: Context.Context< @@ -336,7 +338,7 @@ export const schemaJson = < const request = Context.get(context, HttpServerRequest.HttpServerRequest) const searchParams = Context.get(context, HttpServerRequest.ParsedSearchParams) const routeContext = Context.get(context, RouteContext) - return Effect.flatMap(request.json, (body) => + return Effect.flatMap(parseBody(request), (body) => parse({ method: request.method, url: request.url, @@ -582,7 +584,7 @@ export const toHttpEffect = ( ): Effect.Effect< Effect.Effect< HttpServerResponse.HttpServerResponse, - Request.Only<"Error", R> | Request.Only<"GlobalRequires", R> | HttpServerError.HttpServerError, + Request.Only<"Error", R> | Request.Only<"GlobalError", R> | HttpServerError.HttpServerError, Scope.Scope | HttpServerRequest.HttpServerRequest | Request.Only<"Requires", R> | Request.Only<"GlobalRequires", R> >, Request.Without, @@ -845,7 +847,8 @@ export interface Middleware< readonly [MiddlewareTypeId]: Config readonly layer: [Config["requires"]] extends [never] ? Layer.Layer< - Request.From<"Requires", Config["provides"]>, + | Request.From<"Requires", Config["provides"]> + | Request.From<"Error", Config["handles"]>, Config["layerError"], | Config["layerRequires"] | Request.From<"Requires", Config["requires"]> @@ -977,7 +980,7 @@ class MiddlewareImpl< const contextKey = `effect/http/HttpRouter/Middleware-${++middlewareId}` as const this.layer = Layer.effectContext(Effect.gen({ self: this }, function*() { const context = yield* Effect.context() - const stack = [context.mapUnsafe.get(fnContextKey)] + const stack = [Context.getOrUndefinedUnsafe(context, fnContextKey)] if (this.dependencies) { const memoMap = yield* Layer.CurrentMemoMap const scope = Context.get(context, Scope.Scope) diff --git a/repos/effect/packages/effect/src/unstable/http/HttpServerRequest.ts b/repos/effect/packages/effect/src/unstable/http/HttpServerRequest.ts index ff4904116c..be18dafa8b 100644 --- a/repos/effect/packages/effect/src/unstable/http/HttpServerRequest.ts +++ b/repos/effect/packages/effect/src/unstable/http/HttpServerRequest.ts @@ -244,7 +244,7 @@ export const schemaSearchParams = < */ export const schemaBodyJson = ( schema: Schema.ConstraintDecoder, - options?: ParseOptions | undefined + options?: (ParseOptions & HttpIncomingMessage.JsonOptions) | undefined ): Effect.Effect => { const parse = HttpIncomingMessage.schemaBodyJson(schema, options) return Effect.flatMap(HttpServerRequest, parse) @@ -346,11 +346,11 @@ export const schemaBodyMultipart = , R */ export const schemaBodyFormJson = ( schema: Schema.ConstraintDecoder, - options?: ParseOptions | undefined + options?: (ParseOptions & HttpIncomingMessage.JsonOptions) | undefined ) => { const parseMultipart = Multipart.schemaJson(schema, options) return (field: string) => { - const parseUrlParams = UrlParams.schemaJsonField(field).pipe( + const parseUrlParams = UrlParams.schemaJsonField(field, options).pipe( Schema.decodeTo(schema), Schema.decodeEffect ) diff --git a/repos/effect/packages/effect/src/unstable/http/HttpStatus.ts b/repos/effect/packages/effect/src/unstable/http/HttpStatus.ts new file mode 100644 index 0000000000..1995a66d4a --- /dev/null +++ b/repos/effect/packages/effect/src/unstable/http/HttpStatus.ts @@ -0,0 +1,100 @@ +/** + * Named HTTP status codes for the unstable HTTP modules. + * + * This module centralizes the mapping from literal names of the known HTTP + * status codes, such as `"OK"` and `"Conflict"`, to their numeric codes. Use + * {@link fromLiteral} to obtain a status code from a literal name instead of + * remembering raw numbers. + * + * @since 4.0.0 + */ + +const codeByLiteral = { + Continue: 100, + SwitchingProtocols: 101, + Processing: 102, + EarlyHints: 103, + OK: 200, + Ok: 200, + Created: 201, + Accepted: 202, + NonAuthoritativeInformation: 203, + NoContent: 204, + ResetContent: 205, + PartialContent: 206, + MultiStatus: 207, + AlreadyReported: 208, + ImUsed: 226, + MultipleChoices: 300, + MovedPermanently: 301, + Found: 302, + SeeOther: 303, + NotModified: 304, + TemporaryRedirect: 307, + PermanentRedirect: 308, + BadRequest: 400, + Unauthorized: 401, + PaymentRequired: 402, + Forbidden: 403, + NotFound: 404, + MethodNotAllowed: 405, + NotAcceptable: 406, + ProxyAuthenticationRequired: 407, + RequestTimeout: 408, + Conflict: 409, + Gone: 410, + LengthRequired: 411, + PreconditionFailed: 412, + PayloadTooLarge: 413, + UriTooLong: 414, + UnsupportedMediaType: 415, + RangeNotSatisfiable: 416, + ExpectationFailed: 417, + ImATeapot: 418, + MisdirectedRequest: 421, + UnprocessableEntity: 422, + Locked: 423, + FailedDependency: 424, + TooEarly: 425, + UpgradeRequired: 426, + PreconditionRequired: 428, + TooManyRequests: 429, + RequestHeaderFieldsTooLarge: 431, + UnavailableForLegalReasons: 451, + InternalServerError: 500, + NotImplemented: 501, + BadGateway: 502, + ServiceUnavailable: 503, + GatewayTimeout: 504, + HttpVersionNotSupported: 505, + VariantAlsoNegotiates: 506, + InsufficientStorage: 507, + LoopDetected: 508, + NotExtended: 510, + NetworkAuthenticationRequired: 511 +} as const + +/** + * Union of literal names for the known HTTP status codes. + * + * @category models + * @since 4.0.0 + */ +export type Literal = keyof typeof codeByLiteral + +/** + * Returns the numeric HTTP status code for a literal name. + * + * **Example** (Obtaining status codes from literal names) + * + * ```ts import.meta.vitest + * import { HttpStatus } from "effect/unstable/http" + * + * HttpStatus.fromLiteral("OK") // => 200 + * HttpStatus.fromLiteral("Conflict") // => 409 + * ``` + * + * @category constructors + * @since 4.0.0 + */ +export const fromLiteral = (literal: L): (typeof codeByLiteral)[L] => codeByLiteral[literal] diff --git a/repos/effect/packages/effect/src/unstable/http/Multipart.ts b/repos/effect/packages/effect/src/unstable/http/Multipart.ts index 344efc4bf7..6dc33511ad 100644 --- a/repos/effect/packages/effect/src/unstable/http/Multipart.ts +++ b/repos/effect/packages/effect/src/unstable/http/Multipart.ts @@ -382,7 +382,10 @@ export const schemaPersisted = , RD>( * @category schemas * @since 4.0.0 */ -export const schemaJson = (schema: Schema.ConstraintDecoder, options?: ParseOptions | undefined): { +export const schemaJson = ( + schema: Schema.ConstraintDecoder, + options?: (ParseOptions & IncomingMessage.JsonOptions) | undefined +): { ( field: string ): (persisted: Persisted) => Effect.Effect @@ -391,7 +394,7 @@ export const schemaJson = (schema: Schema.ConstraintDecoder, optio field: string ): Effect.Effect } => { - const fromJson = Schema.fromJsonString(schema) + const fromJson = Schema.fromJsonString(schema, options) return dual(2, (persisted: Persisted, field: string): Effect.Effect => Effect.map( Schema.decodeUnknownEffect(Schema.Struct({ [field]: fromJson }))(persisted, options), diff --git a/repos/effect/packages/effect/src/unstable/http/UrlParams.ts b/repos/effect/packages/effect/src/unstable/http/UrlParams.ts index d83dfcea2e..da716fd382 100644 --- a/repos/effect/packages/effect/src/unstable/http/UrlParams.ts +++ b/repos/effect/packages/effect/src/unstable/http/UrlParams.ts @@ -25,6 +25,7 @@ import * as Schema from "../../Schema.ts" import * as SchemaIssue from "../../SchemaIssue.ts" import * as SchemaTransformation from "../../SchemaTransformation.ts" import * as Tuple from "../../Tuple.ts" +import type { JsonOptions } from "./HttpIncomingMessage.ts" const TypeId = "~effect/http/UrlParams" @@ -545,10 +546,10 @@ export interface schemaJsonField extends Schema.decodeTo +export const schemaJsonField = (field: string, options?: JsonOptions | undefined): schemaJsonField => UrlParamsSchema.pipe( Schema.decodeTo( - Schema.UnknownFromJsonString, + Schema.fromJsonString(Schema.Unknown, options), SchemaTransformation.transformOrFail({ decode: (params) => Option.match(getFirst(params, field), { diff --git a/repos/effect/packages/effect/src/unstable/http/index.ts b/repos/effect/packages/effect/src/unstable/http/index.ts index 30cbeead09..b2b1c09a04 100644 --- a/repos/effect/packages/effect/src/unstable/http/index.ts +++ b/repos/effect/packages/effect/src/unstable/http/index.ts @@ -114,6 +114,11 @@ export * as HttpServerResponse from "./HttpServerResponse.ts" */ export * as HttpStaticServer from "./HttpStaticServer.ts" +/** + * @since 4.0.0 + */ +export * as HttpStatus from "./HttpStatus.ts" + /** * @since 4.0.0 */ diff --git a/repos/effect/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/repos/effect/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index 24553a1fc3..72bddab7c4 100644 --- a/repos/effect/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/repos/effect/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -15,7 +15,7 @@ import * as Effect from "../../Effect.ts" import * as Encoding from "../../Encoding.ts" import * as Fiber from "../../Fiber.ts" import type { FileSystem } from "../../FileSystem.ts" -import { identity } from "../../Function.ts" +import * as Function from "../../Function.ts" import { stringOrRedacted } from "../../internal/redacted.ts" import * as Layer from "../../Layer.ts" import type { Path } from "../../Path.ts" @@ -100,8 +100,14 @@ export const layer = } yield* (router.addAll(routes) as Effect.Effect) if (options?.openapiPath) { - const spec = OpenApi.fromApi(api) - yield* router.add("GET", options.openapiPath, Effect.succeed(Response.jsonUnsafe(spec))) + const makeResponse = Function.memoize((api: HttpApi.HttpApi): HttpServerResponse => + Response.jsonUnsafe(OpenApi.fromApi(api)) + ) + yield* router.add( + "GET", + options.openapiPath, + Effect.sync(() => makeResponse(api)) + ) } })) @@ -758,7 +764,10 @@ function handlerToHttpEffect( const encodeError = Schema.encodeUnknownEffect(makeErrorSchema(endpoint)) const decodeParams = UndefinedOr.map(endpoint.params, Schema.decodeUnknownEffect) const decodeHeaders = UndefinedOr.map(endpoint.headers, Schema.decodeUnknownEffect) - const decodeQuery = UndefinedOr.map(endpoint.query, Schema.decodeUnknownEffect) + const decodeQuery = UndefinedOr.map( + endpoint.query, + (schema) => Schema.decodeUnknownEffect(Schema.toCodecArrayFromSingle(schema)) + ) const encodeStream = makeStreamEncoder(endpoint) const encodeWithHeaders = makeWithHeadersEncoder(endpoint) @@ -881,7 +890,7 @@ const makeSecurityMiddleware = ( middleware: service[securityKey] })) if (entries.length === 0) { - return identity + return Function.identity } const middleware = Effect.fnUntraced(function*(handler: Effect.Effect, options: { @@ -1055,7 +1064,7 @@ function encodeSseStream( event: "message", data: value })) : - identity, + Function.identity, Stream.mapArrayEffect((chunk) => Effect.orDie(encoder.encodeEvents(chunk))), Stream.catchCause((cause) => Stream.fromEffect(encodeFailureEvent(cause, encoder))), Stream.map(renderSseEvent), diff --git a/repos/effect/packages/effect/src/unstable/httpapi/HttpApiScalar.ts b/repos/effect/packages/effect/src/unstable/httpapi/HttpApiScalar.ts index 22900eedef..110eaebcbc 100644 --- a/repos/effect/packages/effect/src/unstable/httpapi/HttpApiScalar.ts +++ b/repos/effect/packages/effect/src/unstable/httpapi/HttpApiScalar.ts @@ -9,6 +9,7 @@ * @since 4.0.0 */ import * as Effect from "../../Effect.ts" +import * as Function from "../../Function.ts" import type * as Layer from "../../Layer.ts" import * as HttpRouter from "../http/HttpRouter.ts" import * as HttpServerResponse from "../http/HttpServerResponse.ts" @@ -144,36 +145,37 @@ const makeHandler = ( readonly source: ScalarSource readonly scalar: ScalarConfig | undefined }) => { - const spec = OpenApi.fromApi(options.api) - const { customFetch, ...scalar } = options.scalar ?? {} - const scalarConfig = { - _integration: "html", - ...scalar - } - const scalarScript = options.source._tag === "Cdn" - ? `` - : `` - const response = HttpServerResponse.html(` + const makeResponse = Function.memoize((api: HttpApi.HttpApi) => { + const spec = OpenApi.fromApi(api) + const { customFetch, ...scalar } = options.scalar ?? {} + const scalarConfig = { + _integration: "html", + ...scalar + } + const scalarScript = options.source._tag === "Cdn" + ? `` + : `` + return HttpServerResponse.html(` ${Html.escape(spec.info.title)} ${ - !spec.info.description - ? "" - : `` - } + !spec.info.description + ? "" + : `` + } ${ - !spec.info.description - ? "" - : `` - } + !spec.info.description + ? "" + : `` + } @@ -185,14 +187,15 @@ const makeHandler = ( window.Scalar.createApiReference(document.getElementById('api-reference-container'), { ...${Html.escapeJson(scalarConfig)}, content: ${Html.escapeJson(spec)}${ - customFetch === undefined ? "" : `, + customFetch === undefined ? "" : `, customFetch: ${customFetch}` - } + } }) `) - return Effect.succeed(response) + }) + return Effect.sync(() => makeResponse(options.api)) } /** diff --git a/repos/effect/packages/effect/src/unstable/httpapi/HttpApiSchema.ts b/repos/effect/packages/effect/src/unstable/httpapi/HttpApiSchema.ts index 43769ccd97..98a01cbfea 100644 --- a/repos/effect/packages/effect/src/unstable/httpapi/HttpApiSchema.ts +++ b/repos/effect/packages/effect/src/unstable/httpapi/HttpApiSchema.ts @@ -17,6 +17,7 @@ import * as SchemaTransformation from "../../SchemaTransformation.ts" import * as Stream from "../../Stream.ts" import type * as Sse from "../encoding/Sse.ts" import { hasBody, type HttpMethod } from "../http/HttpMethod.ts" +import * as HttpStatus from "../http/HttpStatus.ts" import type * as Multipart_ from "../http/Multipart.ts" declare module "../../Schema.ts" { @@ -87,71 +88,6 @@ export type ResponseEncoding = { readonly contentType: string } -const statusCodeByLiteral = { - Continue: 100, - SwitchingProtocols: 101, - Processing: 102, - EarlyHints: 103, - OK: 200, - Ok: 200, - Created: 201, - Accepted: 202, - NonAuthoritativeInformation: 203, - NoContent: 204, - ResetContent: 205, - PartialContent: 206, - MultiStatus: 207, - AlreadyReported: 208, - ImUsed: 226, - MultipleChoices: 300, - MovedPermanently: 301, - Found: 302, - SeeOther: 303, - NotModified: 304, - TemporaryRedirect: 307, - PermanentRedirect: 308, - BadRequest: 400, - Unauthorized: 401, - PaymentRequired: 402, - Forbidden: 403, - NotFound: 404, - MethodNotAllowed: 405, - NotAcceptable: 406, - ProxyAuthenticationRequired: 407, - RequestTimeout: 408, - Conflict: 409, - Gone: 410, - LengthRequired: 411, - PreconditionFailed: 412, - PayloadTooLarge: 413, - UriTooLong: 414, - UnsupportedMediaType: 415, - RangeNotSatisfiable: 416, - ExpectationFailed: 417, - ImATeapot: 418, - MisdirectedRequest: 421, - UnprocessableEntity: 422, - Locked: 423, - FailedDependency: 424, - TooEarly: 425, - UpgradeRequired: 426, - PreconditionRequired: 428, - TooManyRequests: 429, - RequestHeaderFieldsTooLarge: 431, - UnavailableForLegalReasons: 451, - InternalServerError: 500, - NotImplemented: 501, - BadGateway: 502, - ServiceUnavailable: 503, - GatewayTimeout: 504, - HttpVersionNotSupported: 505, - VariantAlsoNegotiates: 506, - InsufficientStorage: 507, - LoopDetected: 508, - NotExtended: 510, - NetworkAuthenticationRequired: 511 -} as const - const StreamSchemaTypeId = "~effect/httpapi/HttpApiSchema/Stream" /** @@ -160,7 +96,7 @@ const StreamSchemaTypeId = "~effect/httpapi/HttpApiSchema/Stream" * @category models * @since 4.0.0 */ -export type StatusLiteral = keyof typeof statusCodeByLiteral +export type StatusLiteral = HttpStatus.Literal /** * Sets the HTTP status code of a schema. @@ -181,7 +117,7 @@ export function status(code: StatusLiteral): { (self: S): S["Rebuild"] } export function status(code: number | StatusLiteral) { - const statusCode = typeof code === "string" ? statusCodeByLiteral[code] : code + const statusCode = typeof code === "string" ? HttpStatus.fromLiteral(code) : code return (self: S): S["Rebuild"] => self.annotate({ httpApiStatus: statusCode }) } diff --git a/repos/effect/packages/effect/src/unstable/httpapi/HttpApiSwagger.ts b/repos/effect/packages/effect/src/unstable/httpapi/HttpApiSwagger.ts index d6870c9ee2..126672de78 100644 --- a/repos/effect/packages/effect/src/unstable/httpapi/HttpApiSwagger.ts +++ b/repos/effect/packages/effect/src/unstable/httpapi/HttpApiSwagger.ts @@ -9,6 +9,7 @@ * @since 4.0.0 */ import * as Effect from "../../Effect.ts" +import * as Function from "../../Function.ts" import type * as Layer from "../../Layer.ts" import * as HttpRouter from "../http/HttpRouter.ts" import * as HttpServerResponse from "../http/HttpServerResponse.ts" @@ -21,8 +22,9 @@ import * as OpenApi from "./OpenApi.ts" const makeHandler = (options: { readonly api: HttpApi.HttpApi }) => { - const spec = OpenApi.fromApi(options.api) - const response = HttpServerResponse.html(` + const makeResponse = Function.memoize((api: HttpApi.HttpApi) => { + const spec = OpenApi.fromApi(api) + return HttpServerResponse.html(` @@ -46,7 +48,8 @@ const makeHandler = ( `) - return Effect.succeed(response) + }) + return Effect.sync(() => makeResponse(options.api)) } /** diff --git a/repos/effect/packages/effect/src/unstable/httpapi/OpenApi.ts b/repos/effect/packages/effect/src/unstable/httpapi/OpenApi.ts index 3b119b4188..a4dc20075c 100644 --- a/repos/effect/packages/effect/src/unstable/httpapi/OpenApi.ts +++ b/repos/effect/packages/effect/src/unstable/httpapi/OpenApi.ts @@ -22,6 +22,7 @@ import * as JsonSchema from "../../JsonSchema.ts" import * as Option from "../../Option.ts" import * as Schema from "../../Schema.ts" import * as SchemaAST from "../../SchemaAST.ts" +import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" import * as HttpMethod from "../http/HttpMethod.ts" import * as HttpApi from "./HttpApi.ts" import * as HttpApiEndpoint from "./HttpApiEndpoint.ts" @@ -211,21 +212,25 @@ export const annotations: ( transform: Transform }) -const apiCache = new WeakMap() +const defaultOptions: SchemaRepresentation.ToRepresentationOptions = {} +const apiCache = new WeakMap< + SchemaRepresentation.ToRepresentationOptions, + WeakMap +>() -type CompileSchemas = ( - asts: readonly [SchemaAST.AST, ...Array] -) => JsonSchema.MultiDocument<"openapi-3.1"> - -const compileSchemas: CompileSchemas = (asts) => - JsonSchema.toMultiDocumentOpenApi3_1( - InternalToJsonSchemaDocument.toJsonSchemaMultiDocument( - InternalToRepresentation.toRepresentations( - Arr.map(asts, Schema.toCodecJsonAST), - InternalToJsonSchemaDocument.toRepresentationOptions - ) - ) - ) +const cloneOpenAPISpec = (value: A): A => { + if (Array.isArray(value)) { + return value.map(cloneOpenAPISpec) as A + } + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + const out: Record = {} + for (const key of Object.keys(value)) { + InternalRecord.assignProperty(out, key, cloneOpenAPISpec((value as Record)[key])) + } + return out as A + } + return value +} /** * This function checks if a given tag exists within the provided context. If @@ -244,7 +249,11 @@ function processAnnotation( } /** - * Converts an `HttpApi` instance into an OpenAPI Specification object. + * Generates an OpenAPI 3.1 specification from an `HttpApi`. + * + * **When to use** + * + * Use when you need a programmatic OpenAPI document for an API definition. * * **Details** * @@ -257,27 +266,40 @@ function processAnnotation( * * The function also deduplicates schemas, applies transformations, and * integrates annotations like descriptions, summaries, external documentation, - * and overrides. Cached results are used for better performance when the same - * `HttpApi` instance is processed multiple times. + * and overrides. The optional reference policy receives canonical JSON encoded + * ASTs and controls which schemas are extracted into components. By default, only candidates with resolved identifiers + * become references; anonymous non-recursive schemas remain inline. + * + * **Gotchas** + * + * Cached results are keyed by both the `HttpApi` instance and the identity of the options object. Reuse the same immutable + * options object to reuse a cached result; mutating an options object after its first use does not invalidate the cached + * specification. Each call returns a copy of the cached specification. * * @category constructors * @since 4.0.0 */ export function fromApi( - api: HttpApi.HttpApi + api: HttpApi.HttpApi, + options?: SchemaRepresentation.ToRepresentationOptions ): OpenAPISpec { - return fromApiWith(api, apiCache, compileSchemas) + const resolvedOptions = options ?? defaultOptions + let cache = apiCache.get(resolvedOptions) + if (cache === undefined) { + cache = new WeakMap() + apiCache.set(resolvedOptions, cache) + } + const cached = cache.get(api) + if (cached !== undefined) return cloneOpenAPISpec(cached) + const spec = makeOpenApi(api, resolvedOptions) + cache.set(api, cloneOpenAPISpec(spec)) + return spec } -function fromApiWith( +function makeOpenApi( api: HttpApi.HttpApi, - cache: WeakMap, - compileSchemas: CompileSchemas + options: SchemaRepresentation.ToRepresentationOptions ): OpenAPISpec { - const cached = cache.get(api) - if (cached !== undefined) { - return cached - } let spec: OpenAPISpec = { openapi: "3.1.0", info: { @@ -647,7 +669,14 @@ function fromApiWith( } if (Arr.isArrayNonEmpty(pathOps)) { - const jsonSchemaMultiDocument = compileSchemas(Arr.map(pathOps, (op) => op.ast)) + const jsonSchemaMultiDocument = JsonSchema.toMultiDocumentOpenApi3_1( + InternalToJsonSchemaDocument.toJsonSchemaMultiDocument( + InternalToRepresentation.toRepresentations( + Arr.map(pathOps, (op) => Schema.toCodecJsonAST(op.ast)), + options + ) + ) + ) const patchOps: Array = pathOps.map((op, i) => { const oppath = escapePath(op.path) const value = jsonSchemaMultiDocument.schemas[i] @@ -685,8 +714,6 @@ function fromApiWith( spec = transformFn(spec) as OpenAPISpec }) - cache.set(api, spec) - return spec } @@ -1037,13 +1064,18 @@ export type OpenAPISpecMethodName = /** * Generated OpenAPI path item mapping HTTP methods to operations for a single route path. + * Parameters declared here are shared by every operation on the path. * * @category models * @since 4.0.0 */ -export type OpenAPISpecPathItem = { - [K in OpenAPISpecMethodName]?: OpenAPISpecOperation -} +export type OpenAPISpecPathItem = + & { + [K in OpenAPISpecMethodName]?: OpenAPISpecOperation + } + & { + parameters?: Array + } /** * Generated OpenAPI parameter object for path, query, header, or cookie parameters. diff --git a/repos/effect/packages/effect/src/unstable/observability/OtlpTracer.ts b/repos/effect/packages/effect/src/unstable/observability/OtlpTracer.ts index d24aad30c4..a33c0b8ad2 100644 --- a/repos/effect/packages/effect/src/unstable/observability/OtlpTracer.ts +++ b/repos/effect/packages/effect/src/unstable/observability/OtlpTracer.ts @@ -15,6 +15,7 @@ import * as Config from "../../Config.ts" import type * as Context from "../../Context.ts" import * as Duration from "../../Duration.ts" import * as Effect from "../../Effect.ts" +import * as Encoding from "../../Encoding.ts" import type * as Exit from "../../Exit.ts" import { flow } from "../../Function.ts" import * as Layer from "../../Layer.ts" @@ -244,22 +245,13 @@ const makeSpan = (options: { if (Option.isSome(self.parent)) { self.traceId = self.parent.value.traceId } else { - self.traceId = generateId(32) + self.traceId = Encoding.randomHex(32) } - self.spanId = generateId(16) + self.spanId = Encoding.randomHex(16) self.events = [] return self } -const generateId = (len: number): string => { - const chars = "0123456789abcdef" - let result = "" - for (let i = 0; i < len; i++) { - result += chars[Math.floor(Math.random() * chars.length)] - } - return result -} - const makeOtlpSpan = (self: SpanImpl): OtlpSpan => { const status = self.status as ExtractTag const attributes = entriesToAttributes(self.attributes.entries()) diff --git a/repos/effect/packages/effect/src/unstable/persistence/PersistedQueue.ts b/repos/effect/packages/effect/src/unstable/persistence/PersistedQueue.ts index c8c0ab1556..80dccd14c4 100644 --- a/repos/effect/packages/effect/src/unstable/persistence/PersistedQueue.ts +++ b/repos/effect/packages/effect/src/unstable/persistence/PersistedQueue.ts @@ -27,6 +27,7 @@ import * as RcMap from "../../RcMap.ts" import * as Schedule from "../../Schedule.ts" import * as Schema from "../../Schema.ts" import * as Scope from "../../Scope.ts" +import * as Migrator from "../sql/Migrator.ts" import * as SqlClient from "../sql/SqlClient.ts" import type { SqlError } from "../sql/SqlError.ts" import * as Redis from "./Redis.ts" @@ -779,6 +780,13 @@ export const makeStoreSql: ( const lockExpirationSql = sql.literal(Math.ceil(Duration.toSeconds(lockExpiration)).toString()) const workerId = crypto.randomUUID() + yield* Effect.orDie( + Migrator.make({})({ + loader: sqlMigrations(tableName), + table: `${tableName}_migrations` + }) + ) + const sqlNow = sql.onDialectOrElse({ mssql: () => sql.literal("GETDATE()"), mysql: () => sql.literal("NOW()"), @@ -794,104 +802,6 @@ export const makeStoreSql: ( orElse: () => sql`datetime(${sqlNow}, '-${lockExpirationSql} seconds')` }) - yield* sql.onDialectOrElse({ - mysql: () => - sql`CREATE TABLE IF NOT EXISTS ${tableNameSql} ( - sequence BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - id VARCHAR(36) NOT NULL, - queue_name VARCHAR(100) NOT NULL, - element TEXT NOT NULL, - completed BOOLEAN NOT NULL, - attempts INT NOT NULL DEFAULT 0, - last_failure TEXT NULL, - acquired_at DATETIME NULL, - acquired_by VARCHAR(36) NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - )`, - pg: () => - sql`CREATE TABLE IF NOT EXISTS ${tableNameSql} ( - sequence SERIAL PRIMARY KEY, - id VARCHAR(36) NOT NULL, - queue_name VARCHAR(100) NOT NULL, - element TEXT NOT NULL, - completed BOOLEAN NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - last_failure TEXT NULL, - acquired_at TIMESTAMP NULL, - acquired_by UUID NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL - )`, - mssql: () => - sql`IF NOT EXISTS (SELECT * FROM sysobjects WHERE name=${tableNameSql} AND xtype='U') - CREATE TABLE ${tableNameSql} ( - sequence INT IDENTITY(1,1) PRIMARY KEY, - id NVARCHAR(36) NOT NULL, - queue_name NVARCHAR(100) NOT NULL, - element NVARCHAR(MAX) NOT NULL, - completed BIT NOT NULL, - attempts INT NOT NULL DEFAULT 0, - last_failure NVARCHAR(MAX) NULL, - acquired_at DATETIME2 NULL, - acquired_by UNIQUEIDENTIFIER NULL, - created_at DATETIME2 NOT NULL, - updated_at DATETIME2 NOT NULL - )`, - // sqlite - orElse: () => - sql`CREATE TABLE IF NOT EXISTS ${tableNameSql} ( - sequence INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL, - queue_name TEXT NOT NULL, - element TEXT NOT NULL, - completed BOOLEAN NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - last_failure TEXT NULL, - acquired_at DATETIME NULL, - acquired_by TEXT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - )` - }) - - yield* sql.onDialectOrElse({ - mssql: () => - sql`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = N'idx_${tableName}_id') - CREATE UNIQUE INDEX idx_${tableNameSql}_id ON ${tableNameSql} (id, queue_name)`, - mysql: () => - sql`CREATE UNIQUE INDEX ${sql(`idx_${tableName}_id`)} ON ${tableNameSql} (id, queue_name)`.pipe(Effect.ignore), - orElse: () => - sql`CREATE UNIQUE INDEX IF NOT EXISTS ${sql(`idx_${tableName}_id`)} ON ${tableNameSql} (id, queue_name)` - }) - - yield* sql.onDialectOrElse({ - mssql: () => - sql`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = N'idx_${tableName}_take') - CREATE INDEX idx_${tableNameSql}_take ON ${tableNameSql} (queue_name, completed, attempts, acquired_at)`, - mysql: () => - sql`CREATE INDEX ${ - sql(`idx_${tableName}_take`) - } ON ${tableNameSql} (queue_name, completed, attempts, acquired_at)` - .pipe(Effect.ignore), - orElse: () => - sql`CREATE INDEX IF NOT EXISTS ${ - sql(`idx_${tableName}_take`) - } ON ${tableNameSql} (queue_name, completed, attempts, acquired_at)` - }) - - yield* sql.onDialectOrElse({ - mssql: () => - sql`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = N'idx_${tableName}_update') - CREATE INDEX ${sql(`idx_${tableName}_update`)} ON ${tableNameSql} (sequence, acquired_by)`, - mysql: () => - sql`CREATE INDEX ${sql(`idx_${tableName}_update`)} ON ${tableNameSql} (sequence, acquired_by)`.pipe( - Effect.ignore - ), - orElse: () => - sql`CREATE INDEX IF NOT EXISTS ${sql(`idx_${tableName}_update`)} ON ${tableNameSql} (sequence, acquired_by)` - }) - const offer = sql.onDialectOrElse({ pg: () => (id: string, name: string, element: string) => sql` @@ -1089,13 +999,16 @@ export const makeStoreSql: ( sql` UPDATE ${tableNameSql} SET acquired_at = ${sqlNow}, acquired_by = ${workerIdSql} - WHERE queue_name = ${name} - AND completed = FALSE - AND attempts < ${maxAttempts} - AND (acquired_at IS NULL OR acquired_at < ${expiresAt}) + WHERE sequence IN ( + SELECT sequence FROM ${tableNameSql} + WHERE queue_name = ${name} + AND completed = FALSE + AND attempts < ${maxAttempts} + AND (acquired_at IS NULL OR acquired_at < ${expiresAt}) + ORDER BY updated_at ASC, sequence ASC + LIMIT ${sql.literal(size.toString())} + ) RETURNING sequence, id, queue_name, element, attempts - ORDER BY updated_at ASC, sequence ASC - LIMIT ${sql.literal(size.toString())} ` }) @@ -1118,6 +1031,7 @@ export const makeStoreSql: ( yield* Effect.yieldNow } }).pipe( + Effect.tapCause(Effect.logWarning), Effect.sandbox, Effect.retry(Schedule.spaced(500)), Effect.forkScoped @@ -1176,6 +1090,114 @@ export const makeStoreSql: ( }) }) +const sqlMigrations = (tableName: string) => + Migrator.fromRecord({ + "0001_create_table": Effect.gen(function*() { + const sql = (yield* SqlClient.SqlClient).withoutTransforms() + const tableNameSql = sql(tableName) + + yield* sql.onDialectOrElse({ + mysql: () => + sql`CREATE TABLE IF NOT EXISTS ${tableNameSql} ( + sequence BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id VARCHAR(36) NOT NULL, + queue_name VARCHAR(100) NOT NULL, + element TEXT NOT NULL, + completed BOOLEAN NOT NULL, + attempts INT NOT NULL DEFAULT 0, + last_failure TEXT NULL, + acquired_at DATETIME NULL, + acquired_by VARCHAR(36) NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`, + pg: () => + sql`CREATE TABLE IF NOT EXISTS ${tableNameSql} ( + sequence SERIAL PRIMARY KEY, + id VARCHAR(36) NOT NULL, + queue_name VARCHAR(100) NOT NULL, + element TEXT NOT NULL, + completed BOOLEAN NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_failure TEXT NULL, + acquired_at TIMESTAMP NULL, + acquired_by UUID NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + )`, + mssql: () => + sql`IF NOT EXISTS (SELECT * FROM sysobjects WHERE name=${tableNameSql} AND xtype='U') + CREATE TABLE ${tableNameSql} ( + sequence INT IDENTITY(1,1) PRIMARY KEY, + id NVARCHAR(36) NOT NULL, + queue_name NVARCHAR(100) NOT NULL, + element NVARCHAR(MAX) NOT NULL, + completed BIT NOT NULL, + attempts INT NOT NULL DEFAULT 0, + last_failure NVARCHAR(MAX) NULL, + acquired_at DATETIME2 NULL, + acquired_by UNIQUEIDENTIFIER NULL, + created_at DATETIME2 NOT NULL, + updated_at DATETIME2 NOT NULL + )`, + // sqlite + orElse: () => + sql`CREATE TABLE IF NOT EXISTS ${tableNameSql} ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL, + queue_name TEXT NOT NULL, + element TEXT NOT NULL, + completed BOOLEAN NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_failure TEXT NULL, + acquired_at DATETIME NULL, + acquired_by TEXT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )` + }) + + yield* sql.onDialectOrElse({ + mssql: () => + sql`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = N'idx_${tableName}_id') + CREATE UNIQUE INDEX idx_${tableNameSql}_id ON ${tableNameSql} (id, queue_name)`, + mysql: () => + sql`CREATE UNIQUE INDEX ${sql(`idx_${tableName}_id`)} ON ${tableNameSql} (id, queue_name)`.pipe( + Effect.ignore + ), + orElse: () => + sql`CREATE UNIQUE INDEX IF NOT EXISTS ${sql(`idx_${tableName}_id`)} ON ${tableNameSql} (id, queue_name)` + }) + + yield* sql.onDialectOrElse({ + mssql: () => + sql`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = N'idx_${tableName}_take') + CREATE INDEX idx_${tableNameSql}_take ON ${tableNameSql} (queue_name, completed, attempts, acquired_at)`, + mysql: () => + sql`CREATE INDEX ${ + sql(`idx_${tableName}_take`) + } ON ${tableNameSql} (queue_name, completed, attempts, acquired_at)` + .pipe(Effect.ignore), + orElse: () => + sql`CREATE INDEX IF NOT EXISTS ${ + sql(`idx_${tableName}_take`) + } ON ${tableNameSql} (queue_name, completed, attempts, acquired_at)` + }) + + yield* sql.onDialectOrElse({ + mssql: () => + sql`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = N'idx_${tableName}_update') + CREATE INDEX ${sql(`idx_${tableName}_update`)} ON ${tableNameSql} (sequence, acquired_by)`, + mysql: () => + sql`CREATE INDEX ${sql(`idx_${tableName}_update`)} ON ${tableNameSql} (sequence, acquired_by)`.pipe( + Effect.ignore + ), + orElse: () => + sql`CREATE INDEX IF NOT EXISTS ${sql(`idx_${tableName}_update`)} ON ${tableNameSql} (sequence, acquired_by)` + }) + }) + }) + class QueueKey extends Data.Class<{ readonly name: string readonly maxAttempts: number diff --git a/repos/effect/packages/effect/src/unstable/persistence/Redis.ts b/repos/effect/packages/effect/src/unstable/persistence/Redis.ts index aea3e7513b..dcef26c61e 100644 --- a/repos/effect/packages/effect/src/unstable/persistence/Redis.ts +++ b/repos/effect/packages/effect/src/unstable/persistence/Redis.ts @@ -1,15 +1,16 @@ /** * Redis support shared by persistence modules. * - * This module defines a `Redis` service that can send Redis commands and run - * Lua scripts. It does not create a Redis client itself; callers provide a - * `send` function from their client or connection pool. The module also + * This module defines a `Redis` service that can send Redis commands, subscribe + * to pub/sub channels, and run Lua scripts. It does not create a Redis client + * itself; callers provide the client-specific operations. The module also * provides helpers for describing Lua scripts, loading them once, and running * them later by their cached Redis id. * * @since 4.0.0 */ import * as Cache from "../../Cache.ts" +import type * as Cause from "../../Cause.ts" import * as Context from "../../Context.ts" import * as Duration from "../../Duration.ts" import * as Effect from "../../Effect.ts" @@ -17,10 +18,24 @@ import * as Equal from "../../Equal.ts" import * as Exit from "../../Exit.ts" import { constant, identity } from "../../Function.ts" import * as Hash from "../../Hash.ts" +import * as Queue from "../../Queue.ts" import * as Schema from "../../Schema.ts" +import * as Scope from "../../Scope.ts" /** - * Service for sending Redis commands and evaluating cached Lua scripts. + * A message received from a Redis pub/sub channel. + * + * @category models + * @since 4.0.0 + */ +export interface RedisMessage { + readonly channel: string + readonly message: string +} + +/** + * Service for sending Redis commands, subscribing to channels, and evaluating + * cached Lua scripts. * * @category services * @since 4.0.0 @@ -28,6 +43,17 @@ import * as Schema from "../../Schema.ts" export class Redis extends Context.Service(command: string, ...args: ReadonlyArray) => Effect.Effect + /** + * Subscribes to a Redis pub/sub channel for the lifetime of the current + * scope. Node and Deno subscribers reconnect and re-subscribe after an + * interruption, so messages published during recovery appear as delivery + * gaps. Bun subscribers do not reconnect: a dropped connection fails the + * dequeue, and the caller must subscribe again. + */ + readonly subscribe: ( + channel: string + ) => Effect.Effect, never, Scope.Scope> + readonly eval: < Config extends { readonly params: ReadonlyArray @@ -37,7 +63,7 @@ export class Redis extends Context.Service()("effect/persistence/Redis") {} /** - * Creates a `Redis` service from a raw command sender. + * Creates a `Redis` service from raw command and subscription operations. * * **Details** * @@ -50,6 +76,10 @@ export class Redis extends Context.Service(command: string, ...args: ReadonlyArray) => Effect.Effect + readonly subscribe: ( + channel: string, + onMessage: (message: RedisMessage) => void + ) => Effect.Effect, RedisError, Scope.Scope> } ) { const scriptCache = yield* Cache.makeWith( @@ -85,8 +115,22 @@ export const make = Effect.fnUntraced(function*( ) } + const subscribe = Effect.fnUntraced(function*(channel: string) { + const queue = yield* Queue.unbounded() + yield* Scope.addFinalizer(yield* Effect.scope, Queue.shutdown(queue)) + const onFailure = (cause: Cause.Cause) => Queue.failCause(queue, cause).pipe(Effect.asVoid) + yield* options.subscribe(channel, (message) => { + Queue.offerUnsafe(queue, message) + }).pipe( + Effect.flatMap((listen) => Effect.forkScoped(Effect.catchCause(listen, onFailure))), + Effect.catchCause(onFailure) + ) + return queue + }) + return identity({ send: options.send, + subscribe, eval: eval_ }) }) diff --git a/repos/effect/packages/effect/src/unstable/reactivity/Atom.ts b/repos/effect/packages/effect/src/unstable/reactivity/Atom.ts index d01751a7ca..e31de58365 100644 --- a/repos/effect/packages/effect/src/unstable/reactivity/Atom.ts +++ b/repos/effect/packages/effect/src/unstable/reactivity/Atom.ts @@ -545,14 +545,14 @@ function makeEffect( ctx.addFinalizer(() => { Effect.runForkWith(services)(Scope.close(scope, Exit.void)) }) - const servicesMap = new Map(services.mapUnsafe) - servicesMap.set(Scope.Scope.key, scope) - servicesMap.set(AtomRegistry.key, ctx.registry) - servicesMap.set(Scheduler.Scheduler.key, ctx.registry.scheduler) let syncResult: AsyncResult.AsyncResult | undefined let isAsync = false const cancel = runCallbackSync( - Context.makeUnsafe(servicesMap), + services.pipe( + Context.add(Scope.Scope, scope), + Context.add(AtomRegistry, ctx.registry), + Context.add(Scheduler.Scheduler, ctx.registry.scheduler) + ), effect, function(exit) { syncResult = AsyncResult.fromExitWithPrevious(exit, previous) @@ -891,12 +891,12 @@ function makeStream( return Effect.void }) ) - const servicesMap = new Map(services.mapUnsafe) - servicesMap.set(AtomRegistry.key, ctx.registry) - servicesMap.set(Scheduler.Scheduler.key, ctx.registry.scheduler) const cancel = runCallbackSync( - Context.makeUnsafe(servicesMap), + services.pipe( + Context.add(AtomRegistry, ctx.registry), + Context.add(Scheduler.Scheduler, ctx.registry.scheduler) + ), run, constVoid, false diff --git a/repos/effect/packages/effect/src/unstable/reactivity/Reactivity.ts b/repos/effect/packages/effect/src/unstable/reactivity/Reactivity.ts index 0fcfcd2863..4b1e6df77c 100644 --- a/repos/effect/packages/effect/src/unstable/reactivity/Reactivity.ts +++ b/repos/effect/packages/effect/src/unstable/reactivity/Reactivity.ts @@ -91,7 +91,7 @@ export const make = Effect.sync(() => { keys: ReadonlyArray | ReadonlyRecord> ): Effect.Effect => Effect.contextWith((services) => { - const pending = services.mapUnsafe.get(PendingInvalidation.key) as Set | undefined + const pending = Context.getOrUndefined(services, PendingInvalidation) if (pending) { keysToHashes(keys, (hash) => { pending.add(hash) diff --git a/repos/effect/packages/effect/src/unstable/rpc/RpcClient.ts b/repos/effect/packages/effect/src/unstable/rpc/RpcClient.ts index 1812e8f553..641e17a60c 100644 --- a/repos/effect/packages/effect/src/unstable/rpc/RpcClient.ts +++ b/repos/effect/packages/effect/src/unstable/rpc/RpcClient.ts @@ -510,7 +510,7 @@ export const makeNoSerialization: Effect.Effect => { const middlewares: Array> = [] for (const tag of rpc.middlewares.values()) { - const middleware = services.mapUnsafe.get(`${tag.key}/Client`) + const middleware = Context.getOrUndefinedUnsafe(services, `${tag.key}/Client`) as any if (!middleware) continue middlewares.push(middleware) } @@ -652,7 +652,9 @@ export const make: } | undefined ) { const clientId = clientIdCounter++ - const { run, send, supportsAck, supportsTransferables } = yield* Protocol + const { codecFor, run, send, supportsAck, supportsTransferables } = yield* Protocol + const rpcSchemas = makeRpcSchemas(codecFor) + const decodeDefect = Schema.decodeSync(codecFor(Schema.Defect())) type ClientEntry = { readonly rpc: Rpc.AnyWithProps @@ -782,23 +784,27 @@ interface RpcSchemas { readonly encodePayload: (payload: any) => Effect.Effect readonly decodeExit: (encoded: unknown) => Effect.Effect, Schema.SchemaError, unknown> } -const rpcSchemasCache = new WeakMap() -const rpcSchemas = (rpc: Rpc.AnyWithProps) => { - let entry = rpcSchemasCache.get(rpc) - if (entry !== undefined) { +// Codecs are compiled per client, because two protocols can fill the message +// holes with different codecs. +const makeRpcSchemas = (codecFor: RpcSerialization.CodecFor) => { + const cache = new WeakMap() + return (rpc: Rpc.AnyWithProps): RpcSchemas => { + let entry = cache.get(rpc) + if (entry !== undefined) { + return entry + } + const streamSchemas = RpcSchema.getStreamSchemas(rpc.successSchema) + entry = { + decodeChunk: Option.map( + streamSchemas, + (streamSchemas) => Schema.decodeUnknownEffect(codecFor(Schema.NonEmptyArray(streamSchemas.success))) + ), + encodePayload: Schema.encodeEffect(codecFor(rpc.payloadSchema)), + decodeExit: Schema.decodeUnknownEffect(codecFor(Rpc.exitSchema(rpc as any))) + } + cache.set(rpc, entry) return entry } - const streamSchemas = RpcSchema.getStreamSchemas(rpc.successSchema) - entry = { - decodeChunk: Option.map( - streamSchemas, - (streamSchemas) => Schema.decodeUnknownEffect(Schema.toCodecJson(Schema.NonEmptyArray(streamSchemas.success))) - ), - encodePayload: Schema.encodeEffect(Schema.toCodecJson(rpc.payloadSchema)), - decodeExit: Schema.decodeUnknownEffect(Schema.toCodecJson(Rpc.exitSchema(rpc as any))) - } - rpcSchemasCache.set(rpc, entry) - return entry } /** @@ -857,6 +863,11 @@ export class Protocol extends Context.Service Effect.Effect readonly supportsAck: boolean readonly supportsTransferables: boolean + /** + * Builds the codec that fills the `unknown` holes of the protocol messages, + * re-passed from the `RpcSerialization` backing this transport. + */ + readonly codecFor: RpcSerialization.CodecFor }>()("effect/rpc/RpcClient/Protocol") { /** * Creates a client protocol service from the supplied RPC request runner. @@ -975,7 +986,8 @@ export const makeProtocolHttp = (client: HttpClient.HttpClient): Effect.Effect< return { send, supportsAck: false, - supportsTransferables: false + supportsTransferables: false, + codecFor: serialization.codecFor } })) @@ -1033,7 +1045,10 @@ export const makeProtocolSocket = (options?: { let parser = serialization.makeUnsafe() - const pinger = yield* makePinger(write(parser.encode(constPing)!)) + // `parser` is replaced on every connect, and a stateful serialization + // encodes against the connection it is writing to, so the ping is encoded + // when it is sent rather than once up front. + const pinger = yield* makePinger(Effect.suspend(() => write(parser.encode(constPing)!))) let currentError: RpcClientError | undefined const onOpen = Effect.suspend(() => { currentError = undefined @@ -1156,7 +1171,8 @@ export const makeProtocolSocket = (options?: { return Effect.orDie(write(encoded)) }, supportsAck: true, - supportsTransferables: false + supportsTransferables: false, + codecFor: serialization.codecFor } })) @@ -1378,7 +1394,10 @@ export const makeProtocolWorker = ( return { send, supportsAck: true, - supportsTransferables: true + supportsTransferables: true, + // Worker protocols use structured clone, so they do not depend on + // `RpcSerialization`. A binary worker protocol is a separate protocol. + codecFor: Schema.toCodecJson as RpcSerialization.CodecFor } })) @@ -1423,7 +1442,3 @@ export class ConnectionHooks extends Context.Service readonly onDisconnect: Effect.Effect }>()("effect/rpc/RpcClient/ConnectionHooks") {} - -// internal - -const decodeDefect = Schema.decodeSync(Schema.Defect()) diff --git a/repos/effect/packages/effect/src/unstable/rpc/RpcGroup.ts b/repos/effect/packages/effect/src/unstable/rpc/RpcGroup.ts index 2b63742a96..4616a75b98 100644 --- a/repos/effect/packages/effect/src/unstable/rpc/RpcGroup.ts +++ b/repos/effect/packages/effect/src/unstable/rpc/RpcGroup.ts @@ -346,7 +346,7 @@ const RpcGroupProto = { accessHandler(this: RpcGroup, service: string) { return Effect.contextWith((parentContext: Context.Context) => { const rpc = this.requests.get(service)! - const { handler, context } = parentContext.mapUnsafe.get(rpc.key) as Rpc.Handler + const { handler, context } = Context.getOrUndefinedUnsafe(parentContext, rpc.key) as Rpc.Handler return Effect.succeed((payload: Rpc.Payload, options: any) => { options.rpc = rpc const result = handler(payload, options) diff --git a/repos/effect/packages/effect/src/unstable/rpc/RpcMessage.ts b/repos/effect/packages/effect/src/unstable/rpc/RpcMessage.ts index dca41316de..b47cdd3e31 100644 --- a/repos/effect/packages/effect/src/unstable/rpc/RpcMessage.ts +++ b/repos/effect/packages/effect/src/unstable/rpc/RpcMessage.ts @@ -54,6 +54,9 @@ export const RequestId = (id: string | number): RequestId => id as RequestId * The transport-encoded RPC request envelope, including the string request id, * RPC tag, encoded payload, headers, and optional trace context. * + * Requests flow in both directions: servers use them for server-originated + * requests and, with `isNotification` set, for server notifications. + * * @category models * @since 4.0.0 */ @@ -195,6 +198,7 @@ export type FromServerEncoded = | ResponseDefectEncoded | Pong | ClientProtocolError + | RequestEncoded /** * The brand identifier used by the `ResponseId` type. @@ -324,18 +328,25 @@ export interface ResponseDefectEncoded { readonly defect: unknown } -const encodeDefect = Schema.encodeSync(Schema.Defect()) - /** - * Creates an encoded terminal response for a request whose exit is a defect - * encoded with `Schema.Defect()`. + * Creates an encoded terminal response for a request whose exit is a defect. + * + * **Details** + * + * The defect must already be encoded, because the codec that fills the defect + * hole belongs to the protocol. Encode it with + * `protocol.codecFor(Schema.Defect())` first. + * + * This constructor produces the structured exit used by JSON-compatible + * protocols. Serializations whose `codecFor` returns bytes must encode the + * complete RPC exit before placing it in the response envelope instead. * * @category constructors * @since 4.0.0 */ export const ResponseExitDieEncoded = (options: { readonly requestId: RequestId - readonly defect: unknown + readonly encodedDefect: unknown }): ResponseExitEncoded => ({ _tag: "Exit", requestId: options.requestId, @@ -343,21 +354,25 @@ export const ResponseExitDieEncoded = (options: { _tag: "Failure", cause: [{ _tag: "Die", - defect: encodeDefect(options.defect) + defect: options.encodedDefect }] } }) /** - * Creates a transport-encoded defect response by encoding the input with - * `Schema.Defect()`. + * Creates a transport-encoded defect response around an already-encoded defect. + * + * **Details** + * + * Encode the defect with `protocol.codecFor(Schema.Defect())` before wrapping + * it, because the codec that fills the defect hole belongs to the protocol. * * @category constructors * @since 4.0.0 */ -export const ResponseDefectEncoded = (input: unknown): ResponseDefectEncoded => ({ +export const ResponseDefectEncoded = (encodedDefect: unknown): ResponseDefectEncoded => ({ _tag: "Defect", - defect: encodeDefect(input) + defect: encodedDefect }) /** @@ -393,6 +408,60 @@ export interface Pong { readonly _tag: "Pong" } +const RequestIdSchema = Schema.Union([Schema.String, Schema.Number]) + +// Schema for transport-encoded RPC requests whose payload hole has already +// been filled by the active serialization. +const RequestEncodedSchema = Schema.Struct({ + _tag: Schema.tag("Request"), + id: RequestIdSchema, + tag: Schema.String, + payload: Schema.Uint8Array, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + isNotification: Schema.optional(Schema.Literal(true)), + traceId: Schema.optional(Schema.String), + spanId: Schema.optional(Schema.String), + sampled: Schema.optional(Schema.Boolean) +}) + +/** + * Schema for every transport-encoded RPC envelope that crosses the wire. + * Binary serializers use it only after each schema-dependent hole has been + * encoded as bytes. `ClientProtocolError` is excluded because clients create + * it locally rather than receiving it from a server. + * + * @category schemas + * @since 4.0.0 + */ +export const EncodedSchema = Schema.Union([ + RequestEncodedSchema, + Schema.Struct({ + _tag: Schema.tag("Ack"), + requestId: RequestIdSchema + }), + Schema.Struct({ + _tag: Schema.tag("Interrupt"), + requestId: RequestIdSchema + }), + Schema.Struct({ _tag: Schema.tag("Ping") }), + Schema.Struct({ _tag: Schema.tag("Eof") }), + Schema.Struct({ + _tag: Schema.tag("Chunk"), + requestId: RequestIdSchema, + values: Schema.Uint8Array + }), + Schema.Struct({ + _tag: Schema.tag("Exit"), + requestId: RequestIdSchema, + exit: Schema.Uint8Array + }), + Schema.Struct({ + _tag: Schema.tag("Defect"), + defect: Schema.Uint8Array + }), + Schema.Struct({ _tag: Schema.tag("Pong") }) +]) + /** * Represents the reusable `Pong` message value. * diff --git a/repos/effect/packages/effect/src/unstable/rpc/RpcSerialization.ts b/repos/effect/packages/effect/src/unstable/rpc/RpcSerialization.ts index f6aa9c7282..5233397752 100644 --- a/repos/effect/packages/effect/src/unstable/rpc/RpcSerialization.ts +++ b/repos/effect/packages/effect/src/unstable/rpc/RpcSerialization.ts @@ -3,9 +3,9 @@ * * `RpcSerialization` is the boundary between `RpcMessage` envelopes and the * bytes or strings carried by a transport. This module provides built-in - * serializers for JSON, newline-delimited JSON, JSON-RPC 2.0, and MessagePack, - * including framed formats that can decode multiple messages from streaming - * chunks. + * serializers for JSON, newline-delimited JSON, JSON-RPC 2.0, MessagePack, and + * SchemaBinary, including framed formats that can decode multiple messages + * from streaming chunks. * * @since 4.0.0 */ @@ -15,7 +15,29 @@ import * as Data from "../../Data.ts" import * as Layer from "../../Layer.ts" import * as Predicate from "../../Predicate.ts" import { hasProperty } from "../../Predicate.ts" -import type * as RpcMessage from "./RpcMessage.ts" +import * as Schema from "../../Schema.ts" +import * as SchemaBinary from "../encoding/SchemaBinary.ts" +import * as RpcMessage from "./RpcMessage.ts" + +/** + * Builds the codec used to fill the `unknown` holes of RPC protocol messages, + * such as request payloads, stream chunks, exits, and defects. + * + * **Details** + * + * The envelope around the hole is framed by {@link Parser}, which never sees the + * schema. JSON based serializations return `Schema.toCodecJson`, so the encoded + * hole is JSON. Other serializations may encode the hole as bytes; both fit the + * `unknown` hole, so `RpcSerialization` has no type parameter. + * + * @category serialization + * @since 4.0.0 + */ +export type CodecFor = ( + schema: S +) => Schema.Codec + +const codecForJson = Schema.toCodecJson as CodecFor /** * Service that describes how RPC protocol messages are encoded and decoded, @@ -34,6 +56,7 @@ export class RpcSerialization extends Context.Service()("effect/rpc/RpcSerialization") {} /** @@ -95,6 +118,7 @@ const isBufferSizeExceeded = ( export const json: RpcSerialization["Service"] = RpcSerialization.of({ contentType: "application/json", includesFraming: false, + codecFor: codecForJson, makeUnsafe: () => { const decoder = new TextDecoder() return { @@ -119,6 +143,7 @@ export const makeNdjson = (options?: StreamOptions): RpcSerialization["Service"] return RpcSerialization.of({ contentType: "application/ndjson", includesFraming: true, + codecFor: codecForJson, makeUnsafe: () => { const decoder = new TextDecoder() let buffer = "" @@ -184,6 +209,7 @@ export const jsonRpc = (options?: { RpcSerialization.of({ contentType: options?.contentType ?? "application/json", includesFraming: false, + codecFor: codecForJson, makeUnsafe: () => { const decoder = new TextDecoder() const batches = new Map { const parser = makeNdjson({ maxBufferSize: options?.maxBufferSize }).makeUnsafe() const batches = new Map 0 ? { headers: response.headers } : {}), traceId: response.traceId, spanId: response.spanId, sampled: response.sampled @@ -510,6 +538,7 @@ export const makeMsgPack = ( return RpcSerialization.of({ contentType: "application/msgpack", includesFraming: true, + codecFor: codecForJson, makeUnsafe: () => { const unpackr = new Msgpackr.Unpackr(msgpackOptions) const packr = new Msgpackr.Packr(msgpackOptions) @@ -562,6 +591,45 @@ export const makeMsgPack = ( */ export const msgPack: RpcSerialization["Service"] = makeMsgPack({ useRecords: true }) +const defaultSchemaBinaryMaxFrameSize = 16 * 1024 * 1024 + +const schemaBinaryTextEncoder = new TextEncoder() + +const makeSchemaBinary = (options?: { + readonly maxFrameSize?: number | undefined + readonly fingerprintPayloads?: boolean | undefined +}): RpcSerialization["Service"] => { + const maxFrameSize = options?.maxFrameSize ?? defaultSchemaBinaryMaxFrameSize + const codecFor: CodecFor = options?.fingerprintPayloads === true + ? (schema) => SchemaBinary.toCodecDirect(schema, { fingerprint: true }) + : SchemaBinary.toCodecDirect + // The envelope repeats itself: the same RPC tag, header names, and trace ids + // come back on message after message. A dictionary shared by every frame on + // the connection sends each of those once and references it afterwards, so + // the writer and the reader here are a matched pair and neither one works + // against a peer that was built without the other. + const envelopeOptions = { fingerprint: true, dictionary: true } as const + return RpcSerialization.of({ + contentType: "application/vnd.effect.rpc+schema-binary", + includesFraming: true, + codecFor, + makeUnsafe: () => { + const parser = SchemaBinary.parser(RpcMessage.EncodedSchema, { ...envelopeOptions, maxFrameSize }) + const encoder = SchemaBinary.encoder(RpcMessage.EncodedSchema, envelopeOptions) + return { + decode: (data) => parser.feedSync(typeof data === "string" ? schemaBinaryTextEncoder.encode(data) : data), + encode: (response) => { + if (!Array.isArray(response)) { + return encoder.encode(response) + } + if (response.length === 0) return undefined + return encoder.encodeMany(response) + } + } + } + }) +} + /** * RPC serialization layer that uses JSON for serialization. * @@ -643,3 +711,16 @@ export const layerMsgPack: Layer.Layer = Layer.succeed(RpcSeri export const layerMsgPackWith = ( options?: (Msgpackr.Options & StreamOptions) | undefined ): Layer.Layer => Layer.succeed(RpcSerialization)(makeMsgPack(options)) + +/** + * RPC serialization layer that uses SchemaBinary with fingerprinted RPC + * envelopes. Payload fingerprints are disabled by default to support compatible + * schema evolution. Frames default to a 16 MiB maximum size. + * + * @category layers + * @since 4.0.0 + */ +export const layerSchemaBinary = (options?: { + readonly maxFrameSize?: number | undefined + readonly fingerprintPayloads?: boolean | undefined +}): Layer.Layer => Layer.sync(RpcSerialization)(() => makeSchemaBinary(options)) diff --git a/repos/effect/packages/effect/src/unstable/rpc/RpcServer.ts b/repos/effect/packages/effect/src/unstable/rpc/RpcServer.ts index 5a944e1530..d179d570f7 100644 --- a/repos/effect/packages/effect/src/unstable/rpc/RpcServer.ts +++ b/repos/effect/packages/effect/src/unstable/rpc/RpcServer.ts @@ -241,7 +241,7 @@ export const makeNoSerialization: ( return Effect.interrupt } const rpc = group.requests.get(request.tag) as any as Rpc.AnyWithProps - const entry = services.mapUnsafe.get(rpc?.key) as Rpc.Handler + const entry = Context.getOrUndefinedUnsafe(services, rpc?.key) as Rpc.Handler if (!rpc || !entry) { const write = Effect.catchDefect( options.onFromServer({ @@ -320,9 +320,10 @@ export const makeNoSerialization: ( effect = opts.onRequest(effect) } if (enableTracing) { - const parentSpan = requestFiber.context.mapUnsafe.get( - Tracer.ParentSpan.key - ) as Tracer.AnySpan | undefined + const parentSpan = Context.getOrUndefined( + requestFiber.context, + Tracer.ParentSpan + ) effect = Effect.withSpan(effect, `${spanPrefix}.${request.tag}`, { captureStackTrace: false, attributes: options.spanAttributes, @@ -516,6 +517,7 @@ export const make: ( } ) { const { + codecFor, disconnects, end, run, @@ -524,6 +526,7 @@ export const make: ( supportsSpanPropagation, supportsTransferables } = yield* Protocol + const encodeDefectUnsafe = Schema.encodeSync(codecFor(Schema.Defect())) const services = yield* Effect.context | Rpc.Middleware>() const scope = yield* Scope.make() @@ -588,7 +591,7 @@ export const make: ( type Schemas = { readonly decode: (u: unknown) => Effect.Effect, Schema.SchemaError> readonly encodeChunk: ( - u: ReadonlyArray + u: NonEmptyReadonlyArray ) => Effect.Effect, Schema.SchemaError> readonly encodeExit: (u: unknown) => Effect.Effect readonly encodeDefect: (u: unknown) => Effect.Effect @@ -600,17 +603,17 @@ export const make: ( const getSchemas = (rpc: Rpc.AnyWithProps) => { let schemas = schemasCache.get(rpc) if (!schemas) { - const entry = services.mapUnsafe.get(rpc.key) as Rpc.Handler + const entry = Context.getOrUndefinedUnsafe(services, rpc.key) as Rpc.Handler const streamSchemas = RpcSchema.getStreamSchemas(rpc.successSchema) schemas = { - decode: Schema.decodeUnknownEffect(Schema.toCodecJson(rpc.payloadSchema)) as any, + decode: Schema.decodeUnknownEffect(codecFor(rpc.payloadSchema)) as any, encodeChunk: Schema.encodeUnknownEffect( - Schema.toCodecJson( - Schema.Array(Option.isSome(streamSchemas) ? streamSchemas.value.success : Schema.Any) + codecFor( + Schema.NonEmptyArray(Option.isSome(streamSchemas) ? streamSchemas.value.success : Schema.Any) ) ) as any, - encodeExit: Schema.encodeUnknownEffect(Schema.toCodecJson(Rpc.exitSchema(rpc as any))) as any, - encodeDefect: Schema.encodeUnknownEffect(Schema.toCodecJson(rpc.defectSchema)) as any, + encodeExit: Schema.encodeUnknownEffect(codecFor(Rpc.exitSchema(rpc as any))) as any, + encodeDefect: Schema.encodeUnknownEffect(codecFor(rpc.defectSchema)) as any, context: entry.context } schemasCache.set(rpc, schemas) @@ -668,7 +671,7 @@ export const make: ( const sendDefect = (client: Client, defect: unknown) => Effect.catchCause( - send(client.id, ResponseDefectEncoded(defect)), + send(client.id, ResponseDefectEncoded(encodeDefectUnsafe(defect))), (cause) => Effect.annotateLogs(Effect.logDebug(cause), { module: "RpcServer", @@ -805,6 +808,7 @@ export const layerHttp = (options: { readonly spanAttributes?: Record | undefined readonly concurrency?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined + readonly streamBufferSize?: number | "unbounded" | undefined }): Layer.Layer< never, never, @@ -853,6 +857,12 @@ export class Protocol extends Context.Service< readonly supportsAck: boolean readonly supportsTransferables: boolean readonly supportsSpanPropagation: boolean + readonly supportsNotifications: boolean + /** + * Builds the codec that fills the `unknown` holes of the protocol messages, + * re-passed from the `RpcSerialization` backing this transport. + */ + readonly codecFor: RpcSerialization.CodecFor } >()("effect/rpc/RpcServer/Protocol") { /** @@ -970,7 +980,11 @@ export const layerProtocolWebsocket = (options: { * @category protocols * @since 4.0.0 */ -export const makeProtocolWithHttpEffect: Effect.Effect< +export const makeProtocolWithHttpEffect: ( + options?: { + readonly streamBufferSize?: number | "unbounded" | undefined + } | undefined +) => Effect.Effect< { readonly protocol: Protocol["Service"] readonly httpEffect: Effect.Effect< @@ -981,8 +995,9 @@ export const makeProtocolWithHttpEffect: Effect.Effect< }, never, RpcSerialization.RpcSerialization -> = Effect.gen(function*() { +> = Effect.fnUntraced(function*(options = {}) { const serialization = yield* RpcSerialization.RpcSerialization + const encodeDefectUnsafe = Schema.encodeSync(serialization.codecFor(Schema.Defect())) const includesFraming = serialization.includesFraming const isBinary = !serialization.contentType.includes("json") @@ -1016,7 +1031,11 @@ export const makeProtocolWithHttpEffect: Effect.Effect< isBinary ? Effect.map(request.arrayBuffer, (buf) => new Uint8Array(buf)) : request.text ) const id = clientId++ - const queue = yield* Queue.make() + const queue = yield* Queue.make({ + capacity: includesFraming && options.streamBufferSize !== "unbounded" + ? options.streamBufferSize ?? 16 + : undefined + }) const parser = serialization.makeUnsafe() const requestIds: Array = [] @@ -1024,14 +1043,18 @@ export const makeProtocolWithHttpEffect: Effect.Effect< typeof data === "string" ? Queue.offer(queue, encoder.encode(data)) : Queue.offer(queue, data) const client: Client = { write: !includesFraming - ? (response) => Queue.offer(queue, response) + ? (response) => + // buffered responses cannot carry notifications, so they are dropped + response._tag === "Request" && response.isNotification === true + ? Effect.void + : Queue.offer(queue, response) : (response) => { try { const encoded = parser.encode(response) if (encoded === undefined) return Effect.void return offer(encoded) } catch (cause) { - return offer(parser.encode(ResponseDefectEncoded(cause))!) + return offer(parser.encode(ResponseDefectEncoded(encodeDefectUnsafe(cause)))!) } }, end: Queue.end(queue) @@ -1063,7 +1086,7 @@ export const makeProtocolWithHttpEffect: Effect.Effect< yield* writeRequest(id, message) } } catch (cause) { - yield* client.write(ResponseDefectEncoded(cause)) + yield* client.write(ResponseDefectEncoded(encodeDefectUnsafe(cause))) } yield* writeRequest(id, constEof) @@ -1110,7 +1133,9 @@ export const makeProtocolWithHttpEffect: Effect.Effect< initialMessage: Effect.succeedNone, supportsAck: false, supportsTransferables: false, - supportsSpanPropagation: false + supportsSpanPropagation: false, + supportsNotifications: includesFraming, + codecFor: serialization.codecFor }) }) @@ -1139,12 +1164,13 @@ const mergeUint8Arrays = (arrays: ReadonlyArray) => { */ export const makeProtocolHttp: (options: { readonly path: HttpRouter.PathInput + readonly streamBufferSize?: number | "unbounded" | undefined }) => Effect.Effect< Protocol["Service"], never, RpcSerialization.RpcSerialization | HttpRouter.HttpRouter > = Effect.fnUntraced(function*(options) { - const { httpEffect, protocol } = yield* makeProtocolWithHttpEffect + const { httpEffect, protocol } = yield* makeProtocolWithHttpEffect(options) const router = yield* HttpRouter.HttpRouter yield* router.add("POST", options.path, httpEffect) return protocol @@ -1159,6 +1185,7 @@ export const makeProtocolHttp: (options: { */ export const layerProtocolHttp = (options: { readonly path: HttpRouter.PathInput + readonly streamBufferSize?: number | "unbounded" | undefined }): Layer.Layer => { return Layer.effect(Protocol)(makeProtocolHttp(options)) } @@ -1177,6 +1204,7 @@ export const toHttpEffect: ( readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly disableFatalDefects?: boolean | undefined + readonly streamBufferSize?: number | "unbounded" | undefined } | undefined ) => Effect.Effect< Effect.Effect, @@ -1193,9 +1221,10 @@ export const toHttpEffect: ( readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly disableFatalDefects?: boolean | undefined + readonly streamBufferSize?: number | "unbounded" | undefined } ) { - const { httpEffect, protocol } = yield* makeProtocolWithHttpEffect + const { httpEffect, protocol } = yield* makeProtocolWithHttpEffect(options) yield* make(group, options).pipe( Effect.provideService(Protocol, protocol), Effect.forkScoped @@ -1301,7 +1330,9 @@ export const makeProtocolStdio = Effect.gen(function*() { initialMessage: Effect.succeedNone, supportsAck: true, supportsTransferables: false, - supportsSpanPropagation: true + supportsSpanPropagation: true, + supportsNotifications: true, + codecFor: serialization.codecFor } })) }) @@ -1360,6 +1391,7 @@ export const makeProtocolWorkerRunner: Effect.Effect< clientIds.delete(clientId) return Queue.offer(disconnects, clientId) }), + Effect.forever, Effect.forkScoped ) } @@ -1374,7 +1406,11 @@ export const makeProtocolWorkerRunner: Effect.Effect< initialMessage: Effect.asSome(Deferred.await(initialMessage)), supportsAck: true, supportsTransferables: true, - supportsSpanPropagation: true + supportsSpanPropagation: true, + supportsNotifications: true, + // Worker protocols use structured clone, so they do not depend on + // `RpcSerialization`. A binary worker protocol is a separate protocol. + codecFor: Schema.toCodecJson as RpcSerialization.CodecFor } })) @@ -1414,6 +1450,7 @@ const makeSocketProtocol: Effect.Effect< RpcSerialization.RpcSerialization > = Effect.gen(function*() { const serialization = yield* RpcSerialization.RpcSerialization + const encodeDefectUnsafe = Schema.encodeSync(serialization.codecFor(Schema.Defect())) const disconnects = yield* Queue.make() let clientId = 0 @@ -1444,7 +1481,7 @@ const makeSocketProtocol: Effect.Effect< return Effect.orDie(writeRaw(encoded)) } catch (cause) { return Effect.orDie( - writeRaw(parser.encode(ResponseDefectEncoded(cause))!) + writeRaw(parser.encode(ResponseDefectEncoded(encodeDefectUnsafe(cause)))!) ) } } @@ -1471,7 +1508,7 @@ const makeSocketProtocol: Effect.Effect< if (Predicate.isTagged(cause, "MaxBufferSizeExceeded")) { return writeRaw(new Socket.CloseEvent(1009, String(cause))) } - return writeRaw(parser.encode(ResponseDefectEncoded(cause))!) + return writeRaw(parser.encode(ResponseDefectEncoded(encodeDefectUnsafe(cause)))!) } }).pipe( Effect.catchReason("SocketError", "SocketCloseError", (_) => Effect.void), @@ -1495,7 +1532,9 @@ const makeSocketProtocol: Effect.Effect< initialMessage: Effect.succeedNone, supportsAck: true, supportsTransferables: false, - supportsSpanPropagation: true + supportsSpanPropagation: true, + supportsNotifications: true, + codecFor: serialization.codecFor }) }) diff --git a/repos/effect/packages/effect/src/unstable/schema/Model.ts b/repos/effect/packages/effect/src/unstable/schema/Model.ts index 59f61695da..fb787fd8d3 100644 --- a/repos/effect/packages/effect/src/unstable/schema/Model.ts +++ b/repos/effect/packages/effect/src/unstable/schema/Model.ts @@ -11,10 +11,10 @@ * * @since 4.0.0 */ -import * as Uuid from "uuid" import type { Brand } from "../../Brand.ts" import * as DateTime from "../../DateTime.ts" import * as Effect from "../../Effect.ts" +import * as Uuid from "../../internal/uuid.ts" import * as Option from "../../Option.ts" import * as Predicate from "../../Predicate.ts" import * as Schema from "../../Schema.ts" @@ -755,7 +755,7 @@ export const Uint8Array: Schema.instanceOf> = Schema.Uin export const UuidV4BytesWithGenerate = ( schema: Schema.brand>, B> ): Schema.withConstructorDefault>, B>> => - schema.pipe(Schema.withConstructorDefault(Effect.sync(() => Uuid.v4({}, new globalThis.Uint8Array(16))))) + schema.pipe(Schema.withConstructorDefault(Effect.sync(() => Uuid.v4Bytes()))) /** * A field that represents a binary UUID v4 that is generated on inserts. @@ -798,7 +798,7 @@ export interface UuidV4Insert extends export const UuidV4WithGenerate = ( schema: Schema.brand ): Schema.withConstructorDefault> => - schema.pipe(Schema.withConstructorDefault(Effect.sync(() => Uuid.v4()))) + schema.pipe(Schema.withConstructorDefault(Effect.sync(Uuid.v4String))) /** * A field that represents a string UUID v4 that is generated on inserts. @@ -841,11 +841,11 @@ export interface UuidV7Insert extends export const UuidV7WithGenerate = ( schema: Schema.brand ): Schema.withConstructorDefault> => - schema.pipe(Schema.withConstructorDefault(Effect.clockWith((clock) => - Effect.succeed(Uuid.v7({ - msecs: clock.currentTimeMillisUnsafe() - })) - ))) + schema.pipe( + Schema.withConstructorDefault( + Effect.clockWith((clock) => Effect.succeed(Uuid.v7String(clock.currentTimeMillisUnsafe()))) + ) + ) /** * A field that represents a string UUID v7 that is generated on inserts. diff --git a/repos/effect/packages/effect/src/unstable/sql/SqlClient.ts b/repos/effect/packages/effect/src/unstable/sql/SqlClient.ts index b2bd662315..f72f36275b 100644 --- a/repos/effect/packages/effect/src/unstable/sql/SqlClient.ts +++ b/repos/effect/packages/effect/src/unstable/sql/SqlClient.ts @@ -12,6 +12,7 @@ import { Clock } from "../../Clock.ts" import * as Context from "../../Context.ts" import * as Effect from "../../Effect.ts" import * as Exit from "../../Exit.ts" +import { identity } from "../../Function.ts" import * as Option from "../../Option.ts" import type * as Queue from "../../Queue.ts" import type { ReadonlyRecord } from "../../Record.ts" @@ -257,37 +258,39 @@ export const makeWithTransaction = (options: { ) => (id === 0 ? options.begin(conn) : options.savepoint(conn, id)).pipe( Effect.flatMap(() => - Effect.provideContext( - restore(effect), - services.pipe( - Context.add(options.transactionService, [conn, id]), - Context.add(transactionSemaphore, Semaphore.makeUnsafe(1)), - Context.add(Tracer.ParentSpan, span) - ) + Effect.onExitPrimitive( + Effect.provideContext( + restore(effect), + services.pipe( + Context.add(options.transactionService, [conn, id]), + Context.add(transactionSemaphore, Semaphore.makeUnsafe(1)), + Context.add(Tracer.ParentSpan, span) + ) + ), + (exit) => { + let effect: Effect.Effect + if (Exit.isSuccess(exit)) { + if (id === 0) { + span.event("db.transaction.commit", clock.currentTimeNanosUnsafe()) + effect = Effect.orDie(options.commit(conn)) + } else { + span.event("db.transaction.savepoint", clock.currentTimeNanosUnsafe()) + effect = Effect.void + } + } else { + span.event("db.transaction.rollback", clock.currentTimeNanosUnsafe()) + effect = Effect.orDie( + id > 0 + ? options.rollbackSavepoint(conn, id) + : options.rollback(conn) + ) + } + return Effect.flatMap(effect, () => exit) + }, + true ) ), - Effect.exit, - Effect.flatMap((exit) => { - let effect: Effect.Effect - if (Exit.isSuccess(exit)) { - if (id === 0) { - span.event("db.transaction.commit", clock.currentTimeNanosUnsafe()) - effect = Effect.orDie(options.commit(conn)) - } else { - span.event("db.transaction.savepoint", clock.currentTimeNanosUnsafe()) - effect = Effect.void - } - } else { - span.event("db.transaction.rollback", clock.currentTimeNanosUnsafe()) - effect = Effect.orDie( - id > 0 - ? options.rollbackSavepoint(conn, id) - : options.rollback(conn) - ) - } - const withScope = scope !== undefined ? Effect.ensuring(effect, Scope.close(scope, exit)) : effect - return Effect.flatMap(withScope, () => exit) - }) + scope ? (eff) => Effect.onExitPrimitive(eff, (exit) => Scope.close(scope, exit), true) : identity ) ) return id === 0 diff --git a/repos/effect/packages/effect/src/unstable/sql/SqlResolver.ts b/repos/effect/packages/effect/src/unstable/sql/SqlResolver.ts index 88a88ecf1d..a60d15f46e 100644 --- a/repos/effect/packages/effect/src/unstable/sql/SqlResolver.ts +++ b/repos/effect/packages/effect/src/unstable/sql/SqlResolver.ts @@ -12,6 +12,7 @@ */ import * as Arr from "../../Array.ts" import * as Cause from "../../Cause.ts" +import * as Context from "../../Context.ts" import * as Effect from "../../Effect.ts" import * as Equal from "../../Equal.ts" import * as Exit from "../../Exit.ts" @@ -395,9 +396,9 @@ const partitionRequestsById = function*( } function transactionKey(entry: Request.Entry): SqlClient.TransactionConnection.Service | undefined { - const client = entry.context.mapUnsafe.get(SqlClient.SqlClient.key) + const client = Context.getOrUndefined(entry.context, SqlClient.SqlClient) if (!client) return undefined - const conn = entry.context.mapUnsafe.get(client.transactionService.key) + const conn = Context.getOrUndefined(entry.context, client.transactionService) if (!conn) return undefined return Equal.byReferenceUnsafe(conn) } diff --git a/repos/effect/packages/effect/src/unstable/workflow/DurableDeferred.ts b/repos/effect/packages/effect/src/unstable/workflow/DurableDeferred.ts index 3c3329a80b..b6fad5e74f 100644 --- a/repos/effect/packages/effect/src/unstable/workflow/DurableDeferred.ts +++ b/repos/effect/packages/effect/src/unstable/workflow/DurableDeferred.ts @@ -144,6 +144,8 @@ const await_: (self: DurableDeferred) { const engine = yield* EngineTag const instance = yield* InstanceTag + // Register before the read so any later completion can preempt the run. + instance.awaitedDeferreds.add(self.name) const exit = yield* Workflow.wrapActivityResult( engine.deferredResult(self), Option.isNone @@ -225,9 +227,12 @@ export const into: { exit.cause.reasons, Filter.fromPredicate(Cause.isInterruptReason) ) - const hasInterruptsOnly = interrupts.length === exit.cause.reasons.length - if (hasInterruptsOnly && instance.suspended) { - parentInstance.suspended = true + if (interrupts.length === exit.cause.reasons.length) { + // An interrupt-only exit is never a result: the effect was + // suspended, preempted or interrupted, so record nothing. + if (instance.suspended) { + parentInstance.suspended = true + } return } else if (interrupts.length > 0) { exit = Exit.failCause(Cause.fromReasons(reasons)) diff --git a/repos/effect/packages/effect/src/unstable/workflow/Workflow.ts b/repos/effect/packages/effect/src/unstable/workflow/Workflow.ts index af541bc40d..69d4feeb37 100644 --- a/repos/effect/packages/effect/src/unstable/workflow/Workflow.ts +++ b/repos/effect/packages/effect/src/unstable/workflow/Workflow.ts @@ -789,6 +789,9 @@ export const provideScope = ( * Adds an exit finalizer to the current workflow scope, preserving the * services available when the finalizer is registered. * + * Body-level `Effect.onExit` finalizers cannot observe a deposited workflow + * interrupt. Use this function for terminal-state work that must observe it. + * * @category resource management * @since 4.0.0 */ diff --git a/repos/effect/packages/effect/src/unstable/workflow/WorkflowEngine.ts b/repos/effect/packages/effect/src/unstable/workflow/WorkflowEngine.ts index fd406e4a21..59125601c2 100644 --- a/repos/effect/packages/effect/src/unstable/workflow/WorkflowEngine.ts +++ b/repos/effect/packages/effect/src/unstable/workflow/WorkflowEngine.ts @@ -9,7 +9,7 @@ * * @since 4.0.0 */ -import type * as Cause from "../../Cause.ts" +import * as Cause from "../../Cause.ts" import * as Context from "../../Context.ts" import * as Effect from "../../Effect.ts" import * as Exit from "../../Exit.ts" @@ -261,6 +261,9 @@ export class WorkflowInstance extends Context.Service< */ cause: Cause.Cause | undefined + /** Deferred names this run parked on; their completions preempt the run. */ + readonly awaitedDeferreds: Set + readonly activityState: { count: number readonly latch: Latch.Latch @@ -279,6 +282,7 @@ export class WorkflowInstance extends Context.Service< suspended: false, interrupted: false, cause: undefined, + awaitedDeferreds: new Set(), activityState: { count: 0, latch: Latch.makeUnsafe() @@ -287,6 +291,88 @@ export class WorkflowInstance extends Context.Service< } } +/** + * In-process deferred state for live workflow executions. + * + * @category models + * @since 4.0.0 + */ +export interface DeferredState { + /** Returns a completion not yet durably readable. */ + readonly pendingResult: ( + executionId: string, + name: string + ) => Exit.Exit | undefined + + /** Tracks and provides a run, retaining pending results across suspension. */ + readonly trackRun: ( + instance: WorkflowInstance["Service"], + effect: Effect.Effect + ) => Effect.Effect> + + /** Records a completion, preempting a run parked on that deferred. */ + readonly deferredDone: ( + executionId: string, + name: string, + exit: Exit.Exit + ) => Effect.Effect +} + +/** + * Creates deferred state shared by workflow engines. + * + * @category constructors + * @since 4.0.0 + */ +export const makeDeferredState = (): DeferredState => { + const pending = new Map>>() + const running = new Map + }>() + return { + pendingResult: (executionId, name) => pending.get(executionId)?.get(name), + trackRun: (instance, effect) => + Effect.withFiber((fiber) => { + const run = { instance, fiber: fiber as Fiber.Fiber } + running.set(instance.executionId, run) + return Effect.ensuring( + Effect.provideService(effect, WorkflowInstance, instance), + Effect.sync(() => { + if (!instance.suspended) { + pending.delete(instance.executionId) + } + if (running.get(instance.executionId) === run) { + running.delete(instance.executionId) + } + }) + ) + }), + deferredDone: (executionId, name, exit) => + Effect.withFiber((current) => { + const run = running.get(executionId) + if (!run) return Effect.void + let entries = pending.get(executionId) + if (!entries) { + entries = new Map() + pending.set(executionId, entries) + } + entries.set(name, exit) + if ( + run.fiber === current || + run.fiber.pollUnsafe() || + !run.instance.awaitedDeferreds.has(name) + ) { + return Effect.void + } + // Suspended retains the pending result; the engine re-runs the + // interrupted run and the replay observes the completion. + run.instance.suspended = true + return Fiber.interrupt(run.fiber) + }) + } +} + /** * Low-level workflow engine contract that works with encoded payloads and * results before `makeUnsafe` adds typed schema decoding and encoding. @@ -595,6 +681,7 @@ export const layerMemory: Layer.Layer = Layer.effect(WorkflowEng ) => Effect.Effect readonly parent: string | undefined instance: WorkflowInstance["Service"] + interrupted: boolean fiber: Fiber.Fiber> | undefined } const executions = new Map() @@ -604,6 +691,8 @@ export const layerMemory: Layer.Layer = Layer.effect(WorkflowEng } const activities = new Map() + const deferredState = makeDeferredState() + const resume = Effect.fnUntraced(function*(executionId: string): Effect.fn.Return { const state = executions.get(executionId) if (!state) return @@ -620,19 +709,19 @@ export const layerMemory: Layer.Layer = Layer.effect(WorkflowEng state.instance.executionId, state.instance.scope ) - instance.interrupted = state.instance.interrupted state.instance = instance state.fiber = yield* state.execute(state.payload, state.instance.executionId).pipe( Effect.onExit(() => { - if (!instance.interrupted) { + if (!state.interrupted) { return Effect.void } + instance.interrupted = true instance.suspended = false return Effect.withFiber((fiber) => Effect.interruptible(Fiber.interrupt(fiber))) }), Workflow.intoResult, - Effect.provideService(WorkflowInstance, instance), Effect.provideService(WorkflowEngine, engine), + (effect) => deferredState.trackRun(instance, effect), Effect.tap((result) => { if (!state.parent || result._tag !== "Complete") { return Effect.void @@ -667,6 +756,7 @@ export const layerMemory: Layer.Layer = Layer.effect(WorkflowEng payload: options.payload, execute: entry.execute, instance: WorkflowInstance.initial(workflow, options.executionId), + interrupted: false, fiber: undefined, parent: options.parent?.executionId } @@ -674,18 +764,25 @@ export const layerMemory: Layer.Layer = Layer.effect(WorkflowEng yield* resume(options.executionId) } if (options.discard) return - return (yield* Fiber.join(state.fiber!)) as any + // Capture together so a wake that swaps in a replay cannot desync them. + const instance = state.instance + const exit = yield* Fiber.await(state.fiber!) + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) && instance.suspended) { + // A completion preempted the run; the caller retries into the replay. + return new Workflow.Suspended({}) as any + } + return (yield* exit) as any }), interrupt: Effect.fnUntraced(function*(_workflow, executionId) { const state = executions.get(executionId) if (!state) return - state.instance.interrupted = true + state.interrupted = true yield* resume(executionId) }), interruptUnsafe: Effect.fnUntraced(function*(_workflow, executionId) { const state = executions.get(executionId) if (!state) return - state.instance.interrupted = true + state.interrupted = true if (state.fiber) { yield* Fiber.interrupt(state.fiber) } @@ -743,7 +840,10 @@ export const layerMemory: Layer.Layer = Layer.effect(WorkflowEng const id = `${options.executionId}/${options.deferredName}` if (deferredResults.has(id)) return Effect.void deferredResults.set(id, options.exit) - return resume(options.executionId) + return Effect.andThen( + deferredState.deferredDone(options.executionId, options.deferredName, options.exit), + resume(options.executionId) + ) }), scheduleClock: (workflow, options) => engine.deferredDone(options.clock.deferred, { diff --git a/repos/effect/packages/effect/src/unstable/workflow/WorkflowProxy.ts b/repos/effect/packages/effect/src/unstable/workflow/WorkflowProxy.ts index a1104b6d42..6309ce8c97 100644 --- a/repos/effect/packages/effect/src/unstable/workflow/WorkflowProxy.ts +++ b/repos/effect/packages/effect/src/unstable/workflow/WorkflowProxy.ts @@ -72,7 +72,8 @@ export const toRpcGroup = < success: workflow.successSchema }).annotateMerge(workflow.annotations), Rpc.make(`${prefix}${workflow._tag}Discard`, { - payload: workflow.payloadSchema + payload: workflow.payloadSchema, + success: Schema.String }).annotateMerge(workflow.annotations), Rpc.make(`${prefix}${workflow._tag}Resume`, { payload: ResumePayload }) .annotateMerge(workflow.annotations) @@ -95,7 +96,7 @@ export type ConvertRpcs = infer _Error > ? | Rpc.Rpc<`${Prefix}${_Name}`, _Payload, _Success, _Error> - | Rpc.Rpc<`${Prefix}${_Name}Discard`, _Payload> + | Rpc.Rpc<`${Prefix}${_Name}Discard`, _Payload, typeof Schema.String> | Rpc.Rpc<`${Prefix}${_Name}Resume`, typeof ResumePayload> : never @@ -153,7 +154,8 @@ export const toHttpApiGroup = = Workflows extends W `/${Lowercase<_Name>}/discard`, never, never, - _Payload + _Payload, + never, + typeof Schema.String > | HttpApiEndpoint.HttpApiEndpoint< `${_Name}Resume`, diff --git a/repos/effect/packages/effect/test/BigInt.test.ts b/repos/effect/packages/effect/test/BigInt.test.ts index 64e83168a9..023349eb37 100644 --- a/repos/effect/packages/effect/test/BigInt.test.ts +++ b/repos/effect/packages/effect/test/BigInt.test.ts @@ -66,4 +66,47 @@ describe("BigInt", () => { it("returns zero for two zero operands", () => { assert.strictEqual(BigInt.lcm(0n, 0n), 0n) }) + + it("Order compares bigints", () => { + assert.strictEqual(BigInt.Order(1n, 2n), -1) + assert.strictEqual(BigInt.Order(2n, 1n), 1) + assert.strictEqual(BigInt.Order(1n, 1n), 0) + }) + + it("isLessThan and isLessThanOrEqualTo compare against an upper bound", () => { + assert.strictEqual(BigInt.isLessThan(2n, 3n), true) + assert.strictEqual(BigInt.isLessThan(3n, 3n), false) + assert.strictEqual(BigInt.isLessThanOrEqualTo(3n, 3n), true) + assert.strictEqual(BigInt.isLessThanOrEqualTo(4n, 3n), false) + }) + + it("isGreaterThan and isGreaterThanOrEqualTo compare against a lower bound", () => { + assert.strictEqual(BigInt.isGreaterThan(4n, 3n), true) + assert.strictEqual(BigInt.isGreaterThan(3n, 3n), false) + assert.strictEqual(BigInt.isGreaterThanOrEqualTo(3n, 3n), true) + assert.strictEqual(BigInt.isGreaterThanOrEqualTo(2n, 3n), false) + }) + + it("between checks inclusive range membership", () => { + assert.strictEqual(BigInt.between(3n, { minimum: 1n, maximum: 5n }), true) + assert.strictEqual(BigInt.between(0n, { minimum: 1n, maximum: 5n }), false) + assert.strictEqual(BigInt.between(6n, { minimum: 1n, maximum: 5n }), false) + }) + + it("clamp restricts a bigint to an inclusive range", () => { + assert.strictEqual(BigInt.clamp(3n, { minimum: 1n, maximum: 5n }), 3n) + assert.strictEqual(BigInt.clamp(0n, { minimum: 1n, maximum: 5n }), 1n) + assert.strictEqual(BigInt.clamp(6n, { minimum: 1n, maximum: 5n }), 5n) + }) + + it("min and max select the smaller and larger bigint", () => { + assert.strictEqual(BigInt.min(2n, 3n), 2n) + assert.strictEqual(BigInt.max(2n, 3n), 3n) + }) + + it("sign returns the ordering of a bigint against zero", () => { + assert.strictEqual(BigInt.sign(5n), 1) + assert.strictEqual(BigInt.sign(-5n), -1) + assert.strictEqual(BigInt.sign(0n), 0) + }) }) diff --git a/repos/effect/packages/effect/test/Channel.test.ts b/repos/effect/packages/effect/test/Channel.test.ts index 2d160f8103..aaa6a3bfcf 100644 --- a/repos/effect/packages/effect/test/Channel.test.ts +++ b/repos/effect/packages/effect/test/Channel.test.ts @@ -168,6 +168,29 @@ describe("Channel", () => { assert.isTrue(yield* Ref.get(acquired)) assert.isTrue(yield* Ref.get(released)) })) + + it.effect("acquireUseRelease combines usage and release failures", () => + Effect.gen(function*() { + const result = yield* Channel.acquireUseRelease( + Effect.void, + () => Channel.fail("usage failure"), + () => Effect.die("release failure") + ).pipe(Channel.runDrain, Effect.exit) + assert.deepStrictEqual( + result, + Exit.failCause(Cause.combine(Cause.fail("usage failure"), Cause.die("release failure"))) + ) + })) + + it.effect("acquireUseRelease surfaces release failure after successful usage", () => + Effect.gen(function*() { + const result = yield* Channel.acquireUseRelease( + Effect.void, + () => Channel.succeed(1), + () => Effect.die("release failure") + ).pipe(Channel.runDrain, Effect.exit) + assert.deepStrictEqual(result, Exit.die("release failure")) + })) }) describe("destructors", () => { diff --git a/repos/effect/packages/effect/test/Deferred.test.ts b/repos/effect/packages/effect/test/Deferred.test.ts index be03652afd..f81371fe2d 100644 --- a/repos/effect/packages/effect/test/Deferred.test.ts +++ b/repos/effect/packages/effect/test/Deferred.test.ts @@ -3,6 +3,7 @@ import { Deferred, Fiber, Option } from "effect" import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" +import * as TestClock from "effect/testing/TestClock" describe("Deferred", () => { describe("success", () => { @@ -116,6 +117,62 @@ describe("Deferred", () => { assert.isTrue(yield* Deferred.succeed(deferred, 42)) assert.strictEqual(yield* Fiber.join(live), 42) })) + + it.effect("await - interrupting a waiter after completion does not die", () => + Effect.gen(function*() { + const deferred = yield* Deferred.make() + const interrupted = yield* Deferred.await(deferred).pipe( + Effect.forkChild({ startImmediately: true }) + ) + const live = yield* Deferred.await(deferred).pipe( + Effect.forkChild({ startImmediately: true }) + ) + assert.strictEqual(deferred.resumes?.length, 2) + + // Waiters resume by running the completion effect, so a suspension + // inside it leaves the await's cleanup on the waiter's stack after + // completion has already cleared `resumes`. Interrupting the waiter + // from the same tick then runs that cleanup post-completion. + yield* Effect.sync(() => { + Deferred.doneUnsafe(deferred, Effect.as(Effect.yieldNow, 42)) + assert.isUndefined(deferred.resumes) + interrupted.interruptUnsafe() + }) + + const exit = yield* Fiber.await(interrupted) + assert.isTrue(Exit.hasInterrupts(exit)) + assert.isFalse(Exit.hasDies(exit)) + + assert.strictEqual(yield* Fiber.join(live), 42) + })) + + it.effect("done with an interrupt cause resumes every suspended waiter", () => + Effect.gen(function*() { + const deferred = yield* Deferred.make() + const first = yield* Deferred.await(deferred).pipe( + Effect.forkChild({ startImmediately: true }) + ) + const second = yield* Deferred.await(deferred).pipe( + Effect.forkChild({ startImmediately: true }) + ) + assert.strictEqual(deferred.resumes?.length, 2) + + // Resuming the first waiter with an interrupt cause kills it + // synchronously, so its await cleanup must not shift later waiters + // out from under the completion loop. + assert.isTrue(yield* Deferred.done(deferred, Exit.failCause(Cause.interrupt()))) + + const settled = yield* Fiber.awaitAll([first, second]).pipe( + Effect.timeoutOption(1000), + Effect.forkChild({ startImmediately: true }) + ) + yield* TestClock.adjust(1000) + const exits = yield* Fiber.join(settled) + assert.isTrue(Option.isSome(exits), "every waiter should settle") + for (const exit of Option.getOrThrow(exits)) { + assert.isTrue(Exit.hasInterrupts(exit)) + } + })) }) describe("polling", () => { diff --git a/repos/effect/packages/effect/test/Effect.test.ts b/repos/effect/packages/effect/test/Effect.test.ts index d67aaff244..6d0c9039a7 100644 --- a/repos/effect/packages/effect/test/Effect.test.ts +++ b/repos/effect/packages/effect/test/Effect.test.ts @@ -613,6 +613,27 @@ describe("Effect", () => { })) }) + describe("head", () => { + it.effect("returns the first element", () => + Effect.gen(function*() { + const result = yield* Effect.head(Effect.succeed([1, 2, 3])) + assert.strictEqual(result, 1) + })) + + it.effect("fails with NoSuchElementError for an empty iterable", () => + Effect.gen(function*() { + const error = yield* Effect.head(Effect.succeed([] as Array)).pipe(Effect.flip) + assert.isTrue(Cause.isNoSuchElementError(error)) + })) + + it.effect("preserves the source failure", () => + Effect.gen(function*() { + const source: Effect.Effect, "failure"> = Effect.fail("failure") + const error = yield* Effect.head(source).pipe(Effect.flip) + assert.strictEqual(error, "failure") + })) + }) + describe("all", () => { it.effect("tuple", () => Effect.gen(function*() { @@ -2056,7 +2077,7 @@ describe("Effect", () => { assert.isTrue(finalized) })) - it.effect("finalizer errors not caught", () => + it.effect("finalizer errors merged", () => Effect.gen(function*() { const e2 = new Error("e2") const e3 = new Error("e3") @@ -2068,7 +2089,10 @@ describe("Effect", () => { Effect.flip, Effect.map((cause) => cause) ) - assert.deepStrictEqual(result, Cause.die(e3)) + assert.deepStrictEqual( + result, + Cause.combine(Cause.combine(Cause.fail(ExampleError), Cause.die(e2)), Cause.die(e3)) + ) })) it.effect("finalizer errors reported", () => @@ -2093,6 +2117,18 @@ describe("Effect", () => { assert.isFalse(reported !== undefined && Exit.isSuccess(reported)) })) + it.effect("scoped combines usage and finalizer failures", () => + Effect.gen(function*() { + const result = yield* Effect.gen(function*() { + yield* Effect.addFinalizer(() => Effect.die("finalizer failure")) + return yield* Effect.fail("usage failure") + }).pipe(Effect.scoped, Effect.exit) + assert.deepStrictEqual( + result, + Exit.failCause(Cause.combine(Cause.fail("usage failure"), Cause.die("finalizer failure"))) + ) + })) + it.effect("acquireUseRelease usage result", () => Effect.gen(function*() { const result = yield* Effect.acquireUseRelease( @@ -2142,6 +2178,19 @@ describe("Effect", () => { assert.deepStrictEqual(result, Exit.fail(ExampleError)) })) + it.effect("combines usage and release failures", () => + Effect.gen(function*() { + const result = yield* Effect.acquireUseRelease( + Effect.void, + () => Effect.fail("usage failure"), + () => Effect.fail("release failure") + ).pipe(Effect.exit) + assert.deepStrictEqual( + result, + Exit.failCause(Cause.combine(Cause.fail("usage failure"), Cause.fail("release failure"))) + ) + })) + it.effect("rethrown caught error in acquisition", () => Effect.gen(function*() { const result = yield* Effect.acquireUseRelease( @@ -3123,6 +3172,21 @@ describe("Effect", () => { }) }) + it.effect("should support self with pipeable arguments", () => { + const self = { prefix: "bound" } + const fn = Effect.fn( + { self }, + function*(this: typeof self, value: string) { + return `${this.prefix}:${value}` + }, + Effect.map((value) => value.toUpperCase()) + ) + return Effect.gen(function*() { + const result = yield* fn("value") + assert.strictEqual(result, "BOUND:VALUE") + }) + }) + it("should proxy body length", () => { const traced = Effect.fn(function*(a: string, b: number) { return a.length + b diff --git a/repos/effect/packages/effect/test/Encoding.test.ts b/repos/effect/packages/effect/test/Encoding.test.ts new file mode 100644 index 0000000000..1ab635d576 --- /dev/null +++ b/repos/effect/packages/effect/test/Encoding.test.ts @@ -0,0 +1,32 @@ +import { assert, describe, it } from "@effect/vitest" +import { Encoding } from "effect" + +const assertRandomHex = (length: number, expectedLength: number): void => { + const value = Encoding.randomHex(length) + assert.strictEqual(value.length, expectedLength) + assert.match(value, /^[0-9a-f]*$/) +} + +describe("Encoding", () => { + describe("randomHex", () => { + it("generates lowercase hexadecimal words", () => { + assertRandomHex(0, 0) + assertRandomHex(8, 8) + assertRandomHex(16, 16) + assertRandomHex(32, 32) + }) + + it("rounds non-negative lengths down to multiples of 8", () => { + assertRandomHex(7, 0) + assertRandomHex(15.9, 8) + assertRandomHex(23, 16) + }) + + it("uses unsigned 32-bit coercion without validation", () => { + assertRandomHex(Number.NaN, 0) + assertRandomHex(Number.POSITIVE_INFINITY, 0) + assertRandomHex(2 ** 32, 0) + assertRandomHex(2 ** 32 + 8, 8) + }) + }) +}) diff --git a/repos/effect/packages/effect/test/Fiber.test.ts b/repos/effect/packages/effect/test/Fiber.test.ts index e8fc312a4a..1405c93d1d 100644 --- a/repos/effect/packages/effect/test/Fiber.test.ts +++ b/repos/effect/packages/effect/test/Fiber.test.ts @@ -7,6 +7,23 @@ describe("Fiber", () => { assert.isTrue(Fiber.isFiber(result)) }) + it("notifies all observers when an observer cancels during exit", () => { + const fiber = Effect.runFork(Effect.never) + const observed: Array = [] + let cancel = () => {} + cancel = fiber.addObserver(() => { + observed.push(1) + cancel() + }) + fiber.addObserver(() => { + observed.push(2) + }) + + fiber.interruptUnsafe() + + assert.deepStrictEqual(observed, [1, 2]) + }) + describe("joinAll", () => { it.effect("cleans up observers on interruption", () => Effect.gen(function*() { diff --git a/repos/effect/packages/effect/test/Formatter.test.ts b/repos/effect/packages/effect/test/Formatter.test.ts index a8d586153e..f0cb4b0b6d 100644 --- a/repos/effect/packages/effect/test/Formatter.test.ts +++ b/repos/effect/packages/effect/test/Formatter.test.ts @@ -2,6 +2,7 @@ import { describe, it } from "@effect/vitest" import { Context, Effect, + Inspectable, Option, Redactable, Redacted, @@ -21,6 +22,14 @@ class SensitiveData implements Redactable.Redactable { this.secret += s } + toString() { + return this.secret + } + + toJSON() { + return { secret: this.secret } + } + [Redactable.symbolRedactable]() { return { secret: "[REDACTED]" } } @@ -238,6 +247,43 @@ describe("Formatter", () => { strictEqual(format(data), `{"secret":"[REDACTED]"}`) strictEqual(format({ a: data }), `{"a":{"secret":"[REDACTED]"}}`) }) + + it("redacts before specialized representations", () => { + const array = Object.assign(["secret"], { + [Redactable.symbolRedactable]: () => ["[REDACTED]"] + }) + const date = Object.assign(new Date(0), { + [Redactable.symbolRedactable]: () => "[REDACTED]" + }) + + strictEqual(format(array), `["[REDACTED]"]`) + strictEqual(format(date), `"[REDACTED]"`) + }) + + it("preserves formatting options for redacted representations", () => { + let toStringCalls = 0 + const value = { + [Redactable.symbolRedactable]: () => ({ + a: 1, + b: 2, + toString() { + toStringCalls++ + return "custom" + } + }) + } + + assertTrue(format(value, { space: 2, ignoreToString: true }).startsWith(`{\n "a": 1,\n "b": 2,`)) + strictEqual(toStringCalls, 0) + }) + + it("tracks circular references through redacted representations", () => { + const value: Redactable.Redactable = { + [Redactable.symbolRedactable]: () => ({ value }) + } + + strictEqual(format(value), `{"value":[Circular]}`) + }) }) describe("formatJson", () => { @@ -256,9 +302,49 @@ describe("Formatter", () => { strictEqual(formatJson({ left: shared, right: shared }), `{"left":{"a":1},"right":{"a":1}}`) }) + it("should stringify BigInt values", () => { + strictEqual(formatJson(123n), `"123n"`) + strictEqual(formatJson({ value: 123n }), `{"value":"123n"}`) + strictEqual(formatJson([1n, 2n]), `["1n","2n"]`) + }) + it("should redact sensitive data", () => { + const date = Object.assign(new Date(0), { + [Redactable.symbolRedactable]: () => "[REDACTED]" + }) + strictEqual(formatJson(data), `{"secret":"[REDACTED]"}`) strictEqual(formatJson({ a: data }), `{"a":{"secret":"[REDACTED]"}}`) + strictEqual(formatJson([data]), `[{"secret":"[REDACTED]"}]`) + strictEqual(formatJson(date), `"[REDACTED]"`) + }) + }) + + describe("Inspectable.toJson", () => { + it("redacts before toJSON", () => { + deepStrictEqual( + Inspectable.toJson(data), + { secret: "[REDACTED]" } + ) + }) + + it("preserves plain objects as structured values", () => { + const value: any = { count: 1n } + value.self = value + + strictEqual(Inspectable.toJson(value), value) + }) + }) + + describe("Inspectable.toStringUnknown", () => { + it("should stringify BigInt values", () => { + strictEqual(Inspectable.toStringUnknown(123n), `123n`) + strictEqual( + Inspectable.toStringUnknown({ value: 123n }), + `{ + "value": "123n" +}` + ) }) }) diff --git a/repos/effect/packages/effect/test/Graph.test.ts b/repos/effect/packages/effect/test/Graph.test.ts index 959fbb8452..f8a94c80d1 100644 --- a/repos/effect/packages/effect/test/Graph.test.ts +++ b/repos/effect/packages/effect/test/Graph.test.ts @@ -1,4721 +1,2501 @@ -import { assert } from "@effect/vitest" -import { assertNone, assertSome, strictEqual, throws } from "@effect/vitest/utils" +import { assert, describe, it } from "@effect/vitest" import { Equal, Graph, Hash, Option } from "effect" -import { describe, expect, it } from "vitest" -const assertSomeEdge = (edge: Option.Option>): Graph.Edge => { - if (Option.isNone(edge)) { - throw new Error("Expected edge to be present") - } - return edge.value -} - -const makeReversedUndirectedPath = () => - Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, c, b, 1) - }) - -const makeSingleEdgeGraph = (weight: number) => - Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "source") - const target = Graph.addNode(mutable, "target") - Graph.addEdge(mutable, source, target, weight) +const directed = ( + nodes: ReadonlyArray, + edges: ReadonlyArray +) => + Graph.directed((mutable) => { + for (const node of nodes) Graph.addNode(mutable, node) + for (const [source, target, data] of edges) Graph.addEdge(mutable, source, target, data) }) -const unsupportedEdgeWeights = [NaN, -Infinity] as const - -const assertGraphError = (thunk: () => void, message: string) => { - throws(thunk, (error) => { - strictEqual(error instanceof Graph.GraphError, true) - if (error instanceof Graph.GraphError) { - strictEqual(error.message, message) - } +const undirected = ( + nodes: ReadonlyArray, + edges: ReadonlyArray +) => + Graph.undirected((mutable) => { + for (const node of nodes) Graph.addNode(mutable, node) + for (const [source, target, data] of edges) Graph.addEdge(mutable, source, target, data) }) -} -type SetNode = { readonly id: string; readonly label: string } - -class SetNodeKey implements Equal.Equal { - readonly id: string - - constructor(id: string) { - this.id = id - } - - [Equal.symbol](that: Equal.Equal): boolean { - return that instanceof SetNodeKey && this.id === that.id - } - - [Hash.symbol](): number { - return Hash.string(this.id) +const assertGraphError = (thunk: () => unknown, message: string): void => { + let error: unknown + try { + thunk() + } catch (cause) { + error = cause } + assert.ok(error instanceof Graph.GraphError) + assert.strictEqual(error.message, message) } -const graphNodeIds = (graph: Graph.Graph) => - new Set(Array.from(graph, ([, node]) => node.id)) +const assertSnapshot = ( + graph: Graph.Graph | Graph.MutableGraph, + expected: Graph.Snapshot +): void => { + assert.deepStrictEqual(Graph.toSnapshot(graph), expected) +} -const graphNodeLabels = (graph: Graph.Graph) => - new Map(Array.from(graph, ([, node]) => [node.id, node.label])) +const assertPath = (actual: Option.Option>, expected: Graph.PathResult): void => { + assert.deepStrictEqual(actual, Option.some(expected)) +} -const graphEdgeKeys = (graph: Graph.Graph) => { - const nodeIds = new Map(Array.from(graph, ([index, node]) => [index, node.id])) - return new Set( - Array.from(Graph.edges(graph), ([, edge]) => - graph.type === "directed" - ? `${nodeIds.get(edge.source)}->${nodeIds.get(edge.target)}` - : `${nodeIds.get(edge.source)}--${nodeIds.get(edge.target)}`) - ) +const assertComponents = ( + actual: ReadonlyArray>, + expected: ReadonlyArray> +) => { + const canonicalize = (components: ReadonlyArray>) => + components.map((component) => Array.from(component).sort((a, b) => a - b)).sort((a, b) => a[0] - b[0]) + assert.deepStrictEqual(canonicalize(actual), canonicalize(expected)) } -const graphEdgeData = (graph: Graph.Graph) => { - const nodeIds = new Map(Array.from(graph, ([index, node]) => [index, node.id])) - return new Map( - Array.from( - Graph.edges(graph), - ([, edge]) => [ - graph.type === "directed" - ? `${nodeIds.get(edge.source)}->${nodeIds.get(edge.target)}` - : `${nodeIds.get(edge.source)}--${nodeIds.get(edge.target)}`, - edge.data - ] - ) - ) +const assertIndices = (walker: Graph.NodeWalker, expected: ReadonlyArray): void => { + assert.deepStrictEqual(Array.from(Graph.indices(walker)), expected) } describe("Graph", () => { - describe("constructors", () => { - it("should create empty directed graph", () => { - const graph = Graph.directed() + describe("construction and protocols", () => { + it("constructs directed and undirected graphs", () => { + const directedGraph = Graph.make("directed")() + const undirectedGraph = Graph.undirected() - expect(graph.type).toBe("directed") - expect(Graph.nodeCount(graph)).toBe(0) - expect(Graph.edgeCount(graph)).toBe(0) + assert.strictEqual(directedGraph.type, "directed") + assert.strictEqual(undirectedGraph.type, "undirected") + assert.strictEqual(directedGraph.mutable, false) + assert.strictEqual(Graph.nodeCount(directedGraph), 0) + assert.strictEqual(Graph.edgeCount(undirectedGraph), 0) }) - it("should create empty undirected graph", () => { - const graph = Graph.undirected() + it("rejects invalid runtime graph kinds", () => { + assertGraphError( + () => Graph.make("invalid" as Graph.Kind)(), + "Graph type must be directed or undirected" + ) + }) - expect(graph.type).toBe("undirected") - expect(Graph.nodeCount(graph)).toBe(0) - expect(Graph.edgeCount(graph)).toBe(0) + it("recognizes immutable and mutable graphs only", () => { + assert.strictEqual(Graph.isGraph(Graph.directed()), true) + assert.strictEqual(Graph.isGraph(Graph.beginMutation(Graph.undirected())), true) + for (const value of [{}, null, undefined, "graph", 1, []]) { + assert.strictEqual(Graph.isGraph(value), false) + } }) - }) - describe("equality and hashing", () => { - const makeGraph = ( - type: Graph.Kind, - edges: ReadonlyArray - ) => - Graph.make(type)((mutable) => { - Graph.addNode(mutable, "A") - Graph.addNode(mutable, "B") - for (const [source, target, data] of edges) { - Graph.addEdge(mutable, source, target, data) - } - }) + it("supports stringification, piping, and node iteration in graph order", () => { + const graph = directed(["A", "B"], [[0, 1, 1]]) + + assert.strictEqual(String(graph), "Graph(directed, 2, 1)") + assert.strictEqual(graph.pipe(Graph.nodeCount), 2) + assert.deepStrictEqual(Array.from(graph), [[0, "A"], [1, "B"]]) + const iterator = graph[Symbol.iterator]() + assert.deepStrictEqual(iterator.next(), { done: false, value: [0, "A"] }) + assert.deepStrictEqual(iterator.next(), { done: false, value: [1, "B"] }) + assert.deepStrictEqual(iterator.next(), { done: true, value: undefined }) + }) + }) - it("treats undirected edge endpoints as unordered", () => { - const left = makeGraph("undirected", [[0, 1, "edge"]]) - const right = makeGraph("undirected", [[1, 0, "edge"]]) + describe("snapshots", () => { + it("round-trips sparse indexes, parallel edges, self-loops, and stored orientation", () => { + const snapshot = { + type: "undirected", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }], + edges: [ + { index: 3, source: 5, target: 2, data: "first" }, + { index: 7, source: 5, target: 2, data: "parallel" }, + { index: 11, source: 5, target: 5, data: "loop" } + ] + } as const + const graph = Graph.fromSnapshot(snapshot) + + assertSnapshot(graph, snapshot) + assert.deepStrictEqual(Graph.neighbors(graph, 2), [5]) + assert.deepStrictEqual(Graph.neighbors(graph, 5), [2, 5]) + assert.strictEqual(Equal.equals(Graph.fromSnapshot(Graph.toSnapshot(graph)), graph), true) + }) + + it("snapshots mutable state without exposing stored records", () => { + const graph = directed(["A", "B"], [[0, 1, 1]]) + const mutable = Graph.beginMutation(graph) + Graph.updateNode(mutable, 0, () => "updated") + const snapshot = Graph.toSnapshot(mutable) + ;(snapshot.nodes[0] as { index: number; data: string }).index = 1 + ;(snapshot.nodes[0] as { index: number; data: string }).data = "exposed" + ;(snapshot.edges[0] as { source: number; data: number }).source = 1 + ;(snapshot.edges[0] as { source: number; data: number }).data = 2 - strictEqual(Equal.equals(left, right), true) - strictEqual(Equal.equals(right, left), true) - strictEqual(Hash.hash(left), Hash.hash(right)) + assertSnapshot(mutable, { + type: "directed", + nodes: [{ index: 0, data: "updated" }, { index: 1, data: "B" }], + edges: [{ index: 0, source: 0, target: 1, data: 1 }] + }) }) - it("keeps directed edge endpoints ordered", () => { - const left = makeGraph("directed", [[0, 1, "edge"]]) - const right = makeGraph("directed", [[1, 0, "edge"]]) + it("continues allocation after the highest active snapshot indexes", () => { + const mutable = Graph.beginMutation(Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }], + edges: [{ index: 4, source: 2, target: 5, data: 1 }] + })) - strictEqual(Equal.equals(left, right), false) - strictEqual(Equal.equals(right, left), false) + assert.strictEqual(Graph.addNode(mutable, "C"), 6) + assert.strictEqual(Graph.addEdge(mutable, 2, 5, 2), 5) }) - it("compares undirected edge data", () => { - const left = makeGraph("undirected", [[0, 1, "left"]]) - const right = makeGraph("undirected", [[1, 0, "right"]]) + it("rejects malformed snapshot records and indexes", () => { + const cases: ReadonlyArray unknown, string]> = [ + [ + () => + Graph.fromSnapshot( + { type: "invalid", nodes: [], edges: [] } as unknown as Graph.Snapshot + ), + "Snapshot type must be directed or undirected" + ], + [ + () => Graph.fromSnapshot({ type: "directed", nodes: new Array(1), edges: [] }), + "Node at position 0 must be defined" + ], + [ + () => + Graph.fromSnapshot( + { type: "directed", nodes: [null], edges: [] } as unknown as Graph.Snapshot + ), + "Node at position 0 must be defined" + ], + [ + () => Graph.fromSnapshot({ type: "directed", nodes: [{ index: -1, data: "A" }], edges: [] }), + "Node index at position 0 must be a non-negative safe integer" + ], + [ + () => + Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: Number.MAX_SAFE_INTEGER + 1, data: "A" }], + edges: [] + }), + "Node index at position 0 must be a non-negative safe integer" + ], + [ + () => + Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 1, data: "A" }, { index: 1, data: "B" }], + edges: [] + }), + "Node indexes must be strictly increasing" + ], + [ + () => Graph.fromSnapshot({ type: "directed", nodes: [{ index: 0, data: "A" }], edges: new Array(1) }), + "Edge at position 0 must be defined" + ], + [ + () => + Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 0, data: "A" }], + edges: [{ index: 0.5, source: 0, target: 0, data: 1 }] + }), + "Edge index at position 0 must be a non-negative safe integer" + ], + [ + () => + Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 0, data: "A" }], + edges: [{ index: 1, source: 0, target: 0, data: 1 }, { index: 1, source: 0, target: 0, data: 2 }] + }), + "Edge indexes must be strictly increasing" + ], + [ + () => + Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 0, data: "A" }], + edges: [{ index: 0, source: -1, target: 0, data: 1 }] + }), + "Edge source at position 0 must be a non-negative safe integer" + ], + [ + () => + Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 0, data: "A" }], + edges: [{ index: 0, source: 0, target: 0.5, data: 1 }] + }), + "Edge target at position 0 must be a non-negative safe integer" + ], + [ + () => + Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 1, data: "A" }], + edges: [{ index: 0, source: 0, target: 1, data: 1 }] + }), + "Edge source 0 does not reference a node" + ], + [ + () => + Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 1, data: "A" }], + edges: [{ index: 0, source: 1, target: 2, data: 1 }] + }), + "Edge target 2 does not reference a node" + ] + ] - strictEqual(Equal.equals(left, right), false) + for (const [run, message] of cases) assertGraphError(run, message) }) - it("keeps parallel edges paired by edge index", () => { - const left = makeGraph("undirected", [[0, 1, "first"], [0, 1, "second"]]) - const reversed = makeGraph("undirected", [[1, 0, "first"], [1, 0, "second"]]) - const reordered = makeGraph("undirected", [[1, 0, "second"], [1, 0, "first"]]) + it("rejects allocation after safe indexes are exhausted", () => { + const nodes = Graph.beginMutation(Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: Number.MAX_SAFE_INTEGER, data: "A" }], + edges: [] + })) + const edges = Graph.beginMutation(Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 0, data: "A" }], + edges: [{ index: Number.MAX_SAFE_INTEGER, source: 0, target: 0, data: 1 }] + })) - strictEqual(Equal.equals(left, reversed), true) - strictEqual(Hash.hash(left), Hash.hash(reversed)) - strictEqual(Equal.equals(left, reordered), false) + assertGraphError(() => Graph.addNode(nodes, "B"), "Graph has exhausted safe node indexes") + assertGraphError(() => Graph.addEdge(edges, 0, 0, 2), "Graph has exhausted safe edge indexes") }) + }) - it("handles undirected self-loops", () => { - const left = makeGraph("undirected", [[0, 0, "loop"]]) - const right = makeGraph("undirected", [[0, 0, "loop"]]) + describe("equality and hashing", () => { + it("gives equal immutable graphs equal hashes", () => { + const left = undirected(["A", "B"], [[0, 1, "first"], [0, 1, "second"], [0, 0, "loop"]]) + const right = undirected(["A", "B"], [[1, 0, "first"], [1, 0, "second"], [0, 0, "loop"]]) + + assert.strictEqual(Equal.equals(left, right), true) + assert.strictEqual(Hash.hash(left), Hash.hash(right)) + }) + + it("distinguishes node payload, edge payload, missing edge, kind, and sparse indexes", () => { + const base = directed(["A", "B"], [[0, 1, "edge"]]) + const cases: ReadonlyArray> = [ + directed(["changed", "B"], [[0, 1, "edge"]]), + directed(["A", "B"], [[0, 1, "changed"]]), + directed(["A", "B"], []), + undirected(["A", "B"], [[0, 1, "edge"]]), + Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }], + edges: [{ index: 3, source: 2, target: 5, data: "edge" }] + }) + ] - strictEqual(Equal.equals(left, right), true) - strictEqual(Hash.hash(left), Hash.hash(right)) + for (const candidate of cases) { + assert.strictEqual(Equal.equals(base, candidate), false) + assert.strictEqual(Equal.equals(candidate, base), false) + } }) - it("keeps Graph.Edge endpoint equality ordered", () => { - const left = new Graph.Edge({ source: 0, target: 1, data: "edge" }) - const right = new Graph.Edge({ source: 1, target: 0, data: "edge" }) + it("keeps directed endpoints and parallel-edge index pairing ordered", () => { + const directedLeft = directed(["A", "B"], [[0, 1, "edge"]]) + const directedRight = directed(["A", "B"], [[1, 0, "edge"]]) + const parallel = undirected(["A", "B"], [[0, 1, "first"], [0, 1, "second"]]) + const reordered = undirected(["A", "B"], [[1, 0, "second"], [1, 0, "first"]]) - strictEqual(Equal.equals(left, right), false) + assert.strictEqual(Equal.equals(directedLeft, directedRight), false) + assert.strictEqual(Equal.equals(parallel, reordered), false) }) - it("compares future node allocation", () => { - const left = Graph.directed() + it("ignores removed trailing allocator history while preserving future allocation", () => { + const left = directed(["A", "B"], [[0, 1, "edge"]]) const right = Graph.directed((mutable) => { + Graph.addNode(mutable, "A") + Graph.addNode(mutable, "B") + Graph.addEdge(mutable, 0, 1, "edge") const node = Graph.addNode(mutable, "removed") + const edge = Graph.addEdge(mutable, 0, 1, "removed") Graph.removeNode(mutable, node) + Graph.removeEdge(mutable, edge) }) - strictEqual(Graph.nodeCount(left), Graph.nodeCount(right)) - strictEqual(Graph.edgeCount(left), Graph.edgeCount(right)) - strictEqual(Equal.equals(left, right), false) - strictEqual(Hash.hash(left) === Hash.hash(right), false) - - let leftNode: Graph.NodeIndex | undefined - let rightNode: Graph.NodeIndex | undefined + assert.strictEqual(Equal.equals(left, right), true) + assert.strictEqual(Hash.hash(left), Hash.hash(right)) Graph.mutate(left, (mutable) => { - leftNode = Graph.addNode(mutable, "next") + assert.strictEqual(Graph.addNode(mutable, "next"), 2) }) Graph.mutate(right, (mutable) => { - rightNode = Graph.addNode(mutable, "next") - }) - - strictEqual(leftNode, 0) - strictEqual(rightNode, 1) - }) - - it("compares future edge allocation", () => { - const left = makeGraph("directed", []) - const right = Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "A") - const target = Graph.addNode(mutable, "B") - const edge = Graph.addEdge(mutable, source, target, "removed") - Graph.removeEdge(mutable, edge) + assert.strictEqual(Graph.addNode(mutable, "next"), 3) }) - - assert.deepStrictEqual(Array.from(left), Array.from(right)) - assert.deepStrictEqual(Array.from(Graph.edges(left)), Array.from(Graph.edges(right))) - strictEqual(Equal.equals(left, right), false) - strictEqual(Hash.hash(left) === Hash.hash(right), false) - - let leftEdge: Graph.EdgeIndex | undefined - let rightEdge: Graph.EdgeIndex | undefined Graph.mutate(left, (mutable) => { - leftEdge = Graph.addEdge(mutable, 0, 1, "next") + assert.strictEqual(Graph.addEdge(mutable, 0, 1, "next"), 1) }) Graph.mutate(right, (mutable) => { - rightEdge = Graph.addEdge(mutable, 0, 1, "next") - }) - - strictEqual(leftEdge, 0) - strictEqual(rightEdge, 1) - }) - - it("preserves allocator equality and hashing through an empty mutation", () => { - const graph = Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "A") - const target = Graph.addNode(mutable, "B") - const removed = Graph.addNode(mutable, "removed") - Graph.removeNode(mutable, removed) - const edge = Graph.addEdge(mutable, source, target, "removed") - Graph.removeEdge(mutable, edge) + assert.strictEqual(Graph.addEdge(mutable, 0, 1, "next"), 2) }) - const clone = Graph.mutate(graph, () => {}) - - strictEqual(Equal.equals(graph, clone), true) - strictEqual(Hash.hash(graph), Hash.hash(clone)) - }) - - it("uses stable reference equality and hashing for mutable graphs", () => { - const mutable = Graph.beginMutation(Graph.directed()) - const initialHash = Hash.hash(mutable) - - strictEqual(Equal.equals(mutable, mutable), true) - const source = Graph.addNode(mutable, "A") - const target = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, source, target, "edge") - - strictEqual(Hash.hash(mutable), initialHash) - strictEqual(Equal.equals(mutable, mutable), true) }) - it("does not structurally compare independently constructed mutable graphs", () => { - const graph = makeGraph("directed", [[0, 1, "edge"]]) + it("uses reference equality for mutable graphs and structural equality after finalization", () => { + const graph = directed(["A", "B"], [[0, 1, "edge"]]) const left = Graph.beginMutation(graph) const right = Graph.beginMutation(graph) + const hash = Hash.hash(left) - strictEqual(Equal.equals(left, right), false) - strictEqual(Equal.equals(right, left), false) + assert.strictEqual(Equal.equals(left, right), false) + Graph.addNode(left, "C") + assert.strictEqual(Hash.hash(left), hash) + const finalized = Graph.endMutation(left) + assert.strictEqual(Equal.equals(finalized, directed(["A", "B", "C"], [[0, 1, "edge"]])), true) }) - it("restores structural equality and hashing after finalization", () => { - const mutable = Graph.beginMutation(Graph.directed()) - Hash.hash(mutable) - const source = Graph.addNode(mutable, "A") - const target = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, source, target, "edge") + it("supports undefined node and edge payloads structurally", () => { + const make = () => directed([undefined, undefined], [[0, 1, undefined]]) + assert.strictEqual(Equal.equals(make(), make()), true) + assert.strictEqual(Hash.hash(make()), Hash.hash(make())) + }) + }) - const graph = Graph.endMutation(mutable) - const expected = makeGraph("directed", [[0, 1, "edge"]]) + describe("mutation lifecycle", () => { + it("isolates mutable changes from the source and finalizes to a new immutable graph", () => { + const source = directed(["A", "B"], [[0, 1, 1]]) + const mutable = Graph.beginMutation(source) + Graph.addNode(mutable, "C") + const result = Graph.endMutation(mutable) - strictEqual(Equal.equals(graph, expected), true) - strictEqual(Hash.hash(graph), Hash.hash(expected)) + assert.strictEqual(Graph.nodeCount(source), 2) + assert.strictEqual(Graph.nodeCount(result), 3) + assert.notStrictEqual(result, source) + assert.strictEqual(result.mutable, false) }) - }) - describe("set operations", () => { - const makeLeft = () => - Graph.directed<{ readonly id: string; readonly label: string }, string>((mutable) => { - const a = Graph.addNode(mutable, { id: "a", label: "A1" }) - const b = Graph.addNode(mutable, { id: "b", label: "B1" }) - const c = Graph.addNode(mutable, { id: "c", label: "C1" }) - Graph.addEdge(mutable, a, b, "left-ab") - Graph.addEdge(mutable, b, c, "shared-bc") + it("supports data-first and data-last scoped mutation", () => { + const graph = Graph.directed() + const first = Graph.mutate(graph, (mutable) => { + Graph.addNode(mutable, "A") }) + const last = graph.pipe(Graph.mutate((mutable) => { + Graph.addNode(mutable, "A") + })) - const makeRight = () => - Graph.directed<{ readonly id: string; readonly label: string }, string>((mutable) => { - const b = Graph.addNode(mutable, { id: "b", label: "B2" }) - const c = Graph.addNode(mutable, { id: "c", label: "C2" }) - const d = Graph.addNode(mutable, { id: "d", label: "D2" }) - Graph.addEdge(mutable, b, c, "shared-bc") - Graph.addEdge(mutable, c, d, "right-cd") - }) + assertSnapshot(first, { type: "directed", nodes: [{ index: 0, data: "A" }], edges: [] }) + assertSnapshot(last, { type: "directed", nodes: [{ index: 0, data: "A" }], edges: [] }) + }) - it("compose merges nodes and edges by identity", () => { - const graph = Graph.compose(makeLeft(), makeRight(), { nodeIdentity: (node) => node.id }) + it("finalizes retained handles when callbacks return or throw", () => { + let returned: Graph.MutableDirectedGraph | undefined + Graph.directed((mutable) => { + returned = mutable + }) + assertGraphError(() => Graph.addNode(returned!, "late"), "Graph is not mutable") - assert.deepStrictEqual(graphNodeIds(graph), new Set(["a", "b", "c", "d"])) - assert.deepStrictEqual( - graphNodeLabels(graph), - new Map([ - ["a", "A1"], - ["b", "B2"], - ["c", "C2"], - ["d", "D2"] - ]) - ) - assert.deepStrictEqual(graphEdgeKeys(graph), new Set(["a->b", "b->c", "c->d"])) - assert.deepStrictEqual( - graphEdgeData(graph), - new Map([ - ["a->b", "left-ab"], - ["b->c", "shared-bc"], - ["c->d", "right-cd"] - ]) - ) + let thrown: Graph.MutableDirectedGraph | undefined + const cause = new Error("boom") + let actual: unknown + try { + Graph.mutate(Graph.directed(), (mutable) => { + thrown = mutable + throw cause + }) + } catch (error) { + actual = error + } + assert.strictEqual(actual, cause) + assertGraphError(() => Graph.addNode(thrown!, "late"), "Graph is not mutable") }) - it("intersection keeps shared nodes and shared edges", () => { - const graph = Graph.intersection(makeLeft(), makeRight(), { nodeIdentity: (node) => node.id }) + it("rejects normal callback return after manual finalization", () => { + assertGraphError(() => { + Graph.directed((mutable) => { + Graph.endMutation(mutable) + }) + }, "Graph is not mutable") + assertGraphError(() => { + Graph.mutate(Graph.directed(), (mutable) => { + Graph.endMutation(mutable) + }) + }, "Graph is not mutable") + }) - assert.deepStrictEqual(graphNodeIds(graph), new Set(["b", "c"])) - assert.deepStrictEqual( - graphNodeLabels(graph), - new Map([ - ["b", "B1"], - ["c", "C1"] - ]) - ) - assert.deepStrictEqual(graphEdgeKeys(graph), new Set(["b->c"])) - assert.deepStrictEqual(graphEdgeData(graph), new Map([["b->c", "shared-bc"]])) + it("preserves callback errors after manual finalization", () => { + const cause = new Error("callback failure") + for ( + const run of [ + () => + Graph.directed((mutable) => { + Graph.endMutation(mutable) + throw cause + }), + () => + Graph.mutate(Graph.directed(), (mutable) => { + Graph.endMutation(mutable) + throw cause + }) + ] + ) { + let actual: unknown + try { + run() + } catch (error) { + actual = error + } + assert.strictEqual(actual, cause) + } }) - it("difference preserves self nodes and removes shared edges", () => { - const graph = Graph.difference(makeLeft(), makeRight(), { nodeIdentity: (node) => node.id }) + it("rejects every mutation entry point on a finalized handle", () => { + const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]])) + Graph.endMutation(mutable) + const mutations: ReadonlyArray<() => unknown> = [ + () => Graph.addNode(mutable, "C"), + () => Graph.addEdge(mutable, 1, 0, 2), + () => Graph.updateNode(mutable, 0, () => "updated"), + () => Graph.updateEdge(mutable, 0, () => 2), + () => Graph.removeNode(mutable, 0), + () => Graph.removeNodes(mutable, [0]), + () => Graph.removeEdge(mutable, 0), + () => Graph.removeEdges(mutable, [0]), + () => Graph.mapNodes(mutable, () => "mapped"), + () => Graph.mapEdges(mutable, () => 3), + () => Graph.filterMapNodes(mutable, () => Option.none()), + () => Graph.filterMapEdges(mutable, () => Option.none()), + () => Graph.filterNodes(mutable, () => true), + () => Graph.filterEdges(mutable, () => true), + () => Graph.reverse(mutable), + () => Graph.endMutation(mutable) + ] + for (const mutation of mutations) assertGraphError(mutation, "Graph is not mutable") + }) + + it("rejects nested mutation or finalization from transformation callbacks", () => { + const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]])) + const mutations: ReadonlyArray<() => unknown> = [ + () => Graph.addNode(mutable, "C"), + () => Graph.addEdge(mutable, 1, 0, 2), + () => Graph.updateNode(mutable, 0, () => "updated"), + () => Graph.updateEdge(mutable, 0, () => 2), + () => Graph.removeNode(mutable, 0), + () => Graph.removeNodes(mutable, [0]), + () => Graph.removeEdge(mutable, 0), + () => Graph.removeEdges(mutable, [0]), + () => Graph.mapNodes(mutable, (node) => node), + () => Graph.mapEdges(mutable, (edge) => edge), + () => Graph.filterMapNodes(mutable, (node) => Option.some(node)), + () => Graph.filterMapEdges(mutable, (edge) => Option.some(edge)), + () => Graph.filterNodes(mutable, () => true), + () => Graph.filterEdges(mutable, () => true), + () => Graph.reverse(mutable), + () => Graph.endMutation(mutable) + ] - assert.deepStrictEqual(graphNodeIds(graph), new Set(["a", "b", "c"])) - assert.deepStrictEqual( - graphNodeLabels(graph), - new Map([ - ["a", "A1"], - ["b", "B1"], - ["c", "C1"] - ]) - ) - assert.deepStrictEqual(graphEdgeKeys(graph), new Set(["a->b"])) - assert.deepStrictEqual(graphEdgeData(graph), new Map([["a->b", "left-ab"]])) + for (const mutation of mutations) { + assertGraphError(() => + Graph.updateNode(mutable, 0, (node) => { + mutation() + return node + }), "Cannot mutate graph during a transformation") + } + assertSnapshot(mutable, { + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }], + edges: [{ index: 0, source: 0, target: 1, data: 1 }] + }) }) - it("symmetricDifference keeps edges present in exactly one graph", () => { - const graph = Graph.symmetricDifference(makeLeft(), makeRight(), { nodeIdentity: (node) => node.id }) + it("guards every transformation callback against nested mutation", () => { + const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]])) + const mutate = () => Graph.addNode(mutable, "C") + const transformations: ReadonlyArray<() => unknown> = [ + () => + Graph.updateNode(mutable, 0, (node) => { + mutate() + return node + }), + () => + Graph.updateEdge(mutable, 0, (edge) => { + mutate() + return edge + }), + () => + Graph.mapNodes(mutable, (node) => { + mutate() + return node + }), + () => + Graph.mapEdges(mutable, (edge) => { + mutate() + return edge + }), + () => + Graph.filterMapNodes(mutable, (node) => { + mutate() + return Option.some(node) + }), + () => + Graph.filterMapEdges(mutable, (edge) => { + mutate() + return Option.some(edge) + }), + () => + Graph.filterNodes(mutable, () => { + mutate() + return true + }), + () => + Graph.filterEdges(mutable, () => { + mutate() + return true + }) + ] - assert.deepStrictEqual(graphNodeIds(graph), new Set(["a", "b", "c", "d"])) - assert.deepStrictEqual( - graphNodeLabels(graph), - new Map([ - ["a", "A1"], - ["b", "B2"], - ["c", "C2"], - ["d", "D2"] - ]) - ) - assert.deepStrictEqual(graphEdgeKeys(graph), new Set(["a->b", "c->d"])) - assert.deepStrictEqual( - graphEdgeData(graph), - new Map([ - ["a->b", "left-ab"], - ["c->d", "right-cd"] - ]) - ) + for (const transformation of transformations) { + assertGraphError(transformation, "Cannot mutate graph during a transformation") + } }) - it("uses node data as identity by default", () => { - const left = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "shared") - }) - const right = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "shared") - }) + it("guards read callbacks against nested mutation", () => { + const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]])) + const mutate = () => Graph.addNode(mutable, "C") + const operations: ReadonlyArray<() => unknown> = [ + () => + Graph.findNode(mutable, () => { + Graph.findNode(mutable, () => false) + mutate() + return false + }), + () => Graph.findNodes(mutable, () => (mutate(), false)), + () => Graph.findEdge(mutable, () => (mutate(), false)), + () => Graph.findEdges(mutable, () => (mutate(), false)), + () => Graph.toGraphViz(mutable, { edgeLabel: (edge) => (mutate(), String(edge)) }), + () => Graph.toMermaid(mutable, { nodeShape: () => (mutate(), "rectangle") }), + () => Graph.maximumFlow(mutable, { source: 0, target: 1, capacity: (edge) => (mutate(), edge) }), + () => Graph.minimumCut(mutable, { source: 0, target: 1, capacity: (edge) => (mutate(), edge) }), + () => Graph.dijkstra(mutable, { source: 0, target: 1, cost: (edge) => (mutate(), edge) }), + () => Graph.floydWarshall(mutable, (edge) => (mutate(), edge)), + () => Graph.astar(mutable, { source: 0, target: 1, cost: (edge) => (mutate(), edge), heuristic: () => 0 }), + () => Graph.astar(mutable, { source: 0, target: 1, cost: (edge) => edge, heuristic: () => (mutate(), 0) }), + () => Graph.bellmanFord(mutable, { source: 0, target: 1, cost: (edge) => (mutate(), edge) }), + () => + Array.from(Graph.allShortestPaths(mutable, { + source: 0, + target: 1, + cost: (edge) => (mutate(), edge) + })), + () => Array.from(Graph.dfs(mutable, { start: [0] }).visit(() => mutate())), + () => Array.from(Graph.nodes(mutable).visit(() => mutate())), + () => Array.from(Graph.edges(mutable).visit(() => mutate())), + () => Array.from(Graph.externals(mutable).visit(() => mutate())) + ] - strictEqual(Graph.nodeCount(Graph.compose(left, right)), 2) - strictEqual(Graph.edgeCount(left.pipe(Graph.intersection(right))), 1) - strictEqual(Graph.edgeCount(Graph.difference(left, right)), 0) - strictEqual(Graph.edgeCount(left.pipe(Graph.symmetricDifference(right))), 0) + for (const operation of operations) { + assertGraphError(operation, "Cannot mutate graph during a transformation") + } + + const undirectedMutable = Graph.beginMutation(undirected(["A", "B"], [[0, 1, 1]])) + assertGraphError(() => + Graph.minimumSpanningForest(undirectedMutable, (edge) => { + Graph.addNode(undirectedMutable, "C") + return edge + }), "Cannot mutate graph during a transformation") }) + }) - it("supports hashable node identities", () => { - const graph = Graph.compose(makeLeft(), makeRight(), { - nodeIdentity: (node) => new SetNodeKey(node.id) - }) + describe("node operations", () => { + it("allocates stable indexes and supports lookup, membership, and count", () => { + const mutable = Graph.beginMutation(Graph.directed()) + assert.strictEqual(Graph.addNode(mutable, undefined), 0) + assert.strictEqual(Graph.addNode(mutable, "B"), 1) - strictEqual(Graph.nodeCount(graph), 4) - strictEqual(Graph.edgeCount(graph), 3) + assert.strictEqual(Graph.nodeCount(mutable), 2) + assert.strictEqual(Graph.hasNode(mutable, 0), true) + assert.strictEqual(Graph.hasNode(mutable, 2), false) + assert.deepStrictEqual(Graph.getNode(mutable, 0), Option.some(undefined)) + assert.deepStrictEqual(Graph.getNode(0)(mutable), Option.some(undefined)) + assert.deepStrictEqual(Graph.getNode(mutable, 2), Option.none()) }) - it("supports undefined node data and identities", () => { - const left = Graph.directed((mutable) => { - Graph.addNode(mutable, undefined) - }) - const right = Graph.directed((mutable) => { - Graph.addNode(mutable, undefined) - }) + it("updates existing payloads and ignores missing indexes", () => { + const mutable = Graph.beginMutation(directed([undefined, "B"], [])) + Graph.updateNode(mutable, 0, () => "A") + Graph.updateNode(mutable, 1, () => undefined) + Graph.updateNode(mutable, 99, () => "missing") - strictEqual(Graph.nodeCount(Graph.compose(left, right)), 1) + assert.deepStrictEqual(Array.from(mutable), [[0, "A"], [1, undefined]]) }) - it("coalesces duplicate node identities", () => { - const left = Graph.directed((mutable) => { - const first = Graph.addNode(mutable, { id: "a", label: "first" }) - const last = Graph.addNode(mutable, { id: "a", label: "last" }) - Graph.addEdge(mutable, first, last, "same") - }) - const right = Graph.directed() - const result = Graph.compose(left, right, { nodeIdentity: (node) => node.id }) - const edge = Array.from(Graph.edges(result))[0][1] + it("finds the first and all matching nodes in graph order", () => { + const graph = directed([undefined, "B", undefined], []) - strictEqual(Graph.nodeCount(result), 1) - strictEqual(Array.from(Graph.values(Graph.nodes(result)))[0].label, "last") - strictEqual(edge.source, edge.target) + assert.deepStrictEqual(Graph.findNode(graph, (node) => node === undefined), Option.some(0)) + assert.deepStrictEqual(Graph.findNode((node: string | undefined) => node === "missing")(graph), Option.none()) + assert.deepStrictEqual(Graph.findNodes(graph, (node) => node === undefined), [0, 2]) }) + }) - it("includes edge data in edge identity", () => { - const left = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "left") + describe("edge operations", () => { + it("allocates parallel edges and self-loops with exact stored orientation", () => { + const graph = Graph.undirected((mutable) => { + Graph.addNode(mutable, "A") + Graph.addNode(mutable, "B") + assert.strictEqual(Graph.addEdge(mutable, 1, 0, "first"), 0) + assert.strictEqual(Graph.addEdge(mutable, 1, 0, "parallel"), 1) + assert.strictEqual(Graph.addEdge(mutable, 0, 0, "loop"), 2) }) - const right = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "right") + + assertSnapshot(graph, { + type: "undirected", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }], + edges: [ + { index: 0, source: 1, target: 0, data: "first" }, + { index: 1, source: 1, target: 0, data: "parallel" }, + { index: 2, source: 0, target: 0, data: "loop" } + ] }) + }) - strictEqual(Graph.edgeCount(Graph.compose(left, right)), 2) - strictEqual(Graph.edgeCount(Graph.intersection(left, right)), 0) - strictEqual(Graph.edgeCount(Graph.difference(left, right)), 1) - strictEqual(Graph.edgeCount(Graph.symmetricDifference(left, right)), 2) + it("rejects missing edge endpoints", () => { + const mutable = Graph.beginMutation(directed(["A"], [])) + assertGraphError(() => Graph.addEdge(mutable, 1, 0, 1), "Node 1 does not exist") + assertGraphError(() => Graph.addEdge(mutable, 0, 1, 1), "Node 1 does not exist") }) - it("uses edge data from that for equivalent edges", () => { - const left = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, { id: "shared", label: "left" }) - }) - const right = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, { id: "shared", label: "right" }) + it("gets and updates undefined edge data without exposing stored records", () => { + const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, undefined]])) + const fromGetter = Option.getOrThrow(Graph.getEdge(mutable, 0)) + const fromWalker = Array.from(Graph.values(Graph.edges(mutable)))[0] + ;(fromGetter as { source: number; data: number | undefined }).source = 1 + ;(fromGetter as { source: number; data: number | undefined }).data = 1 + ;(fromWalker as { target: number; data: number | undefined }).target = 0 + ;(fromWalker as { target: number; data: number | undefined }).data = 1 + + assert.deepStrictEqual(Graph.getEdge(mutable, 0), Option.some({ source: 0, target: 1, data: undefined })) + assertSnapshot(mutable, { + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }], + edges: [{ index: 0, source: 0, target: 1, data: undefined }] }) - const options = { edgeIdentity: (edge: { readonly id: string }) => edge.id } + assert.deepStrictEqual(Graph.neighbors(mutable, 0), [1]) + assert.deepStrictEqual(Graph.neighbors(mutable, 1), []) - strictEqual(Array.from(Graph.edges(Graph.compose(left, right, options)))[0][1].data.label, "right") - strictEqual(Array.from(Graph.edges(Graph.intersection(left, right, options)))[0][1].data.label, "right") - }) + Graph.updateEdge(mutable, 0, () => 2) + Graph.updateEdge(mutable, 99, () => 3) - it("deduplicates equal edges in intersections", () => { - const left = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "shared") - }) - const right = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "shared") - Graph.addEdge(mutable, a, b, "shared") + assert.deepStrictEqual(Graph.getEdge(mutable, 0), Option.some({ source: 0, target: 1, data: 2 })) + assert.deepStrictEqual(Graph.getEdge(99)(mutable), Option.none()) + assertSnapshot(mutable, { + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }], + edges: [{ index: 0, source: 0, target: 1, data: 2 }] }) - - strictEqual(Graph.edgeCount(Graph.intersection(left, right)), 1) + assert.deepStrictEqual(Graph.neighbors(mutable, 0), [1]) }) - it("treats equal parallel edges as set members rather than occurrences", () => { - const left = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "shared") - Graph.addEdge(mutable, a, b, "shared") - }) - const empty = Graph.directed() - const right = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "shared") - }) + it("removes parallel undirected edges and self-loops independently", () => { + const mutable = Graph.beginMutation(undirected(["A", "B"], [[1, 0, "first"], [0, 1, "second"], [1, 1, "loop"]])) + Graph.removeEdge(mutable, 0) + Graph.removeEdge(mutable, 2) + Graph.removeEdge(mutable, 99) - strictEqual(Graph.edgeCount(Graph.compose(left, empty)), 1) - strictEqual(Graph.edgeCount(Graph.intersection(left, right)), 1) - strictEqual(Graph.edgeCount(Graph.difference(left, empty)), 2) - strictEqual(Graph.edgeCount(Graph.difference(left, right)), 0) - strictEqual(Graph.edgeCount(Graph.symmetricDifference(left, empty)), 1) + assertSnapshot(mutable, { + type: "undirected", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }], + edges: [{ index: 1, source: 0, target: 1, data: "second" }] + }) + assert.deepStrictEqual(Graph.neighbors(mutable, 0), [1]) + assert.deepStrictEqual(Graph.neighbors(mutable, 1), [0]) }) - it("rejects graphs with different kinds", () => { - const directed: Graph.Graph = Graph.directed() - const undirected: Graph.Graph = Graph.undirected() + it("removes undirected nodes with reversed edges, parallel edges, and self-loops", () => { + const mutable = Graph.beginMutation(undirected(["A", "B", "C"], [ + [1, 0, "reverse"], + [0, 1, "parallel"], + [1, 1, "loop"], + [2, 0, "keep"] + ])) + Graph.removeNode(mutable, 1) - throws( - () => Graph.compose(directed, undirected), - (error) => { - strictEqual(error instanceof Graph.GraphError, true) - if (error instanceof Graph.GraphError) { - strictEqual(error.message, "Cannot combine directed and undirected graphs") - } - } - ) - throws(() => Graph.sum(directed, undirected), (error) => { - strictEqual(error instanceof Graph.GraphError, true) + assertSnapshot(mutable, { + type: "undirected", + nodes: [{ index: 0, data: "A" }, { index: 2, data: "C" }], + edges: [{ index: 3, source: 2, target: 0, data: "keep" }] }) }) - it("matches undirected edges regardless of endpoint order", () => { - const left = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "shared") - }) - const right = Graph.undirected((mutable) => { - const b = Graph.addNode(mutable, "B") - const a = Graph.addNode(mutable, "A") - Graph.addEdge(mutable, b, a, "shared") - }) + it("collects graph-backed iterables before bulk removal", () => { + const edges = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1], [1, 0, 2]])) + Graph.removeEdges(edges, Graph.indices(Graph.edges(edges))) + assert.strictEqual(Graph.edgeCount(edges), 0) - const options = { nodeIdentity: (node: string) => new SetNodeKey(node) } - strictEqual(Graph.edgeCount(Graph.intersection(left, right, options)), 1) - strictEqual(Graph.edgeCount(Graph.difference(left, right, options)), 0) + const nodes = Graph.beginMutation(directed(["A", "B"], [])) + Graph.removeNodes(nodes, Graph.indices(Graph.nodes(nodes))) + assert.strictEqual(Graph.nodeCount(nodes), 0) }) - it("complement adds missing directed edges", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, { id: "a", label: "A" }) - const b = Graph.addNode(mutable, { id: "b", label: "B" }) - const c = Graph.addNode(mutable, { id: "c", label: "C" }) - Graph.addEdge(mutable, a, b, "A-B") - Graph.addEdge(mutable, b, c, "B-C") + it("keeps caches fresh when bulk-removal iterables query the graph", () => { + const edges = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]])) + Graph.removeEdges(edges, { + *[Symbol.iterator]() { + assert.strictEqual(Graph.hasPath(edges, 0, 1), true) + yield 0 + } }) + assert.strictEqual(Graph.hasPath(edges, 0, 1), false) - const result = Graph.complement(graph, (source, target) => `${source.label}-${target.label}`) - - strictEqual(Graph.nodeCount(result), 3) - strictEqual(Graph.edgeCount(result), 4) - assert.deepStrictEqual( - graphEdgeData(result), - new Map([ - ["a->c", "A-C"], - ["b->a", "B-A"], - ["c->a", "C-A"], - ["c->b", "C-B"] - ]) - ) + const nodes = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1], [1, 0, 2]])) + Graph.removeNodes(nodes, { + *[Symbol.iterator]() { + assert.strictEqual(Graph.isAcyclic(nodes), false) + yield 1 + } + }) + assert.strictEqual(Graph.isAcyclic(nodes), true) }) - it("complement adds missing undirected edges once", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, { id: "A", label: "A" }) - const b = Graph.addNode(mutable, { id: "B", label: "B" }) - Graph.addNode(mutable, { id: "C", label: "C" }) - Graph.addEdge(mutable, a, b, "A-B") - }) + it("rejects finalization from bulk-removal iterables", () => { + const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]])) + assertGraphError(() => + Graph.removeEdges(mutable, { + *[Symbol.iterator]() { + Graph.endMutation(mutable) + yield 0 + } + }), "Cannot mutate graph during a transformation") + assert.strictEqual(mutable.mutable, true) + assert.strictEqual(Graph.edgeCount(mutable), 1) + }) + }) - const result = Graph.complement(graph, (source, target) => `${source.label}-${target.label}`) + describe("transformations", () => { + it("maps nodes and edges while preserving indexes and structure", () => { + const mutable = Graph.beginMutation(directed(["a", "b"], [[0, 1, 2]])) + Graph.mapNodes(mutable, (node) => node.toUpperCase()) + Graph.mapEdges(mutable, (edge) => edge * 3) - strictEqual(result.type, "undirected") - strictEqual(Graph.edgeCount(result), 2) - assert.deepStrictEqual( - graphEdgeData(result), - new Map([ - ["A--C", "A-C"], - ["B--C", "B-C"] - ]) - ) + assertSnapshot(mutable, { + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }], + edges: [{ index: 0, source: 0, target: 1, data: 6 }] + }) }) - it("neighborhood returns the induced subgraph within radius", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, { id: "A", label: "A" }) - const b = Graph.addNode(mutable, { id: "B", label: "B" }) - const c = Graph.addNode(mutable, { id: "C", label: "C" }) - const d = Graph.addNode(mutable, { id: "D", label: "D" }) - Graph.addEdge(mutable, a, b, "A-B") - Graph.addEdge(mutable, b, c, "B-C") - Graph.addEdge(mutable, c, d, "C-D") - Graph.addEdge(mutable, c, b, "C-B") + it("filter-maps nodes and removes incident edges", () => { + const mutable = Graph.beginMutation(directed([1, 2, 3], [[0, 1, "remove"], [1, 2, "remove"], [0, 2, "keep"]])) + Graph.filterMapNodes(mutable, (node) => node === 2 ? Option.none() : Option.some(node * 10)) + + assertSnapshot(mutable, { + type: "directed", + nodes: [{ index: 0, data: 10 }, { index: 2, data: 30 }], + edges: [{ index: 2, source: 0, target: 2, data: "keep" }] }) + }) - const result = Graph.neighborhood(graph, 1, { radius: 1, direction: "outgoing" }) + it("filter-maps edges without removing nodes", () => { + const mutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2], [2, 0, 3]])) + Graph.filterMapEdges(mutable, (edge) => edge % 2 === 0 ? Option.none() : Option.some(edge * 10)) - assert.deepStrictEqual(graphNodeIds(result), new Set(["B", "C"])) - assert.deepStrictEqual(graphEdgeKeys(result), new Set(["B->C", "C->B"])) + assertSnapshot(mutable, { + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }, { index: 2, data: "C" }], + edges: [{ index: 0, source: 0, target: 1, data: 10 }, { index: 2, source: 2, target: 0, data: 30 }] + }) }) - it("neighborhood follows outgoing edges by default", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, { id: "A", label: "A" }) - const b = Graph.addNode(mutable, { id: "B", label: "B" }) - const c = Graph.addNode(mutable, { id: "C", label: "C" }) - Graph.addEdge(mutable, a, b, "A-B") - Graph.addEdge(mutable, b, c, "B-C") + it("filters nodes and removes their incident edges", () => { + const nodes = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2], [0, 2, 3]])) + Graph.filterNodes(nodes, (node) => node !== "B") + assertSnapshot(nodes, { + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 2, data: "C" }], + edges: [{ index: 2, source: 0, target: 2, data: 3 }] }) - - const result = Graph.neighborhood(graph, 1) - - assert.deepStrictEqual(graphNodeIds(result), new Set(["B", "C"])) - assert.deepStrictEqual(graphEdgeKeys(result), new Set(["B->C"])) }) - it("neighborhood can ignore edge direction", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, { id: "A", label: "A" }) - const b = Graph.addNode(mutable, { id: "B", label: "B" }) - const c = Graph.addNode(mutable, { id: "C", label: "C" }) - Graph.addEdge(mutable, a, b, "A-B") - Graph.addEdge(mutable, a, c, "A-C") + it("filters edges without removing nodes", () => { + const edges = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2], [0, 2, 3]])) + Graph.filterEdges(edges, (edge) => edge % 2 === 1) + assertSnapshot(edges, { + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }, { index: 2, data: "C" }], + edges: [{ index: 0, source: 0, target: 1, data: 1 }, { index: 2, source: 0, target: 2, data: 3 }] }) + }) - const result = Graph.neighborhood(graph, 1, { radius: 2, direction: "undirected" }) + it("exposes earlier bulk writes to later callbacks", () => { + const nodes = Graph.beginMutation(directed(["a", "b"], [])) + const nodeStates: Array> = [] + Graph.mapNodes(nodes, (node) => { + nodeStates.push(Array.from(Graph.values(Graph.nodes(nodes)))) + return node.toUpperCase() + }) + assert.deepStrictEqual(nodeStates, [["a", "b"], ["A", "b"]]) - assert.deepStrictEqual(graphNodeIds(result), new Set(["A", "B", "C"])) - assert.deepStrictEqual(graphEdgeKeys(result), new Set(["A->B", "A->C"])) + const edges = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2]])) + const edgeStates: Array> = [] + Graph.mapEdges(edges, (edge) => { + edgeStates.push(Array.from(Graph.values(Graph.edges(edges)), (value) => value.data)) + return edge * 2 + }) + assert.deepStrictEqual(edgeStates, [[1, 2], [2, 2]]) }) - it("sum keeps equal nodes disjoint", () => { - const left = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "left") + it("publishes updated callback values to subsequent graph reads", () => { + const nodes = Graph.beginMutation(directed(["old"], [])) + Graph.updateNode(nodes, 0, () => { + assert.deepStrictEqual(Array.from(Graph.values(Graph.bfs(nodes, { start: [0] }))), ["old"]) + return "new" }) - const right = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, "right") + assert.deepStrictEqual(Array.from(Graph.values(Graph.bfs(nodes, { start: [0] }))), ["new"]) + + const edges = Graph.beginMutation(directed(["source", "target"], [[0, 1, 1]])) + Graph.updateEdge(edges, 0, () => { + assert.deepStrictEqual(Array.from(Graph.simplePaths(edges, { source: 0, target: 1 }))[0].costs, [1]) + return 2 }) + assert.deepStrictEqual(Array.from(Graph.simplePaths(edges, { source: 0, target: 1 }))[0].costs, [2]) + }) - const result = Graph.sum(left, right) + it("reverses directed edges and leaves undirected stored orientation unchanged", () => { + const directedMutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2]])) + Graph.reverse(directedMutable) + Graph.addEdge(directedMutable, 0, 1, 3) + assert.deepStrictEqual(Graph.toSnapshot(directedMutable).edges, [ + { index: 0, source: 1, target: 0, data: 1 }, + { index: 1, source: 2, target: 1, data: 2 }, + { index: 2, source: 0, target: 1, data: 3 } + ]) + assert.deepStrictEqual(Graph.neighbors(directedMutable, 2), [1]) + assert.strictEqual(Graph.hasEdge(directedMutable, 1, 0), true) + assert.strictEqual(Graph.hasEdge(directedMutable, 0, 1), true) - strictEqual(Graph.nodeCount(result), 4) - strictEqual(Graph.edgeCount(result), 2) - assert.deepStrictEqual(Array.from(Graph.values(Graph.nodes(result))), ["A", "B", "A", "B"]) + const undirectedMutable = Graph.beginMutation(undirected(["A", "B"], [[1, 0, 1]])) + Graph.reverse(undirectedMutable) + assert.deepStrictEqual(Graph.toSnapshot(undirectedMutable).edges, [{ index: 0, source: 1, target: 0, data: 1 }]) }) }) - it("toString", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, undefined) - const nodeB = Graph.addNode(mutable, undefined) - Graph.addEdge(mutable, nodeA, nodeB, 1) + describe("set operations", () => { + type Node = { readonly id: string; readonly label: string } + const left = () => + directed( + [{ id: "a", label: "A1" }, { id: "b", label: "B1" }, { id: "c", label: "C1" }], + [[0, 1, "left"], [1, 2, "shared"]] + ) + const right = () => + directed( + [{ id: "b", label: "B2" }, { id: "c", label: "C2" }, { id: "d", label: "D2" }], + [[0, 1, "shared"], [1, 2, "right"]] + ) + const identity = { nodeIdentity: (node: Node) => node.id } + const semantic = (graph: Graph.Graph) => { + const nodes = new Map(Array.from(graph, ([index, node]) => [index, node])) + return { + nodes: Array.from(nodes.values(), (node) => `${node.id}:${node.label}`).sort(), + edges: Array.from( + Graph.values(Graph.edges(graph)), + (edge) => `${nodes.get(edge.source)!.id}->${nodes.get(edge.target)!.id}:${edge.data}` + ).sort() + } + } + + it("composes, intersects, differs, and symmetrically differs by projected identity", () => { + const composed = { + nodes: ["a:A1", "b:B2", "c:C2", "d:D2"], + edges: ["a->b:left", "b->c:shared", "c->d:right"] + } + const intersected = { + nodes: ["b:B1", "c:C1"], + edges: ["b->c:shared"] + } + const differed = { + nodes: ["a:A1", "b:B1", "c:C1"], + edges: ["a->b:left"] + } + const symmetric = { + nodes: ["a:A1", "b:B2", "c:C2", "d:D2"], + edges: ["a->b:left", "c->d:right"] + } + assert.deepStrictEqual(semantic(Graph.compose(left(), right(), identity)), composed) + assert.deepStrictEqual(semantic(Graph.compose(right(), identity)(left())), composed) + assert.deepStrictEqual(semantic(Graph.intersection(left(), right(), identity)), intersected) + assert.deepStrictEqual(semantic(Graph.intersection(right(), identity)(left())), intersected) + assert.deepStrictEqual(semantic(Graph.difference(left(), right(), identity)), differed) + assert.deepStrictEqual(semantic(Graph.difference(right(), identity)(left())), differed) + assert.deepStrictEqual(semantic(Graph.symmetricDifference(left(), right(), identity)), symmetric) + assert.deepStrictEqual(semantic(Graph.symmetricDifference(right(), identity)(left())), symmetric) + }) + + it("supports Effect Equal and Hash node identities", () => { + class NodeKey implements Equal.Equal { + constructor(readonly id: string) {} + [Equal.symbol](that: Equal.Equal): boolean { + return that instanceof NodeKey && this.id === that.id + } + [Hash.symbol](): number { + return Hash.string(this.id) + } + } + + const result = Graph.compose(left(), right(), { nodeIdentity: (node) => new NodeKey(node.id) }) + assert.deepStrictEqual(semantic(result), { + nodes: ["a:A1", "b:B2", "c:C2", "d:D2"], + edges: ["a->b:left", "b->c:shared", "c->d:right"] + }) }) - strictEqual(String(graph), "Graph(directed, 2, 1)") - }) - describe("isGraph", () => { - it("should return true for graph instances", () => { - const directedGraph = Graph.directed() - const undirectedGraph = Graph.undirected() + it("coalesces duplicate identities to the last payload and redirects edges", () => { + const graph = directed([{ id: "a", label: "first" }, { id: "a", label: "last" }], [[ + 0, + 1, + "edge" + ]]) + const result = Graph.compose(graph, Graph.directed(), identity) + const edge = Array.from(Graph.values(Graph.edges(result)))[0] - strictEqual(Graph.isGraph(directedGraph), true) - strictEqual(Graph.isGraph(undirectedGraph), true) + assert.deepStrictEqual(Array.from(Graph.values(Graph.nodes(result))), [{ id: "a", label: "last" }]) + assert.strictEqual(edge.source, edge.target) }) - it("should return true for mutable graph instances", () => { - const directedGraph = Graph.beginMutation(Graph.directed()) - const undirectedGraph = Graph.beginMutation(Graph.undirected()) + it("uses right edge payloads for custom-identity compose and intersection", () => { + type Edge = { readonly id: string; readonly label: string } + const left = directed(["A", "B"], [[0, 1, { id: "shared", label: "left" }]]) + const right = directed(["A", "B"], [[0, 1, { id: "shared", label: "right" }]]) + const options = { edgeIdentity: (edge: Edge) => edge.id } - strictEqual(Graph.isGraph(directedGraph), true) - strictEqual(Graph.isGraph(undirectedGraph), true) + assert.strictEqual( + Array.from(Graph.values(Graph.edges(Graph.compose(left, right, options))))[0].data.label, + "right" + ) + assert.strictEqual( + Array.from(Graph.values(Graph.edges(Graph.intersection(left, right, options))))[0].data.label, + "right" + ) }) - it("should return false for non-graph values", () => { - strictEqual(Graph.isGraph({}), false) - strictEqual(Graph.isGraph(null), false) - strictEqual(Graph.isGraph(undefined), false) - strictEqual(Graph.isGraph("string"), false) - strictEqual(Graph.isGraph(42), false) - strictEqual(Graph.isGraph([]), false) + it("includes edge data in the default edge identity", () => { + const first = directed(["A", "B"], [[0, 1, "left"]]) + const second = directed(["A", "B"], [[0, 1, "right"]]) + + assert.strictEqual(Graph.edgeCount(Graph.compose(first, second)), 2) + assert.strictEqual(Graph.edgeCount(Graph.intersection(first, second)), 0) + assert.strictEqual(Graph.edgeCount(Graph.difference(first, second)), 1) + assert.strictEqual(Graph.edgeCount(Graph.symmetricDifference(first, second)), 2) }) - it("should be iterable using for...of syntax", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") - Graph.addNode(mutable, "Node B") - Graph.addNode(mutable, "Node C") - }) + it("uses undefined node data as the default node identity", () => { + assert.strictEqual( + Graph.nodeCount(Graph.compose(directed([undefined], []), directed([undefined], []))), + 1 + ) + }) - const collected: Array = [] - for (const entry of graph) { - collected.push(entry) - } + it("treats equal parallel edges as set members while difference preserves unmatched occurrences", () => { + const parallel = directed(["A", "B"], [[0, 1, "same"], [0, 1, "same"]]) + const one = directed(["A", "B"], [[0, 1, "same"]]) + const empty = Graph.directed() - expect(collected).toHaveLength(3) - expect(collected).toEqual([ - [0, "Node A"], - [1, "Node B"], - [2, "Node C"] - ]) + assert.strictEqual(Graph.edgeCount(Graph.compose(parallel, empty)), 1) + assert.strictEqual(Graph.edgeCount(Graph.intersection(parallel, one)), 1) + assert.strictEqual(Graph.edgeCount(Graph.difference(parallel, empty)), 2) + assert.strictEqual(Graph.edgeCount(Graph.difference(parallel, one)), 0) + assert.strictEqual(Graph.edgeCount(Graph.symmetricDifference(parallel, empty)), 1) + }) + + it("matches undirected identities independent of stored orientation", () => { + const first = undirected(["A", "B"], [[0, 1, "same"]]) + const second = undirected(["B", "A"], [[0, 1, "same"]]) + assert.strictEqual(Graph.edgeCount(Graph.intersection(first, second)), 1) + assert.strictEqual(Graph.edgeCount(Graph.difference(first, second)), 0) + }) + + it("rejects runtime kind mismatches for every binary set operation", () => { + const first = Graph.directed() as Graph.Graph + const second = Graph.undirected() as Graph.Graph + const operations: ReadonlyArray<() => unknown> = [ + () => Graph.compose(first, second), + () => Graph.intersection(first, second), + () => Graph.difference(first, second), + () => Graph.symmetricDifference(first, second), + () => Graph.sum(first, second) + ] + for (const operation of operations) { + assertGraphError(operation, "Cannot combine directed and undirected graphs") + } }) - it("should support manual iterator operations", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") - Graph.addNode(mutable, "Node B") + it("builds directed and undirected complements without self-loops", () => { + assertSnapshot(Graph.complement(directed(["A", "B"], [[0, 1, "existing"]]), (a, b) => `${a}-${b}`), { + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }], + edges: [{ index: 0, source: 1, target: 0, data: "B-A" }] }) + assertSnapshot(Graph.complement(undirected(["A", "B", "C"], [[0, 1, "existing"]]), (a, b) => `${a}-${b}`), { + type: "undirected", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }, { index: 2, data: "C" }], + edges: [{ index: 0, source: 0, target: 2, data: "A-C" }, { index: 1, source: 1, target: 2, data: "B-C" }] + }) + }) - const iterator = graph[Symbol.iterator]() - const first = iterator.next() - const second = iterator.next() - const third = iterator.next() + it("returns induced neighborhoods and validates radius", () => { + const graph = directed(["A", "B", "C", "D"], [[0, 1, "AB"], [1, 2, "BC"], [2, 1, "CB"], [2, 3, "CD"]]) + assertSnapshot(Graph.neighborhood(graph, 1, { radius: 1 }), { + type: "directed", + nodes: [{ index: 0, data: "B" }, { index: 1, data: "C" }], + edges: [{ index: 0, source: 0, target: 1, data: "BC" }, { index: 1, source: 1, target: 0, data: "CB" }] + }) + assertSnapshot(Graph.neighborhood(1, { radius: Infinity })(graph), { + type: "directed", + nodes: [{ index: 0, data: "B" }, { index: 1, data: "C" }, { index: 2, data: "D" }], + edges: [ + { index: 0, source: 0, target: 1, data: "BC" }, + { index: 1, source: 1, target: 0, data: "CB" }, + { index: 2, source: 1, target: 2, data: "CD" } + ] + }) + for (const radius of [NaN, -1, 0.5]) { + assertGraphError( + () => Graph.neighborhood(graph, 1, { radius }), + "Traversal radius must be a non-negative integer or Infinity" + ) + } + }) - expect(first.done).toBe(false) - expect(first.value).toEqual([0, "Node A"]) - expect(second.done).toBe(false) - expect(second.value).toEqual([1, "Node B"]) - expect(third.done).toBe(true) + it("can ignore edge direction when selecting a neighborhood", () => { + const graph = directed(["A", "B", "C"], [[0, 1, "AB"], [0, 2, "AC"]]) + assertSnapshot(Graph.neighborhood(graph, 1, { radius: 2, direction: "undirected" }), { + type: "directed", + nodes: [{ index: 0, data: "B" }, { index: 1, data: "A" }, { index: 2, data: "C" }], + edges: [{ index: 0, source: 1, target: 0, data: "AB" }, { index: 1, source: 1, target: 2, data: "AC" }] + }) + }) + + it("preserves sparse indexes in induced subgraphs and rejects missing nodes", () => { + const graph = Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }], + edges: [ + { index: 3, source: 2, target: 5, data: "AB" }, + { index: 7, source: 5, target: 9, data: "BC" }, + { index: 11, source: 5, target: 5, data: "loop" } + ] + }) + assertSnapshot(Graph.inducedSubgraph([9, 5, 5])(graph), { + type: "directed", + nodes: [{ index: 5, data: "B" }, { index: 9, data: "C" }], + edges: [{ index: 7, source: 5, target: 9, data: "BC" }, { index: 11, source: 5, target: 5, data: "loop" }] + }) + assertGraphError(() => Graph.inducedSubgraph(graph, [2, 4]), "Node 4 does not exist") + }) + + it("preserves graph kind for empty induced subgraphs", () => { + assertSnapshot(Graph.inducedSubgraph(Graph.undirected(), []), { + type: "undirected", + nodes: [], + edges: [] + }) + }) + + it("keeps equal nodes and their edges disjoint in sums", () => { + const expected = { + type: "directed", + nodes: [ + { index: 0, data: "A" }, + { index: 1, data: "B" }, + { index: 2, data: "A" }, + { index: 3, data: "B" } + ], + edges: [ + { index: 0, source: 0, target: 1, data: "left" }, + { index: 1, source: 2, target: 3, data: "right" } + ] + } as const + const first = directed(["A", "B"], [[0, 1, "left"]]) + const second = directed(["A", "B"], [[0, 1, "right"]]) + assertSnapshot(Graph.sum(first, second), expected) + assertSnapshot(Graph.sum(second)(first), expected) }) }) - describe("undefined data handling", () => { - describe("undefined node data", () => { - it("should allow adding nodes with undefined data", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, undefined) - const nodeB = Graph.addNode(mutable, undefined) - Graph.addEdge(mutable, nodeA, nodeB, 1) - }) - - expect(Graph.nodeCount(graph)).toBe(2) - expect(Graph.edgeCount(graph)).toBe(1) - expect(Graph.getNode(graph, 0)).toEqual(Option.some(undefined)) - expect(Graph.getNode(graph, 1)).toEqual(Option.some(undefined)) - }) - - it("should correctly update nodes with undefined data", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, undefined) - Graph.addNode(mutable, "defined") - }) - - const updated = Graph.mutate(graph, (mutable) => { - Graph.updateNode(mutable, 0, () => "now defined") - Graph.updateNode(mutable, 1, () => undefined) - }) - - expect(Graph.getNode(updated, 0)).toEqual(Option.some("now defined")) - expect(Graph.getNode(updated, 1)).toEqual(Option.some(undefined)) - }) - - it("should correctly compare graphs with undefined node data", () => { - const graph1 = Graph.directed((mutable) => { - Graph.addNode(mutable, undefined) - Graph.addNode(mutable, undefined) - }) - - const graph2 = Graph.directed((mutable) => { - Graph.addNode(mutable, undefined) - Graph.addNode(mutable, undefined) - }) - - expect(Equal.equals(graph1, graph2)).toBe(true) - }) - - it("should find nodes with undefined data using predicates", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, undefined) - Graph.addNode(mutable, "defined") - Graph.addNode(mutable, undefined) - }) - - const undefinedNode = Graph.findNode(graph, (data) => data === undefined) - const undefinedNodes = Graph.findNodes(graph, (data) => data === undefined) - - expect(undefinedNode).toEqual(Option.some(0)) - expect(undefinedNodes).toEqual([0, 2]) - }) - - it("should iterate correctly over graphs with undefined node data", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, undefined) - Graph.addNode(mutable, undefined) - }) - - const collected: Array = [] - for (const entry of graph) { - collected.push(entry) - } - - expect(collected).toEqual([ - [0, undefined], - [1, undefined] - ]) - }) - }) - - describe("undefined edge data", () => { - it("should allow adding edges with undefined data", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "A") - const nodeB = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, nodeA, nodeB, undefined) - }) - - expect(Graph.edgeCount(graph)).toBe(1) - expect(Graph.getEdge(graph, 0)).toEqual(Option.some(new Graph.Edge({ source: 0, target: 1, data: undefined }))) - }) - - it("should correctly update edges with undefined data", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "A") - const nodeB = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, nodeA, nodeB, undefined) - Graph.addEdge(mutable, nodeB, nodeA, 42) - }) - - const updated = Graph.mutate(graph, (mutable) => { - Graph.updateEdge(mutable, 0, () => 100) - Graph.updateEdge(mutable, 1, () => undefined) - }) - - const edge0 = Graph.getEdge(updated, 0) - const edge1 = Graph.getEdge(updated, 1) - - expect(edge0).toEqual(Option.some(new Graph.Edge({ source: 0, target: 1, data: 100 }))) - expect(edge1).toEqual(Option.some(new Graph.Edge({ source: 1, target: 0, data: undefined }))) - }) - - it("should correctly compare graphs with undefined edge data", () => { - const graph1 = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, undefined) - }) - - const graph2 = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, undefined) - }) - - expect(Equal.equals(graph1, graph2)).toBe(true) - }) - - it("should find edges with undefined data using predicates", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, undefined) - Graph.addEdge(mutable, b, c, 42) - Graph.addEdge(mutable, c, a, undefined) - }) - - const undefinedEdge = Graph.findEdge(graph, (data) => data === undefined) - const undefinedEdges = Graph.findEdges(graph, (data) => data === undefined) - - expect(undefinedEdge).toEqual(Option.some(0)) - expect(undefinedEdges).toEqual([0, 2]) - }) - - it("should produce consistent hashes for graphs with undefined edge data", () => { - const graph1 = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, undefined) - Graph.addEdge(mutable, b, c, 42) - }) - - const graph2 = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, undefined) - Graph.addEdge(mutable, b, c, 42) - }) - - // Graphs with identical structure should have the same hash - expect(Hash.hash(graph1)).toBe(Hash.hash(graph2)) - - // Graph with different edge data should have different hash - const graph3 = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 100) // Different data - Graph.addEdge(mutable, b, c, 42) - }) - - expect(Hash.hash(graph1)).not.toBe(Hash.hash(graph3)) - }) - - it("should correctly handle Equal.equals with graphs containing undefined edge data", () => { - const graph1 = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, undefined) - }) - - const graph2 = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, undefined) - }) - - const graph3 = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 42) - }) - - // Equal graphs with undefined edge data should be equal - expect(Equal.equals(graph1, graph2)).toBe(true) - - // Graphs with different edge data should not be equal - expect(Equal.equals(graph1, graph3)).toBe(false) - }) - }) - - describe("mixed undefined scenarios", () => { - it("should handle graphs with both undefined nodes and edges", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, undefined) - const nodeB = Graph.addNode(mutable, undefined) - Graph.addEdge(mutable, nodeA, nodeB, undefined) - }) - - expect(Graph.nodeCount(graph)).toBe(2) - expect(Graph.edgeCount(graph)).toBe(1) - expect(Graph.getNode(graph, 0)).toEqual(Option.some(undefined)) - expect(Graph.getEdge(graph, 0)).toEqual(Option.some(new Graph.Edge({ source: 0, target: 1, data: undefined }))) - }) - - it("should correctly handle graph operations with mixed undefined data", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, undefined) - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, undefined) - Graph.addEdge(mutable, a, b, undefined) - Graph.addEdge(mutable, b, c, 42) - Graph.addEdge(mutable, c, a, undefined) - }) - - // Test neighbors - const neighborsOfA = Graph.neighbors(graph, 0) - const neighborsOfB = Graph.neighbors(graph, 1) - - expect(neighborsOfA).toEqual([1]) - expect(neighborsOfB).toEqual([2]) - - // Test filtering - const nodesWithUndefined = Graph.findNodes(graph, (data) => data === undefined) - const edgesWithUndefined = Graph.findEdges(graph, (data) => data === undefined) - - expect(nodesWithUndefined).toEqual([0, 2]) - expect(edgesWithUndefined).toEqual([0, 2]) - }) - }) - }) - - describe("beginMutation", () => { - it("should create a mutable graph from an immutable graph", () => { - const graph = Graph.directed() - const mutable = Graph.beginMutation(graph) - - expect(mutable.type).toBe("directed") - expect(Graph.nodeCount(mutable)).toBe(Graph.nodeCount(graph)) - expect(Graph.edgeCount(mutable)).toBe(Graph.edgeCount(graph)) - }) - }) - - describe("endMutation", () => { - it("should convert a mutable graph back to immutable", () => { - const graph = Graph.directed() - const mutable = Graph.beginMutation(graph) - const result = Graph.endMutation(mutable) - - expect(result.type).toBe("directed") - expect(Graph.nodeCount(result)).toBe(Graph.nodeCount(mutable)) - expect(Graph.edgeCount(result)).toBe(Graph.edgeCount(mutable)) - }) - - it("should reject mutations on a finalized mutable graph", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - let edgeIndex: Graph.EdgeIndex - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "A") - nodeB = Graph.addNode(mutable, "B") - edgeIndex = Graph.addEdge(mutable, nodeA, nodeB, 1) - }) - - const mutable = Graph.beginMutation(graph) - const result = Graph.endMutation(mutable) - - throws( - () => Graph.removeEdge(mutable, edgeIndex!), - (error) => { - strictEqual(error instanceof Graph.GraphError, true) - if (error instanceof Graph.GraphError) { - strictEqual(error.message, "Graph is not mutable") - } - } - ) - strictEqual(Graph.hasEdge(result, nodeA!, nodeB!), true) - assert.deepStrictEqual(Graph.neighbors(result, nodeA!), [nodeB!]) - assert.deepStrictEqual(Graph.predecessors(result, nodeB!), [nodeA!]) - }) - }) - - describe("mutate", () => { - it("should create a new graph instance", () => { - const graph = Graph.directed() - - const result = Graph.mutate(graph, () => { - // No mutations performed - }) - - expect(result).not.toBe(graph) - expect(Equal.equals(result, graph)).toBe(true) // Structural equality - }) - - it("should handle empty mutation function", () => { - const graph = Graph.directed() - - const result = Graph.mutate(graph, () => { - // Do nothing - }) - - expect(Graph.nodeCount(result)).toBe(0) - expect(Graph.edgeCount(result)).toBe(0) - }) - - it("should finalize the mutable graph when the callback throws", () => { - let mutable: Graph.MutableDirectedGraph | undefined - const error = new Error("boom") - - throws( - () => - Graph.mutate(Graph.directed(), (graph) => { - mutable = graph - throw error - }), - (cause) => { - strictEqual(cause, error) - } - ) - assertGraphError(() => Graph.addNode(mutable!, "late"), "Graph is not mutable") - }) - }) - - describe("addNode", () => { - it("should add a node to a mutable graph and return its index", () => { - const graph = Graph.directed() - let nodeIndex: Graph.NodeIndex - - const result = Graph.mutate(graph, (mutable) => { - nodeIndex = Graph.addNode(mutable, "Node A") - }) - - expect(Graph.nodeCount(result)).toBe(1) - expect(Graph.getNode(result, nodeIndex!)).toEqual(Option.some("Node A")) - }) - }) - - describe("getNode", () => { - it("should return the node data for existing nodes", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "Node A") - nodeB = Graph.addNode(mutable, "Node B") - }) - - expect(Graph.getNode(graph, nodeA!)).toEqual(Option.some("Node A")) - expect(Graph.getNode(graph, nodeB!)).toEqual(Option.some("Node B")) - }) - - it("should return None for non-existent nodes", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") - }) - - const nonExistent = Graph.getNode(graph, 999) - expect(Option.isNone(nonExistent)).toBe(true) - }) - }) - - describe("hasNode", () => { - it("should return true for existing nodes", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "Node A") - nodeB = Graph.addNode(mutable, "Node B") - }) - - expect(Graph.hasNode(graph, nodeA!)).toBe(true) - expect(Graph.hasNode(graph, nodeB!)).toBe(true) - }) - - it("should return false for non-existent nodes", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") - }) - - expect(Graph.hasNode(graph, 999)).toBe(false) - expect(Graph.hasNode(graph, -1)).toBe(false) - }) - }) - - describe("nodeCount", () => { - it("should return 0 for empty graph", () => { - const graph = Graph.directed() - expect(Graph.nodeCount(graph)).toBe(0) - }) - - it("should return correct count after adding nodes", () => { - const graph = Graph.directed((mutable) => { - expect(Graph.nodeCount(mutable)).toBe(0) - Graph.addNode(mutable, "Node A") - expect(Graph.nodeCount(mutable)).toBe(1) - Graph.addNode(mutable, "Node B") - expect(Graph.nodeCount(mutable)).toBe(2) - Graph.addNode(mutable, "Node C") - expect(Graph.nodeCount(mutable)).toBe(3) - }) - - expect(Graph.nodeCount(graph)).toBe(3) - }) - }) - - describe("findNode", () => { - it("should find node by predicate", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") - Graph.addNode(mutable, "Node B") - Graph.addNode(mutable, "Node C") - }) - - const result = Graph.findNode(graph, (data) => data === "Node B") - expect(result).toEqual(Option.some(1)) - }) - - it("should return None when no node matches", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") - Graph.addNode(mutable, "Node B") - }) - - const result = Graph.findNode(graph, (data) => data === "Node C") - expect(result).toEqual(Option.none()) - }) - - it("should find first matching node when multiple match", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Start A") - Graph.addNode(mutable, "Start B") - Graph.addNode(mutable, "Start C") - }) - - const result = Graph.findNode(graph, (data) => data.startsWith("Start")) - expect(result).toEqual(Option.some(0)) - }) - }) - - describe("findNodes", () => { - it("should find all matching nodes", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Start A") - Graph.addNode(mutable, "Node B") - Graph.addNode(mutable, "Start C") - Graph.addNode(mutable, "Start D") - }) - - const result = Graph.findNodes(graph, (data) => data.startsWith("Start")) - expect(result).toEqual([0, 2, 3]) - }) - }) - - describe("findEdge", () => { - it("should find edge by predicate", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 10) - Graph.addEdge(mutable, nodeB, nodeC, 20) - }) - - const result = Graph.findEdge(graph, (data) => data === 20) - expect(result).toEqual(Option.some(1)) - }) - - it("should return None when no edge matches", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - Graph.addEdge(mutable, nodeA, nodeB, 10) - }) - - const result = Graph.findEdge(graph, (data) => data === 99) - expect(result).toEqual(Option.none()) - }) - - it("should find first matching edge when multiple match", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 15) - Graph.addEdge(mutable, nodeB, nodeC, 25) - Graph.addEdge(mutable, nodeC, nodeA, 35) - }) - - const result = Graph.findEdge(graph, (data) => data > 20) - expect(result).toEqual(Option.some(1)) - }) - }) - - describe("findEdges", () => { - it("should find all matching edges", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 10) - Graph.addEdge(mutable, nodeB, nodeC, 20) - Graph.addEdge(mutable, nodeC, nodeA, 30) - Graph.addEdge(mutable, nodeA, nodeC, 25) - }) - - const result = Graph.findEdges(graph, (data) => data >= 20) - expect(result).toEqual([1, 2, 3]) - }) - }) - - describe("updateNode", () => { - it("should update node data", () => { - const updated = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") - Graph.addNode(mutable, "Node B") - Graph.updateNode(mutable, 0, (data) => data.toUpperCase()) - }) - - const nodeData = Graph.getNode(updated, 0) - assertSome(nodeData, "NODE A") - }) - - it("should do nothing if node doesn't exist", () => { - let nodeA: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "Node A") - Graph.updateNode(mutable, 999, (data) => data.toUpperCase()) - }) - - // Original node should be unchanged - const nodeData = Graph.getNode(graph, nodeA!) - assertSome(nodeData, "Node A") - }) - }) - - describe("updateEdge", () => { - it("should update edge data", () => { - const result = Graph.mutate(Graph.directed(), (mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const edgeIndex = Graph.addEdge(mutable, nodeA, nodeB, 10) - Graph.updateEdge(mutable, edgeIndex, (data) => data * 2) - }) - - const edge = Graph.getEdge(result, 0) - assertSome(edge, new Graph.Edge({ source: 0, target: 1, data: 20 })) - }) - - it("should do nothing if edge doesn't exist", () => { - Graph.mutate(Graph.directed(), (mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const edgeIndex = Graph.addEdge(mutable, nodeA, nodeB, 10) - - // Try to update non-existent edge - Graph.updateEdge(mutable, 999, (data) => data * 2) - - // Original edge should be unchanged - const edge = Graph.getEdge(mutable, edgeIndex) - assertSome(edge, new Graph.Edge({ source: 0, target: 1, data: 10 })) - }) - }) - }) - - describe("mapNodes", () => { - it("should transform all node data", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - let nodeC: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "node a") - nodeB = Graph.addNode(mutable, "node b") - nodeC = Graph.addNode(mutable, "node c") - Graph.mapNodes(mutable, (data) => data.toUpperCase()) - }) - - expect(Graph.getNode(graph, nodeA!)).toEqual(Option.some("NODE A")) - expect(Graph.getNode(graph, nodeB!)).toEqual(Option.some("NODE B")) - expect(Graph.getNode(graph, nodeC!)).toEqual(Option.some("NODE C")) - }) - - it("should apply transformation to all nodes", () => { - let firstNode: Graph.NodeIndex - let secondNode: Graph.NodeIndex - let thirdNode: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - firstNode = Graph.addNode(mutable, "first") - secondNode = Graph.addNode(mutable, "second") - thirdNode = Graph.addNode(mutable, "third") - Graph.mapNodes(mutable, (data) => data + " (transformed)") - }) - - const node0 = Graph.getNode(graph, firstNode!) - const node1 = Graph.getNode(graph, secondNode!) - const node2 = Graph.getNode(graph, thirdNode!) - - assertSome(node0, "first (transformed)") - assertSome(node1, "second (transformed)") - assertSome(node2, "third (transformed)") - }) - - it("should modify graph in place during construction", () => { - let originalNode: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - originalNode = Graph.addNode(mutable, "original") - // Before transformation - const beforeData = Graph.getNode(mutable, originalNode!) - assertSome(beforeData, "original") - - // Apply transformation - Graph.mapNodes(mutable, (data) => data.toUpperCase()) - }) - - // After transformation - const afterData = Graph.getNode(graph, originalNode!) - assertSome(afterData, "ORIGINAL") - }) - }) - - describe("mapEdges", () => { - it("should transform all edge data", () => { - let edgeAB: Graph.EdgeIndex - let edgeBC: Graph.EdgeIndex - let edgeCA: Graph.EdgeIndex - - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - edgeAB = Graph.addEdge(mutable, a, b, 10) - edgeBC = Graph.addEdge(mutable, b, c, 20) - edgeCA = Graph.addEdge(mutable, c, a, 30) - Graph.mapEdges(mutable, (data) => data * 2) - }) - - const edge0 = Graph.getEdge(graph, edgeAB!) - const edge1 = Graph.getEdge(graph, edgeBC!) - const edge2 = Graph.getEdge(graph, edgeCA!) - - assertSome(edge0, new Graph.Edge({ source: 0, target: 1, data: 20 })) - assertSome(edge1, new Graph.Edge({ source: 1, target: 2, data: 40 })) - assertSome(edge2, new Graph.Edge({ source: 2, target: 0, data: 60 })) - strictEqual(edge0.value instanceof Graph.Edge, true) - strictEqual(edge1.value instanceof Graph.Edge, true) - strictEqual(edge2.value instanceof Graph.Edge, true) - - const expected = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 20) - Graph.addEdge(mutable, b, c, 40) - Graph.addEdge(mutable, c, a, 60) - }) - - strictEqual(Equal.equals(graph, expected), true) - }) - - it("should modify graph in place during construction", () => { - let edgeAB: Graph.EdgeIndex - - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - edgeAB = Graph.addEdge(mutable, a, b, 10) - - // Before transformation - const beforeData = Graph.getEdge(mutable, edgeAB!) - assertSome(beforeData, new Graph.Edge({ source: 0, target: 1, data: 10 })) - - // Apply transformation - Graph.mapEdges(mutable, (data) => data * 5) - }) - - // After transformation - const afterData = Graph.getEdge(graph, edgeAB!) - expect(assertSomeEdge(afterData).data).toBe(50) - }) - }) - - describe("reverse", () => { - it("should reverse all edge directions", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - let nodeC: Graph.NodeIndex - let edgeAB: Graph.EdgeIndex - let edgeBC: Graph.EdgeIndex - let edgeCA: Graph.EdgeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "A") - nodeB = Graph.addNode(mutable, "B") - nodeC = Graph.addNode(mutable, "C") - edgeAB = Graph.addEdge(mutable, nodeA, nodeB, 1) // A -> B - edgeBC = Graph.addEdge(mutable, nodeB, nodeC, 2) // B -> C - edgeCA = Graph.addEdge(mutable, nodeC, nodeA, 3) // C -> A - Graph.reverse(mutable) // Now B -> A, C -> B, A -> C - }) - - const edge0 = Graph.getEdge(graph, edgeAB!) - const edge1 = Graph.getEdge(graph, edgeBC!) - const edge2 = Graph.getEdge(graph, edgeCA!) - - assertSome(edge0, new Graph.Edge({ source: nodeB!, target: nodeA!, data: 1 })) - assertSome(edge1, new Graph.Edge({ source: nodeC!, target: nodeB!, data: 2 })) - assertSome(edge2, new Graph.Edge({ source: nodeA!, target: nodeC!, data: 3 })) - }) - - it("should update adjacency lists correctly", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) // A -> B - Graph.addEdge(mutable, a, c, 2) // A -> C - Graph.reverse(mutable) // Now B -> A, C -> A - }) - - // After reversal: - // - Node A should have no outgoing edges - // - Node B should have edge to A - // - Node C should have edge to A - - const neighborsA = Graph.neighbors(graph, 0) - const neighborsB = Graph.neighbors(graph, 1) - const neighborsC = Graph.neighbors(graph, 2) - - expect(Array.from(neighborsA)).toEqual([]) // A has no outgoing edges - expect(Array.from(neighborsB)).toEqual([0]) // B -> A - expect(Array.from(neighborsC)).toEqual([0]) // C -> A - }) - - it("should preserve adjacency lists when adding edges after reversal", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - - Graph.addEdge(mutable, a, b, 1) - Graph.reverse(mutable) - Graph.addEdge(mutable, a, b, 2) - }) - - expect(Graph.edgeCount(graph)).toBe(2) - expect(Graph.neighbors(graph, 0)).toEqual([1]) - expect(Graph.hasEdge(graph, 0, 1)).toBe(true) - expect(Graph.hasEdge(graph, 1, 0)).toBe(true) - }) - - it("should be a no-op for undirected graphs", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - - Graph.addEdge(mutable, a, b, 1) - Graph.reverse(mutable) - }) - - expect(Graph.neighbors(graph, 0)).toEqual([1]) - expect(Graph.neighbors(graph, 1)).toEqual([0]) - expect(Graph.hasEdge(graph, 0, 1)).toBe(true) - expect(Graph.hasEdge(graph, 1, 0)).toBe(true) - }) - }) - - describe("filterMapNodes", () => { - it("should filter and transform nodes", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "active") - Graph.addNode(mutable, "inactive") - Graph.addNode(mutable, "active") - Graph.addNode(mutable, "pending") - - // Keep only "active" nodes and transform to uppercase - Graph.filterMapNodes(mutable, (data) => data === "active" ? Option.some(data.toUpperCase()) : Option.none()) - }) - - // Should only have 2 nodes remaining (the "active" ones) - expect(Graph.nodeCount(graph)).toBe(2) - - // Check the remaining nodes have been transformed - const nodeData0 = Graph.getNode(graph, 0) - const nodeData2 = Graph.getNode(graph, 2) - - expect(Option.isSome(nodeData0)).toBe(true) - expect(Option.isSome(nodeData2)).toBe(true) - - if (Option.isSome(nodeData0) && Option.isSome(nodeData2)) { - expect(nodeData0.value).toBe("ACTIVE") - expect(nodeData2.value).toBe("ACTIVE") - } - - // Filtered out nodes should not exist - expect(Option.isNone(Graph.getNode(graph, 1))).toBe(true) - expect(Option.isNone(Graph.getNode(graph, 3))).toBe(true) - }) - - it("should remove edges connected to filtered nodes", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "keep") - const b = Graph.addNode(mutable, "remove") - const c = Graph.addNode(mutable, "keep") - - Graph.addEdge(mutable, a, b, 1) // keep -> remove - Graph.addEdge(mutable, b, c, 2) // remove -> keep - Graph.addEdge(mutable, a, c, 3) // keep -> keep - - // Filter out "remove" nodes - Graph.filterMapNodes(mutable, (data) => data === "keep" ? Option.some(data) : Option.none()) - }) - - // Should have 2 nodes and 1 edge remaining - expect(Graph.nodeCount(graph)).toBe(2) - expect(Graph.edgeCount(graph)).toBe(1) - - // Only the keep -> keep edge should remain - const remainingEdge = Graph.getEdge(graph, 2) - assertSome(remainingEdge, new Graph.Edge({ source: 0, target: 2, data: 3 })) - - // Edges involving removed node should be gone - expect(Graph.getEdge(graph, 0)).toEqual(Option.none()) - expect(Graph.getEdge(graph, 1)).toEqual(Option.none()) - }) - - it("should handle transformation without filtering", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, 1) - Graph.addNode(mutable, 2) - Graph.addNode(mutable, 3) - - // Transform all nodes by doubling them - Graph.filterMapNodes(mutable, (data) => Option.some(data * 2)) - }) - - expect(Graph.nodeCount(graph)).toBe(3) - - const node0 = Graph.getNode(graph, 0) - const node1 = Graph.getNode(graph, 1) - const node2 = Graph.getNode(graph, 2) - - assertSome(node0, 2) - assertSome(node1, 4) - assertSome(node2, 6) - }) - - it("should handle filtering without transformation", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, 1) - Graph.addNode(mutable, 2) - Graph.addNode(mutable, 3) - Graph.addNode(mutable, 4) - - // Keep only even numbers - Graph.filterMapNodes(mutable, (data) => data % 2 === 0 ? Option.some(data) : Option.none()) - }) - - expect(Graph.nodeCount(graph)).toBe(2) - - const node1 = Graph.getNode(graph, 1) - const node3 = Graph.getNode(graph, 3) - - assertSome(node1, 2) - assertSome(node3, 4) - - // Odd numbers should be removed - assertNone(Graph.getNode(graph, 0)) - assertNone(Graph.getNode(graph, 2)) - }) - }) - - describe("filterMapEdges", () => { - it("should filter and transform edges", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 5) // Remove (< 10) - Graph.addEdge(mutable, b, c, 15) // Keep and double (30) - Graph.addEdge(mutable, c, a, 25) // Keep and double (50) - - // Keep only edges with weight >= 10 and double their weight - Graph.filterMapEdges(mutable, (data) => data >= 10 ? Option.some(data * 2) : Option.none()) - }) - - // Should have 2 edges remaining - expect(Graph.edgeCount(graph)).toBe(2) - expect(Graph.nodeCount(graph)).toBe(3) // All nodes should remain - - // Check that remaining edges have been transformed - const edge1 = Graph.getEdge(graph, 1) - const edge2 = Graph.getEdge(graph, 2) - - assertSome(edge1, new Graph.Edge({ source: 1, target: 2, data: 30 })) // 15 * 2 - assertSome(edge2, new Graph.Edge({ source: 2, target: 0, data: 50 })) // 25 * 2 - strictEqual(edge1.value instanceof Graph.Edge, true) - strictEqual(edge2.value instanceof Graph.Edge, true) - - // Filtered out edge should not exist - expect(Graph.getEdge(graph, 0)).toEqual(Option.none()) - - const expected = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const removed = Graph.addEdge(mutable, a, b, 5) - Graph.addEdge(mutable, b, c, 30) - Graph.addEdge(mutable, c, a, 50) - Graph.removeEdge(mutable, removed) - }) - - strictEqual(Equal.equals(graph, expected), true) - }) - - it("should update adjacency lists when removing edges", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - - Graph.addEdge(mutable, a, b, 1) // Keep - Graph.addEdge(mutable, a, c, 2) // Remove - Graph.addEdge(mutable, b, c, 3) // Keep - - // Keep only odd numbers - Graph.filterMapEdges(mutable, (data) => data % 2 === 1 ? Option.some(data) : Option.none()) - }) - - // Should have 2 edges remaining (1 and 3) - expect(Graph.edgeCount(graph)).toBe(2) - - // Check adjacency: A should only connect to B now - const neighborsA = Array.from(Graph.neighbors(graph, 0)) - expect(neighborsA).toEqual([1]) // A -> B only - - // Check that B still connects to C - const neighborsB = Array.from(Graph.neighbors(graph, 1)) - expect(neighborsB).toEqual([2]) // B -> C - - // Check that C has no outgoing edges - const neighborsC = Array.from(Graph.neighbors(graph, 2)) - expect(neighborsC).toEqual([]) // C has no outgoing edges - }) - - it("should handle transformation without filtering", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 10) - Graph.addEdge(mutable, b, c, 20) - Graph.addEdge(mutable, c, a, 30) - - // Transform all edges by adding 100 - Graph.filterMapEdges(mutable, (data) => Option.some(data + 100)) - }) - - expect(Graph.edgeCount(graph)).toBe(3) - - const edge0 = Graph.getEdge(graph, 0) - const edge1 = Graph.getEdge(graph, 1) - const edge2 = Graph.getEdge(graph, 2) - - expect(assertSomeEdge(edge0).data).toBe(110) - expect(assertSomeEdge(edge1).data).toBe(120) - expect(assertSomeEdge(edge2).data).toBe(130) - }) - - it("should handle filtering without transformation", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, { weight: 10, type: "primary" }) - Graph.addEdge(mutable, b, c, { weight: 20, type: "secondary" }) - Graph.addEdge(mutable, c, a, { weight: 30, type: "primary" }) - - // Keep only "primary" edges - Graph.filterMapEdges(mutable, (data) => data.type === "primary" ? Option.some(data) : Option.none()) - }) - - expect(Graph.edgeCount(graph)).toBe(2) - - const edge0 = Graph.getEdge(graph, 0) - const edge2 = Graph.getEdge(graph, 2) - - expect(assertSomeEdge(edge0).data.type).toBe("primary") - expect(assertSomeEdge(edge2).data.type).toBe("primary") - - // Secondary edge should be removed - expect(Graph.getEdge(graph, 1)).toEqual(Option.none()) - }) - }) - - describe("filterNodes", () => { - it("should filter nodes by predicate", () => { - let activeNode1: Graph.NodeIndex - let inactiveNode: Graph.NodeIndex - let activeNode2: Graph.NodeIndex - let pendingNode: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - activeNode1 = Graph.addNode(mutable, "active") - inactiveNode = Graph.addNode(mutable, "inactive") - activeNode2 = Graph.addNode(mutable, "active") - pendingNode = Graph.addNode(mutable, "pending") - - // Keep only "active" nodes - Graph.filterNodes(mutable, (data) => data === "active") - }) - - expect(Graph.nodeCount(graph)).toBe(2) - - const node0 = Graph.getNode(graph, activeNode1!) - const node2 = Graph.getNode(graph, activeNode2!) - - assertSome(node0, "active") - assertSome(node2, "active") - - // Filtered out nodes should be removed - assertNone(Graph.getNode(graph, inactiveNode!)) - assertNone(Graph.getNode(graph, pendingNode!)) - }) - - it("should remove connected edges when filtering nodes", () => { - let edgeAB: Graph.EdgeIndex - let edgeBC: Graph.EdgeIndex - let edgeAC: Graph.EdgeIndex - - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "keep") - const b = Graph.addNode(mutable, "remove") - const c = Graph.addNode(mutable, "keep") - - edgeAB = Graph.addEdge(mutable, a, b, "A-B") - edgeBC = Graph.addEdge(mutable, b, c, "B-C") - edgeAC = Graph.addEdge(mutable, a, c, "A-C") - - // Remove node "remove" - Graph.filterNodes(mutable, (data) => data === "keep") - }) - - expect(Graph.nodeCount(graph)).toBe(2) // Only "keep" nodes remain - expect(Graph.edgeCount(graph)).toBe(1) // Only A-C edge remains - - // Check remaining edge - const edge2 = Graph.getEdge(graph, edgeAC!) - assertSome(edge2, new Graph.Edge({ source: 0, target: 2, data: "A-C" })) - - // Check removed edges - expect(Graph.getEdge(graph, edgeAB!)).toEqual(Option.none()) // A-B removed - expect(Graph.getEdge(graph, edgeBC!)).toEqual(Option.none()) // B-C removed - }) - }) - - describe("filterEdges", () => { - it("should filter edges by predicate", () => { - let edgeAB: Graph.EdgeIndex - let edgeBC: Graph.EdgeIndex - let edgeCA: Graph.EdgeIndex - - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - - edgeAB = Graph.addEdge(mutable, a, b, 5) - edgeBC = Graph.addEdge(mutable, b, c, 15) - edgeCA = Graph.addEdge(mutable, c, a, 25) - - // Keep only edges with weight >= 10 - Graph.filterEdges(mutable, (data) => data >= 10) - }) - - expect(Graph.nodeCount(graph)).toBe(3) // All nodes remain - expect(Graph.edgeCount(graph)).toBe(2) // Edge with weight 5 removed - - const edge1 = Graph.getEdge(graph, edgeBC!) - const edge2 = Graph.getEdge(graph, edgeCA!) - - assertSome(edge1, new Graph.Edge({ source: 1, target: 2, data: 15 })) - assertSome(edge2, new Graph.Edge({ source: 2, target: 0, data: 25 })) - - // Edge with weight 5 should be removed - expect(Graph.getEdge(graph, edgeAB!)).toEqual(Option.none()) - }) - - it("should update adjacency lists when filtering edges", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - let nodeC: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "A") - nodeB = Graph.addNode(mutable, "B") - nodeC = Graph.addNode(mutable, "C") - - Graph.addEdge(mutable, nodeA, nodeB, "primary") - Graph.addEdge(mutable, nodeA, nodeC, "secondary") - Graph.addEdge(mutable, nodeB, nodeC, "primary") - - // Keep only "primary" edges - Graph.filterEdges(mutable, (data) => data === "primary") - }) - - expect(Graph.edgeCount(graph)).toBe(2) - - // Check adjacency - A should only connect to B now - const neighborsA = Array.from(Graph.neighbors(graph, nodeA!)) - expect(neighborsA).toEqual([nodeB!]) // A -> B only - - const neighborsB = Array.from(Graph.neighbors(graph, nodeB!)) - expect(neighborsB).toEqual([nodeC!]) // B -> C - - const neighborsC = Array.from(Graph.neighbors(graph, nodeC!)) - expect(neighborsC).toEqual([]) // C has no outgoing edges - }) - }) - - describe("addEdge", () => { - it("should add an edge between two existing nodes", () => { - let edgeIndex: Graph.EdgeIndex - - const result = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - edgeIndex = Graph.addEdge(mutable, nodeA, nodeB, 42) - }) - - expect(edgeIndex!).toBe(0) - expect(Graph.edgeCount(result)).toBe(1) - }) - - it("should add multiple edges with sequential indices", () => { - let edgeA: Graph.EdgeIndex - let edgeB: Graph.EdgeIndex - let edgeC: Graph.EdgeIndex - - const result = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - - edgeA = Graph.addEdge(mutable, nodeA, nodeB, 10) - edgeB = Graph.addEdge(mutable, nodeB, nodeC, 20) - edgeC = Graph.addEdge(mutable, nodeA, nodeC, 30) - }) - - expect(edgeA!).toBe(0) - expect(edgeB!).toBe(1) - expect(edgeC!).toBe(2) - expect(Graph.edgeCount(result)).toBe(3) - }) - - it("should throw error when source node doesn't exist", () => { - expect(() => { - Graph.directed((mutable) => { - const nodeB = Graph.addNode(mutable, "Node B") - const nonExistentNode = 999 - Graph.addEdge(mutable, nonExistentNode, nodeB, 42) - }) - }).toThrow("Node 999 does not exist") - }) - - it("should throw error when target node doesn't exist", () => { - expect(() => { - Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nonExistentNode = 999 - Graph.addEdge(mutable, nodeA, nonExistentNode, 42) - }) - }).toThrow("Node 999 does not exist") - }) - }) - - describe("removeNode", () => { - it("should remove a node and all its incident edges", () => { - const result = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - - Graph.addEdge(mutable, nodeA, nodeB, 10) - Graph.addEdge(mutable, nodeB, nodeC, 20) - Graph.addEdge(mutable, nodeC, nodeA, 30) - - expect(Graph.nodeCount(mutable)).toBe(3) - expect(Graph.edgeCount(mutable)).toBe(3) - - // Remove nodeB which has 2 incident edges - Graph.removeNode(mutable, nodeB) - - expect(Graph.nodeCount(mutable)).toBe(2) - expect(Graph.edgeCount(mutable)).toBe(1) // Only nodeC -> nodeA edge remains - }) - - expect(Graph.nodeCount(result)).toBe(2) - expect(Graph.edgeCount(result)).toBe(1) - }) - - it("should handle removing non-existent node gracefully", () => { - const result = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") // Just need one node for count - const nonExistentNode = 999 - - expect(Graph.nodeCount(mutable)).toBe(1) - Graph.removeNode(mutable, nonExistentNode) // Should not throw - expect(Graph.nodeCount(mutable)).toBe(1) // Should remain unchanged - }) - - expect(Graph.nodeCount(result)).toBe(1) - }) - - it("should handle isolated node removal", () => { - const result = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") // Keep for final count - const nodeB = Graph.addNode(mutable, "Node B") // Isolated node to remove - - expect(Graph.nodeCount(mutable)).toBe(2) - expect(Graph.edgeCount(mutable)).toBe(0) - - Graph.removeNode(mutable, nodeB) - - expect(Graph.nodeCount(mutable)).toBe(1) - expect(Graph.edgeCount(mutable)).toBe(0) - }) - - expect(Graph.nodeCount(result)).toBe(1) - }) - }) - - describe("removeEdge", () => { - it("should remove an edge between two nodes", () => { - let edgeIndex: Graph.EdgeIndex - - const result = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - edgeIndex = Graph.addEdge(mutable, nodeA, nodeB, 42) - - expect(Graph.edgeCount(mutable)).toBe(1) - - Graph.removeEdge(mutable, edgeIndex) - - expect(Graph.edgeCount(mutable)).toBe(0) - }) - - expect(Graph.edgeCount(result)).toBe(0) - }) - - it("should handle removing non-existent edge gracefully", () => { - const result = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - Graph.addEdge(mutable, nodeA, nodeB, 42) - - const nonExistentEdge = 999 - - expect(Graph.edgeCount(mutable)).toBe(1) - Graph.removeEdge(mutable, nonExistentEdge) // Should not throw - expect(Graph.edgeCount(mutable)).toBe(1) // Should remain unchanged - }) - - expect(Graph.edgeCount(result)).toBe(1) - }) - - it("should handle multiple edges between same nodes", () => { - const result = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - - const edge1 = Graph.addEdge(mutable, nodeA, nodeB, 10) - const edge2 = Graph.addEdge(mutable, nodeA, nodeB, 20) - - expect(Graph.edgeCount(mutable)).toBe(2) - - Graph.removeEdge(mutable, edge1) - - expect(Graph.edgeCount(mutable)).toBe(1) - - // Verify second edge still exists - assertSome(Graph.getEdge(mutable, edge2), new Graph.Edge({ source: nodeA, target: nodeB, data: 20 })) - }) - - expect(Graph.edgeCount(result)).toBe(1) - }) - }) - - describe("getEdge", () => { - it("should return edge data for existing edge", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - Graph.addEdge(mutable, nodeA, nodeB, 42) - }) - - const edgeIndex = 0 - const edge = Graph.getEdge(graph, edgeIndex) - - assertSome(edge, new Graph.Edge({ source: 0, target: 1, data: 42 })) - }) - - it("should return None for non-existent edge", () => { - const graph = Graph.directed() - const edgeIndex = 999 - const edge = Graph.getEdge(graph, edgeIndex) - - expect(edge).toEqual(Option.none()) - }) - - describe("hasEdge", () => { - it("should return true for existing edge", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - Graph.addEdge(mutable, nodeA, nodeB, 42) - }) - - const nodeA = 0 - const nodeB = 1 - - expect(Graph.hasEdge(graph, nodeA, nodeB)).toBe(true) - }) - - it("should return false for non-existent edge", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 42) - }) - - const nodeA = 0 - const nodeC = 2 - - expect(Graph.hasEdge(graph, nodeA, nodeC)).toBe(false) - }) - - it("should return false for non-existent source node", () => { - const graph = Graph.directed() - const nodeA = 0 - const nodeB = 1 - - expect(Graph.hasEdge(graph, nodeA, nodeB)).toBe(false) - }) - - it("should be symmetric for undirected graphs", () => { - const graph = Graph.undirected((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - Graph.addEdge(mutable, nodeA, nodeB, 42) - }) - - expect(Graph.hasEdge(graph, 0, 1)).toBe(true) - expect(Graph.hasEdge(graph, 1, 0)).toBe(true) - }) - }) - - describe("edgeCount", () => { - it("should return 0 for empty graph", () => { - const graph = Graph.directed() - expect(Graph.edgeCount(graph)).toBe(0) - }) - - it("should return correct edge count", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 1) - Graph.addEdge(mutable, nodeB, nodeC, 2) - Graph.addEdge(mutable, nodeC, nodeA, 3) - }) - - expect(Graph.edgeCount(graph)).toBe(3) - }) - }) - - describe("neighbors", () => { - it("should return correct neighbors for directed graph", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 1) - Graph.addEdge(mutable, nodeA, nodeC, 2) - }) - - const nodeA = 0 - const nodeB = 1 - const nodeC = 2 - - const neighborsA = Graph.neighbors(graph, nodeA) - expect(neighborsA).toContain(nodeB) - expect(neighborsA).toContain(nodeC) - expect(neighborsA).toHaveLength(2) - - const neighborsB = Graph.neighbors(graph, nodeB) - expect(neighborsB).toEqual([]) - }) - }) - - describe("neighbors with undirected graphs", () => { - it("should return correct neighbors for single edge", () => { - const graph = Graph.undirected((mutable) => { - Graph.addNode(mutable, 0) - Graph.addNode(mutable, 1) - Graph.addEdge(mutable, 0, 1, undefined) - }) - - expect(Graph.neighbors(graph, 0)).toEqual([1]) - expect(Graph.neighbors(graph, 1)).toEqual([0]) - }) - - it("should return correct neighbors for linear graph", () => { - const graph = Graph.undirected((mutable) => { - Graph.addNode(mutable, 0) - Graph.addNode(mutable, 1) - Graph.addNode(mutable, 2) - Graph.addEdge(mutable, 0, 1, undefined) - Graph.addEdge(mutable, 1, 2, undefined) - }) - - expect(Graph.neighbors(graph, 0)).toEqual([1]) - expect(Graph.neighbors(graph, 1).sort()).toEqual([0, 2]) - expect(Graph.neighbors(graph, 2)).toEqual([1]) - }) - - it("should handle multiple edges between same nodes", () => { - const graph = Graph.undirected((mutable) => { - Graph.addNode(mutable, 0) - Graph.addNode(mutable, 1) - Graph.addEdge(mutable, 0, 1, undefined) - Graph.addEdge(mutable, 0, 1, undefined) - }) - - // Should deduplicate neighbors - expect(Graph.neighbors(graph, 0)).toEqual([1]) - expect(Graph.neighbors(graph, 1)).toEqual([0]) - }) - - it("should handle self-loops", () => { - const graph = Graph.undirected((mutable) => { - Graph.addNode(mutable, 0) - Graph.addEdge(mutable, 0, 0, undefined) - }) - - expect(Graph.neighbors(graph, 0)).toEqual([0]) - }) - - it("should handle node with no neighbors", () => { - const graph = Graph.undirected((mutable) => { - Graph.addNode(mutable, 0) - Graph.addNode(mutable, 1) - }) - - expect(Graph.neighbors(graph, 0)).toEqual([]) - expect(Graph.neighbors(graph, 1)).toEqual([]) - }) - }) - - describe("successors and predecessors", () => { - it("should return outgoing and incoming directed neighbors", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 1) - Graph.addEdge(mutable, nodeC, nodeB, 2) - }) - - expect(Graph.successors(graph, 0)).toEqual([1]) - expect(Graph.predecessors(graph, 1).sort()).toEqual([0, 2]) - }) - - it("should throw for undirected graphs", () => { - const graph = Graph.undirected((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - Graph.addEdge(mutable, nodeA, nodeB, 1) - }) - - expect(() => Graph.successors(graph as any, 0)).toThrow("Cannot get successors of undirected graph") - expect(() => Graph.predecessors(graph as any, 0)).toThrow("Cannot get predecessors of undirected graph") - }) - }) - - describe("neighborsDirected", () => { - it("should return incoming neighbors", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - let nodeC: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "Node A") - nodeB = Graph.addNode(mutable, "Node B") - nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 1) - Graph.addEdge(mutable, nodeC, nodeB, 2) - }) - - const incomingB = Graph.neighborsDirected(graph, nodeB!, "incoming") - expect(incomingB.sort()).toEqual([nodeA!, nodeC!].sort()) - - const incomingA = Graph.neighborsDirected(graph, nodeA!, "incoming") - expect(incomingA).toEqual([]) - }) - - it("should return outgoing neighbors", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - let nodeC: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "Node A") - nodeB = Graph.addNode(mutable, "Node B") - nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 1) - Graph.addEdge(mutable, nodeA, nodeC, 2) - }) - - const outgoingA = Graph.neighborsDirected(graph, nodeA!, "outgoing") - expect(outgoingA.sort()).toEqual([nodeB!, nodeC!].sort()) - - const outgoingB = Graph.neighborsDirected(graph, nodeB!, "outgoing") - expect(outgoingB).toEqual([]) - }) - - it("should handle node with no connections", () => { - let nodeA: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "Node A") - }) - - expect(Graph.neighborsDirected(graph, nodeA!, "incoming")).toEqual([]) - expect(Graph.neighborsDirected(graph, nodeA!, "outgoing")).toEqual([]) - }) - - it("should throw for undirected graphs", () => { - const graph = Graph.undirected((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - Graph.addEdge(mutable, nodeA, nodeB, 1) - }) - - expect(() => Graph.neighborsDirected(graph as any, 0, "outgoing")) - .toThrow("Cannot get directed neighbors of undirected graph") - }) - }) - }) - - describe("toGraphViz", () => { - it("should export empty directed graph", () => { - const graph = Graph.directed() - const dot = Graph.toGraphViz(graph) - - expect(dot).toBe("digraph \"G\" {\n}") - }) - - it("should export empty undirected graph", () => { - const graph = Graph.undirected() - const dot = Graph.toGraphViz(graph) - - expect(dot).toBe("graph \"G\" {\n}") - }) - - it("should export directed graph with nodes and edges", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 1) - Graph.addEdge(mutable, nodeB, nodeC, 2) - Graph.addEdge(mutable, nodeC, nodeA, 3) - }) - - const dot = Graph.toGraphViz(graph) - - expect(dot).toContain("digraph \"G\" {") - expect(dot).toContain("\"0\" [label=\"Node A\"];") - expect(dot).toContain("\"1\" [label=\"Node B\"];") - expect(dot).toContain("\"2\" [label=\"Node C\"];") - expect(dot).toContain("\"0\" -> \"1\" [label=\"1\"];") - expect(dot).toContain("\"1\" -> \"2\" [label=\"2\"];") - expect(dot).toContain("\"2\" -> \"0\" [label=\"3\"];") - expect(dot).toContain("}") - }) - - it("should export undirected graph with correct edge format", () => { - const graph = Graph.undirected((mutable) => { - const nodeA = Graph.addNode(mutable, "A") - const nodeB = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, nodeA, nodeB, 1) - }) - - const dot = Graph.toGraphViz(graph) - - expect(dot).toContain("graph \"G\" {") - expect(dot).toContain("\"0\" -- \"1\" [label=\"1\"];") - }) - - it("should support custom node and edge labels", () => { - const graph = Graph.directed<{ name: string }, { weight: number }>((mutable) => { - const nodeA = Graph.addNode(mutable, { name: "Alice" }) - const nodeB = Graph.addNode(mutable, { name: "Bob" }) - Graph.addEdge(mutable, nodeA, nodeB, { weight: 42 }) - }) - - const dot = Graph.toGraphViz(graph, { - nodeLabel: (data) => data.name, - edgeLabel: (data) => `weight: ${data.weight}`, - graphName: "MyGraph" - }) - - expect(dot).toContain("digraph \"MyGraph\" {") - expect(dot).toContain("\"0\" [label=\"Alice\"];") - expect(dot).toContain("\"1\" [label=\"Bob\"];") - expect(dot).toContain("\"0\" -> \"1\" [label=\"weight: 42\"];") - }) - - it("should escape quotes in labels", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node \"A\"") - const nodeB = Graph.addNode(mutable, "Node \"B\"") - Graph.addEdge(mutable, nodeA, nodeB, "Edge \"1\"") - }) - - const dot = Graph.toGraphViz(graph) - - expect(dot).toContain("\"0\" [label=\"Node \\\"A\\\"\"];") - expect(dot).toContain("\"1\" [label=\"Node \\\"B\\\"\"];") - expect(dot).toContain("\"0\" -> \"1\" [label=\"Edge \\\"1\\\"\"];") - }) - - it("should quote graph names", () => { - const graph = Graph.directed() - - strictEqual(Graph.toGraphViz(graph, { graphName: "MyGraph" }), "digraph \"MyGraph\" {\n}") - strictEqual(Graph.toGraphViz(graph, { graphName: "My Graph" }), "digraph \"My Graph\" {\n}") - strictEqual(Graph.toGraphViz(graph, { graphName: "" }), "digraph \"\" {\n}") - strictEqual(Graph.toGraphViz(graph, { graphName: "graph" }), "digraph \"graph\" {\n}") - strictEqual(Graph.toGraphViz(graph, { graphName: "Node" }), "digraph \"Node\" {\n}") - strictEqual(Graph.toGraphViz(graph, { graphName: "My \"Graph\"" }), "digraph \"My \\\"Graph\\\"\" {\n}") - }) - - it("should escape labels as literal text", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "C:\\new\\path") - const nodeB = Graph.addNode(mutable, "Line 1\nLine 2") - Graph.addEdge(mutable, nodeA, nodeB, "edge\\label\nnext") - }) - - const dot = Graph.toGraphViz(graph) - - strictEqual( - dot, - [ - "digraph \"G\" {", - " \"0\" [label=\"C:\\\\new\\\\path\"];", - " \"1\" [label=\"Line 1\\nLine 2\"];", - " \"0\" -> \"1\" [label=\"edge\\\\label\\nnext\"];", - "}" - ].join("\n") - ) - }) - - it("should demonstrate graph visualization", () => { - // Create a simple directed graph representing a dependency graph - const graph = Graph.directed((mutable) => { - const app = Graph.addNode(mutable, "App") - const auth = Graph.addNode(mutable, "Auth") - const db = Graph.addNode(mutable, "Database") - const cache = Graph.addNode(mutable, "Cache") - - Graph.addEdge(mutable, app, auth, "uses") - Graph.addEdge(mutable, app, db, "stores") - Graph.addEdge(mutable, auth, db, "validates") - Graph.addEdge(mutable, app, cache, "caches") - }) - - const dot = Graph.toGraphViz(graph, { - graphName: "DependencyGraph" - }) - - // Uncomment the next line to see the GraphViz output in test console - // console.log("\nDependency Graph DOT format:\n" + dot) - - expect(dot).toContain("digraph \"DependencyGraph\" {") - expect(dot).toContain("\"0\" [label=\"App\"];") - expect(dot).toContain("\"0\" -> \"1\" [label=\"uses\"];") - expect(dot).toContain("\"0\" -> \"2\" [label=\"stores\"];") - expect(dot).toContain("\"1\" -> \"2\" [label=\"validates\"];") - expect(dot).toContain("\"0\" -> \"3\" [label=\"caches\"];") - }) - - it("should demonstrate undirected graph visualization", () => { - // Create a simple social network graph - const graph = Graph.undirected((mutable) => { - const alice = Graph.addNode(mutable, "Alice") - const bob = Graph.addNode(mutable, "Bob") - const charlie = Graph.addNode(mutable, "Charlie") - const diana = Graph.addNode(mutable, "Diana") - - Graph.addEdge(mutable, alice, bob, "friends") - Graph.addEdge(mutable, bob, charlie, "friends") - Graph.addEdge(mutable, charlie, diana, "friends") - Graph.addEdge(mutable, alice, diana, "friends") - }) - - const dot = Graph.toGraphViz(graph, { - graphName: "SocialNetwork" - }) - - // Uncomment the next line to see the GraphViz output in test console - // console.log("\nSocial Network DOT format:\n" + dot) - - expect(dot).toContain("graph \"SocialNetwork\" {") - expect(dot).toContain("\"0\" [label=\"Alice\"];") - expect(dot).toContain("\"0\" -- \"1\" [label=\"friends\"];") - expect(dot).toContain("\"1\" -- \"2\" [label=\"friends\"];") - expect(dot).toContain("\"2\" -- \"3\" [label=\"friends\"];") - expect(dot).toContain("\"0\" -- \"3\" [label=\"friends\"];") - }) - }) - - describe("toMermaid", () => { - it("should export empty directed graph", () => { - const graph = Graph.directed() - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toBe("flowchart TD") - }) - - it("should export empty undirected graph", () => { - const graph = Graph.undirected() - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toBe("graph TD") - }) - - it("should export directed graph with nodes", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node A") - Graph.addNode(mutable, "Node B") - Graph.addNode(mutable, "Node C") - }) - - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toContain("flowchart TD") - expect(mermaid).toContain("0[\"Node A\"]") - expect(mermaid).toContain("1[\"Node B\"]") - expect(mermaid).toContain("2[\"Node C\"]") - }) - - it("should export undirected graph with nodes", () => { - const graph = Graph.undirected((mutable) => { - Graph.addNode(mutable, "Alice") - Graph.addNode(mutable, "Bob") - }) - - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toContain("graph TD") - expect(mermaid).toContain("0[\"Alice\"]") - expect(mermaid).toContain("1[\"Bob\"]") - }) - - it("should support all node shapes", () => { - const shapes: Array<[string, any]> = [ - ["rectangle", "rectangle"], - ["rounded", "rounded"], - ["circle", "circle"], - ["diamond", "diamond"], - ["hexagon", "hexagon"], - ["stadium", "stadium"], - ["subroutine", "subroutine"], - ["cylindrical", "cylindrical"] - ] - - shapes.forEach(([shapeName, shapeValue]) => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Test") - }) - - const mermaid = Graph.toMermaid(graph, { - nodeShape: () => shapeValue - }) - - expect(mermaid).toContain("flowchart TD") - - // Test expected shape format - switch (shapeName) { - case "rectangle": - expect(mermaid).toContain("0[\"Test\"]") - break - case "rounded": - expect(mermaid).toContain("0(\"Test\")") - break - case "circle": - expect(mermaid).toContain("0((\"Test\"))") - break - case "diamond": - expect(mermaid).toContain("0{\"Test\"}") - break - case "hexagon": - expect(mermaid).toContain("0{{\"Test\"}}") - break - case "stadium": - expect(mermaid).toContain("0([\"Test\"])") - break - case "subroutine": - expect(mermaid).toContain("0[[\"Test\"]]") - break - case "cylindrical": - expect(mermaid).toContain("0[(\"Test\")]") - break - } - }) - }) - - it("should escape special characters in labels", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Node with \"quotes\"") - Graph.addNode(mutable, "Node with [brackets]") - Graph.addNode(mutable, "Node with | pipe") - Graph.addNode(mutable, "Node with \\ backslash") - Graph.addNode(mutable, "Node with \n newline") - }) - - const mermaid = Graph.toMermaid(graph) - - expect(mermaid).toContain("0[\"Node with #quot;quotes#quot;\"]") - expect(mermaid).toContain("1[\"Node with #91;brackets#93;\"]") - expect(mermaid).toContain("2[\"Node with #124; pipe\"]") - expect(mermaid).toContain("3[\"Node with #92; backslash\"]") - expect(mermaid).toContain("4[\"Node with
newline\"]") - }) - - it("should export directed graph with edges", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "Node A") - const nodeB = Graph.addNode(mutable, "Node B") - const nodeC = Graph.addNode(mutable, "Node C") - Graph.addEdge(mutable, nodeA, nodeB, 1) - Graph.addEdge(mutable, nodeB, nodeC, 2) - Graph.addEdge(mutable, nodeC, nodeA, 3) - }) - - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toContain("flowchart TD") - expect(mermaid).toContain("0[\"Node A\"]") - expect(mermaid).toContain("1[\"Node B\"]") - expect(mermaid).toContain("2[\"Node C\"]") - expect(mermaid).toContain("0 -->|\"1\"| 1") - expect(mermaid).toContain("1 -->|\"2\"| 2") - expect(mermaid).toContain("2 -->|\"3\"| 0") - }) - - it("should export undirected graph with edges", () => { - const graph = Graph.undirected((mutable) => { - const alice = Graph.addNode(mutable, "Alice") - const bob = Graph.addNode(mutable, "Bob") - const charlie = Graph.addNode(mutable, "Charlie") - Graph.addEdge(mutable, alice, bob, "friends") - Graph.addEdge(mutable, bob, charlie, "colleagues") - }) - - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toContain("graph TD") - expect(mermaid).toContain("0[\"Alice\"]") - expect(mermaid).toContain("1[\"Bob\"]") - expect(mermaid).toContain("2[\"Charlie\"]") - expect(mermaid).toContain("0 ---|\"friends\"| 1") - expect(mermaid).toContain("1 ---|\"colleagues\"| 2") - }) - - it("should handle empty edge labels", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "A") - const nodeB = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, nodeA, nodeB, "") - }) - - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toContain("0 --> 1") - }) - - it("should support all diagram directions", () => { - const directions = ["TB", "TD", "BT", "RL", "LR"] as const - - directions.forEach((dir) => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - Graph.addNode(mutable, "B") - }) - - const mermaid = Graph.toMermaid(graph, { direction: dir }) - expect(mermaid).toContain(`flowchart ${dir}`) - expect(mermaid).toContain("0[\"A\"]") - expect(mermaid).toContain("1[\"B\"]") - }) - }) - - it("should auto-detect diagram type based on graph type", () => { - // Directed graph should auto-detect as flowchart - const directedGraph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - }) - const directedMermaid = Graph.toMermaid(directedGraph) - expect(directedMermaid).toContain("flowchart TD") - - // Undirected graph should auto-detect as graph - const undirectedGraph = Graph.undirected((mutable) => { - Graph.addNode(mutable, "A") - }) - const undirectedMermaid = Graph.toMermaid(undirectedGraph) - expect(undirectedMermaid).toContain("graph TD") - }) - - it("should allow manual diagram type override", () => { - // Override directed graph to use 'graph' type - const directedGraph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - }) - const overriddenMermaid = Graph.toMermaid(directedGraph, { - diagramType: "graph" - }) - expect(overriddenMermaid).toContain("graph TD") - - // Override undirected graph to use 'flowchart' type - const undirectedGraph = Graph.undirected((mutable) => { - Graph.addNode(mutable, "B") - }) - const overriddenFlowchart = Graph.toMermaid(undirectedGraph, { - diagramType: "flowchart" - }) - expect(overriddenFlowchart).toContain("flowchart TD") - }) - - it("should combine direction and diagram type options", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "Test") - }) - - const mermaid = Graph.toMermaid(graph, { - direction: "LR", - diagramType: "graph" - }) - - expect(mermaid).toContain("graph LR") - expect(mermaid).toContain("0[\"Test\"]") - }) - - it("should handle self-loops correctly", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "A") - Graph.addEdge(mutable, nodeA, nodeA, "self") - }) - - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toContain("flowchart TD") - expect(mermaid).toContain("0[\"A\"]") - expect(mermaid).toContain("0 -->|\"self\"| 0") - }) - - it("should handle multi-edges correctly", () => { - const graph = Graph.directed((mutable) => { - const nodeA = Graph.addNode(mutable, "A") - const nodeB = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, nodeA, nodeB, 1) - Graph.addEdge(mutable, nodeA, nodeB, 2) - Graph.addEdge(mutable, nodeA, nodeB, 3) - }) - - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toContain("flowchart TD") - expect(mermaid).toContain("0[\"A\"]") - expect(mermaid).toContain("1[\"B\"]") - // Should contain all three edges - expect(mermaid).toContain("0 -->|\"1\"| 1") - expect(mermaid).toContain("0 -->|\"2\"| 1") - expect(mermaid).toContain("0 -->|\"3\"| 1") - }) - - it("should handle disconnected components", () => { - const graph = Graph.directed((mutable) => { - // Component 1: A -> B - const nodeA = Graph.addNode(mutable, "A") - const nodeB = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, nodeA, nodeB, "A->B") - - // Component 2: C -> D (disconnected) - const nodeC = Graph.addNode(mutable, "C") - const nodeD = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, nodeC, nodeD, "C->D") - - // Isolated node E - Graph.addNode(mutable, "E") - }) - - const mermaid = Graph.toMermaid(graph) - expect(mermaid).toContain("flowchart TD") - expect(mermaid).toContain("0[\"A\"]") - expect(mermaid).toContain("1[\"B\"]") - expect(mermaid).toContain("2[\"C\"]") - expect(mermaid).toContain("3[\"D\"]") - expect(mermaid).toContain("4[\"E\"]") - expect(mermaid).toContain("0 -->|\"A-#gt;B\"| 1") - expect(mermaid).toContain("2 -->|\"C-#gt;D\"| 3") - }) - - it("should handle custom labels with complex data", () => { - interface NodeData { - id: string - value: number - metadata: { type: string } - } - - interface EdgeData { - weight: number - type: string - } - - const graph = Graph.directed((mutable) => { - const node1 = Graph.addNode(mutable, { - id: "node1", - value: 42, - metadata: { type: "input" } - }) - const node2 = Graph.addNode(mutable, { - id: "node2", - value: 84, - metadata: { type: "processing" } - }) - Graph.addEdge(mutable, node1, node2, { weight: 1.5, type: "data" }) - }) - - const mermaid = Graph.toMermaid(graph, { - nodeLabel: (data) => `${data.id}:${data.value}`, - edgeLabel: (data) => `${data.type}(${data.weight})`, - direction: "LR" - }) - - expect(mermaid).toContain("flowchart LR") - expect(mermaid).toContain("0[\"node1:42\"]") - expect(mermaid).toContain("1[\"node2:84\"]") - expect(mermaid).toContain("0 -->|\"data#40;1.5#41;\"| 1") - }) - }) - - describe("isAcyclic", () => { - it("should detect acyclic directed graphs (DAGs)", () => { - const dag = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, "A->B") - Graph.addEdge(mutable, a, c, "A->C") - Graph.addEdge(mutable, b, d, "B->D") - Graph.addEdge(mutable, c, d, "C->D") - }) - - expect(Graph.isAcyclic(dag)).toBe(true) - }) - - it("should detect cycles in directed graphs", () => { - const cyclic = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, "A->B") - Graph.addEdge(mutable, b, c, "B->C") - Graph.addEdge(mutable, c, a, "C->A") // Creates cycle - }) - - expect(Graph.isAcyclic(cyclic)).toBe(false) - }) - - it("should handle disconnected components", () => { - const disconnected = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, "A->B") // Component 1: A->B (acyclic) - Graph.addEdge(mutable, c, d, "C->D") // Component 2: C->D (acyclic) - // No connections between components - }) - - expect(Graph.isAcyclic(disconnected)).toBe(true) - }) - - it("should detect cycles in one component of disconnected graph", () => { - const mixedComponents = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, "A->B") // Component 1: A->B (acyclic) - Graph.addEdge(mutable, c, d, "C->D") // Component 2: C->D->C (cyclic) - Graph.addEdge(mutable, d, c, "D->C") - }) - - expect(Graph.isAcyclic(mixedComponents)).toBe(false) - }) - - it("should treat a reversed-storage undirected chain as acyclic", () => { - const graph = makeReversedUndirectedPath() - - expect(Graph.isAcyclic(graph)).toBe(true) - }) - - it("should treat a single undirected edge as acyclic", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - }) - - strictEqual(Graph.isAcyclic(graph), true) - }) - - it("should detect parallel undirected edges as a cycle", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, a, b, 2) - }) - - strictEqual(Graph.isAcyclic(graph), false) - }) - - it("should detect undirected self-loops as cycles", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - Graph.addEdge(mutable, a, a, 1) - }) - - strictEqual(Graph.isAcyclic(graph), false) - }) - - it("should detect cycles in undirected graphs", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 1) - Graph.addEdge(mutable, c, a, 1) - }) - - expect(Graph.isAcyclic(graph)).toBe(false) - }) - }) - - describe("isBipartite", () => { - it("should detect bipartite undirected graphs", () => { - const bipartite = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, "edge") // Set 1: {A, C}, Set 2: {B, D} - Graph.addEdge(mutable, b, c, "edge") - Graph.addEdge(mutable, c, d, "edge") - Graph.addEdge(mutable, d, a, "edge") - }) - - expect(Graph.isBipartite(bipartite)).toBe(true) - }) - - it("should detect non-bipartite graphs (odd cycles)", () => { - const triangle = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, "edge") - Graph.addEdge(mutable, b, c, "edge") - Graph.addEdge(mutable, c, a, "edge") // Triangle (3-cycle) - }) - - expect(Graph.isBipartite(triangle)).toBe(false) - }) - - it("should handle path graphs (always bipartite)", () => { - const path = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, "edge") - Graph.addEdge(mutable, b, c, "edge") - Graph.addEdge(mutable, c, d, "edge") - }) - - expect(Graph.isBipartite(path)).toBe(true) - }) - - it("should handle disconnected components", () => { - const disconnected = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, "edge") // Component 1: A-B (bipartite) - Graph.addEdge(mutable, c, d, "edge") // Component 2: C-D (bipartite) - // No connections between components - }) - - expect(Graph.isBipartite(disconnected)).toBe(true) - }) - - it("should detect non-bipartite component in disconnected graph", () => { - const mixedComponents = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - const e = Graph.addNode(mutable, "E") - Graph.addEdge(mutable, a, b, "edge") // Component 1: A-B (bipartite) - Graph.addEdge(mutable, c, d, "edge") // Component 2: triangle (non-bipartite) - Graph.addEdge(mutable, d, e, "edge") - Graph.addEdge(mutable, e, c, "edge") - }) - - expect(Graph.isBipartite(mixedComponents)).toBe(false) - }) - }) - - describe("connectedComponents", () => { - it("should find connected components in disconnected undirected graph", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addNode(mutable, "E") - Graph.addEdge(mutable, a, b, "edge") // Component 1: A-B - Graph.addEdge(mutable, c, d, "edge") // Component 2: C-D - // E is isolated - Component 3: E - }) - - const components = Graph.connectedComponents(graph) - expect(components).toHaveLength(3) - - // Sort components by size and first element for deterministic testing - components.sort((a, b) => a.length - b.length || a[0] - b[0]) - expect(components[0]).toEqual([4]) // E isolated - expect(components[1]).toHaveLength(2) // A-B or C-D - expect(components[2]).toHaveLength(2) // A-B or C-D - }) - - it("should handle fully connected component", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, "edge") - Graph.addEdge(mutable, b, c, "edge") - Graph.addEdge(mutable, c, a, "edge") - }) - - const components = Graph.connectedComponents(graph) - expect(components).toHaveLength(1) - expect(components[0]).toHaveLength(3) - expect(components[0].sort()).toEqual([0, 1, 2]) - }) - }) - - describe("stronglyConnectedComponents", () => { - it("should find strongly connected components in directed graph", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, "A->B") - Graph.addEdge(mutable, b, c, "B->C") - Graph.addEdge(mutable, c, a, "C->A") // SCC: A-B-C - Graph.addEdge(mutable, b, d, "B->D") // D is separate - }) - - const sccs = Graph.stronglyConnectedComponents(graph) - expect(sccs).toHaveLength(2) - - // Sort SCCs by size for deterministic testing - sccs.sort((a, b) => a.length - b.length) - expect(sccs[0]).toEqual([3]) // D is alone - expect(sccs[1]).toHaveLength(3) // A-B-C cycle - expect(sccs[1].sort()).toEqual([0, 1, 2]) - }) - - it("should handle acyclic directed graph (each node is its own SCC)", () => { - const dag = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, "A->B") - Graph.addEdge(mutable, b, c, "B->C") - }) - - const sccs = Graph.stronglyConnectedComponents(dag) - expect(sccs).toHaveLength(3) - // Each SCC should contain exactly one node - sccs.forEach((scc) => { - expect(scc).toHaveLength(1) - }) - }) - - it("should handle fully connected components", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - // Create bidirectional edges (fully connected) - Graph.addEdge(mutable, a, b, "A->B") - Graph.addEdge(mutable, b, a, "B->A") - Graph.addEdge(mutable, b, c, "B->C") - Graph.addEdge(mutable, c, b, "C->B") - Graph.addEdge(mutable, a, c, "A->C") - Graph.addEdge(mutable, c, a, "C->A") - }) - - const sccs = Graph.stronglyConnectedComponents(graph) - expect(sccs).toHaveLength(1) - expect(sccs[0]).toHaveLength(3) - expect(sccs[0].sort()).toEqual([0, 1, 2]) - }) - - it("should handle disconnected components with cycles", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - // First SCC: A->B->A - Graph.addEdge(mutable, a, b, "A->B") - Graph.addEdge(mutable, b, a, "B->A") - // Second SCC: C->D->C - Graph.addEdge(mutable, c, d, "C->D") - Graph.addEdge(mutable, d, c, "D->C") - }) - - const sccs = Graph.stronglyConnectedComponents(graph) - expect(sccs).toHaveLength(2) - sccs.forEach((scc) => { - expect(scc).toHaveLength(2) - }) - }) - - it("should throw for undirected graphs", () => { - const graph = makeReversedUndirectedPath() - - expect(() => Graph.stronglyConnectedComponents(graph as any)) - .toThrow("Cannot find strongly connected components of undirected graph") - }) - }) - - describe("dijkstra", () => { - it("should find shortest path in simple graph", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - let nodeC: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "A") - nodeB = Graph.addNode(mutable, "B") - nodeC = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, nodeA, nodeB, 5) - Graph.addEdge(mutable, nodeA, nodeC, 10) - Graph.addEdge(mutable, nodeB, nodeC, 2) - }) - - const result = Graph.dijkstra(graph, { - source: nodeA!, - target: nodeC!, - cost: (edge) => edge - }) - - assertSome(result, { path: [nodeA!, nodeB!, nodeC!], distance: 7, costs: [5, 2] }) - }) - - it("should preserve insertion order for equal priorities", () => { - const graph = Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "source") - const first = Graph.addNode(mutable, "first") - const second = Graph.addNode(mutable, "second") - const target = Graph.addNode(mutable, "target") - Graph.addEdge(mutable, source, first, 1) - Graph.addEdge(mutable, source, second, 1) - Graph.addEdge(mutable, first, target, 1) - Graph.addEdge(mutable, second, target, 1) - }) - - const result = Graph.dijkstra(graph, { - source: 0, - target: 3, - cost: (edge) => edge - }) - - assertSome(result, { path: [0, 1, 3], distance: 2, costs: [1, 1] }) - }) - - it("should skip stale priority queue entries", () => { - const graph = Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "source") - const improved = Graph.addNode(mutable, "improved") - const shortcut = Graph.addNode(mutable, "shortcut") - const middle = Graph.addNode(mutable, "middle") - const target = Graph.addNode(mutable, "target") - Graph.addEdge(mutable, source, improved, 10) - Graph.addEdge(mutable, source, shortcut, 1) - Graph.addEdge(mutable, shortcut, improved, 1) - Graph.addEdge(mutable, improved, middle, 20) - Graph.addEdge(mutable, middle, target, 20) - }) - - const result = Graph.dijkstra(graph, { - source: 0, - target: 4, - cost: (edge) => edge - }) - - assertSome(result, { path: [0, 2, 1, 3, 4], distance: 42, costs: [1, 1, 20, 20] }) - }) - - it("should return None for unreachable nodes", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - let nodeC: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "A") - nodeB = Graph.addNode(mutable, "B") - nodeC = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, nodeA, nodeB, 1) - // No path from A to C - }) - - const result = Graph.dijkstra(graph, { - source: nodeA!, - target: nodeC!, - cost: (edge) => edge - }) - - expect(result).toEqual(Option.none()) - }) - - it("should handle same source and target", () => { - let nodeA: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "A") - }) - - const result = Graph.dijkstra(graph, { - source: nodeA!, - target: nodeA!, - cost: (edge) => edge - }) - - assertSome(result, { path: [nodeA!], distance: 0, costs: [] }) - }) - - it("should throw for negative weights", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - - const graph = Graph.directed((mutable) => { - nodeA = Graph.addNode(mutable, "A") - nodeB = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, nodeA, nodeB, -1) - }) - - expect(() => - Graph.dijkstra(graph, { - source: nodeA!, - target: nodeB!, - cost: (edge) => edge - }) - ).toThrow( - "Dijkstra's algorithm requires non-negative edge weights" - ) - }) - - it("should throw for NaN and negative infinity weights", () => { - for (const weight of unsupportedEdgeWeights) { - const graph = makeSingleEdgeGraph(weight) - - assertGraphError( - () => - Graph.dijkstra(graph, { - source: 0, - target: 1, - cost: (edge) => edge - }), - "Dijkstra's algorithm requires non-negative edge weights" - ) - } - }) - - it("should treat infinity weights as unreachable", () => { - const graph = makeSingleEdgeGraph(Infinity) - - const result = Graph.dijkstra(graph, { - source: 0, - target: 1, - cost: (edge) => edge - }) - - assertNone(result) - }) - - it("should throw for negative weights before early target termination", () => { - const graph = Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "source") - const target = Graph.addNode(mutable, "target") - const other = Graph.addNode(mutable, "other") - Graph.addEdge(mutable, source, target, 1) - Graph.addEdge(mutable, source, other, 2) - Graph.addEdge(mutable, other, target, -5) - }) - - expect(() => - Graph.dijkstra(graph, { - source: 0, - target: 1, - cost: (edge) => edge - }) - ).toThrow("Dijkstra's algorithm requires non-negative edge weights") - }) - - it("should validate weights before returning same source and target", () => { - const graph = Graph.directed((mutable) => { - const node = Graph.addNode(mutable, "node") - Graph.addEdge(mutable, node, node, -1) - }) - - expect(() => - Graph.dijkstra(graph, { - source: 0, - target: 0, - cost: (edge) => edge - }) - ).toThrow("Dijkstra's algorithm requires non-negative edge weights") - }) - - it("should throw for non-existent nodes", () => { - const graph = Graph.directed() - - expect(() => - Graph.dijkstra(graph, { - source: 0, - target: 1, - cost: (edge) => edge - }) - ).toThrow("Node 0 does not exist") - }) - - it("should traverse undirected edges in reverse storage direction", () => { - const graph = makeReversedUndirectedPath() - - const result = Graph.dijkstra(graph, { - source: 0, - target: 2, - cost: (edge) => edge - }) - - assertSome(result, { path: [0, 1, 2], distance: 2, costs: [1, 1] }) - }) - }) - - describe("astar", () => { - it("should find shortest path with heuristic", () => { - let nodeA: Graph.NodeIndex - let nodeB: Graph.NodeIndex - let nodeC: Graph.NodeIndex - - const graph = Graph.directed<{ x: number; y: number }, number>((mutable) => { - nodeA = Graph.addNode(mutable, { x: 0, y: 0 }) - nodeB = Graph.addNode(mutable, { x: 1, y: 0 }) - nodeC = Graph.addNode(mutable, { x: 2, y: 0 }) - Graph.addEdge(mutable, nodeA, nodeB, 1) - Graph.addEdge(mutable, nodeB, nodeC, 1) - }) - - const heuristic = (source: { x: number; y: number }, target: { x: number; y: number }) => - Math.abs(source.x - target.x) + Math.abs(source.y - target.y) - - const result = Graph.astar(graph, { - source: nodeA!, - target: nodeC!, - cost: (edge) => edge, - heuristic - }) - - assertSome(result, { path: [nodeA!, nodeB!, nodeC!], distance: 2, costs: [1, 1] }) - }) - - it("should preserve insertion order for equal priorities", () => { - const graph = Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "source") - const first = Graph.addNode(mutable, "first") - const second = Graph.addNode(mutable, "second") - const target = Graph.addNode(mutable, "target") - Graph.addEdge(mutable, source, first, 1) - Graph.addEdge(mutable, source, second, 1) - Graph.addEdge(mutable, first, target, 1) - Graph.addEdge(mutable, second, target, 1) - }) - - const result = Graph.astar(graph, { - source: 0, - target: 3, - cost: (edge) => edge, - heuristic: () => 0 - }) - - assertSome(result, { path: [0, 1, 3], distance: 2, costs: [1, 1] }) - }) - - it("should skip stale open set entries", () => { - const graph = Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "source") - const improved = Graph.addNode(mutable, "improved") - const shortcut = Graph.addNode(mutable, "shortcut") - const middle = Graph.addNode(mutable, "middle") - const target = Graph.addNode(mutable, "target") - Graph.addEdge(mutable, source, improved, 10) - Graph.addEdge(mutable, source, shortcut, 1) - Graph.addEdge(mutable, shortcut, improved, 1) - Graph.addEdge(mutable, improved, middle, 20) - Graph.addEdge(mutable, middle, target, 20) - }) - - const result = Graph.astar(graph, { - source: 0, - target: 4, - cost: (edge) => edge, - heuristic: () => 0 - }) - - assertSome(result, { path: [0, 2, 1, 3, 4], distance: 42, costs: [1, 1, 20, 20] }) - }) - - it("should return None for unreachable nodes", () => { - const graph = Graph.directed<{ x: number; y: number }, number>((mutable) => { - const a = Graph.addNode(mutable, { x: 0, y: 0 }) - const b = Graph.addNode(mutable, { x: 1, y: 0 }) - Graph.addNode(mutable, { x: 2, y: 0 }) - Graph.addEdge(mutable, a, b, 1) - // No path from A to C - }) - - const heuristic = (source: { x: number; y: number }, target: { x: number; y: number }) => - Math.abs(source.x - target.x) + Math.abs(source.y - target.y) - - const result = Graph.astar(graph, { - source: 0, - target: 2, - cost: (edge) => edge, - heuristic - }) - assertNone(result) - }) - - it("should handle same source and target", () => { - const graph = Graph.directed<{ x: number; y: number }, number>((mutable) => { - Graph.addNode(mutable, { x: 0, y: 0 }) - }) - - const heuristic = (source: { x: number; y: number }, target: { x: number; y: number }) => - Math.abs(source.x - target.x) + Math.abs(source.y - target.y) - - const result = Graph.astar(graph, { - source: 0, - target: 0, - cost: (edge) => edge, - heuristic - }) - - assertSome(result, { path: [0], distance: 0, costs: [] }) - }) - - it("should throw for negative weights", () => { - const graph = Graph.directed<{ x: number; y: number }, number>((mutable) => { - const a = Graph.addNode(mutable, { x: 0, y: 0 }) - const b = Graph.addNode(mutable, { x: 1, y: 0 }) - Graph.addEdge(mutable, a, b, -1) - }) - - const heuristic = (source: { x: number; y: number }, target: { x: number; y: number }) => - Math.abs(source.x - target.x) + Math.abs(source.y - target.y) - - expect(() => - Graph.astar(graph, { - source: 0, - target: 1, - cost: (edge) => edge, - heuristic - }) - ).toThrow("A* algorithm requires non-negative edge weights") - }) - - it("should throw for NaN and negative infinity weights", () => { - for (const weight of unsupportedEdgeWeights) { - const graph = makeSingleEdgeGraph(weight) - - assertGraphError( - () => - Graph.astar(graph, { - source: 0, - target: 1, - cost: (edge) => edge, - heuristic: () => 0 - }), - "A* algorithm requires non-negative edge weights" - ) - } - }) - - it("should treat infinity weights as unreachable", () => { - const graph = makeSingleEdgeGraph(Infinity) - - const result = Graph.astar(graph, { - source: 0, - target: 1, - cost: (edge) => edge, - heuristic: () => 0 - }) - - assertNone(result) - }) - - it("should throw for negative weights before early target termination", () => { - const graph = Graph.directed<{ x: number; y: number }, number>((mutable) => { - const source = Graph.addNode(mutable, { x: 0, y: 0 }) - const target = Graph.addNode(mutable, { x: 1, y: 0 }) - const other = Graph.addNode(mutable, { x: 2, y: 0 }) - Graph.addEdge(mutable, source, target, 1) - Graph.addEdge(mutable, source, other, 2) - Graph.addEdge(mutable, other, target, -5) - }) - - expect(() => - Graph.astar(graph, { - source: 0, - target: 1, - cost: (edge) => edge, - heuristic: () => 0 - }) - ).toThrow("A* algorithm requires non-negative edge weights") - }) - - it("should validate weights before returning same source and target", () => { - const graph = Graph.directed<{ x: number; y: number }, number>((mutable) => { - const node = Graph.addNode(mutable, { x: 0, y: 0 }) - Graph.addEdge(mutable, node, node, -1) - }) - - expect(() => - Graph.astar(graph, { - source: 0, - target: 0, - cost: (edge) => edge, - heuristic: () => 0 - }) - ).toThrow("A* algorithm requires non-negative edge weights") - }) - - it("should traverse undirected edges in reverse storage direction", () => { - const graph = makeReversedUndirectedPath() - - const result = Graph.astar(graph, { - source: 0, - target: 2, - cost: (edge) => edge, - heuristic: () => 0 - }) - - assertSome(result, { path: [0, 1, 2], distance: 2, costs: [1, 1] }) - }) - }) - - describe("Bellman-Ford", () => { - it("should find shortest path with negative weights", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, -1) - Graph.addEdge(mutable, b, c, 3) - Graph.addEdge(mutable, a, c, 5) - }) - - const result = Graph.bellmanFord(graph, { - source: 0, - target: 2, - cost: (edge) => edge - }) - - assertSome(result, { path: [0, 1, 2], distance: 2, costs: [-1, 3] }) - }) - - it("should return None for unreachable nodes", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - // No path from A to C - }) - - const result = Graph.bellmanFord(graph, { - source: 0, - target: 2, - cost: (edge) => edge - }) - - assertNone(result) - }) - - it("should handle same source and target", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - }) - - const result = Graph.bellmanFord(graph, { - source: 0, - target: 0, - cost: (edge) => edge - }) - - assertSome(result, { path: [0], distance: 0, costs: [] }) - }) - - it("should throw for NaN and negative infinity weights", () => { - for (const weight of unsupportedEdgeWeights) { - const graph = makeSingleEdgeGraph(weight) - - assertGraphError( - () => - Graph.bellmanFord(graph, { - source: 0, - target: 1, - cost: (edge) => edge - }), - "Bellman-Ford algorithm does not support NaN or -Infinity edge weights" - ) - } - }) - - it("should treat infinity weights as unreachable", () => { - const graph = makeSingleEdgeGraph(Infinity) - - const result = Graph.bellmanFord(graph, { - source: 0, - target: 1, - cost: (edge) => edge - }) - - assertNone(result) - }) - - it("should detect a directed negative self-loop when source equals target", () => { - const graph = Graph.directed((mutable) => { - const node = Graph.addNode(mutable, "A") - Graph.addEdge(mutable, node, node, -1) - }) - - const result = Graph.bellmanFord(graph, { - source: 0, - target: 0, - cost: (edge) => edge - }) - - assertNone(result) + describe("queries", () => { + const graph = directed(["A", "B", "C"], [ + [0, 1, "first"], + [0, 1, "parallel"], + [2, 0, "incoming"], + [0, 0, "loop"], + [0, 2, "last"] + ]) + + it("reports exact edge order, multiplicity, and directed degrees", () => { + assert.deepStrictEqual(Graph.incidentEdges(graph, 0), [0, 1, 2, 3, 4]) + assert.deepStrictEqual(Graph.outgoingEdges(graph, 0), [0, 1, 3, 4]) + assert.deepStrictEqual(Graph.incomingEdges(graph, 0), [2, 3]) + assert.deepStrictEqual(Graph.edgesBetween(graph, 0, 1), [0, 1]) + assert.deepStrictEqual(Graph.edgesBetween(graph, 1, 0), []) + assert.strictEqual(Graph.outDegree(graph, 0), 4) + assert.strictEqual(Graph.inDegree(graph, 0), 2) + }) + + it("deduplicates neighbors in first-edge occurrence order", () => { + assert.deepStrictEqual(Graph.neighbors(graph, 0), [1, 0, 2]) + assert.deepStrictEqual(Graph.successors(0)(graph), [1, 0, 2]) + assert.deepStrictEqual(Graph.predecessors(graph, 0), [2, 0]) + assert.deepStrictEqual(Graph.neighborsDirected(graph, 0, "outgoing"), [1, 0, 2]) + assert.deepStrictEqual(Graph.successors(graph, 0.5), []) + }) + + it("handles undirected orientation, self-loops, parallel edges, and degree", () => { + const graph = undirected(["A", "B"], [[1, 0, 1], [0, 1, 2], [0, 0, 3]]) + assert.deepStrictEqual(Graph.neighbors(graph, 0), [1, 0]) + assert.deepStrictEqual(Graph.neighbors(graph, 1), [0]) + assert.deepStrictEqual(Graph.incidentEdges(graph, 0), [0, 1, 2]) + assert.deepStrictEqual(Graph.edgesBetween(graph, 0, 1), [0, 1]) + assert.strictEqual(Graph.degree(graph, 0), 4) + assert.strictEqual(Graph.hasEdge(graph, 0, 1), true) + assert.strictEqual(Graph.hasEdge(graph, 1, 0), true) + }) + + it("preserves edge order when merging directed incidence and scanning undirected adjacency", () => { + const directedGraph = directed([0, 1, 2], [ + [0, 1, "out-first"], + [2, 0, "in-first"], + [0, 0, "loop"], + [0, 2, "out-last"], + [1, 0, "in-last"] + ]) + assert.deepStrictEqual(Graph.incidentEdges(directedGraph, 0), [0, 1, 2, 3, 4]) + assert.deepStrictEqual(Graph.edgesBetween(directedGraph, 0, 0), [2]) + + const undirectedGraph = undirected([0, 1, 2], [ + [1, 0, "reverse"], + [0, 0, "loop"], + [0, 2, "forward"], + [2, 0, "reverse-last"] + ]) + assert.deepStrictEqual(Graph.incidentEdges(undirectedGraph, 0), [0, 1, 2, 3]) + assert.deepStrictEqual(Graph.edgesBetween(undirectedGraph, 0, 2), [2, 3]) + assert.deepStrictEqual(Graph.edgesBetween(undirectedGraph, 2, 0), [2, 3]) }) - it("should detect an undirected negative self-loop when source equals target", () => { - const graph = Graph.undirected((mutable) => { - const node = Graph.addNode(mutable, "A") - Graph.addEdge(mutable, node, node, -1) - }) - - const result = Graph.bellmanFord(graph, { - source: 0, - target: 0, - cost: (edge) => edge - }) - - assertNone(result) + it("reports directed edge membership without assuming symmetry", () => { + const graph = directed(["A", "B", "C"], [[0, 1, 1]]) + assert.strictEqual(Graph.hasEdge(graph, 0, 1), true) + assert.strictEqual(Graph.hasEdge(graph, 1, 0), false) + assert.strictEqual(Graph.hasEdge(graph, 0, 2), false) + assert.strictEqual(Graph.hasEdge(graph, 99, 0), false) }) - it("should detect negative cycles", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, -3) - Graph.addEdge(mutable, c, a, 1) - }) - - const result = Graph.bellmanFord(graph, { - source: 0, - target: 2, - cost: (edge) => edge - }) - - expect(result).toEqual(Option.none()) + it("passes stored endpoints to edge finder predicates", () => { + const graph = undirected(["A", "B", "C"], [[2, 0, "match"], [0, 1, "match"]]) + const calls: Array = [] + assert.deepStrictEqual( + Graph.findEdge(graph, (data, source, target) => { + calls.push([data, source, target]) + return source === 0 + }), + Option.some(1) + ) + assert.deepStrictEqual(calls, [["match", 2, 0], ["match", 0, 1]]) + assert.deepStrictEqual(Graph.findEdges(graph, (_, source, target) => source > target), [0]) }) - it("should traverse undirected edges in reverse storage direction", () => { - const graph = makeReversedUndirectedPath() - - const result = Graph.bellmanFord(graph, { - source: 0, - target: 2, - cost: (edge) => edge - }) - - assertSome(result, { path: [0, 1, 2], distance: 2, costs: [1, 1] }) + it("finds undefined edge payloads", () => { + const graph = directed(["A", "B", "C"], [ + [0, 1, undefined], + [1, 2, 42], + [2, 0, undefined] + ]) + assert.deepStrictEqual(Graph.findEdge(graph, (edge) => edge === undefined), Option.some(0)) + assert.deepStrictEqual(Graph.findEdges(graph, (edge) => edge === undefined), [0, 2]) }) - it("should treat a reachable negative undirected edge as a negative cycle", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, -1) - }) - - const result = Graph.bellmanFord(graph, { - source: 0, - target: 1, - cost: (edge) => edge - }) - - assertNone(result) + it("reports kind and missing-node errors consistently", () => { + const one = directed(["A"], []) + const undirectedOne = undirected(["A"], []) + assertGraphError( + () => Graph.degree(one as unknown as Graph.UndirectedGraph, 0), + "Cannot get degree of directed graph" + ) + assertGraphError( + () => Graph.outgoingEdges(undirectedOne as unknown as Graph.DirectedGraph, 0), + "Cannot get outgoing edges of undirected graph" + ) + assertGraphError( + () => Graph.incomingEdges(undirectedOne as unknown as Graph.DirectedGraph, 0), + "Cannot get incoming edges of undirected graph" + ) + assertGraphError( + () => Graph.successors(undirectedOne as unknown as Graph.DirectedGraph, 0), + "Cannot get successors of undirected graph" + ) + assertGraphError(() => Graph.incidentEdges(one, 1), "Node 1 does not exist") + assertGraphError(() => Graph.edgesBetween(one, 0, 1), "Node 1 does not exist") }) }) - describe("Floyd-Warshall", () => { - it("should find all-pairs shortest paths", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 3) - Graph.addEdge(mutable, b, c, 2) - Graph.addEdge(mutable, a, c, 7) - }) - - const result = Graph.floydWarshall(graph, (edge) => edge) - - // Check distance A to C (should be 5 via B, not 7 direct) - expect(result.distances.get(0)?.get(2)).toBe(5) - expect(result.paths.get(0)?.get(2)).toEqual([0, 1, 2]) - expect(result.costs.get(0)?.get(2)).toEqual([3, 2]) - - // Check distance A to B - expect(result.distances.get(0)?.get(1)).toBe(3) - expect(result.paths.get(0)?.get(1)).toEqual([0, 1]) - - // Check distance B to C - expect(result.distances.get(1)?.get(2)).toBe(2) - expect(result.paths.get(1)?.get(2)).toEqual([1, 2]) + describe("serialization", () => { + it("serializes representative directed and undirected GraphViz graphs exactly", () => { + const directedGraph = directed(["A", "B"], [[0, 1, 1]]) + const directedExpected = [ + "digraph \"G\" {", + " \"0\" [label=\"A\"];", + " \"1\" [label=\"B\"];", + " \"0\" -> \"1\" [label=\"1\"];", + "}" + ].join("\n") + assert.strictEqual(Graph.toGraphViz(directedGraph), directedExpected) + assert.strictEqual(Graph.toGraphViz()(directedGraph), directedExpected) + assert.strictEqual( + Graph.toGraphViz(undirected(["A", "B"], [[1, 0, "edge"]])), + [ + "graph \"G\" {", + " \"0\" [label=\"A\"];", + " \"1\" [label=\"B\"];", + " \"1\" -- \"0\" [label=\"edge\"];", + "}" + ].join("\n") + ) }) - it("should handle unreachable nodes", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - // No path from A to C - }) - - const result = Graph.floydWarshall(graph, (edge) => edge) - - expect(result.distances.get(0)?.get(2)).toBe(Infinity) - expect(result.paths.get(0)?.get(2)).toBeNull() + it("escapes GraphViz graph names and labels exactly", () => { + const graph = directed([{ label: "C:\\new\n\"line\"" }, { label: "end" }], [[ + 0, + 1, + { label: "edge\\path\n\"quoted\"" } + ]]) + assert.strictEqual( + Graph.toGraphViz(graph, { + graphName: "My \"Graph\"", + nodeLabel: (node) => `node:${node.label}`, + edgeLabel: (edge) => edge.label + }), + [ + "digraph \"My \\\"Graph\\\"\" {", + " \"0\" [label=\"node:C:\\\\new\\n\\\"line\\\"\"];", + " \"1\" [label=\"node:end\"];", + " \"0\" -> \"1\" [label=\"edge\\\\path\\n\\\"quoted\\\"\"];", + "}" + ].join("\n") + ) }) - it("should handle same source and target", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - }) - - const result = Graph.floydWarshall(graph, (edge) => edge) - - expect(result.distances.get(0)?.get(0)).toBe(0) - expect(result.paths.get(0)?.get(0)).toEqual([0]) - expect(result.costs.get(0)?.get(0)).toEqual([]) + it("serializes representative directed and undirected Mermaid graphs exactly", () => { + const directedGraph = directed(["A", "B"], [[0, 1, "edge"]]) + const directedExpected = [ + "flowchart TD", + " 0[\"A\"]", + " 1[\"B\"]", + " 0 -->|\"edge\"| 1" + ].join("\n") + assert.strictEqual(Graph.toMermaid(directedGraph), directedExpected) + assert.strictEqual(Graph.toMermaid()(directedGraph), directedExpected) + assert.strictEqual( + Graph.toMermaid(undirected(["A", "B"], [[1, 0, ""]])), + [ + "graph TD", + " 0[\"A\"]", + " 1[\"B\"]", + " 1 --- 0" + ].join("\n") + ) }) - it("should throw for NaN and negative infinity weights", () => { - for (const weight of unsupportedEdgeWeights) { - const graph = makeSingleEdgeGraph(weight) - - assertGraphError( - () => Graph.floydWarshall(graph, (edge) => edge), - "Floyd-Warshall algorithm does not support NaN or -Infinity edge weights" + it("escapes Mermaid labels and applies direction, type, and custom labels", () => { + const graph = directed([{ id: "#\"<>&[]{}()|\\" }, { id: "B\r\n2\r3\n4" }], [[0, 1, { weight: 2 }]]) + const serialized = Graph.toMermaid(graph, { + direction: "LR", + diagramType: "graph", + nodeLabel: (node) => node.id, + edgeLabel: (edge) => `w(${edge.weight})` + }) + assert.strictEqual( + serialized, + [ + "graph LR", + " 0[\"#35;#quot;#lt;#gt;#amp;#91;#93;#123;#125;#40;#41;#124;#92;\"]", + " 1[\"B
2
3
4\"]", + " 0 ---|\"w#40;2#41;\"| 1" + ].join("\n") + ) + assert.strictEqual(serialized.includes("\r"), false) + }) + + it("supports every Mermaid node shape", () => { + const expected: ReadonlyArray = [ + ["rectangle", "0[\"A\"]"], + ["rounded", "0(\"A\")"], + ["circle", "0((\"A\"))"], + ["diamond", "0{\"A\"}"], + ["hexagon", "0{{\"A\"}}"], + ["stadium", "0([\"A\"])"], + ["subroutine", "0[[\"A\"]]"], + ["cylindrical", "0[(\"A\")]"] + ] + for (const [shape, node] of expected) { + assert.strictEqual( + Graph.toMermaid(directed(["A"], []), { nodeShape: () => shape }), + `flowchart TD\n ${node}` ) } }) + }) - it("should treat infinity weights as unreachable", () => { - const graph = makeSingleEdgeGraph(Infinity) - - const result = Graph.floydWarshall(graph, (edge) => edge) - - expect(result.distances.get(0)?.get(1)).toBe(Infinity) - expect(result.paths.get(0)?.get(1)).toBeNull() - expect(result.costs.get(0)?.get(1)).toEqual([]) + describe("cycles and connectivity", () => { + it("returns exact sparse cycle witnesses including self-loops and parallel edges", () => { + const directedCycle = Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }], + edges: [ + { index: 3, source: 2, target: 5, data: 1 }, + { index: 7, source: 5, target: 9, data: 1 }, + { index: 11, source: 9, target: 2, data: 1 } + ] + }) + assert.deepStrictEqual(Graph.findCycle(directedCycle), Option.some({ path: [2, 5, 9, 2], edges: [3, 7, 11] })) + assert.deepStrictEqual(Graph.findCycle(undirected(["A"], [[0, 0, 1]])), Option.some({ path: [0, 0], edges: [0] })) + assert.deepStrictEqual( + Graph.findCycle(undirected(["A", "B"], [[0, 1, 1], [1, 0, 2]])), + Option.some({ + path: [0, 1, 0], + edges: [0, 1] + }) + ) }) - it("should preserve null edge data in direct paths", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, null) - }) - - const result = Graph.floydWarshall(graph, () => 1) - - assert.strictEqual(result.distances.get(0)?.get(1), 1) - assert.deepStrictEqual(result.paths.get(0)?.get(1), [0, 1]) - assert.deepStrictEqual(result.costs.get(0)?.get(1), [null]) + it("returns None for acyclic directed and reversed-storage undirected graphs", () => { + assert.deepStrictEqual(Graph.findCycle(directed([0, 1, 2], [[0, 1, 1], [1, 2, 1]])), Option.none()) + assert.deepStrictEqual(Graph.findCycle(undirected([0, 1, 2], [[0, 1, 1], [2, 1, 1]])), Option.none()) }) - it("should preserve null edge data in multihop paths", () => { - type EdgeData = null | { readonly weight: number } - - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, null) - Graph.addEdge(mutable, b, c, null) - Graph.addEdge(mutable, a, c, { weight: 10 }) - }) - - const result = Graph.floydWarshall(graph, (edge) => edge === null ? 1 : edge.weight) - - assert.strictEqual(result.distances.get(0)?.get(2), 2) - assert.deepStrictEqual(result.paths.get(0)?.get(2), [0, 1, 2]) - assert.deepStrictEqual(result.costs.get(0)?.get(2), [null, null]) + it("invalidates acyclic results after adding and removing a cycle edge", () => { + const mutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 1]])) + assert.strictEqual(Graph.isAcyclic(mutable), true) + const cycle = Graph.addEdge(mutable, 2, 0, 1) + assert.strictEqual(Graph.isAcyclic(mutable), false) + Graph.removeEdge(mutable, cycle) + assert.strictEqual(Graph.isAcyclic(mutable), true) }) - it("should detect negative cycles", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, -3) - Graph.addEdge(mutable, c, a, 1) - }) - - expect(() => Graph.floydWarshall(graph, (edge) => edge)).toThrow("Negative cycle detected") + it("handles undirected reversed orientation, self-loops, and parallel cycles", () => { + assert.strictEqual(Graph.isAcyclic(undirected(["A", "B", "C"], [[0, 1, 1], [2, 1, 1]])), true) + assert.strictEqual(Graph.isAcyclic(undirected(["A"], [[0, 0, 1]])), false) + assert.strictEqual(Graph.isAcyclic(undirected(["A", "B"], [[0, 1, 1], [0, 1, 2]])), false) }) - it("should traverse undirected edges in reverse storage direction", () => { - const graph = makeReversedUndirectedPath() + it("returns complete connected, weak, and strong component partitions", () => { + const connected = undirected(["A", "B", "C", "D", "E"], [[0, 1, 1], [2, 3, 1]]) + const directedGraph = directed(["A", "B", "C", "D", "E"], [[0, 1, 1], [1, 0, 1], [2, 1, 1], [3, 4, 1]]) - const result = Graph.floydWarshall(graph, (edge) => edge) - - expect(result.distances.get(0)?.get(2)).toBe(2) - expect(result.paths.get(0)?.get(2)).toEqual([0, 1, 2]) - expect(result.costs.get(0)?.get(2)).toEqual([1, 1]) + assertComponents(Graph.connectedComponents(connected), [[0, 1], [2, 3], [4]]) + assertComponents(Graph.weaklyConnectedComponents(directedGraph), [[0, 1, 2], [3, 4]]) + assertComponents(Graph.stronglyConnectedComponents(directedGraph), [[0, 1], [2], [3], [4]]) + assertComponents(Graph.connectedComponents(Graph.beginMutation(connected)), [[0, 1], [2, 3], [4]]) + assertComponents(Graph.stronglyConnectedComponents(Graph.beginMutation(directedGraph)), [[0, 1], [2], [3], [4]]) }) - it("should treat negative undirected edges as negative cycles", () => { - const graph = Graph.undirected((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, -1) - }) - - expect(() => Graph.floydWarshall(graph, (edge) => edge)).toThrow("Negative cycle detected") + it("computes reachability in outgoing, incoming, and undirected modes", () => { + const graph = directed(["A", "B", "C", "D"], [[0, 1, 1], [1, 2, 1], [3, 1, 1]]) + assert.deepStrictEqual(Array.from(Graph.unweightedDistances(graph, 0)), [[0, 0], [1, 1], [2, 2]]) + assert.deepStrictEqual(Array.from(Graph.unweightedDistances(graph, 2, { direction: "incoming" })), [ + [0, 2], + [1, 1], + [2, 0], + [3, 2] + ]) + assert.strictEqual(Graph.hasPath(graph, 0, 2), true) + assert.strictEqual(Graph.hasPath(graph, 2, 0), false) + assert.strictEqual(Graph.hasPath(2, 0, { direction: "incoming" })(graph), true) + assert.strictEqual(Graph.hasPath(graph, 2, 3), false) + assert.strictEqual(Graph.hasPath(graph, 2, 3, { direction: "undirected" }), true) + }) + + it("updates connectivity results after mutable changes", () => { + const mutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1]])) + assertComponents(Graph.weaklyConnectedComponents(mutable), [[0, 1], [2]]) + assert.strictEqual(Graph.hasPath(mutable, 0, 2), false) + Graph.addEdge(mutable, 1, 2, 1) + assertComponents(Graph.weaklyConnectedComponents(mutable), [[0, 1, 2]]) + assert.strictEqual(Graph.hasPath(mutable, 0, 2), true) + }) + + it("checks connected, weak, strong, and tree predicates including empty graphs", () => { + const tree = undirected(["A", "B", "C"], [[0, 1, 1], [1, 2, 1]]) + const disconnected = undirected(["A", "B"], []) + const weak = directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 1]]) + const weaklyDisconnected = directed(["A", "B"], []) + const strong = Graph.mutate(weak, (mutable) => { + Graph.addEdge(mutable, 2, 0, 1) + }) + assert.strictEqual(Graph.isConnected(tree), true) + assert.strictEqual(Graph.isConnected(disconnected), false) + assert.strictEqual(Graph.isTree(tree), true) + assert.strictEqual(Graph.isWeaklyConnected(weak), true) + assert.strictEqual(Graph.isWeaklyConnected(weaklyDisconnected), false) + assert.strictEqual(Graph.isStronglyConnected(weak), false) + assert.strictEqual(Graph.isStronglyConnected(strong), true) + assert.strictEqual(Graph.isConnected(Graph.undirected()), true) + assert.strictEqual(Graph.isTree(Graph.undirected()), false) + }) + + it("rejects runtime connectivity kind mismatches and missing endpoints", () => { + const directedGraph = Graph.directed() as unknown as Graph.Graph + const undirectedGraph = Graph.undirected() as unknown as Graph.Graph + assertGraphError( + () => Graph.connectedComponents(directedGraph as Graph.UndirectedGraph), + "Cannot find connected components of directed graph" + ) + assertGraphError( + () => Graph.weaklyConnectedComponents(undirectedGraph as Graph.DirectedGraph), + "Cannot find weakly connected components of undirected graph" + ) + assertGraphError( + () => Graph.stronglyConnectedComponents(undirectedGraph as Graph.DirectedGraph), + "Cannot find strongly connected components of undirected graph" + ) + assertGraphError( + () => Graph.isTree(directedGraph as Graph.UndirectedGraph), + "Cannot determine tree status of directed graph" + ) + assertGraphError(() => Graph.hasPath(Graph.directed(), 0, 1), "Node 0 does not exist") }) }) - describe("Iterator Base Methods", () => { - const staleTraversalNodeCount = 20000 + describe("bipartite graphs", () => { + it("recognizes even, odd, disconnected, and self-loop cases", () => { + const even = undirected([0, 1, 2, 3], [[0, 1, 1], [1, 2, 1], [2, 3, 1], [3, 0, 1]]) + assert.strictEqual(Graph.isBipartite(even), true) + assert.strictEqual(Graph.isBipartite(Graph.beginMutation(even)), true) + assert.strictEqual(Graph.isBipartite(undirected([0, 1, 2], [[0, 1, 1], [1, 2, 1], [2, 0, 1]])), false) + assert.strictEqual(Graph.isBipartite(undirected([0, 1, 2, 3], [[0, 1, 1], [2, 3, 1]])), true) + assert.strictEqual(Graph.isBipartite(undirected([0], [[0, 0, 1]])), false) + }) + + it("reads fresh mutable structure after bipartite mutations", () => { + const mutable = Graph.beginMutation(undirected([0, 1, 2], [[0, 1, 1], [1, 2, 1]])) + assert.strictEqual(Graph.isBipartite(mutable), true) + Graph.addEdge(mutable, 2, 0, 1) + assert.strictEqual(Graph.isBipartite(mutable), false) + Graph.removeEdge(mutable, 2) + Graph.addEdge(mutable, 0, 1, 1) + assert.strictEqual(Graph.isBipartite(mutable), true) + Graph.addEdge(mutable, 2, 2, 1) + assert.strictEqual(Graph.isBipartite(mutable), false) + }) + + it("returns deterministic maximum matches and the first parallel edge", () => { + const graph = undirected([0, 1, 2, 3], [[0, 2, "first"], [2, 0, "parallel"], [0, 3, "edge"], [1, 2, "edge"], [ + 1, + 3, + "edge" + ]]) + assert.deepStrictEqual(Graph.maximumBipartiteMatching(graph), [ + { left: 0, right: 2, edge: 0 }, + { left: 1, right: 3, edge: 4 } + ]) + assert.deepStrictEqual(Graph.maximumBipartiteMatching(Graph.beginMutation(graph)), [ + { left: 0, right: 2, edge: 0 }, + { left: 1, right: 3, edge: 4 } + ]) + }) - const makeSparseMutableGraph = () => { - const graph = Graph.directed((mutable) => { - for (let i = 0; i < staleTraversalNodeCount; i++) { - Graph.addNode(mutable, String(i)) + it("matches a brute-force oracle for every three-by-three bipartite graph", () => { + const oracle = (adjacency: ReadonlyArray>, left = 0, used = 0): number => { + if (left === adjacency.length) return 0 + let best = oracle(adjacency, left + 1, used) + for (const right of adjacency[left]) { + if ((used & (1 << right)) === 0) best = Math.max(best, 1 + oracle(adjacency, left + 1, used | (1 << right))) } - }) - return { - mutable: Graph.beginMutation(graph), - start: Array.from({ length: staleTraversalNodeCount }, (_, index) => index) + return best } - } - - const removeNodes = (mutable: Graph.MutableDirectedGraph, nodes: Array) => { - for (const node of nodes) { - Graph.removeNode(mutable, node) + for (let mask = 0; mask < 1 << 9; mask++) { + const adjacency: Array> = [[], [], []] + const graph = Graph.undirected((mutable) => { + for (let i = 0; i < 6; i++) Graph.addNode(mutable, undefined) + for (let left = 0; left < 3; left++) { + for (let right = 0; right < 3; right++) { + if ((mask & (1 << (left * 3 + right))) !== 0) { + adjacency[left].push(right) + Graph.addEdge(mutable, left, right + 3, undefined) + } + } + } + }) + const matching = Graph.maximumBipartiteMatching(graph) + assert.strictEqual(matching.length, oracle(adjacency)) + assert.strictEqual(new Set(matching.map((match) => match.left)).size, matching.length) + assert.strictEqual(new Set(matching.map((match) => match.right)).size, matching.length) + for (const match of matching) { + assert.ok(match.left >= 0 && match.left < 3) + assert.ok(match.right >= 3 && match.right < 6) + const edge = Option.getOrThrow(Graph.getEdge(graph, match.edge)) + assert.ok( + (edge.source === match.left && edge.target === match.right) || + (edge.source === match.right && edge.target === match.left) + ) + } } - } - - it("should provide values() method for DFS iterator", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - - const dfsIterator = Graph.dfs(graph, { start: [0] }) - const values = Array.from(Graph.values(dfsIterator)) - - expect(values).toEqual(["A", "B", "C"]) - }) - - it("should provide entries() method for DFS iterator", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - - const dfsIterator = Graph.dfs(graph, { start: [0] }) - const entries = Array.from(Graph.entries(dfsIterator)) - - expect(entries).toEqual([[0, "A"], [1, "B"], [2, "C"]]) - }) - - it("should provide values() method for BFS iterator", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, a, c, 2) - }) - - const bfsIterator = Graph.bfs(graph, { start: [0] }) - const values = Array.from(Graph.values(bfsIterator)) - - expect(values).toEqual(["A", "B", "C"]) - }) - - it("should provide entries() method for BFS iterator", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, a, c, 2) - }) - - const bfsIterator = Graph.bfs(graph, { start: [0] }) - const entries = Array.from(Graph.entries(bfsIterator)) - - expect(entries).toEqual([[0, "A"], [1, "B"], [2, "C"]]) - }) - - it("should skip stale BFS nodes without recursion", () => { - const { mutable, start } = makeSparseMutableGraph() - const iterator = Graph.indices(Graph.bfs(mutable, { start }))[Symbol.iterator]() - - removeNodes(mutable, start) - - assert.strictEqual(iterator.next().done, true) - }) - - it("should limit DFS traversal by radius", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - Graph.addEdge(mutable, c, d, 3) - }) - - const dfsIterator = Graph.dfs(graph, { start: [0], radius: 1 }) - - assert.deepStrictEqual(Array.from(Graph.indices(dfsIterator)), [0, 1]) - }) - - it("should use the shortest discovered depth when limiting DFS traversal", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - Graph.addEdge(mutable, a, c, 3) - Graph.addEdge(mutable, c, d, 4) - }) - - const dfsIterator = Graph.dfs(graph, { start: [0], radius: 2 }) - - assert.deepStrictEqual(Array.from(Graph.indices(dfsIterator)), [0, 1, 2, 3]) - }) - - it("should limit BFS traversal by radius", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, a, c, 2) - Graph.addEdge(mutable, b, d, 3) - }) - - const bfsIterator = Graph.bfs(graph, { start: [0], radius: 1 }) - - assert.deepStrictEqual(Array.from(Graph.indices(bfsIterator)), [0, 1, 2]) - }) - - it("should only include start nodes when radius is zero", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - }) - - assert.deepStrictEqual(Array.from(Graph.indices(Graph.dfs(graph, { start: [0], radius: 0 }))), [0]) - assert.deepStrictEqual(Array.from(Graph.indices(Graph.bfs(graph, { start: [0], radius: 0 }))), [0]) - }) - - it("should provide values() method for Topo iterator", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - - const topoIterator = Graph.topo(graph) - - const values = Array.from(Graph.values(topoIterator)) - expect(values).toEqual(["A", "B", "C"]) - }) - - it("should provide entries() method for Topo iterator", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - - const topoIterator = Graph.topo(graph) - - const entries = Array.from(Graph.entries(topoIterator)) - expect(entries).toEqual([[0, "A"], [1, "B"], [2, "C"]]) - }) - - it("should skip stale topological sort nodes without recursion", () => { - const { mutable, start } = makeSparseMutableGraph() - const iterator = Graph.indices(Graph.topo(mutable))[Symbol.iterator]() - - removeNodes(mutable, start) - - assert.strictEqual(iterator.next().done, true) }) - it("should reject cycles introduced before topological iteration", () => { - const mutable = Graph.beginMutation(Graph.directed((graph) => { - Graph.addNode(graph, "A") - Graph.addNode(graph, "B") - })) - const walker = Graph.topo(mutable) - - Graph.addEdge(mutable, 0, 1, 1) - Graph.addEdge(mutable, 1, 0, 2) - + it("rejects directed and non-bipartite graphs", () => { assertGraphError( - () => Array.from(Graph.indices(walker)), - "Cannot perform topological sort on cyclic graph" + () => Graph.isBipartite(Graph.directed() as unknown as Graph.UndirectedGraph), + "Cannot determine bipartite status of directed graph" ) - }) - - it("should use fresh graph state for each topological iteration", () => { - const mutable = Graph.beginMutation(Graph.directed((graph) => { - Graph.addNode(graph, "A") - Graph.addNode(graph, "B") - })) - const walker = Graph.topo(mutable) - - assert.deepStrictEqual(Array.from(Graph.indices(walker)), [0, 1]) - Graph.addEdge(mutable, 0, 1, 1) - Graph.addEdge(mutable, 1, 0, 2) - assertGraphError( - () => Array.from(Graph.indices(walker)), - "Cannot perform topological sort on cyclic graph" + () => Graph.maximumBipartiteMatching(Graph.directed() as unknown as Graph.UndirectedGraph), + "Cannot find bipartite matching of directed graph" + ) + assertGraphError( + () => Graph.maximumBipartiteMatching(undirected([0, 1, 2], [[0, 1, 1], [1, 2, 1], [2, 0, 1]])), + "Cannot find bipartite matching of non-bipartite graph" ) }) + }) - it("should prioritize valid initials and still include all nodes", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, c, d, 2) + describe("low-link connectivity", () => { + const analyze = (graph: Graph.UndirectedGraph | Graph.MutableUndirectedGraph) => ({ + bridges: Graph.bridges(graph), + articulationPoints: Graph.articulationPoints(graph), + biconnectedComponents: Graph.biconnectedComponents(graph) + }) + + it("handles paths, cycles, disconnected components, parallel edges, loops, and sparse indexes", () => { + const graph = Graph.fromSnapshot({ + type: "undirected", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }, { index: 12, data: "D" }], + edges: [ + { index: 3, source: 5, target: 2, data: "first" }, + { index: 7, source: 2, target: 5, data: "parallel" }, + { index: 11, source: 5, target: 9, data: "bridge" }, + { index: 13, source: 9, target: 9, data: "loop" } + ] + }) + const expected = { + bridges: [11], + articulationPoints: [5], + biconnectedComponents: [[2, 5], [5, 9], [9]] + } + assert.deepStrictEqual(analyze(graph), expected) + assert.deepStrictEqual(analyze(Graph.beginMutation(graph)), expected) + }) + + it("groups cycles sharing an articulation point across disconnected components", () => { + const graph = undirected(new Array(8).fill(undefined), [ + [0, 1, undefined], + [1, 2, undefined], + [2, 0, undefined], + [2, 3, undefined], + [3, 4, undefined], + [4, 2, undefined], + [5, 6, undefined] + ]) + assert.deepStrictEqual(analyze(graph), { + bridges: [6], + articulationPoints: [2], + biconnectedComponents: [[0, 1, 2], [2, 3, 4], [5, 6]] }) - - const order = Array.from(Graph.indices(Graph.topo(graph, { initials: [2] }))) - expect(order).toEqual([2, 0, 3, 1]) }) - it("should reject initials with incoming edges", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) + it("returns empty results for empty and isolated graphs", () => { + assert.deepStrictEqual(analyze(Graph.undirected()), { + bridges: [], + articulationPoints: [], + biconnectedComponents: [] }) - - expect(() => Array.from(Graph.topo(graph, { initials: [1] }))) - .toThrow("Initial node 1 has incoming edges") - }) - - it("should throw for cyclic graphs", () => { - const cyclicGraph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, a, 2) // Creates cycle + assert.deepStrictEqual(analyze(undirected([undefined], [])), { + bridges: [], + articulationPoints: [], + biconnectedComponents: [] }) - - expect(() => Graph.topo(cyclicGraph)).toThrow("Cannot perform topological sort on cyclic graph") }) - it("should throw for undirected graphs", () => { - const graph = makeReversedUndirectedPath() - - expect(() => Graph.topo(graph as any)).toThrow("Cannot perform topological sort on undirected graph") + it("rejects directed graphs at runtime", () => { + const graph = Graph.directed() as unknown as Graph.UndirectedGraph + for (const operation of [Graph.bridges, Graph.articulationPoints, Graph.biconnectedComponents]) { + assertGraphError(() => operation(graph), "Cannot analyze undirected connectivity of directed graph") + } }) + }) - it("should handle corrupted graph state during topological sort", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - }) - - // Test edge case by corrupting graph internals during iteration - const mutableGraph = graph as any - const originalGetNode = mutableGraph.nodes.get - - let callCount = 0 - // Mock getNode to return undefined for certain calls to trigger the recursive edge case - mutableGraph.nodes.get = function(key: any) { - callCount++ - // On specific call, return undefined to trigger the Option.isNone path - if (callCount === 2) { - return undefined + describe("flow", () => { + it("returns fixed flow and cut results in data-first and data-last forms", () => { + const graph = directed(["source", "target"], [[0, 1, 3]]) + const config = { source: 0, target: 1, capacity: (edge: number) => edge } + const flow = { value: 3, flows: new Map([[0, 3]]), cut: [0] } + const cut = { value: 3, edges: [0], source: [0], target: [1] } + assert.deepStrictEqual(Graph.maximumFlow(graph, config), flow) + assert.deepStrictEqual(Graph.maximumFlow(config)(graph), flow) + assert.deepStrictEqual(Graph.minimumCut(graph, config), cut) + assert.deepStrictEqual(Graph.minimumCut(config)(Graph.beginMutation(graph)), cut) + }) + + it("enforces capacities, conservation, and max-flow/min-cut equality", () => { + const capacities = [16, 13, 10, 4, 12, 9, 14, 7, 20, 4] + const endpoints = [[0, 1], [0, 2], [1, 2], [2, 1], [1, 3], [3, 2], [2, 4], [4, 3], [3, 5], [4, 5]] as const + const graph = directed( + new Array(6).fill(undefined), + endpoints.map(([source, target], index) => [source, target, capacities[index]]) + ) + const config = { source: 0, target: 5, capacity: (edge: number) => edge } + for (const candidate of [graph, Graph.beginMutation(graph)]) { + const flow = Graph.maximumFlow(candidate, config) + const cut = Graph.minimumCut(candidate, config) + const balance = new Float64Array(6) + for (let edge = 0; edge < endpoints.length; edge++) { + const value = flow.flows.get(edge)! + assert.ok(value >= 0 && value <= capacities[edge]) + balance[endpoints[edge][0]] -= value + balance[endpoints[edge][1]] += value } - return originalGetNode.call(this, key) + assert.strictEqual(flow.value, 23) + assert.strictEqual(cut.value, 23) + assert.strictEqual(cut.edges.reduce((total, edge) => total + capacities[edge], 0), 23) + assert.deepStrictEqual(Array.from(balance), [-23, 0, 0, 0, 0, 23]) + assert.deepStrictEqual([...cut.source, ...cut.target].sort((a, b) => a - b), [0, 1, 2, 3, 4, 5]) } - - const iterator = Graph.topo(graph) - const results = Array.from(iterator) - - // Restore original method - mutableGraph.nodes.get = originalGetNode - - // Should complete without crashing - expect(results.length).toBeGreaterThanOrEqual(0) }) - it("should provide values() method for DfsPostOrder iterator", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - - const dfsPostIterator = Graph.dfsPostOrder(graph, { start: [0] }) - const values = Array.from(Graph.values(dfsPostIterator)) - - expect(values).toEqual(["C", "B", "A"]) // Postorder: children before parents - }) - - it("should provide entries() method for DfsPostOrder iterator", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - - const dfsPostIterator = Graph.dfsPostOrder(graph, { start: [0] }) - const entries = Array.from(Graph.entries(dfsPostIterator)) - - expect(entries).toEqual([[2, "C"], [1, "B"], [0, "A"]]) // Postorder: children before parents - }) - - it("should skip stale DFS postorder nodes without recursion", () => { - const { mutable, start } = makeSparseMutableGraph() - const iterator = Graph.indices(Graph.dfsPostOrder(mutable, { start }))[Symbol.iterator]() - - removeNodes(mutable, start) - - assert.strictEqual(iterator.next().done, true) - }) - - it("should traverse undirected edges in reverse storage direction", () => { - const graph = makeReversedUndirectedPath() - - expect(Array.from(Graph.indices(Graph.dfs(graph, { start: [0] })))).toEqual([0, 1, 2]) - expect(Array.from(Graph.indices(Graph.dfs(graph, { start: [0], direction: "incoming" })))).toEqual([0, 1, 2]) - expect(Array.from(Graph.indices(Graph.bfs(graph, { start: [0] })))).toEqual([0, 1, 2]) - expect(Array.from(Graph.indices(Graph.dfsPostOrder(graph, { start: [0] })))).toEqual([2, 1, 0]) + it("uses reverse residual edges to reroute flow", () => { + const graph = directed(new Array(6).fill(undefined), [ + [0, 1, 1], + [0, 2, 1], + [1, 3, 1], + [2, 3, 1], + [3, 5, 1], + [1, 4, 1], + [4, 5, 1] + ]) + assert.strictEqual(Graph.maximumFlow(graph, { source: 0, target: 5, capacity: (edge) => edge }).value, 2) }) - it("should ignore edge direction during traversal", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, a, c, 2) + it("handles parallel edges, self-loops, fractions, and disconnected targets", () => { + const graph = directed(new Array(4).fill(undefined), [ + [0, 0, 100], + [0, 1, 0], + [0, 2, 0.75], + [2, 1, 1], + [0, 2, 0.25] + ]) + assert.deepStrictEqual(Graph.maximumFlow(graph, { source: 0, target: 1, capacity: (edge) => edge }), { + value: 1, + flows: new Map([[0, 0], [1, 0], [2, 0.75], [3, 1], [4, 0.25]]), + cut: [1, 2, 4] + }) + assert.deepStrictEqual(Graph.maximumFlow(graph, { source: 0, target: 3, capacity: (edge) => edge }), { + value: 0, + flows: new Map([[0, 0], [1, 0], [2, 0], [3, 0], [4, 0]]), + cut: [] }) + }) - assert.deepStrictEqual( - Array.from(Graph.indices(Graph.dfs(graph, { start: [1], direction: "undirected" }))), - [1, 0, 2] + it("rejects invalid graphs, endpoints, capacities, and finite-range overflow", () => { + const graph = directed([undefined, undefined], []) + assertGraphError( + () => Graph.maximumFlow(graph, { source: 0, target: 2, capacity: (edge) => edge }), + "Node 2 does not exist" ) - assert.deepStrictEqual( - Array.from(Graph.indices(Graph.bfs(graph, { start: [1], direction: "undirected" }))), - [1, 0, 2] + assertGraphError( + () => Graph.maximumFlow(graph, { source: 0, target: 0, capacity: (edge) => edge }), + "Flow source and target must be different nodes" + ) + assertGraphError( + () => + Graph.maximumFlow(Graph.undirected() as unknown as Graph.DirectedGraph, { + source: 0, + target: 1, + capacity: (edge) => edge + }), + "Cannot compute flow of undirected graph" + ) + for (const capacity of [-1, NaN, Infinity, -Infinity]) { + const invalid = directed([undefined, undefined], [[0, 0, capacity]]) + assertGraphError( + () => Graph.maximumFlow(invalid, { source: 0, target: 1, capacity: (edge) => edge }), + "Edge 0 capacity must be a finite non-negative number" + ) + } + const overflow = directed([undefined, undefined], [[0, 1, Number.MAX_VALUE], [ + 0, + 1, + Number.MAX_VALUE + ]]) + assertGraphError( + () => Graph.maximumFlow(overflow, { source: 0, target: 1, capacity: (edge) => edge }), + "Maximum flow exceeds the finite number range" ) }) }) - describe("DfsPostOrder Iterator", () => { - it("should limit traversal by radius", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - + describe("spanning forests and reductions", () => { + it("preserves sparse indexes, isolated nodes, and fixed mutable parity in minimum spanning forests", () => { + const graph = Graph.fromSnapshot({ + type: "undirected", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }, { + index: 20, + data: "isolated" + }], + edges: [ + { index: 3, source: 2, target: 5, data: 4 }, + { index: 7, source: 2, target: 5, data: 1 }, + { index: 11, source: 5, target: 9, data: -2 }, + { index: 13, source: 2, target: 9, data: 2 }, + { index: 17, source: 9, target: 20, data: Infinity } + ] + }) + const expected = { + type: "undirected", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }, { + index: 20, + data: "isolated" + }], + edges: [{ index: 7, source: 2, target: 5, data: 1 }, { index: 11, source: 5, target: 9, data: -2 }] + } as const + assertSnapshot(Graph.minimumSpanningForest(graph, (edge) => edge), expected) + assertSnapshot(Graph.minimumSpanningForest(Graph.beginMutation(graph), (edge) => edge), expected) + }) + + it("breaks equal spanning-forest weights by first edge order", () => { + const graph = undirected([0, 1, 2], [[0, 1, 1], [1, 2, 1], [0, 2, 1]]) assert.deepStrictEqual( - Array.from(Graph.indices(Graph.dfsPostOrder(graph, { start: [0], radius: 1 }))), - [1, 0] + Graph.toSnapshot(Graph.minimumSpanningForest(graph, (edge) => edge)).edges.map((edge) => edge.index), + [0, 1] ) }) - it("should traverse in postorder for simple chain", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) + it("preserves sparse reachability and first parallel edges in transitive reductions", () => { + const graph = Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }], + edges: [ + { index: 3, source: 2, target: 5, data: "first" }, + { index: 4, source: 2, target: 5, data: "parallel" }, + { index: 7, source: 5, target: 9, data: "next" }, + { index: 11, source: 2, target: 9, data: "redundant" } + ] }) - - const postOrder = Array.from(Graph.indices(Graph.dfsPostOrder(graph, { start: [0] }))) - expect(postOrder).toEqual([2, 1, 0]) // Children before parents + const expected = { + type: "directed", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 9, data: "C" }], + edges: [{ index: 3, source: 2, target: 5, data: "first" }, { index: 7, source: 5, target: 9, data: "next" }] + } as const + assertSnapshot(Graph.transitiveReduction(graph), expected) + assertSnapshot(Graph.transitiveReduction(Graph.beginMutation(graph)), expected) }) - it("should traverse in postorder for branching tree", () => { - const graph = Graph.directed((mutable) => { - const root = Graph.addNode(mutable, "root") // 0 - const left = Graph.addNode(mutable, "left") // 1 - const right = Graph.addNode(mutable, "right") // 2 - const leaf1 = Graph.addNode(mutable, "leaf1") // 3 - const leaf2 = Graph.addNode(mutable, "leaf2") // 4 - - Graph.addEdge(mutable, root, left, 1) - Graph.addEdge(mutable, root, right, 2) - Graph.addEdge(mutable, left, leaf1, 3) - Graph.addEdge(mutable, right, leaf2, 4) - }) - - const postOrder = Array.from(Graph.indices(Graph.dfsPostOrder(graph, { start: [0] }))) - // Should visit leaves first, then parents - expect(postOrder).toEqual([3, 1, 4, 2, 0]) + it("keeps both branches of a diamond", () => { + const graph = directed([0, 1, 2, 3], [[0, 1, 1], [0, 2, 1], [1, 3, 1], [2, 3, 1]]) + assert.deepStrictEqual(Array.from(Graph.indices(Graph.edges(Graph.transitiveReduction(graph)))), [0, 1, 2, 3]) }) - it("should handle empty start nodes", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - }) - - const postOrder = Array.from(Graph.dfsPostOrder(graph, { start: [] })) - expect(postOrder).toEqual([]) + it("rejects invalid kinds, weights, and cyclic reductions", () => { + assertGraphError( + () => Graph.minimumSpanningForest(Graph.directed() as unknown as Graph.UndirectedGraph, () => 1), + "Cannot find minimum spanning forest of directed graph" + ) + for (const weight of [NaN, -Infinity]) { + assertGraphError( + () => Graph.minimumSpanningForest(undirected([0, 1], [[0, 1, weight]]), (edge) => edge), + "Minimum spanning forest does not support NaN or -Infinity edge weights" + ) + } + assertGraphError( + () => Graph.transitiveReduction(Graph.undirected() as unknown as Graph.DirectedGraph), + "Cannot transitively reduce undirected graph" + ) + assertGraphError( + () => Graph.transitiveReduction(directed([0, 1], [[0, 1, 1], [1, 0, 1]])), + "Cannot transitively reduce cyclic graph" + ) }) + }) - it("should handle disconnected components with multiple start nodes", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - const d = Graph.addNode(mutable, "D") - - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, c, d, 2) - // No connection between (A,B) and (C,D) + describe("pathfinding", () => { + const graph = directed(["source", "first", "second", "target"], [ + [0, 1, 1], + [0, 2, 1], + [2, 3, 1], + [1, 3, 1] + ]) + const expected = { path: [0, 1, 3], edges: [0, 3], distance: 2, costs: [1, 1] } + + it("returns complete Dijkstra and A* paths with deterministic ties and fixed mutable parity", () => { + const dijkstra = { source: 0, target: 3, cost: (edge: number) => edge } + const astar = { ...dijkstra, heuristic: () => 0 } + const bellmanFord = { path: [0, 2, 3], edges: [1, 2], distance: 2, costs: [1, 1] } + assertPath(Graph.dijkstra(graph, dijkstra), expected) + assertPath(Graph.dijkstra(Graph.beginMutation(graph), dijkstra), expected) + assertPath(Graph.dijkstra(dijkstra)(graph), expected) + assertPath(Graph.astar(graph, astar), expected) + assertPath(Graph.astar(Graph.beginMutation(graph), astar), expected) + assertPath(Graph.astar(astar)(graph), expected) + assertPath(Graph.bellmanFord(dijkstra)(graph), bellmanFord) + const all = Graph.floydWarshall((edge: number) => edge)(graph) + assert.strictEqual(all.distances.get(0)?.get(3), 2) + assert.deepStrictEqual(all.paths.get(0)?.get(3), expected.path) + assert.deepStrictEqual(all.edges.get(0)?.get(3), expected.edges) + assert.deepStrictEqual(all.costs.get(0)?.get(3), expected.costs) + }) + + it("preserves parallel edges for topological and weighted algorithms", () => { + const graph = directed([0, 1, 2], [[0, 1, 10], [0, 1, 1], [1, 2, 2]]) + + assertIndices(Graph.topo(graph), [0, 1, 2]) + assertPath(Graph.dijkstra(graph, { source: 0, target: 2, cost: (edge) => edge }), { + path: [0, 1, 2], + edges: [1, 2], + distance: 3, + costs: [1, 2] + }) + }) + + it("handles decreased Dijkstra priorities with fresh order and stale entries", () => { + const reordered = directed(["source", "improved", "shortcut", "direct", "target"], [ + [0, 1, 10], + [0, 2, 1], + [0, 3, 2], + [2, 1, 1], + [1, 4, 1], + [3, 4, 1] + ]) + assertPath(Graph.dijkstra(reordered, { source: 0, target: 4, cost: (edge) => edge }), { + path: [0, 3, 4], + edges: [2, 5], + distance: 3, + costs: [2, 1] + }) + + const stale = directed(["source", "improved", "shortcut", "middle", "target"], [ + [0, 1, 10], + [0, 2, 1], + [2, 1, 1], + [1, 3, 20], + [3, 4, 20] + ]) + assertPath(Graph.dijkstra(stale, { source: 0, target: 4, cost: (edge) => edge }), { + path: [0, 2, 1, 3, 4], + edges: [1, 2, 3, 4], + distance: 42, + costs: [1, 1, 20, 20] }) - - const postOrder = Array.from(Graph.indices(Graph.dfsPostOrder(graph, { start: [0, 2] }))) - expect(postOrder).toEqual([1, 0, 3, 2]) // Each component in postorder }) - it("should support incoming direction", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - - // Starting from C, going backwards - const postOrder = Array.from( - Graph.indices(Graph.dfsPostOrder(graph, { - start: [2], - direction: "incoming" - })) + it("skips stale A* entries and does not reopen closed nodes", () => { + const stale = directed(["source", "improved", "shortcut", "middle", "target"], [ + [0, 1, 10], + [0, 2, 1], + [2, 1, 1], + [1, 3, 20], + [3, 4, 20] + ]) + assertPath(Graph.astar(stale, { source: 0, target: 4, cost: (edge) => edge, heuristic: () => 0 }), { + path: [0, 2, 1, 3, 4], + edges: [1, 2, 3, 4], + distance: 42, + costs: [1, 1, 20, 20] + }) + + const closed = directed(["source", "closed", "later", "target"], [ + [0, 1, 10], + [1, 3, 1], + [0, 2, 1], + [2, 1, 1] + ]) + assertPath( + Graph.astar(closed, { + source: 0, + target: 3, + cost: (edge) => edge, + heuristic: (node) => node === "closed" ? -100 : 0 + }), + { path: [0, 1, 3], edges: [0, 1], distance: 11, costs: [10, 1] } ) - expect(postOrder).toEqual([0, 1, 2]) // A, B, C in reverse postorder }) - it("should handle cycles correctly", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - Graph.addEdge(mutable, c, a, 3) // Creates cycle - }) - - const postOrder = Array.from(Graph.indices(Graph.dfsPostOrder(graph, { start: [0] }))) - // Should handle cycle without infinite loop, visiting each node once - expect(postOrder.length).toBe(3) - expect(new Set(postOrder)).toEqual(new Set([0, 1, 2])) + it("preserves sparse parallel edge identity across all shortest-path algorithms", () => { + const graph = Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 2, data: "source" }, { index: 5, data: "target" }], + edges: [{ index: 3, source: 2, target: 5, data: 1 }, { index: 1_000_000, source: 2, target: 5, data: 1 }] + }) + const expected = { path: [2, 5], edges: [3], distance: 1, costs: [1] } + for (const candidate of [graph, Graph.beginMutation(graph)]) { + assertPath(Graph.dijkstra(candidate, { source: 2, target: 5, cost: (edge) => edge }), expected) + assertPath(Graph.astar(candidate, { source: 2, target: 5, cost: (edge) => edge, heuristic: () => 0 }), expected) + assertPath(Graph.bellmanFord(candidate, { source: 2, target: 5, cost: (edge) => edge }), expected) + const all = Graph.floydWarshall(candidate, (edge) => edge) + assert.strictEqual(all.distances.get(2)?.get(5), 1) + assert.deepStrictEqual(all.paths.get(2)?.get(5), [2, 5]) + assert.deepStrictEqual(all.edges.get(2)?.get(5), [3]) + assert.deepStrictEqual(all.costs.get(2)?.get(5), [1]) + } }) - it("should throw error for non-existent start node", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - }) - - expect(() => Graph.dfsPostOrder(graph, { start: [99] })) - .toThrow("Node 99 does not exist") + it("handles unreachable and same-node paths completely", () => { + const graph = directed(["A", "B"], []) + const expected = { path: [0], edges: [], distance: 0, costs: [] } + assert.deepStrictEqual(Graph.dijkstra(graph, { source: 0, target: 1, cost: (edge) => edge }), Option.none()) + assertPath(Graph.dijkstra(graph, { source: 0, target: 0, cost: (edge) => edge }), expected) + assertPath(Graph.astar(graph, { source: 0, target: 0, cost: (edge) => edge, heuristic: () => 0 }), expected) + assertPath(Graph.bellmanFord(graph, { source: 0, target: 0, cost: (edge) => edge }), expected) + const all = Graph.floydWarshall(graph, (edge) => edge) + assert.strictEqual(all.distances.get(0)?.get(1), Infinity) + assert.strictEqual(all.paths.get(0)?.get(1), null) + assert.deepStrictEqual(all.edges.get(0)?.get(1), []) + assert.deepStrictEqual(all.costs.get(0)?.get(1), []) + assert.strictEqual(all.distances.get(0)?.get(0), 0) + assert.deepStrictEqual(all.paths.get(0)?.get(0), [0]) + assert.deepStrictEqual(all.edges.get(0)?.get(0), []) + assert.deepStrictEqual(all.costs.get(0)?.get(0), []) + }) + + it("uses negative Bellman-Ford edges in shortest paths", () => { + assertPath( + Graph.bellmanFord(directed([0, 1, 2], [[0, 1, -1], [1, 2, 3], [0, 2, 5]]), { + source: 0, + target: 2, + cost: (edge) => edge + }), + { path: [0, 1, 2], edges: [0, 1], distance: 2, costs: [-1, 3] } + ) }) - it("should be iterable multiple times with fresh state", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - }) + it("ignores negative cycles that cannot affect the Bellman-Ford target", () => { + const graph = directed([0, 1, 2, 3], [[0, 1, 1], [1, 2, -2], [2, 1, 1], [0, 3, 5]]) + const expected = { path: [0, 3], edges: [3], distance: 5, costs: [5] } + assertPath(Graph.bellmanFord(graph, { source: 0, target: 3, cost: (edge) => edge }), expected) + assertPath( + Graph.bellmanFord(Graph.beginMutation(graph), { source: 0, target: 3, cost: (edge) => edge }), + expected + ) + }) - const iterator = Graph.dfsPostOrder(graph, { start: [0] }) + it("detects directed and undirected negative self-loops when source equals target", () => { + for (const graph of [directed([0], [[0, 0, -1]]), undirected([0], [[0, 0, -1]])]) { + assertGraphError( + () => Graph.bellmanFord(graph, { source: 0, target: 0, cost: (edge) => edge }), + "Negative cycle affects path to node 0" + ) + } + }) - const firstRun = Array.from(Graph.indices(iterator)) - const secondRun = Array.from(Graph.indices(iterator)) + it("preserves null edge payloads in Floyd-Warshall multihop paths", () => { + type Edge = null | { readonly weight: number } + const graph = directed(["A", "B", "C"], [ + [0, 1, null], + [1, 2, null], + [0, 2, { weight: 10 }] + ]) + const result = Graph.floydWarshall(graph, (edge) => edge === null ? 1 : edge.weight) + assert.strictEqual(result.distances.get(0)?.get(2), 2) + assert.deepStrictEqual(result.paths.get(0)?.get(2), [0, 1, 2]) + assert.deepStrictEqual(result.edges.get(0)?.get(2), [0, 1]) + assert.deepStrictEqual(result.costs.get(0)?.get(2), [null, null]) + }) - expect(firstRun).toEqual([1, 0]) - expect(secondRun).toEqual([1, 0]) - expect(firstRun).toEqual(secondRun) + it("treats positive Infinity as unreachable in every shortest-path algorithm", () => { + const graph = directed([0, 1], [[0, 1, Infinity]]) + assert.deepStrictEqual(Graph.dijkstra(graph, { source: 0, target: 1, cost: (edge) => edge }), Option.none()) + assert.deepStrictEqual( + Graph.astar(graph, { source: 0, target: 1, cost: (edge) => edge, heuristic: () => 0 }), + Option.none() + ) + assert.deepStrictEqual(Graph.bellmanFord(graph, { source: 0, target: 1, cost: (edge) => edge }), Option.none()) + const all = Graph.floydWarshall(graph, (edge) => edge) + assert.strictEqual(all.distances.get(0)?.get(1), Infinity) + assert.strictEqual(all.paths.get(0)?.get(1), null) + assert.deepStrictEqual(all.edges.get(0)?.get(1), []) + assert.deepStrictEqual(all.costs.get(0)?.get(1), []) }) - it("should handle corrupted graph state during iteration", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - }) + it("reports relevant Bellman-Ford and Floyd-Warshall negative cycles", () => { + const cycle = directed([0, 1, 2], [[0, 1, 1], [1, 2, -3], [2, 0, 1]]) + assertGraphError( + () => Graph.bellmanFord(cycle, { source: 0, target: 2, cost: (edge) => edge }), + "Negative cycle affects path to node 2" + ) + assertGraphError(() => Graph.floydWarshall(cycle, (edge) => edge), "Negative cycle detected involving node 0") + const negativeUndirected = undirected([0, 1], [[0, 1, -1]]) + assertGraphError( + () => Graph.bellmanFord(negativeUndirected, { source: 0, target: 1, cost: (edge) => edge }), + "Negative cycle affects path to node 1" + ) + }) - // Test edge case by corrupting graph internals during iteration - const mutableGraph = graph as any - const originalGetNode = mutableGraph.nodes.get - - let callCount = 0 - // Mock getNode to return undefined for certain calls to trigger the recursive edge case - mutableGraph.nodes.get = function(key: any) { - callCount++ - // On specific call, return undefined to trigger the Option.isNone path - if (callCount === 3) { - return undefined - } - return originalGetNode.call(this, key) + it("validates endpoints, edge weights, heuristics, and finite arithmetic", () => { + assertGraphError( + () => Graph.dijkstra(Graph.directed(), { source: 0, target: 1, cost: () => 1 }), + "Node 0 does not exist" + ) + for (const weight of [-1, NaN, -Infinity]) { + const invalid = directed([0, 1], [[0, 1, weight]]) + assertGraphError( + () => Graph.dijkstra(invalid, { source: 0, target: 1, cost: (edge) => edge }), + "Dijkstra's algorithm requires non-negative edge weights" + ) + assertGraphError( + () => Graph.astar(invalid, { source: 0, target: 1, cost: (edge) => edge, heuristic: () => 0 }), + "A* algorithm requires non-negative edge weights" + ) } + for (const heuristic of [NaN, Infinity, -Infinity]) { + assertGraphError(() => + Graph.astar(directed(["A"], []), { + source: 0, + target: 0, + cost: () => 1, + heuristic: () => heuristic + }), "A* algorithm requires finite heuristic values") + assertGraphError(() => + Graph.astar(directed(["source", "middle", "target"], [[0, 1, 1], [1, 2, 1]]), { + source: 0, + target: 2, + cost: (edge) => edge, + heuristic: (node) => node === "middle" ? heuristic : 0 + }), "A* algorithm requires finite heuristic values") + } + for (const weight of [NaN, -Infinity]) { + const invalid = directed([0, 1], [[0, 1, weight]]) + assertGraphError( + () => Graph.bellmanFord(invalid, { source: 0, target: 1, cost: (edge) => edge }), + "Bellman-Ford algorithm does not support NaN or -Infinity edge weights" + ) + assertGraphError( + () => Graph.floydWarshall(invalid, (edge) => edge), + "Floyd-Warshall algorithm does not support NaN or -Infinity edge weights" + ) + } + const overflow = directed([0, 1, 2], [[0, 1, Number.MAX_VALUE], [1, 2, Number.MAX_VALUE]]) + assertGraphError( + () => Graph.dijkstra(overflow, { source: 0, target: 2, cost: (edge) => edge }), + "Dijkstra distance calculation exceeded the finite number range" + ) + assertGraphError( + () => Graph.astar(overflow, { source: 0, target: 2, cost: (edge) => edge, heuristic: () => 0 }), + "A* distance calculation exceeded the finite number range" + ) + assertGraphError( + () => + Graph.astar(directed([0, 1], [[0, 1, Number.MAX_VALUE]]), { + source: 0, + target: 1, + cost: (edge) => edge, + heuristic: (node) => node === 1 ? Number.MAX_VALUE : 0 + }), + "A* priority calculation exceeded the finite number range" + ) + assertGraphError( + () => Graph.bellmanFord(overflow, { source: 0, target: 2, cost: (edge) => edge }), + "Bellman-Ford distance calculation exceeded the finite number range" + ) + assertGraphError( + () => Graph.floydWarshall(overflow, (edge) => edge), + "Floyd-Warshall distance calculation exceeded the finite number range" + ) + const underflow = directed([0, 1, 2], [[0, 1, -Number.MAX_VALUE], [1, 2, -Number.MAX_VALUE]]) + assertGraphError( + () => Graph.bellmanFord(underflow, { source: 0, target: 2, cost: (edge) => edge }), + "Bellman-Ford distance calculation exceeded the finite number range" + ) + assertGraphError( + () => Graph.floydWarshall(underflow, (edge) => edge), + "Floyd-Warshall distance calculation exceeded the finite number range" + ) + }) - const iterator = Graph.dfsPostOrder(graph, { start: [0] }) - const results = Array.from(iterator) - - // Restore original method - mutableGraph.nodes.get = originalGetNode + it("validates negative Dijkstra and A* weights before early returns", () => { + const early = directed([0, 1, 2], [[0, 1, 1], [0, 2, 2], [2, 1, -5]]) + const same = directed([0], [[0, 0, -1]]) + for (const [graph, source, target] of [[early, 0, 1], [same, 0, 0]] as const) { + assertGraphError( + () => Graph.dijkstra(graph, { source, target, cost: (edge) => edge }), + "Dijkstra's algorithm requires non-negative edge weights" + ) + assertGraphError( + () => Graph.astar(graph, { source, target, cost: (edge) => edge, heuristic: () => 0 }), + "A* algorithm requires non-negative edge weights" + ) + } + }) - // Should complete without crashing - expect(results.length).toBeGreaterThanOrEqual(0) + it("traverses undirected edges against stored orientation", () => { + const graph = undirected(["A", "B", "C"], [[0, 1, 1], [2, 1, 1]]) + const expected = { path: [0, 1, 2], edges: [0, 1], distance: 2, costs: [1, 1] } + assertPath(Graph.dijkstra(graph, { source: 0, target: 2, cost: (edge) => edge }), expected) + assertPath(Graph.astar(graph, { source: 0, target: 2, cost: (edge) => edge, heuristic: () => 0 }), expected) + assertPath(Graph.bellmanFord(graph, { source: 0, target: 2, cost: (edge) => edge }), expected) + const all = Graph.floydWarshall(graph, (edge) => edge) + assert.strictEqual(all.distances.get(0)?.get(2), 2) + assert.deepStrictEqual(all.paths.get(0)?.get(2), [0, 1, 2]) + assert.deepStrictEqual(all.edges.get(0)?.get(2), [0, 1]) + assert.deepStrictEqual(all.costs.get(0)?.get(2), [1, 1]) }) }) - describe("Graph Element Iterators", () => { - describe("nodes", () => { - it("should iterate over all node indices", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - Graph.addNode(mutable, "B") - Graph.addNode(mutable, "C") - }) - - const indices = Array.from(Graph.indices(Graph.nodes(graph))) - expect(indices).toEqual([0, 1, 2]) - }) - - it("should work with manual iterator control", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - Graph.addNode(mutable, "B") - }) - - const iterator = Graph.indices(Graph.nodes(graph))[Symbol.iterator]() - expect(iterator.next().value).toBe(0) - expect(iterator.next().value).toBe(1) - expect(iterator.next().done).toBe(true) - }) + describe("path enumeration", () => { + it("lazily and repeatably enumerates complete simple paths in edge order", () => { + const graph = directed([0, 1, 2, 3], [ + [0, 1, "01"], + [0, 2, "02"], + [1, 2, "12"], + [1, 3, "13"], + [2, 3, "23"], + [2, 1, "21"] + ]) + const paths = Graph.simplePaths(graph, { source: 0, target: 3, limit: 3 }) + const expected = [ + { path: [0, 1, 2, 3], edges: [0, 2, 4], distance: 3, costs: ["01", "12", "23"] }, + { path: [0, 1, 3], edges: [0, 3], distance: 2, costs: ["01", "13"] }, + { path: [0, 2, 3], edges: [1, 4], distance: 2, costs: ["02", "23"] } + ] + assert.deepStrictEqual(Array.from(paths), expected) + assert.deepStrictEqual(Array.from(paths), expected) + assert.deepStrictEqual(Array.from(Graph.simplePaths({ source: 0, target: 3, limit: 3 })(graph)), expected) }) - describe("edges", () => { - it("should iterate over all edge indices", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - Graph.addEdge(mutable, c, a, 3) - }) - - const indices = Array.from(Graph.indices(Graph.edges(graph))) - expect(indices).toEqual([0, 1, 2]) - }) - - it("should handle graph with no edges", () => { - const graph = Graph.directed((mutable) => { - Graph.addNode(mutable, "A") - Graph.addNode(mutable, "B") - }) - - const indices = Array.from(Graph.indices(Graph.edges(graph))) - expect(indices).toEqual([]) - }) + it("enumerates parallel and structurally tied shortest paths exactly", () => { + const graph = directed([0, 1, 2, 3], [[0, 1, 1], [0, 2, 1], [1, 3, 1], [2, 3, 1], [0, 1, 1]]) + const expected = [ + { path: [0, 1, 3], edges: [0, 2], distance: 2, costs: [1, 1] }, + { path: [0, 1, 3], edges: [4, 2], distance: 2, costs: [1, 1] }, + { path: [0, 2, 3], edges: [1, 3], distance: 2, costs: [1, 1] } + ] + assert.deepStrictEqual( + Array.from(Graph.allShortestPaths(graph, { source: 0, target: 3, cost: (edge) => edge })), + expected + ) + assert.deepStrictEqual( + Array.from(Graph.allShortestPaths({ source: 0, target: 3, cost: (edge: number) => edge })(graph)), + expected + ) + assert.deepStrictEqual( + Array.from(Graph.allShortestPaths(Graph.beginMutation(graph), { + source: 0, + target: 3, + cost: (edge) => edge, + limit: 2 + })), + expected.slice(0, 2) + ) }) - describe("externals", () => { - it("should find nodes with no outgoing edges (sinks)", () => { - const graph = Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "source") // 0 - const middle = Graph.addNode(mutable, "middle") // 1 - const sink = Graph.addNode(mutable, "sink") // 2 - Graph.addNode(mutable, "isolated") // 3 - - Graph.addEdge(mutable, source, middle, 1) - Graph.addEdge(mutable, middle, sink, 2) - // No outgoing edges from sink (2) or isolated (3) - }) - - const sinks = Array.from(Graph.indices(Graph.externals(graph, { direction: "outgoing" }))) - expect(sinks.sort()).toEqual([2, 3]) - }) - - it("should find nodes with no incoming edges (sources)", () => { - const graph = Graph.directed((mutable) => { - const source = Graph.addNode(mutable, "source") // 0 - const middle = Graph.addNode(mutable, "middle") // 1 - const sink = Graph.addNode(mutable, "sink") // 2 - Graph.addNode(mutable, "isolated") // 3 - - Graph.addEdge(mutable, source, middle, 1) - Graph.addEdge(mutable, middle, sink, 2) - // No incoming edges to source (0) or isolated (3) - }) - - const sources = Array.from(Graph.indices(Graph.externals(graph, { direction: "incoming" }))) - expect(sources.sort()).toEqual([0, 3]) - }) - - it("should default to outgoing direction", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - // b has no outgoing edges - }) - - const externalsDefault = Array.from(Graph.indices(Graph.externals(graph))) - const externalsExplicit = Array.from(Graph.indices(Graph.externals(graph, { direction: "outgoing" }))) - - expect(externalsDefault).toEqual(externalsExplicit) - expect(externalsDefault).toEqual([1]) - }) - - it("should handle fully connected components", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - Graph.addEdge(mutable, c, a, 3) // Creates cycle - }) - - const outgoingExternals = Array.from(Graph.indices(Graph.externals(graph, { direction: "outgoing" }))) - const incomingExternals = Array.from(Graph.indices(Graph.externals(graph, { direction: "incoming" }))) + it("terminates zero-cost predecessor cycles and emits only simple shortest paths", () => { + const graph = directed([0, 1, 2, 3], [[0, 1, 0], [1, 2, 0], [2, 1, 0], [1, 3, 1], [2, 3, 1]]) + assert.deepStrictEqual( + Array.from(Graph.allShortestPaths(graph, { source: 0, target: 3, cost: (edge) => edge })), + [ + { path: [0, 1, 3], edges: [0, 3], distance: 1, costs: [0, 1] }, + { path: [0, 1, 2, 3], edges: [0, 1, 4], distance: 1, costs: [0, 0, 1] } + ] + ) + }) - expect(outgoingExternals).toEqual([]) // All nodes have outgoing edges - expect(incomingExternals).toEqual([]) // All nodes have incoming edges + it("defers shortest-path cost evaluation until iteration", () => { + let calls = 0 + const paths = Graph.allShortestPaths(directed([0, 1], [[0, 1, 1]]), { + source: 0, + target: 1, + cost: (edge) => { + calls++ + return edge + } }) + assert.strictEqual(calls, 0) + assert.deepStrictEqual(Array.from(paths), [{ path: [0, 1], edges: [0], distance: 1, costs: [1] }]) + assert.strictEqual(calls, 1) + }) - it("should work with manual iterator control", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - // b and c have no outgoing edges - }) - - const iterator = Graph.indices(Graph.externals(graph, { direction: "outgoing" }))[Symbol.iterator]() - - const first = iterator.next().value - const second = iterator.next().value - const third = iterator.next() + it("revalidates mutable endpoints and isolates active snapshots", () => { + const removed = Graph.beginMutation(directed([0, 1], [[0, 1, 1]])) + const pending = Graph.simplePaths(removed, { source: 0, target: 1 }) + Graph.removeNode(removed, 0) + assertGraphError(() => Array.from(pending), "Node 0 does not exist") - expect([first, second].sort()).toEqual([1, 2]) - expect(third.done).toBe(true) + const shortestRemoved = Graph.beginMutation(directed([0, 1], [[0, 1, 1]])) + const shortestPending = Graph.allShortestPaths(shortestRemoved, { + source: 0, + target: 1, + cost: (edge) => edge }) + Graph.removeNode(shortestRemoved, 1) + assertGraphError(() => Array.from(shortestPending), "Node 1 does not exist") + + const mutable = Graph.beginMutation(directed([0, 1, 2, 3], [[0, 1, 1], [0, 2, 1], [1, 3, 1], [2, 3, 1]])) + const paths = Graph.simplePaths(mutable, { source: 0, target: 3 }) + const iterator = paths[Symbol.iterator]() + assert.deepStrictEqual(iterator.next().value, { + path: [0, 1, 3], + edges: [0, 2], + distance: 2, + costs: [1, 1] + }) + Graph.removeEdge(mutable, 1) + assert.deepStrictEqual(iterator.next().value, { + path: [0, 2, 3], + edges: [1, 3], + distance: 2, + costs: [1, 1] + }) + assert.deepStrictEqual(Array.from(paths), [{ path: [0, 1, 3], edges: [0, 2], distance: 2, costs: [1, 1] }]) + + const shortestMutable = Graph.beginMutation(directed([0, 1, 2, 3], [ + [0, 1, 1], + [0, 2, 1], + [1, 3, 1], + [2, 3, 1] + ])) + const shortest = Graph.allShortestPaths(shortestMutable, { source: 0, target: 3, cost: (edge) => edge }) + const shortestIterator = shortest[Symbol.iterator]() + assert.deepStrictEqual(shortestIterator.next().value, { + path: [0, 1, 3], + edges: [0, 2], + distance: 2, + costs: [1, 1] + }) + Graph.removeEdge(shortestMutable, 1) + assert.deepStrictEqual(shortestIterator.next().value, { + path: [0, 2, 3], + edges: [1, 3], + distance: 2, + costs: [1, 1] + }) + assert.deepStrictEqual(Array.from(shortest), [ + { path: [0, 1, 3], edges: [0, 2], distance: 2, costs: [1, 1] } + ]) }) - it("should allow combining different element iterators", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 100) - }) + it("handles empty path enumerations", () => { + const graph = directed(["A", "B"], []) + const same = [{ path: [0], edges: [], distance: 0, costs: [] }] - // Combine different iterators - const nodeCount = Array.from(Graph.indices(Graph.nodes(graph))).length - const edgeCount = Array.from(Graph.indices(Graph.edges(graph))).length - const nodeData = Array.from(Graph.values(Graph.nodes(graph))) - const edge = Array.from(Graph.values(Graph.edges(graph))) + assert.deepStrictEqual(Array.from(Graph.simplePaths(graph, { source: 0, target: 0 })), same) + assert.deepStrictEqual( + Array.from(Graph.allShortestPaths(graph, { source: 0, target: 0, cost: (edge) => edge })), + same + ) + assert.deepStrictEqual( + Array.from(Graph.allShortestPaths(graph, { source: 0, target: 1, cost: (edge) => edge })), + [] + ) + }) - expect(nodeCount).toBe(2) - expect(edgeCount).toBe(1) - expect(nodeData).toEqual(["A", "B"]) - expect(edge).toEqual([{ source: 0, target: 1, data: 100 }]) + it("validates limits, endpoints, and non-negative shortest-path costs", () => { + const graph = directed([0, 1], [[0, 1, -1]]) + assertGraphError( + () => Array.from(Graph.allShortestPaths(graph, { source: 0, target: 1, cost: (edge) => edge })), + "All shortest paths requires non-negative edge weights" + ) + assertGraphError( + () => Array.from(Graph.allShortestPaths(graph, { source: 0, target: 1, cost: (edge) => edge, limit: 0 })), + "All shortest paths requires non-negative edge weights" + ) + const overflow = directed([0, 1, 2], [[0, 1, Number.MAX_VALUE], [1, 2, Number.MAX_VALUE]]) + assertGraphError( + () => Array.from(Graph.allShortestPaths(overflow, { source: 0, target: 2, cost: (edge) => edge })), + "All shortest paths distance calculation exceeded the finite number range" + ) + assertGraphError( + () => Graph.simplePaths(graph, { source: 0, target: 1, limit: -1 }), + "Path enumeration limit must be a non-negative integer or Infinity" + ) + assertGraphError(() => Graph.simplePaths(graph, { source: 0, target: 2 }), "Node 2 does not exist") + assert.deepStrictEqual(Array.from(Graph.simplePaths(graph, { source: 0, target: 1, limit: 0 })), []) }) }) - describe("GraphIterable abstraction", () => { - it("should enable iteration over different types", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - - // Should work with different iterator types - const dfsIterable = Graph.dfs(graph, { start: [0] }) - const nodesIterable = Graph.nodes(graph) - const externalsIterable = Graph.externals(graph) - - // All should be iterable and have expected structure - expect(Array.from(dfsIterable)).toHaveLength(3) - expect(Array.from(nodesIterable)).toHaveLength(3) - expect(Array.from(externalsIterable)).toHaveLength(1) // Only one node with no outgoing edges + describe("traversal", () => { + const graph = directed(["A", "B", "C", "D"], [[0, 1, 1], [0, 2, 2], [1, 3, 3], [2, 3, 4]]) + + it("traverses DFS, BFS, and postorder in documented order", () => { + assertIndices(Graph.dfs(graph, { start: [0] }), [0, 1, 3, 2]) + assertIndices(Graph.bfs(graph, { start: [0] }), [0, 1, 2, 3]) + assertIndices(Graph.dfsPostOrder(graph, { start: [0] }), [3, 1, 2, 0]) + assertIndices(Graph.dfs(graph), []) + }) + + it("preserves postorder with parallel edges, self-loops, and directionless traversal", () => { + const parallel = directed([0, 1, 2, 3, 4], [ + [0, 1, 1], + [0, 1, 2], + [0, 1, 3], + [1, 2, 4], + [1, 3, 5], + [1, 4, 6], + [1, 1, 7] + ]) + assertIndices(Graph.dfsPostOrder(parallel, { start: [0] }), [2, 3, 4, 1, 0]) + assertIndices(Graph.dfsPostOrder(parallel, { start: [0, 1] }), [2, 3, 4, 1, 0]) + + const directionless = directed([0, 1, 2, 3], [[0, 1, 1], [2, 1, 2], [1, 3, 3], [1, 1, 4]]) + assertIndices(Graph.dfsPostOrder(directionless, { start: [1], direction: "undirected" }), [3, 0, 2, 1]) + }) + + it("uses shortest distance for radius membership and supports traversal directions", () => { + const graph = directed([0, 1, 2, 3], [[0, 1, 1], [1, 2, 1], [0, 2, 1], [2, 3, 1]]) + assertIndices(Graph.dfs(graph, { start: [0], radius: 2 }), [0, 1, 2, 3]) + assertIndices(Graph.bfs(graph, { start: [2], direction: "incoming" }), [2, 1, 0]) + assertIndices(Graph.dfsPostOrder(graph, { start: [2], direction: "incoming" }), [0, 1, 2]) + assertIndices(Graph.bfs(graph, { start: [1], direction: "undirected", radius: 1 }), [1, 2, 0]) + assertIndices( + Graph.dfsPostOrder(directed([0, 1, 2], [[0, 1, 1], [1, 2, 1]]), { + start: [0], + radius: 1 + }), + [1, 0] + ) }) - it("should preserve the receiver for iterable iterator methods", () => { - const walker = new Graph.Walker((f) => new Set([f(0, "A"), f(1, "B")])) + it("preserves bounded undirected DFS order for reciprocal neighbors", () => { + const graph = directed(new Array(5).fill(undefined), [[0, 1, 1], [4, 1, 2], [1, 4, 3]]) - assert.deepStrictEqual(Array.from(walker), [[0, "A"], [1, "B"]]) + assertIndices(Graph.dfs(graph, { start: [1], direction: "undirected", radius: 2 }), [1, 4, 0]) }) - it("should create fresh iterators for generator-backed walkers", () => { - const walker = new Graph.Walker(function*(f) { - yield f(0, "A") - yield f(1, "B") - }) - - const assertRepeated =
(iterable: Iterable, expected: Array) => { - assert.deepStrictEqual(Array.from(iterable), expected) - assert.deepStrictEqual(Array.from(iterable), expected) + it("validates radius in data-first and data-last forms", () => { + for (const radius of [NaN, -Infinity, -1, 0.5]) { + for ( + const run of [ + () => Graph.dfs(graph, { start: [0], radius }), + () => Graph.bfs({ start: [0], radius })(graph), + () => Graph.dfsPostOrder(graph, { start: [0], radius }) + ] + ) assertGraphError(run, "Traversal radius must be a non-negative integer or Infinity") } - - assertRepeated(walker, [[0, "A"], [1, "B"]]) - assert.deepStrictEqual(Array.from(walker.visit((index, data) => `${index}:${data}`)), ["0:A", "1:B"]) - assert.deepStrictEqual(Array.from(walker.visit((index, data) => `${index}:${data}`)), ["0:A", "1:B"]) - assert.deepStrictEqual(Array.from(Graph.indices(walker)), [0, 1]) - assert.deepStrictEqual(Array.from(Graph.indices(walker)), [0, 1]) - assert.deepStrictEqual(Array.from(Graph.values(walker)), ["A", "B"]) - assert.deepStrictEqual(Array.from(Graph.values(walker)), ["A", "B"]) - assert.deepStrictEqual(Array.from(Graph.entries(walker)), [[0, "A"], [1, "B"]]) - assert.deepStrictEqual(Array.from(Graph.entries(walker)), [[0, "A"], [1, "B"]]) - - const left = walker[Symbol.iterator]() - const right = walker[Symbol.iterator]() - assert.deepStrictEqual(left.next(), { done: false, value: [0, "A"] }) - assert.deepStrictEqual(right.next(), { done: false, value: [0, "A"] }) - assert.deepStrictEqual(left.next(), { done: false, value: [1, "B"] }) - assert.deepStrictEqual(right.next(), { done: false, value: [1, "B"] }) - assert.deepStrictEqual(left.next(), { done: true, value: undefined }) - assert.deepStrictEqual(right.next(), { done: true, value: undefined }) }) - }) - - describe("NodeIterable abstraction", () => { - it("should provide common interface for node index iterables", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - // Utility function that works with any NodeWalker - function collectNodes( - nodeIterable: Graph.NodeWalker - ): Array { - return Array.from(Graph.indices(nodeIterable)).sort() - } - - // Both traversal and element iterators implement NodeWalker - const dfsNodes = Graph.dfs(graph, { start: [0] }) - const allNodes = Graph.nodes(graph) - const externalNodes = Graph.externals(graph, { direction: "outgoing" }) - - // All should work with the same utility function - expect(collectNodes(dfsNodes)).toEqual([0, 1, 2]) - expect(collectNodes(allNodes)).toEqual([0, 1, 2]) - expect(collectNodes(externalNodes)).toEqual([2]) // Only node 2 has no outgoing edges + it("copies starts, prioritizes distinct roots, and preserves sparse indexes", () => { + const start = [0] + const walkers = [Graph.dfs(graph, { start }), Graph.bfs(graph, { start }), Graph.dfsPostOrder(graph, { start })] + start[0] = 3 + assertIndices(walkers[0], [0, 1, 3, 2]) + assertIndices(walkers[1], [0, 1, 2, 3]) + assertIndices(walkers[2], [3, 1, 2, 0]) + assertIndices(Graph.bfs(graph, { start: [0, 0, 2, 2] }), [0, 2, 1, 3]) + + const sparse = Graph.fromSnapshot({ + type: "directed", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }, { index: 1_000_000, data: "C" }], + edges: [{ index: 3, source: 2, target: 5, data: 1 }, { + index: 1_000_000, + source: 5, + target: 1_000_000, + data: 2 + }] + }) + assertIndices(Graph.bfs(sparse, { start: [2] }), [2, 5, 1_000_000]) + assertIndices(Graph.dfsPostOrder(sparse, { start: [2] }), [1_000_000, 5, 2]) + }) + + it("revalidates missing starts for fresh DFS, BFS, and postorder iterators", () => { + const mutable = Graph.beginMutation(directed([0, 1], [[0, 1, 1]])) + const walkers = [ + Graph.indices(Graph.dfs(mutable, { start: [0] })), + Graph.indices(Graph.bfs(mutable, { start: [0] })), + Graph.indices(Graph.dfsPostOrder(mutable, { start: [0] })) + ] + Graph.removeNode(mutable, 0) + for (const walker of walkers) assertGraphError(() => walker[Symbol.iterator](), "Node 0 does not exist") }) - it("should allow type-safe node iterable operations", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - }) - - const nodeIterable: Graph.NodeWalker = Graph.nodes(graph) - const traversalIterable: Graph.NodeWalker = Graph.dfs(graph, { - start: [0] - }) - - expect(Array.from(Graph.indices(nodeIterable))).toEqual([0, 1]) - expect(Array.from(Graph.indices(traversalIterable))).toEqual([0, 1]) + it("isolates active mutable traversal snapshots while fresh iterators see mutations", () => { + const mutable = Graph.beginMutation(directed(["A", "B", "C"], [[0, 1, 1], [1, 2, 2]])) + const walkers = [ + Graph.indices(Graph.dfs(mutable, { start: [0] })), + Graph.indices(Graph.bfs(mutable, { start: [0] })), + Graph.indices(Graph.dfsPostOrder(mutable, { start: [0] })) + ] + const active = walkers.map((walker) => walker[Symbol.iterator]()) + Graph.removeNode(mutable, 1) + assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => active[0] }), [0, 1, 2]) + assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => active[1] }), [0, 1, 2]) + assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => active[2] }), [2, 1, 0]) + for (const walker of walkers) assert.deepStrictEqual(Array.from(walker), [0]) }) - }) - - describe("Standalone utility functions", () => { - it("should work with values() function on any NodeIterable", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - const c = Graph.addNode(mutable, "C") - Graph.addEdge(mutable, a, b, 1) - Graph.addEdge(mutable, b, c, 2) - }) - // Test with traversal iterators - const dfsIterable = Graph.dfs(graph, { start: [0] }) - const dfsValues = Array.from(Graph.values(dfsIterable)) - expect(dfsValues).toEqual(["A", "B", "C"]) + it("topologically sorts with prioritized initials and validates fresh mutable state", () => { + const graph = directed(["A", "B", "C", "D"], [[0, 1, 1], [2, 3, 1]]) + assertIndices(Graph.topo(graph, { initials: [2] }), [2, 0, 3, 1]) - // Test with element iterators - const nodesIterable = Graph.nodes(graph) - const nodeValues = Array.from(Graph.values(nodesIterable)) - expect(nodeValues.sort()).toEqual(["A", "B", "C"]) - - // Test with externals iterator - const externalsIterable = Graph.externals(graph, { direction: "outgoing" }) - const externalValues = Array.from(Graph.values(externalsIterable)) - expect(externalValues).toEqual(["C"]) // Only C has no outgoing edges + const mutable = Graph.beginMutation(directed(["A", "B"], [])) + const walker = Graph.topo(mutable) + assertIndices(walker, [0, 1]) + Graph.addEdge(mutable, 0, 1, 1) + Graph.addEdge(mutable, 1, 0, 2) + assertGraphError(() => Array.from(Graph.indices(walker)), "Cannot perform topological sort on cyclic graph") }) - it("should work with entries() function on any NodeIterable", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - }) - - // Test with traversal iterator - const dfsIterable = Graph.dfs(graph, { start: [0] }) - const dfsEntries = Array.from(Graph.entries(dfsIterable)) - expect(dfsEntries).toEqual([[0, "A"], [1, "B"]]) + it("isolates active mutable topological snapshots while fresh iterators see mutations", () => { + const mutable = Graph.beginMutation(directed([0, 1, 2], [[0, 1, 1], [1, 2, 1]])) + const walker = Graph.indices(Graph.topo(mutable)) + const active = walker[Symbol.iterator]() + Graph.removeNode(mutable, 1) - // Test with element iterator - const nodesIterable = Graph.nodes(graph) - const nodeEntries = Array.from(Graph.entries(nodesIterable)) - expect(nodeEntries.sort()).toEqual([[0, "A"], [1, "B"]]) - - // Test with externals iterator - const externalsIterable = Graph.externals(graph, { direction: "outgoing" }) - const externalEntries = Array.from(Graph.entries(externalsIterable)) - expect(externalEntries).toEqual([[1, "B"]]) // Only B has no outgoing edges + assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => active }), [0, 1, 2]) + assert.deepStrictEqual(Array.from(walker), [0, 2]) }) - it("should work with instance methods", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) - }) + it("rejects invalid topological graphs and initials", () => { + assertGraphError( + () => Graph.topo(Graph.undirected() as unknown as Graph.DirectedGraph), + "Cannot perform topological sort on undirected graph" + ) + assertGraphError( + () => Graph.topo(directed([0, 1], [[0, 1, 1], [1, 0, 1]])), + "Cannot perform topological sort on cyclic graph" + ) + const graph = directed([0, 1], [[0, 1, 1]]) + assertGraphError(() => Array.from(Graph.topo(graph, { initials: [1] })), "Initial node 1 has incoming edges") + assertGraphError(() => Graph.topo(graph, { initials: [2] }), "Node 2 does not exist") + }) - const dfs = Graph.dfs(graph, { start: [0] }) + it("keeps nodes, edges, and externals live on mutable graphs", () => { + const mutable = Graph.beginMutation(directed(["A", "B"], [[0, 1, 1]])) + const nodes = Graph.indices(Graph.nodes(mutable))[Symbol.iterator]() + const edges = Graph.indices(Graph.edges(mutable))[Symbol.iterator]() + const externals = Graph.indices(Graph.externals(mutable))[Symbol.iterator]() + assert.strictEqual(nodes.next().value, 0) + assert.strictEqual(edges.next().value, 0) + assert.strictEqual(externals.next().value, 1) - // Instance methods should work - const instanceValues = Array.from(Graph.values(dfs)) - const instanceEntries = Array.from(Graph.entries(dfs)) + Graph.addNode(mutable, "C") + Graph.addEdge(mutable, 1, 2, 2) + assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => nodes }), [1, 2]) + assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => edges }), [1]) + assert.deepStrictEqual(Array.from({ [Symbol.iterator]: () => externals }), [2]) + }) - expect(instanceValues).toEqual(["A", "B"]) - expect(instanceEntries).toEqual([[0, "A"], [1, "B"]]) + it("selects outgoing sinks and incoming sources", () => { + const graph = directed(["source", "middle", "sink", "isolated"], [[0, 1, 1], [1, 2, 2]]) + assertIndices(Graph.externals(graph), [2, 3]) + assertIndices(Graph.externals(graph, { direction: "outgoing" }), [2, 3]) + assertIndices(Graph.externals(graph, { direction: "incoming" }), [0, 3]) }) + }) - it("should work with mapEntry for NodeIterable", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 1) + describe("Walker", () => { + it("projects representative walkers through indices, values, entries, and visit", () => { + const walker = new Graph.Walker(function*(visit) { + yield visit(2, "A") + yield visit(5, "B") }) - const dfs = Graph.dfs(graph, { start: [0] }) - - // Test mapEntry with custom mapping - const custom = Array.from(dfs.visit((index, data) => ({ id: index, name: data }))) - expect(custom).toEqual([{ id: 0, name: "A" }, { id: 1, name: "B" }]) - - // Test that values() is implemented using mapEntry - const values = Array.from(Graph.values(dfs)) - expect(values).toEqual(["A", "B"]) - - // Test that entries() is implemented using mapEntry - const entries = Array.from(Graph.entries(dfs)) - expect(entries).toEqual([[0, "A"], [1, "B"]]) + assert.deepStrictEqual(Array.from(Graph.indices(walker)), [2, 5]) + assert.deepStrictEqual(Array.from(Graph.values(walker)), ["A", "B"]) + assert.deepStrictEqual(Array.from(Graph.entries(walker)), [[2, "A"], [5, "B"]]) + assert.deepStrictEqual(Array.from(walker.visit((index, value) => `${index}:${value}`)), ["2:A", "5:B"]) }) - it("should work with mapEntry for EdgeIterable", () => { - const graph = Graph.directed((mutable) => { - const a = Graph.addNode(mutable, "A") - const b = Graph.addNode(mutable, "B") - Graph.addEdge(mutable, a, b, 42) + it("is repeatable and gives independent iterators fresh state", () => { + const walker = new Graph.Walker(function*(visit) { + yield visit(0, "A") + yield visit(1, "B") }) + assert.deepStrictEqual(Array.from(walker), [[0, "A"], [1, "B"]]) + assert.deepStrictEqual(Array.from(walker), [[0, "A"], [1, "B"]]) - const edgesIterable = Graph.edges(graph) - - // Test mapEntry with custom mapping - const connections = Array.from(edgesIterable.visit((index, edge) => ({ - id: index, - from: edge.source, - to: edge.target, - weight: edge.data - }))) - expect(connections).toEqual([{ id: 0, from: 0, to: 1, weight: 42 }]) - - // Test that values() is implemented using mapEntry - const weights = Array.from(edgesIterable.visit((_, edge) => edge.data)) - expect(weights).toEqual([42]) + const left = walker[Symbol.iterator]() + const right = walker[Symbol.iterator]() + assert.deepStrictEqual(left.next(), { done: false, value: [0, "A"] }) + assert.deepStrictEqual(right.next(), { done: false, value: [0, "A"] }) + assert.deepStrictEqual(left.next(), { done: false, value: [1, "B"] }) + assert.deepStrictEqual(right.next(), { done: false, value: [1, "B"] }) + }) - // Test that entries() is implemented using mapEntry - const entries = Array.from(Graph.entries(edgesIterable)) - expect(entries).toEqual([[0, { source: 0, target: 1, data: 42 }]]) + it("preserves receivers supplied by iterable iterator methods", () => { + const walker = new Graph.Walker((visit) => new Set([visit(0, "A"), visit(1, "B")])) + assert.deepStrictEqual(Array.from(walker), [[0, "A"], [1, "B"]]) }) }) }) diff --git a/repos/effect/packages/effect/test/HttpClient.test.ts b/repos/effect/packages/effect/test/HttpClient.test.ts index db563ad5d0..58ad6c0a3d 100644 --- a/repos/effect/packages/effect/test/HttpClient.test.ts +++ b/repos/effect/packages/effect/test/HttpClient.test.ts @@ -37,7 +37,7 @@ const makeJsonPlaceholder = Effect.gen(function*() { }) interface JsonPlaceholder extends Effect.Success {} const JsonPlaceholder = Context.Service("test/JsonPlaceholder") -const JsonPlaceholderLive = Layer.effect(JsonPlaceholder)(makeJsonPlaceholder) +const JsonPlaceholderLayer = Layer.effect(JsonPlaceholder)(makeJsonPlaceholder) const TestRoutes = HttpRouter.serve(HttpRouter.use(Effect.fnUntraced(function*(router) { yield* router.addAll([ HttpRouter.route("GET", "/", Effect.succeed(HttpServerResponse.text("test"))), @@ -75,8 +75,8 @@ const TestRoutes = HttpRouter.serve(HttpRouter.use(Effect.fnUntraced(function*(r HttpRouter.route("HEAD", "/todos", Effect.succeed(HttpServerResponse.empty({ status: 200 }))) ]) }))) -const DenoHttpServerUrl = new URL("../../platform-deno/src/DenoHttpServer.ts", import.meta.url).href -const TestServerLive = Layer.unwrap(Effect.promise(() => +const DenoHttpServerUrl = new URL("../../platform/deno/src/DenoHttpServer.ts", import.meta.url).href +const TestServerLayer = Layer.unwrap(Effect.promise(() => "Deno" in globalThis ? (import(DenoHttpServerUrl) as Promise<{ readonly layerServer: (options: { @@ -98,9 +98,9 @@ const TestServerLive = Layer.unwrap(Effect.promise(() => ].forEach(({ layer, name }) => { const layerTest = HttpServer.layerTestClient.pipe( Layer.provide(layer), - Layer.provideMerge(TestServerLive) + Layer.provideMerge(TestServerLayer) ) - const testLayer = Layer.merge(JsonPlaceholderLive, TestRoutes).pipe( + const testLayer = Layer.merge(JsonPlaceholderLayer, TestRoutes).pipe( Layer.provideMerge(layerTest) ) diff --git a/repos/effect/packages/effect/test/JsonSchema.test.ts b/repos/effect/packages/effect/test/JsonSchema.test.ts index 1473b06315..f5603f7736 100644 --- a/repos/effect/packages/effect/test/JsonSchema.test.ts +++ b/repos/effect/packages/effect/test/JsonSchema.test.ts @@ -6,9 +6,12 @@ import * as Schema from "effect/Schema" // oxlint-disable-next-line @typescript-eslint/no-require-imports const AjvDraft07 = require("ajv") // oxlint-disable-next-line @typescript-eslint/no-require-imports +const AjvDraft2020 = require("ajv/dist/2020") +// oxlint-disable-next-line @typescript-eslint/no-require-imports const AjvDraft04 = require("ajv-draft-04") const ajvDraft07 = new AjvDraft07.default({ allErrors: true, strict: false }) +const ajvDraft2020 = new AjvDraft2020.default({ allErrors: true, strict: false }) const ajvDraft04 = new AjvDraft04.default({ allErrors: true, strict: false }) function makeSchema(document: JsonSchema.Document<"draft-04" | "draft-07">): JsonSchema.JsonSchema { @@ -21,6 +24,14 @@ function makeSchema(document: JsonSchema.Document<"draft-04" | "draft-07">): Jso } } +function makeCanonicalSchema(document: JsonSchema.Document<"draft-2020-12">): JsonSchema.JsonSchema { + return { + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + ...document.schema, + ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}) + } +} + function assertDoesNotMutate(input: A, f: (input: A) => unknown): void { const before = structuredClone(input) f(input) @@ -36,80 +47,6 @@ describe("JsonSchema", () => { }) }) - describe("resolve$ref", () => { - it("resolves a definition", () => { - const definition: JsonSchema.JsonSchema = { type: "string" } - deepStrictEqual(JsonSchema.resolve$ref("#/$defs/A", { A: definition }), definition) - }) - - it("unescapes the referenced JSON Pointer token", () => { - const definition: JsonSchema.JsonSchema = { type: "string" } - deepStrictEqual(JsonSchema.resolve$ref("#/$defs/A~1B~0C", { "A/B~C": definition }), definition) - }) - - it("returns undefined for a missing definition", () => { - deepStrictEqual(JsonSchema.resolve$ref("#/$defs/Missing", {}), undefined) - }) - - it("ignores inherited definitions", () => { - deepStrictEqual(JsonSchema.resolve$ref("#/$defs/constructor", {}), undefined) - }) - - it("resolves __proto__ when it is an own definition", () => { - const definition: JsonSchema.JsonSchema = { type: "string" } - deepStrictEqual( - JsonSchema.resolve$ref("#/$defs/__proto__", { ["__proto__"]: definition }), - definition - ) - }) - }) - - describe("resolveTopLevel$ref", () => { - it("resolves a top-level ref without mutating the document definitions", () => { - const definition: JsonSchema.JsonSchema = { type: "string" } - const document: JsonSchema.Document<"draft-2020-12"> = { - dialect: "draft-2020-12", - schema: { $ref: "#/$defs/A" }, - definitions: { A: definition } - } - const result = JsonSchema.resolveTopLevel$ref(document) - - assert.notStrictEqual(result, document) - assert.strictEqual(result.definitions, document.definitions) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: definition, - definitions: document.definitions - }) - }) - - it("returns the same document when the top-level ref cannot be resolved", () => { - const document: JsonSchema.Document<"draft-2020-12"> = { - dialect: "draft-2020-12", - schema: { $ref: "#/$defs/Missing" }, - definitions: {} - } - - assert.strictEqual(JsonSchema.resolveTopLevel$ref(document), document) - }) - - it("returns the same document when there is no string top-level ref", () => { - const withoutRef: JsonSchema.Document<"draft-2020-12"> = { - dialect: "draft-2020-12", - schema: { type: "string" }, - definitions: {} - } - const withNonStringRef: JsonSchema.Document<"draft-2020-12"> = { - dialect: "draft-2020-12", - schema: { $ref: 1 }, - definitions: {} - } - - assert.strictEqual(JsonSchema.resolveTopLevel$ref(withoutRef), withoutRef) - assert.strictEqual(JsonSchema.resolveTopLevel$ref(withNonStringRef), withNonStringRef) - }) - }) - describe("sanitizeOpenApiComponentsSchemasKey", () => { const sanitizeOpenApiComponentsKey = JsonSchema.sanitizeOpenApiComponentsSchemasKey @@ -178,6 +115,167 @@ describe("JsonSchema", () => { }) describe("fromSchemaDraft07", () => { + it("rewrites the Draft-07 meta-schema URI without an empty fragment", () => { + deepStrictEqual( + JsonSchema.fromSchemaDraft07({ $schema: "http://json-schema.org/draft-07/schema" }).schema, + { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12 } + ) + }) + + it("preserves all Draft-07 semantics and opaque custom keywords", () => { + const custom = { $ref: "#/definitions/Literal" } + const input: JsonSchema.JsonSchema = { + if: { properties: { kind: { const: "full" } } }, + // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword + then: { required: ["value"] }, + else: { not: { required: ["value"] } }, + contains: { type: "number" }, + dependencies: { + enabled: ["value"], + kind: { required: ["label"] } + }, + "x-custom": custom + } + + deepStrictEqual(JsonSchema.fromSchemaDraft07(input), { + dialect: "draft-2020-12", + schema: { + if: { properties: { kind: { const: "full" } } }, + // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword + then: { required: ["value"] }, + else: { not: { required: ["value"] } }, + contains: { type: "number" }, + dependentRequired: { enabled: ["value"] }, + dependentSchemas: { kind: { required: ["label"] } }, + "x-custom": custom + }, + definitions: {} + }) + }) + + it("preserves Draft-07 validation semantics in the canonical dialect", () => { + const source: JsonSchema.JsonSchema = { + type: "object", + properties: { + kind: { enum: ["full", "compact"] }, + value: { type: "string" }, + extra: true, + values: { type: "array", contains: { type: "number" } } + }, + required: ["kind", "values"], + dependencies: { value: ["extra"] }, + if: { properties: { kind: { const: "full" } } }, + // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword + then: { required: ["value"] }, + else: { not: { required: ["value"] } } + } + const canonical = makeCanonicalSchema(JsonSchema.fromSchemaDraft07(source)) + deepStrictEqual(ajvDraft07.validateSchema(source), true) + deepStrictEqual(ajvDraft2020.validateSchema(canonical), true) + + const validateSource = ajvDraft07.compile(source) + const validateCanonical = ajvDraft2020.compile(canonical) + for ( + const value of [ + { kind: "full", value: "ok", extra: true, values: [1] }, + { kind: "full", values: [1] }, + { kind: "full", value: "missing dependency", values: [1] }, + { kind: "compact", values: [1] }, + { kind: "compact", value: "forbidden", extra: true, values: [1] }, + { kind: "full", value: "ok", extra: true, values: ["no"] } + ] + ) { + deepStrictEqual(validateCanonical(value), validateSource(value)) + } + }) + + it("converts Draft-07 plain-name identifiers to canonical anchors", () => { + const document = JsonSchema.fromSchemaDraft07({ + $id: "https://example.com/node#entry", + type: "string" + }) + + deepStrictEqual(document.schema, { + $id: "https://example.com/node", + $anchor: "entry", + type: "string" + }) + }) + + it("converts canonical anchors without schema identifiers", () => { + const document: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { $anchor: "entry" }, + definitions: {} + } + + deepStrictEqual(JsonSchema.toDocumentDraft07(document).schema, { + $id: "#entry" + }) + deepStrictEqual(JsonSchema.toDocumentDraft04(document).schema, { + id: "#entry" + }) + }) + + it("rejects canonical anchors combined with schema identifiers", () => { + for (const $id of ["https://example.com/node", "https://example.com/node#"]) { + const document: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { $id, $anchor: "entry" }, + definitions: {} + } + + assert.throws( + () => JsonSchema.toDocumentDraft07(document), + /Cannot convert JSON Schema keyword "\$anchor" to Draft-07/ + ) + assert.throws( + () => JsonSchema.toDocumentDraft04(document), + /Cannot convert JSON Schema keyword "\$anchor" to Draft-04/ + ) + } + }) + + it("rejects canonical anchors that cannot become legacy plain-name identifiers", () => { + const document: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { $anchor: "_node" }, + definitions: {} + } + + assert.throws( + () => JsonSchema.toDocumentDraft07(document), + /Cannot convert JSON Schema keyword "\$anchor" to Draft-07/ + ) + assert.throws( + () => JsonSchema.toDocumentDraft04(document), + /Cannot convert JSON Schema keyword "\$anchor" to Draft-04/ + ) + }) + + it("rejects Draft-07 identifiers with fragments that cannot become anchors", () => { + assert.throws( + () => JsonSchema.fromSchemaDraft07({ $id: "https://example.com/node#not/a/plain-name" }), + /Cannot convert JSON Schema keyword "\$id" to Draft 2020-12/ + ) + }) + + it("rejects Draft-07 custom keywords that would become active in the canonical dialect", () => { + for ( + const schema of [ + { dependentRequired: { value: ["other"] } }, + { $vocabulary: { "https://example.com/vocabulary": true } }, + { contentSchema: {} }, + { deprecated: true } + ] + ) { + assert.throws( + () => JsonSchema.fromSchemaDraft07(schema), + /Cannot convert JSON Schema keyword .* to Draft 2020-12/ + ) + } + }) + it("preserves not", () => { const input: JsonSchema.JsonSchema = { not: { type: "string" } } const result = JsonSchema.fromSchemaDraft07(input) @@ -231,7 +329,6 @@ describe("JsonSchema", () => { }, definitions: { A: { - type: "string", $ref: "#/$defs/B" }, B: { @@ -239,6 +336,28 @@ describe("JsonSchema", () => { } } }) + + const validateCanonical = ajvDraft2020.compile(makeCanonicalSchema(result)) + const values = [ + { a: 1, b: 2 }, + { a: "not a number", b: 2 }, + { a: 1, b: "not a number" } + ] + deepStrictEqual(values.map(validateCanonical), [true, false, false]) + }) + + it("ignores $id next to Draft-07 refs when tracking resources", () => { + const result = JsonSchema.fromSchemaDraft07({ + definitions: { + Target: { type: "string" }, + Reference: { + $id: "nested.json", + $ref: "#/definitions/Target" + } + } + }) + + deepStrictEqual(result.definitions.Reference, { $ref: "#/$defs/Target" }) }) it("converts Draft-07 tuple items to prefixItems", () => { @@ -265,6 +384,30 @@ describe("JsonSchema", () => { }) }) + it("relocates refs into converted tuple items and schema dependencies", () => { + const result = JsonSchema.fromSchemaDraft07({ + properties: { + container: { + items: [{ type: "string" }], + dependencies: { value: { type: "number" } } + }, + tupleItem: { $ref: "#/properties/container/items/0" }, + dependency: { $ref: "#/properties/container/dependencies/value" } + } + }) + + deepStrictEqual(result.schema, { + properties: { + container: { + prefixItems: [{ type: "string" }], + dependentSchemas: { value: { type: "number" } } + }, + tupleItem: { $ref: "#/properties/container/prefixItems/0" }, + dependency: { $ref: "#/properties/container/dependentSchemas/value" } + } + }) + }) + it("preserves a single items schema as items", () => { const input: JsonSchema.JsonSchema = { type: "array", @@ -453,7 +596,7 @@ describe("JsonSchema", () => { }) }) - it("preserves nested definitions and local JSON Pointer refs", () => { + it("moves nested definitions to $defs and relocates local JSON Pointer refs", () => { const input: JsonSchema.JsonSchema = { type: "object", properties: { @@ -474,12 +617,12 @@ describe("JsonSchema", () => { type: "object", properties: { nested: { - definitions: { + $defs: { NestedType: { type: "number" } }, - $ref: "#/properties/nested/definitions/NestedType" + $ref: "#/properties/nested/$defs/NestedType" } } }, @@ -487,7 +630,105 @@ describe("JsonSchema", () => { }) }) - it("drops non-standard properties in Draft-07 input", () => { + it("relocates fragment refs relative to nested schema resources", () => { + const result = JsonSchema.fromSchemaDraft07({ + properties: { + nested: { + $id: "nested.json", + definitions: { Value: { type: "string" } }, + allOf: [{ $ref: "#/definitions/Value" }] + } + } + }) + + deepStrictEqual(result.schema, { + properties: { + nested: { + $id: "nested.json", + $defs: { Value: { type: "string" } }, + allOf: [{ $ref: "#/$defs/Value" }] + } + } + }) + }) + + it("relocates URI refs to nested schema resources", () => { + const result = JsonSchema.fromSchemaDraft07({ + $id: "https://example.com/root.json", + properties: { + value: { $ref: "nested.json#/definitions/Value" } + }, + definitions: { + Nested: { + $id: "schemas/../nested.json", + definitions: { Value: { type: "string" } } + } + } + }) + + deepStrictEqual(result, { + dialect: "draft-2020-12", + schema: { + $id: "https://example.com/root.json", + properties: { + value: { $ref: "nested.json#/$defs/Value" } + } + }, + definitions: { + Nested: { + $id: "schemas/../nested.json", + $defs: { Value: { type: "string" } } + } + } + }) + }) + + it("relocates JSON Pointer URI fragments", () => { + const result = JsonSchema.fromSchemaDraft07({ + properties: { + value: { $ref: "#/definitions/a%20b" }, + equivalent: { $ref: "#/definitions/%56alue" }, + encodedSeparators: { $ref: "#%2Fdefinitions%2FValue" }, + percent: { $ref: "#/definitions/a%25b" }, + escaped: { $ref: "#/definitions/a~1b" }, + utf8: { $ref: "#/definitions/caf%C3%A9" }, + invalid: { $ref: "#/%" }, + invalidUtf8: { $ref: "#/%FF" } + }, + definitions: { + "a b": { type: "string" }, + Value: { type: "number" }, + "a%b": { type: "boolean" }, + "a/b": { type: "object" }, + "café": { type: "array" } + } + }) + + deepStrictEqual(result, { + dialect: "draft-2020-12", + schema: { + properties: { + value: { $ref: "#/$defs/a%20b" }, + equivalent: { $ref: "#/$defs/Value" }, + encodedSeparators: { $ref: "#/$defs/Value" }, + percent: { $ref: "#/$defs/a%25b" }, + escaped: { $ref: "#/$defs/a~1b" }, + utf8: { $ref: "#/$defs/caf%C3%A9" }, + invalid: { $ref: "#/%" }, + invalidUtf8: { $ref: "#/%FF" } + } + }, + definitions: { + "a b": { type: "string" }, + Value: { type: "number" }, + "a%b": { type: "boolean" }, + "a/b": { type: "object" }, + "café": { type: "array" } + } + }) + }) + + it("preserves custom properties in Draft-07 input", () => { const input: JsonSchema.JsonSchema = { type: "string", "x-custom": "value" @@ -496,7 +737,8 @@ describe("JsonSchema", () => { deepStrictEqual(result, { dialect: "draft-2020-12", schema: { - type: "string" + type: "string", + "x-custom": "value" }, definitions: {} }) @@ -524,7 +766,7 @@ describe("JsonSchema", () => { $ref: 1, properties: { nested: { - definitions: "invalid", + $defs: "invalid", not: [false, { type: "string" }] } }, @@ -658,6 +900,13 @@ describe("JsonSchema", () => { }) describe("fromSchemaOpenApi3_1", () => { + it("normalizes the OpenAPI 3.1 base dialect URI", () => { + deepStrictEqual( + JsonSchema.fromSchemaOpenApi3_1({ $schema: "https://spec.openapis.org/oas/3.1/dialect/base" }).schema, + { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12 } + ) + }) + it("preserves non-string refs and malformed schema maps", () => { const input: JsonSchema.JsonSchema = { $ref: null, @@ -677,7 +926,8 @@ describe("JsonSchema", () => { type: "object", properties: { a: { $ref: "#/components/schemas/A" }, - b: { $ref: "#/components/schemas/B" } + b: { $ref: "#/components/schemas/B" }, + dynamic: { $dynamicRef: "#/components/schemas/Dynamic" } } } const result = JsonSchema.fromSchemaOpenApi3_1(input) @@ -687,13 +937,53 @@ describe("JsonSchema", () => { type: "object", properties: { a: { $ref: "#/$defs/A" }, - b: { $ref: "#/$defs/B" } + b: { $ref: "#/$defs/B" }, + dynamic: { $dynamicRef: "#/$defs/Dynamic" } } }, definitions: {} }) }) + it("rewrites percent-encoded OpenAPI component refs", () => { + deepStrictEqual( + JsonSchema.fromSchemaOpenApi3_1({ $ref: "#%2Fcomponents%2Fschemas%2FA%20B" }), + { + dialect: "draft-2020-12", + schema: { $ref: "#/$defs/A%20B" }, + definitions: {} + } + ) + }) + + it("normalizes singular examples throughout OpenAPI 3.1 schemas", () => { + deepStrictEqual( + JsonSchema.fromSchemaOpenApi3_1({ + example: "root", + properties: { + value: { example: "nested" } + } + }), + { + dialect: "draft-2020-12", + schema: { + examples: ["root"], + properties: { + value: { examples: ["nested"] } + } + }, + definitions: {} + } + ) + }) + + it("merges singular and array examples in OpenAPI 3.1 schemas", () => { + deepStrictEqual( + JsonSchema.fromSchemaOpenApi3_1({ example: "singular", examples: ["first", "second"] }).schema, + { examples: ["singular", "first", "second"] } + ) + }) + it("rewrites refs only in schema positions", () => { const literal = { $ref: "#/components/schemas/Literal" } const input: JsonSchema.JsonSchema = { @@ -768,6 +1058,22 @@ describe("JsonSchema", () => { }) }) + it("does not rewrite refs inside root schema resources", () => { + const input: JsonSchema.JsonSchema = { + $id: "inline.json", + allOf: [ + { $ref: "#/components/schemas/Value" }, + { $dynamicRef: "#/components/schemas/Value" } + ] + } + + deepStrictEqual(JsonSchema.fromSchemaOpenApi3_1(input), { + dialect: "draft-2020-12", + schema: input, + definitions: {} + }) + }) + it("extracts root $defs after rewriting OpenAPI component refs", () => { const input: JsonSchema.JsonSchema = { type: "object", @@ -887,36 +1193,23 @@ describe("JsonSchema", () => { }) }) - it("extracts root definitions after rewriting OpenAPI component refs", () => { - const input: JsonSchema.JsonSchema = { - type: "object", - properties: { - a: { - $ref: "#/components/schemas/A" - } - }, - definitions: { - MyType: { - type: "string" - } + it("rewrites percent-encoded OpenAPI 3.0 component refs", () => { + deepStrictEqual( + JsonSchema.fromSchemaOpenApi3_0({ $ref: "#%2Fcomponents%2Fschemas%2FA%20B" }), + { + dialect: "draft-2020-12", + schema: { $ref: "#/$defs/A%20B" }, + definitions: {} } - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { + ) + }) + + it("preserves definitions as an opaque OpenAPI 3.0 extension", () => { + const definitions = { Value: { type: "string" } } + deepStrictEqual(JsonSchema.fromSchemaOpenApi3_0({ definitions }), { dialect: "draft-2020-12", - schema: { - type: "object", - properties: { - a: { - $ref: "#/$defs/A" - } - } - }, - definitions: { - MyType: { - type: "string" - } - } + schema: { definitions }, + definitions: {} }) }) @@ -936,17 +1229,44 @@ describe("JsonSchema", () => { }) }) - it("prefers examples over a singular example", () => { + it("preserves the OpenAPI 3.0 deprecated annotation", () => { + assertFromSchemaOpenApi3_0( + { type: "string", deprecated: true }, + { schema: { type: "string", deprecated: true } } + ) + }) + + it("rejects examples because it is not active in OpenAPI 3.0", () => { + assert.throws( + () => JsonSchema.fromSchemaOpenApi3_0({ examples: ["value"] }), + /Cannot convert JSON Schema keyword "examples" to Draft 2020-12/ + ) + }) + + it("rejects Draft 2020-12 keywords that are not supported by OpenAPI 3.0", () => { + assert.throws( + () => JsonSchema.fromSchemaOpenApi3_0({ unevaluatedProperties: false }), + /Cannot convert JSON Schema keyword "unevaluatedProperties" to Draft 2020-12/ + ) + }) + + it("does not traverse opaque OpenAPI values", () => { + const literal = { nullable: true, $ref: "#/components/schemas/Literal" } assertFromSchemaOpenApi3_0( { - type: "string", - example: "ignored", - examples: ["kept"] + type: "object", + default: literal, + example: literal, + discriminator: { mapping: { value: "#/components/schemas/Value" } }, + "x-custom": literal }, { schema: { - type: "string", - examples: ["kept"] + type: "object", + default: literal, + examples: [literal], + discriminator: { mapping: { value: "#/components/schemas/Value" } }, + "x-custom": literal } } ) @@ -967,9 +1287,9 @@ describe("JsonSchema", () => { describe("nullable", () => { testOpenApi3_0Cases([ { - name: "expands a schema without other keywords to anyOf", + name: "ignores nullable without an explicit type", input: { nullable: true }, - expected: { anyOf: [{}, { type: "null" }] } + expected: {} }, { name: "adds null to a string type", @@ -977,44 +1297,9 @@ describe("JsonSchema", () => { expected: { type: ["string", "null"] } }, { - name: "adds null to a type array", - input: { type: ["string", "number"], nullable: true }, - expected: { type: ["string", "number", "null"] } - }, - { - name: "does not widen the null type", - input: { type: "null", nullable: true }, - expected: { type: "null" } - }, - { - name: "does not duplicate null in a type array", - input: { type: ["string", "null"], nullable: true }, - expected: { type: ["string", "null"] } - }, - { - name: "leaves a malformed type unchanged", - input: { type: 1, nullable: true }, - expected: { type: 1 } - }, - { - name: "keeps a non-null const while adding null to the type", - input: { type: "string", const: "a", nullable: true }, - expected: { type: ["string", "null"], const: "a" } - }, - { - name: "wraps a non-null const in anyOf when type is absent", - input: { const: "a", nullable: true }, - expected: { anyOf: [{ const: "a" }, { type: "null" }] } - }, - { - name: "keeps a null const without adding anyOf", - input: { const: null, nullable: true }, - expected: { const: null } - }, - { - name: "adds null to enum values and type", + name: "does not widen enum values", input: { type: "string", enum: ["a", "b"], nullable: true }, - expected: { type: ["string", "null"], enum: ["a", "b", null] } + expected: { type: ["string", "null"], enum: ["a", "b"] } }, { name: "does not duplicate null in enum values", @@ -1027,9 +1312,9 @@ describe("JsonSchema", () => { expected: { type: ["string", "null"], enum: [null] } }, { - name: "uses anyOf for schemas without type", + name: "ignores nullable when another constraint is present without type", input: { nullable: true, minimum: 0 }, - expected: { anyOf: [{ minimum: 0 }, { type: "null" }] } + expected: { minimum: 0 } }, { name: "drops nullable false", @@ -1041,57 +1326,258 @@ describe("JsonSchema", () => { input: { type: "string", allOf: [{ nullable: true }] }, expected: { type: "string", - allOf: [{ anyOf: [{}, { type: "null" }] }] + allOf: [{}] + } + }, + { + name: "normalizes nullable on both a parent and its allOf member", + input: { type: "string", nullable: true, allOf: [{ nullable: true }] }, + expected: { + type: ["string", "null"], + allOf: [{}] } + } + ]) + }) + + describe("exclusivity", () => { + testOpenApi3_0Cases([ + { + name: "turns exclusiveMinimum true into the minimum value", + input: { type: "number", minimum: 10, exclusiveMinimum: true }, + expected: { type: "number", exclusiveMinimum: 10 } + }, + { + name: "turns exclusiveMaximum true into the maximum value", + input: { type: "number", maximum: 100, exclusiveMaximum: true }, + expected: { type: "number", exclusiveMaximum: 100 } + }, + { + name: "drops exclusiveMinimum false", + input: { type: "number", minimum: 10, exclusiveMinimum: false }, + expected: { type: "number", minimum: 10 } + }, + { + name: "drops exclusiveMaximum false", + input: { type: "number", maximum: 100, exclusiveMaximum: false }, + expected: { type: "number", maximum: 100 } + }, + { + name: "drops exclusiveMinimum true when minimum is absent", + input: { type: "number", exclusiveMinimum: true }, + expected: { type: "number" } + }, + { + name: "drops exclusiveMaximum true when maximum is absent", + input: { type: "number", exclusiveMaximum: true }, + expected: { type: "number" } + } + ]) + }) + }) + + describe("toDocumentDraft07", () => { + it("rewrites the canonical meta-schema URI with an empty fragment", () => { + deepStrictEqual( + JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: { $schema: `${JsonSchema.META_SCHEMA_URI_DRAFT_2020_12}#` }, + definitions: {} + }).schema, + { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07 } + ) + }) + + it("omits the canonical meta-schema URI from embedded resources", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: {}, + definitions: { + Embedded: { + $id: "embedded.json", + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + type: "string" + } + } + } + + deepStrictEqual(JsonSchema.toDocumentDraft07(input).definitions.Embedded, { + $id: "embedded.json", + type: "string" + }) + }) + + it("rejects custom dialects in embedded resources", () => { + assert.throws( + () => + JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: {}, + definitions: { + Embedded: { + $id: "embedded.json", + $schema: "https://example.com/dialect", + type: "string" + } + } + }), + /Cannot convert JSON Schema keyword "\$schema" to Draft-07/ + ) + }) + + it("lowers canonical keywords and preserves opaque custom keywords", () => { + const custom = { $ref: "#/$defs/Literal" } + const result = JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: { + if: { properties: { kind: { const: "full" } } }, + // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword + then: { required: ["value"] }, + else: { not: { required: ["value"] } }, + contains: { type: "number" }, + dependentRequired: { + enabled: ["value"], + kind: ["label"] + }, + dependentSchemas: { + kind: { minProperties: 1 }, + mode: { required: ["value"] } + }, + "x-custom": custom + }, + definitions: {} + }) + + deepStrictEqual(result.schema, { + if: { properties: { kind: { const: "full" } } }, + // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword + then: { required: ["value"] }, + else: { not: { required: ["value"] } }, + contains: { type: "number" }, + dependencies: { + enabled: ["value"], + kind: { allOf: [{ minProperties: 1 }, { required: ["label"] }] }, + mode: { required: ["value"] } + }, + "x-custom": custom + }) + }) + + it("moves nested $defs to definitions and relocates local JSON Pointer refs", () => { + const result = JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: { + properties: { + nested: { + $defs: { Value: { type: "string" } }, + $ref: "#/properties/nested/$defs/Value" + } + } + }, + definitions: {} + }) + + deepStrictEqual(result.schema, { + properties: { + nested: { + definitions: { Value: { type: "string" } }, + allOf: [{ $ref: "#/properties/nested/definitions/Value" }] + } + } + }) + }) + + it("relocates fragment refs when emitting nested schema resources", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { + allOf: [ + { $ref: "nested.json#/$defs/Value" }, + { $ref: "external.json#/$defs/Value" } + ] + }, + definitions: { + Nested: { + $id: "nested.json", + $defs: { Value: { type: "string" } }, + allOf: [{ $ref: "#/$defs/Value" }] + } + } + } + + deepStrictEqual(JsonSchema.toDocumentDraft07(input), { + dialect: "draft-07", + schema: { + allOf: [ + { $ref: "nested.json#/definitions/Value" }, + { $ref: "external.json#/$defs/Value" } + ] + }, + definitions: { + Nested: { + $id: "nested.json", + definitions: { Value: { type: "string" } }, + allOf: [{ $ref: "#/definitions/Value" }] + } + } + }) + deepStrictEqual(JsonSchema.toDocumentDraft04(input), { + dialect: "draft-04", + schema: { + allOf: [ + { $ref: "nested.json#/definitions/Value" }, + { $ref: "external.json#/$defs/Value" } + ] }, - { - name: "normalizes nullable on both a parent and its allOf member", - input: { type: "string", nullable: true, allOf: [{ nullable: true }] }, - expected: { - type: ["string", "null"], - allOf: [{ anyOf: [{}, { type: "null" }] }] + definitions: { + Nested: { + id: "nested.json", + definitions: { Value: { type: "string" } }, + allOf: [{ $ref: "#/definitions/Value" }] } } - ]) + }) }) - describe("exclusivity", () => { - testOpenApi3_0Cases([ - { - name: "turns exclusiveMinimum true into the minimum value", - input: { type: "number", minimum: 10, exclusiveMinimum: true }, - expected: { type: "number", exclusiveMinimum: 10 } - }, - { - name: "turns exclusiveMaximum true into the maximum value", - input: { type: "number", maximum: 100, exclusiveMaximum: true }, - expected: { type: "number", exclusiveMaximum: 100 } - }, - { - name: "drops exclusiveMinimum false", - input: { type: "number", minimum: 10, exclusiveMinimum: false }, - expected: { type: "number", minimum: 10 } - }, - { - name: "drops exclusiveMaximum false", - input: { type: "number", maximum: 100, exclusiveMaximum: false }, - expected: { type: "number", maximum: 100 } - }, - { - name: "drops exclusiveMinimum true when minimum is absent", - input: { type: "number", exclusiveMinimum: true }, - expected: { type: "number" } + it("converts contentSchema bodies and references", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { + contentSchema: { $defs: { Value: { const: "value" } } }, + allOf: [{ $ref: "#/contentSchema/$defs/Value" }] }, - { - name: "drops exclusiveMaximum true when maximum is absent", - input: { type: "number", exclusiveMaximum: true }, - expected: { type: "number" } - } - ]) + definitions: {} + } + + deepStrictEqual(JsonSchema.toDocumentDraft07(input).schema, { + contentSchema: { definitions: { Value: { const: "value" } } }, + allOf: [{ $ref: "#/contentSchema/definitions/Value" }] + }) + deepStrictEqual(JsonSchema.toDocumentDraft04(input).schema, { + contentSchema: { definitions: { Value: { enum: ["value"] } } }, + allOf: [{ $ref: "#/contentSchema/definitions/Value" }] + }) + }) + + it("rejects canonical constraints that Draft-07 cannot represent", () => { + for ( + const schema of [ + { contains: { type: "string" }, minContains: 2 }, + { contains: { type: "string" }, maxContains: 3 }, + { unevaluatedProperties: false }, + { unevaluatedItems: false }, + { $dynamicRef: "#node" }, + { $vocabulary: { "https://example.com/vocabulary": true } }, + { dependencies: { value: ["other"] } } + ] + ) { + assert.throws( + () => JsonSchema.toDocumentDraft07({ dialect: "draft-2020-12", schema, definitions: {} }), + /Cannot convert JSON Schema keyword .* to Draft-07/ + ) + } }) - }) - describe("toDocumentDraft07", () => { it("preserves Schema.Never", () => { const result = JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(Schema.Never)) deepStrictEqual(result, { @@ -1266,8 +1752,8 @@ describe("JsonSchema", () => { deepStrictEqual(document.schema, { minLength: 3, allOf: [ - { $ref: "#/definitions/S" }, - { maxLength: 5 } + { maxLength: 5 }, + { $ref: "#/definitions/S" } ] }) @@ -1279,6 +1765,26 @@ describe("JsonSchema", () => { deepStrictEqual(validate("abcdef"), false) }) + it("keeps existing allOf locations stable when wrapping ref siblings", () => { + const document = JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: { + $ref: "#/$defs/Base", + allOf: [{ type: "string" }], + properties: { copy: { $ref: "#/allOf/0" } } + }, + definitions: { Base: { type: "object" } } + }) + + deepStrictEqual(document.schema, { + allOf: [ + { type: "string" }, + { $ref: "#/definitions/Base" } + ], + properties: { copy: { $ref: "#/allOf/0" } } + }) + }) + it("converts prefixItems to a Draft-07 items tuple", () => { const input: JsonSchema.Document<"draft-2020-12"> = { dialect: "draft-2020-12", @@ -1327,7 +1833,7 @@ describe("JsonSchema", () => { }) }) - it("drops non-standard properties in Draft-07 output", () => { + it("preserves custom properties in Draft-07 output", () => { const input: JsonSchema.Document<"draft-2020-12"> = { dialect: "draft-2020-12", schema: { @@ -1340,7 +1846,8 @@ describe("JsonSchema", () => { deepStrictEqual(result, { dialect: "draft-07", schema: { - type: "string" + type: "string", + "x-custom": "value" }, definitions: {} }) @@ -1374,6 +1881,74 @@ describe("JsonSchema", () => { }) describe("toDocumentDraft04", () => { + it("rewrites the canonical meta-schema URI with an empty fragment", () => { + deepStrictEqual( + JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { $schema: `${JsonSchema.META_SCHEMA_URI_DRAFT_2020_12}#` }, + definitions: {} + }).schema, + { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_04 } + ) + }) + + it("omits the canonical meta-schema URI from embedded resources", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: {}, + definitions: { + Embedded: { + $id: "embedded.json", + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + type: "string" + } + } + } + + deepStrictEqual(JsonSchema.toDocumentDraft04(input).definitions.Embedded, { + id: "embedded.json", + type: "string" + }) + }) + + it("rejects custom dialects in embedded resources", () => { + assert.throws( + () => + JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: {}, + definitions: { + Embedded: { + $id: "embedded.json", + $schema: "https://example.com/dialect", + type: "string" + } + } + }), + /Cannot convert JSON Schema keyword "\$schema" to Draft-04/ + ) + }) + + it("lowers canonical dependencies directly and preserves custom keywords", () => { + const result = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { + dependentRequired: { enabled: ["value"] }, + dependentSchemas: { mode: { required: ["value"] } }, + "x-custom": { value: true } + }, + definitions: {} + }) + + deepStrictEqual(result.schema, { + dependencies: { + enabled: ["value"], + mode: { required: ["value"] } + }, + "x-custom": { value: true } + }) + }) + it("rewrites $defs refs to Draft-04 definitions refs", () => { const input: JsonSchema.Document<"draft-2020-12"> = { dialect: "draft-2020-12", @@ -1402,7 +1977,7 @@ describe("JsonSchema", () => { }) }) - it("preserves every supported Draft-04 keyword and drops newer annotations", () => { + it("preserves every supported Draft-04 keyword and newer annotations as extensions", () => { const input: JsonSchema.JsonSchema = { type: "object", required: ["value"], @@ -1432,8 +2007,7 @@ describe("JsonSchema", () => { items: { type: "string" }, examples: ["a"], readOnly: true, - writeOnly: true, - propertyNames: { minLength: 1 } + writeOnly: true } const expected: JsonSchema.JsonSchema = { type: "object", @@ -1457,11 +2031,14 @@ describe("JsonSchema", () => { properties: { value: { type: "string" } }, patternProperties: { "^x-": { type: "string" } }, not: { type: "null" }, - additionalProperties: false, + additionalProperties: { not: {} }, allOf: [{ type: "object" }], anyOf: [{ type: "string" }], oneOf: [{ type: "number" }], - items: { type: "string" } + items: { type: "string" }, + examples: ["a"], + readOnly: true, + writeOnly: true } deepStrictEqual( @@ -1474,6 +2051,79 @@ describe("JsonSchema", () => { ) }) + it("rejects validation keywords that Draft-04 cannot represent", () => { + for ( + const schema of [ + { propertyNames: { minLength: 1 } }, + { contains: { type: "string" }, minContains: 0 }, + { contains: { type: "string" }, minContains: 2 }, + { + if: { $id: "condition" }, + // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword + then: {}, + else: {} + }, + { unevaluatedProperties: false }, + { $dynamicRef: "#node" }, + { $vocabulary: { "https://example.com/vocabulary": true } }, + { id: "legacy-id" } + ] + ) { + assert.throws( + () => JsonSchema.toDocumentDraft04({ dialect: "draft-2020-12", schema, definitions: {} }), + /Cannot convert JSON Schema keyword .* to Draft-04/ + ) + } + }) + + it("converts inactive conditional schema bodies", () => { + for (const key of ["if", "then", "else"] as const) { + const result = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { + [key]: { $defs: { Value: { const: "value" } } }, + allOf: [{ $ref: `#/${key}/$defs/Value` }] + }, + definitions: {} + }) + + deepStrictEqual(result.schema, { + [key]: { definitions: { Value: { enum: ["value"] } } }, + allOf: [{ $ref: `#/${key}/definitions/Value` }] + }) + } + }) + + it("lowers conditionals and contains while preserving validation semantics", () => { + const document = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { + type: "object", + properties: { + mode: { enum: ["full", "compact"] }, + value: { type: "string" }, + values: { contains: { type: "number" } } + }, + required: ["mode", "values"], + if: { properties: { mode: { const: "full" } } }, + // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword + then: { required: ["value"] }, + else: { not: { required: ["value"] } } + }, + definitions: {} + }) + const schema = makeSchema(document) + deepStrictEqual(ajvDraft04.validateSchema(schema), true) + + const validate = ajvDraft04.compile(schema) + deepStrictEqual(validate({ mode: "full", value: "ok", values: [1] }), true) + deepStrictEqual(validate({ mode: "full", values: [1] }), false) + deepStrictEqual(validate({ mode: "compact", values: [1] }), true) + deepStrictEqual(validate({ mode: "compact", value: "unexpected", values: [1] }), false) + deepStrictEqual(validate({ mode: "full", value: "ok", values: ["no"] }), false) + deepStrictEqual(validate({ mode: "full", value: "ok", values: 1 }), true) + }) + it("omits an empty required array", () => { const document = JsonSchema.toDocumentDraft04({ dialect: "draft-2020-12", @@ -1485,6 +2135,27 @@ describe("JsonSchema", () => { deepStrictEqual(ajvDraft04.validateSchema(makeSchema(document)), true) }) + it("omits empty dependentRequired arrays", () => { + const empty = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { dependentRequired: { value: [] } }, + definitions: {} + }) + deepStrictEqual(empty.schema, {}) + deepStrictEqual(ajvDraft04.validateSchema(makeSchema(empty)), true) + + const combined = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { + dependentRequired: { value: [] }, + dependentSchemas: { value: { type: "string" } } + }, + definitions: {} + }) + deepStrictEqual(combined.schema, { dependencies: { value: { type: "string" } } }) + deepStrictEqual(ajvDraft04.validateSchema(makeSchema(combined)), true) + }) + it("converts const to enum", () => { const input: JsonSchema.Document<"draft-2020-12"> = { dialect: "draft-2020-12", @@ -1590,13 +2261,13 @@ describe("JsonSchema", () => { denied: { not: {} }, nested: { not: { not: {} } } }, - additionalProperties: false, + additionalProperties: { not: {} }, allOf: [{}], anyOf: [{ not: {} }] }) }) - it("converts tuple members while preserving additionalItems booleans", () => { + it("converts tuple members and their trailing boolean schema", () => { const input: JsonSchema.Document<"draft-2020-12"> = { dialect: "draft-2020-12", schema: { @@ -1610,7 +2281,7 @@ describe("JsonSchema", () => { deepStrictEqual(result.schema, { type: "array", items: [{}, { not: {} }], - additionalItems: false + additionalItems: { not: {} } }) deepStrictEqual( @@ -1686,18 +2357,17 @@ describe("JsonSchema", () => { deepStrictEqual(result.schema, input.schema) }) - it("drops keywords unavailable in Draft-04", () => { + it("preserves newer annotations as Draft-04 extensions", () => { const input: JsonSchema.Document<"draft-2020-12"> = { dialect: "draft-2020-12", schema: { type: "object", - propertyNames: { pattern: "^[a-z]+$" }, examples: [{ value: 1 }] }, definitions: {} } const result = JsonSchema.toDocumentDraft04(input) - deepStrictEqual(result.schema, { type: "object" }) + deepStrictEqual(result.schema, { type: "object", examples: [{ value: 1 }] }) }) it("preserves constraints next to refs", () => { @@ -1772,6 +2442,34 @@ describe("JsonSchema", () => { }) describe("toMultiDocumentOpenApi3_1", () => { + it("rejects opaque keywords that OpenAPI 3.1 would activate", () => { + for ( + const input of [ + { schemas: [{ example: "value" }], definitions: {} }, + { schemas: [{ properties: { value: { discriminator: {} } } }], definitions: {} }, + { schemas: [{}], definitions: { Value: { xml: {} } } }, + { schemas: [{ externalDocs: {} }], definitions: {} } + ] as const + ) { + assert.throws( + () => JsonSchema.toMultiDocumentOpenApi3_1({ dialect: "draft-2020-12", ...input }), + /Cannot convert JSON Schema keyword .* to OpenAPI 3.1/ + ) + } + }) + + it("does not inspect opaque values for OpenAPI 3.1 keyword collisions", () => { + const literal = { example: "value", discriminator: {}, xml: {}, externalDocs: {} } + deepStrictEqual( + JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [{ const: literal }], + definitions: {} + }).schemas, + [{ const: literal }] + ) + }) + it("rewrites `$defs` references to `components/schemas`", () => { const input: JsonSchema.MultiDocument<"draft-2020-12"> = { dialect: "draft-2020-12", @@ -1897,6 +2595,122 @@ describe("JsonSchema", () => { }) }) + it("decodes a definition key before sanitizing its ref", () => { + const result = JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [{ + allOf: [ + { $ref: "#/$defs/A%20B" }, + { $dynamicRef: "#/$defs/A%20B" } + ] + }], + definitions: { + "A B": { type: "string" } + } + }) + + deepStrictEqual(result, { + dialect: "openapi-3.1", + schemas: [{ + allOf: [ + { $ref: "#/components/schemas/A_B" }, + { $dynamicRef: "#/components/schemas/A_B" } + ] + }], + definitions: { + A_B: { type: "string" } + } + }) + }) + + it("does not rewrite local refs inside embedded schema resources", () => { + const result = JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [{ + allOf: [ + { $dynamicRef: "#/$defs/Embedded" }, + { + $id: "inline.json", + $defs: { Inner: { type: "string" } }, + $dynamicRef: "#/$defs/Inner" + } + ] + }], + definitions: { + Embedded: { + $id: "embedded.json", + $defs: { Inner: { type: "number" } }, + allOf: [ + { $ref: "#/$defs/Inner" }, + { $dynamicRef: "#/$defs/Inner" } + ] + } + } + }) + + deepStrictEqual(result, { + dialect: "openapi-3.1", + schemas: [{ + allOf: [ + { $dynamicRef: "#/components/schemas/Embedded" }, + { + $id: "inline.json", + $defs: { Inner: { type: "string" } }, + $dynamicRef: "#/$defs/Inner" + } + ] + }], + definitions: { + Embedded: { + $id: "embedded.json", + $defs: { Inner: { type: "number" } }, + allOf: [ + { $ref: "#/$defs/Inner" }, + { $dynamicRef: "#/$defs/Inner" } + ] + } + } + }) + }) + + it("does not rewrite local refs inside root schema resources", () => { + const schema = { + $id: "inline.json", + $defs: { Inner: { type: "string" } }, + allOf: [ + { $ref: "#/$defs/Inner" }, + { $dynamicRef: "#/$defs/Inner" } + ] + } + + deepStrictEqual( + JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [schema], + definitions: {} + }), + { + dialect: "openapi-3.1", + schemas: [schema], + definitions: {} + } + ) + }) + + it("rejects shared definition refs from root schema resources", () => { + for (const keyword of ["$ref", "$dynamicRef"] as const) { + assert.throws( + () => + JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [{ $id: "inline.json", [keyword]: "#/$defs/Value" }], + definitions: { Value: { type: "string" } } + }), + new RegExp(`Cannot convert JSON Schema keyword "\\${keyword}" to OpenAPI 3\\.1`) + ) + } + }) + it("suffixes a sanitized key that collides with a valid key", () => { const result = JsonSchema.toMultiDocumentOpenApi3_1({ dialect: "draft-2020-12", @@ -2067,31 +2881,40 @@ describe("JsonSchema", () => { }) describe("input immutability", () => { - const schema: JsonSchema.JsonSchema = { + const canonicalSchema: JsonSchema.JsonSchema = { type: "object", properties: { value: { type: "array", prefixItems: [{ type: "string" }], - items: false, - nullable: true + items: false } - }, - $defs: { - Value: { type: "string" } } } for ( - const [name, convert] of [ - ["fromSchemaDraft07", JsonSchema.fromSchemaDraft07], - ["fromSchemaDraft2020_12", JsonSchema.fromSchemaDraft2020_12], - ["fromSchemaOpenApi3_1", JsonSchema.fromSchemaOpenApi3_1], - ["fromSchemaOpenApi3_0", JsonSchema.fromSchemaOpenApi3_0] + const [name, convert, input] of [ + [ + "fromSchemaDraft07", + JsonSchema.fromSchemaDraft07, + { + type: "array", + items: [{ type: "string" }], + additionalItems: false, + definitions: { Value: { type: "string" } } + } + ], + ["fromSchemaDraft2020_12", JsonSchema.fromSchemaDraft2020_12, canonicalSchema], + ["fromSchemaOpenApi3_1", JsonSchema.fromSchemaOpenApi3_1, canonicalSchema], + [ + "fromSchemaOpenApi3_0", + JsonSchema.fromSchemaOpenApi3_0, + { type: "array", items: { type: "string" }, nullable: true } + ] ] as const ) { it(`${name} does not mutate its input`, () => { - assertDoesNotMutate(structuredClone(schema), convert) + assertDoesNotMutate(structuredClone(input), convert) }) } @@ -2105,7 +2928,7 @@ describe("JsonSchema", () => { assertDoesNotMutate( { dialect: "draft-2020-12", - schema: structuredClone(schema), + schema: structuredClone(canonicalSchema), definitions: { Value: { type: "string" } } }, convert @@ -2117,7 +2940,7 @@ describe("JsonSchema", () => { assertDoesNotMutate( { dialect: "draft-2020-12", - schemas: [structuredClone(schema)] as const, + schemas: [structuredClone(canonicalSchema)] as const, definitions: { Value: { type: "string" } } }, JsonSchema.toMultiDocumentOpenApi3_1 diff --git a/repos/effect/packages/effect/test/LayerMap.test.ts b/repos/effect/packages/effect/test/LayerMap.test.ts index 5ff1d4b9b1..f433561766 100644 --- a/repos/effect/packages/effect/test/LayerMap.test.ts +++ b/repos/effect/packages/effect/test/LayerMap.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest" -import { Effect, Layer, LayerMap } from "effect" +import { Effect, Exit, Layer, LayerMap, Option, Scope } from "effect" import { TestClock } from "effect/testing" const makeLayer = (key: string, acquired: Array, released: Array): Layer.Layer => @@ -16,6 +16,50 @@ const makeLayer = (key: string, acquired: Array, released: Array ) as Layer.Layer describe("LayerMap", () => { + it.effect("contextEffectOption does not build missing entries and returns existing entries", () => + Effect.gen(function*() { + const acquired: Array = [] + const layerMap = yield* LayerMap.make( + (key: string) => Layer.effectDiscard(Effect.sync(() => acquired.push(key))) as Layer.Layer + ) + + assert.deepStrictEqual(yield* layerMap.contextEffectOption("key"), Option.none()) + assert.deepStrictEqual(acquired, []) + + const ownerScope = yield* Scope.make() + const borrowerScope = yield* Scope.make() + yield* layerMap.contextEffect("key").pipe(Scope.provide(ownerScope)) + const cached = yield* layerMap.contextEffectOption("key").pipe(Scope.provide(borrowerScope)) + assert.isTrue(Option.isSome(cached)) + assert.deepStrictEqual(acquired, ["key"]) + + yield* Scope.close(ownerScope, Exit.void) + yield* Scope.close(borrowerScope, Exit.void) + })) + + it.effect("Service contextEffectOption does not build missing entries and returns existing entries", () => + Effect.gen(function*() { + const acquired: Array = [] + class TestMap extends LayerMap.Service()("LayerMapTest/ContextEffectOption", { + lookup: (key: string) => Layer.effectDiscard(Effect.sync(() => acquired.push(key))) as Layer.Layer + }) {} + + yield* Effect.gen(function*() { + assert.deepStrictEqual(yield* TestMap.contextEffectOption("key"), Option.none()) + assert.deepStrictEqual(acquired, []) + + const ownerScope = yield* Scope.make() + const borrowerScope = yield* Scope.make() + yield* TestMap.contextEffect("key").pipe(Scope.provide(ownerScope)) + const cached = yield* TestMap.contextEffectOption("key").pipe(Scope.provide(borrowerScope)) + assert.isTrue(Option.isSome(cached)) + assert.deepStrictEqual(acquired, ["key"]) + + yield* Scope.close(ownerScope, Exit.void) + yield* Scope.close(borrowerScope, Exit.void) + }).pipe(Effect.provide(TestMap.layer)) + })) + it.effect("make preloads the requested keys", () => Effect.gen(function*() { const acquired: Array = [] diff --git a/repos/effect/packages/effect/test/Logger.test.ts b/repos/effect/packages/effect/test/Logger.test.ts index 5871e87521..b121e1f408 100644 --- a/repos/effect/packages/effect/test/Logger.test.ts +++ b/repos/effect/packages/effect/test/Logger.test.ts @@ -90,6 +90,31 @@ describe("Logger", () => { assert.ok(!output.includes("annotation=\"\\\"value with spaces\\\"\"")) })) + it.effect("formats BigInt messages consistently", () => + Effect.gen(function*() { + const simple: Array = [] + const logFmt: Array = [] + const structured: Array<{ readonly message: unknown; readonly level: string }> = [] + const json: Array<{ readonly message: unknown; readonly level: string }> = [] + const loggers = [ + Logger.formatSimple.pipe(Logger.map((output) => void simple.push(output))), + Logger.formatLogFmt.pipe(Logger.map((output) => void logFmt.push(output))), + Logger.formatStructured.pipe(Logger.map((output) => void structured.push(output))), + Logger.formatJson.pipe(Logger.map((output) => void json.push(JSON.parse(output)))) + ] + + yield* Effect.logInfo(123n, { value: 123n }).pipe(Effect.provide(Logger.layer(loggers))) + + assert.include(simple[0], ` level=INFO fiber=`) + assert.include(simple[0], `message=123n message="{\\"value\\":123n}"`) + assert.include(logFmt[0], ` level=INFO fiber=`) + assert.include(logFmt[0], `message=123n message="{\\"value\\":123n}"`) + assert.deepStrictEqual(structured[0].message, [123n, { value: 123n }]) + assert.strictEqual(structured[0].level, "INFO") + assert.deepStrictEqual(json[0].message, ["123n", { value: "123n" }]) + assert.strictEqual(json[0].level, "INFO") + })) + it.effect("annotateLogsScoped applies annotations only while scoped", () => Effect.gen(function*() { const annotations: Array> = [] diff --git a/repos/effect/packages/effect/test/Match.test.ts b/repos/effect/packages/effect/test/Match.test.ts index 2b14f6e8ee..84fd4cbee8 100644 --- a/repos/effect/packages/effect/test/Match.test.ts +++ b/repos/effect/packages/effect/test/Match.test.ts @@ -3,6 +3,38 @@ import { Match, pipe } from "effect" import { strictEqual } from "./utils/assert.ts" describe("Match", () => { + it("fn keeps the selector arguments", () => { + type Todo = { readonly completed: boolean } + type Todos = Array + type Filter = "All" | "Active" | "Completed" + + const filterTodos = Match.fn((_todos: Todos, filter: Filter) => filter).pipe( + Match.withReturnType(), + Match.when("All", (_filter, todos) => todos), + Match.when("Active", (_filter, todos) => todos.filter((todo) => !todo.completed)), + Match.when("Completed", (_filter, todos) => todos.filter((todo) => todo.completed)), + Match.exhaustive + ) + const todos: Todos = [{ completed: false }, { completed: true }] + + strictEqual(filterTodos(todos, "All"), todos) + strictEqual(filterTodos(todos, "Active").length, 1) + strictEqual(filterTodos(todos, "Active")[0], todos[0]) + strictEqual(filterTodos(todos, "Completed").length, 1) + strictEqual(filterTodos(todos, "Completed")[0], todos[1]) + }) + + it("fn can match a projected value", () => { + type Todo = { readonly status: "Active" | "Completed"; readonly title: string } + const formatTodo = Match.fn((todo: Todo) => todo.status).pipe( + Match.when("Active", (_status, todo) => `Active: ${todo.title}`), + Match.when("Completed", (_status, todo) => `Completed: ${todo.title}`), + Match.exhaustive + ) + + strictEqual(formatTodo({ status: "Active", title: "Write tests" }), "Active: Write tests") + }) + it("tag matches an exact _tag and falls through for null", () => { const match = pipe( Match.type<{ _tag: "A" } | null>(), diff --git a/repos/effect/packages/effect/test/Optic.test.ts b/repos/effect/packages/effect/test/Optic.test.ts index 989f9c3df0..80ab2c11cb 100644 --- a/repos/effect/packages/effect/test/Optic.test.ts +++ b/repos/effect/packages/effect/test/Optic.test.ts @@ -78,6 +78,35 @@ describe("Optic", () => { strictEqual(iso.modify(addOne)(1), 2) }) + describe("standalone functions", () => { + const lens = Optic.id<{ readonly value: number }>().key("value") + const optional = Optic.id>().at("value") + const prism = Optic.some() + const traversal = Optic.id>().forEach((item) => item) + + it("supports data-first usage", () => { + strictEqual(Optic.get({ value: 1 }, lens), 1) + assertSuccess(Optic.getResult({ value: 1 }, optional), 1) + deepStrictEqual(Optic.set(1, prism), Option.some(1)) + deepStrictEqual(Optic.replace({ value: 1 }, lens, 2), { value: 2 }) + assertSuccess(Optic.replaceResult({ value: 1 }, optional, 2), { value: 2 }) + deepStrictEqual(Optic.modify({ value: 1 }, lens, addOne), { value: 2 }) + deepStrictEqual(Optic.getAll([1, 2, 3], traversal), [1, 2, 3]) + deepStrictEqual(Optic.modifyAll([1, 2, 3], traversal, addOne), [2, 3, 4]) + }) + + it("supports data-last usage", () => { + strictEqual(Optic.get(lens)({ value: 1 }), 1) + assertSuccess(Optic.getResult(optional)({ value: 1 }), 1) + deepStrictEqual(Optic.set(prism)(1), Option.some(1)) + deepStrictEqual(Optic.replace(lens, 2)({ value: 1 }), { value: 2 }) + assertSuccess(Optic.replaceResult(optional, 2)({ value: 1 }), { value: 2 }) + deepStrictEqual(Optic.modify(lens, addOne)({ value: 1 }), { value: 2 }) + deepStrictEqual(Optic.getAll(traversal)([1, 2, 3]), [1, 2, 3]) + deepStrictEqual(Optic.modifyAll(traversal, addOne)([1, 2, 3]), [2, 3, 4]) + }) + }) + describe("compose", () => { it("sets through composed isos without reading a source", () => { const value = Optic.makeIso<{ readonly value: number }, number>( diff --git a/repos/effect/packages/effect/test/Ordering.test.ts b/repos/effect/packages/effect/test/Ordering.test.ts index 301196cb33..2e7c103793 100644 --- a/repos/effect/packages/effect/test/Ordering.test.ts +++ b/repos/effect/packages/effect/test/Ordering.test.ts @@ -15,4 +15,27 @@ describe("Ordering", () => { deepStrictEqual(R.combine(0, -1), -1) deepStrictEqual(R.combine(-1, 0), -1) }) + + it("reverse flips less-than and greater-than and leaves equal unchanged", () => { + deepStrictEqual(Ordering.reverse(-1), 1) + deepStrictEqual(Ordering.reverse(1), -1) + deepStrictEqual(Ordering.reverse(0), 0) + }) + + it("match selects the branch for the ordering in both data-first and data-last forms", () => { + const toMessage = Ordering.match({ + onLessThan: () => "less than", + onEqual: () => "equal", + onGreaterThan: () => "greater than" + }) + + deepStrictEqual(toMessage(-1), "less than") + deepStrictEqual(toMessage(0), "equal") + deepStrictEqual(toMessage(1), "greater than") + + deepStrictEqual( + Ordering.match(1, { onLessThan: () => "l", onEqual: () => "e", onGreaterThan: () => "g" }), + "g" + ) + }) }) diff --git a/repos/effect/packages/effect/test/Pathfinding.test.ts b/repos/effect/packages/effect/test/Pathfinding.test.ts index e48787c935..0638ad6264 100644 --- a/repos/effect/packages/effect/test/Pathfinding.test.ts +++ b/repos/effect/packages/effect/test/Pathfinding.test.ts @@ -109,13 +109,10 @@ const pathFromSequence = ( } const index = terrain.nodes.get(start)! - const node = Graph.getNode(terrain.graph, index).pipe( - Option.getOrThrowWith(() => new Error(`Start location ${start} not found in terrain`)) - ) - const output: Types.Mutable> = { - distance: node.weight, - costs: [node.weight], + distance: 0, + costs: [], + edges: [], path: [index] } @@ -144,6 +141,12 @@ const pathFromSequence = ( output.distance += node.weight output.costs.push(node.weight) + const previousIndex = output.path[output.path.length - 1] + const edge = Graph.edgesBetween(terrain.graph, previousIndex, index)[0] + if (edge === undefined) { + throw new Error(`No edge from ${previousIndex} to ${index}`) + } + output.edges.push(edge) output.path.push(index) } diff --git a/repos/effect/packages/effect/test/Pool.test.ts b/repos/effect/packages/effect/test/Pool.test.ts index 1948975f16..982f867e72 100644 --- a/repos/effect/packages/effect/test/Pool.test.ts +++ b/repos/effect/packages/effect/test/Pool.test.ts @@ -399,6 +399,117 @@ describe("Pool", () => { deepStrictEqual(yield* Fiber.await(fiber), Exit.interrupt(fiberId)) })) + it.effect("interrupts pending gets without leaking usage", () => + Effect.gen(function*() { + const pool = yield* Pool.make({ acquire: Effect.succeed("resource"), size: 1 }) + const scope = yield* Scope.make() + yield* Scope.provide(Pool.get(pool), scope) + const fibers: Array> = [] + for (let i = 0; i < 10; i++) { + fibers.push(yield* Effect.forkChild(Pool.get(pool), { startImmediately: true })) + } + yield* Effect.repeat(Effect.andThen(Effect.yieldNow, Effect.sync(() => pool.state.waiters.size)), { + until: (size) => size === fibers.length + }) + yield* Effect.all(fibers.map(Fiber.interrupt), { concurrency: "unbounded", discard: true }) + strictEqual(pool.state.waiters.size, 0) + strictEqual(pool.state.usage, 1) + yield* Scope.close(scope, Exit.void) + strictEqual(pool.state.usage, 0) + })) + + it.effect("does not re-wake waiters registered during notification", () => + Effect.gen(function*() { + const acquire = yield* Deferred.make() + const pool = yield* Pool.makeWithTTL({ + acquire: Effect.as(Deferred.await(acquire), "resource"), + min: 0, + max: 1, + timeToLive: Duration.infinity + }) + const fibers: Array> = [] + for (let i = 0; i < 10; i++) { + fibers.push(yield* Effect.forkChild(Effect.scoped(Pool.get(pool)), { startImmediately: true })) + } + yield* Effect.repeat(Effect.andThen(Effect.yieldNow, Effect.sync(() => pool.state.waiters.size)), { + until: (size) => size === fibers.length + }) + yield* Deferred.succeed(acquire, undefined) + yield* Effect.all(fibers.map(Fiber.join), { concurrency: "unbounded", discard: true }) + strictEqual(pool.state.waiters.size, 0) + strictEqual(pool.state.usage, 0) + })) + + it.effect("use borrows and returns an item", () => + Effect.gen(function*() { + const count = yield* Ref.make(0) + const get = Effect.acquireRelease( + Ref.updateAndGet(count, (n) => n + 1), + () => Ref.update(count, (n) => n - 1) + ) + const pool = yield* Pool.make({ acquire: get, size: 2 }) + yield* Effect.repeat(Ref.get(count), { until: (n) => n === 2 }) + const results: Array = [] + yield* Effect.repeat( + Effect.tap(Pool.use(pool, (item) => Effect.succeed(item)), (item) => + Effect.sync(() => { + results.push(item) + })), + { times: 9 } + ) + deepStrictEqual(results, [1, 2, 1, 2, 1, 2, 1, 2, 1, 2]) + strictEqual(yield* Ref.get(count), 2) + })) + + it.effect("use releases the item on failure", () => + Effect.gen(function*() { + const pool = yield* Pool.make({ acquire: Effect.succeed("resource"), size: 1 }) + const failure = yield* Effect.flip(Pool.use(pool, () => Effect.fail("boom"))) + strictEqual(failure, "boom") + strictEqual(yield* Pool.use(pool, (item) => Effect.succeed(item)), "resource") + })) + + it.effect("use releases the item on interruption", () => + Effect.gen(function*() { + const started = yield* Deferred.make() + const pool = yield* Pool.make({ acquire: Effect.succeed("resource"), size: 1 }) + const fiber = yield* Effect.forkChild( + Pool.use(pool, () => Effect.andThen(Deferred.succeed(started, void 0), Effect.never)), + { startImmediately: true } + ) + yield* Deferred.await(started) + yield* Fiber.interrupt(fiber) + strictEqual(yield* Pool.use(pool, (item) => Effect.succeed(item)), "resource") + })) + + it.effect("use waits for an available item", () => + Effect.gen(function*() { + const pool = yield* Pool.make({ acquire: Effect.succeed("resource"), size: 1 }) + const scope = yield* Scope.make() + yield* Scope.provide(Pool.get(pool), scope) + const fiber = yield* Effect.forkChild( + Pool.use(pool, (item) => Effect.succeed(item)), + { startImmediately: true } + ) + assert.isUndefined(fiber.pollUnsafe()) + yield* Scope.close(scope, Exit.void) + strictEqual(yield* Fiber.join(fiber), "resource") + strictEqual(pool.state.usage, 0) + })) + + it.effect("use reports acquire failures", () => + Effect.gen(function*() { + const pool = yield* Pool.makeWithTTL({ + acquire: Effect.fail("nope"), + min: 0, + max: 1, + timeToLive: Duration.infinity + }) + const failure = yield* Effect.flip(Pool.use(pool, () => Effect.void)) + strictEqual(failure, "nope") + strictEqual(pool.state.usage, 0) + })) + it.effect("finalizer is called for failed allocations", () => Effect.gen(function*() { const scope = yield* Scope.make() diff --git a/repos/effect/packages/effect/test/PubSub.test.ts b/repos/effect/packages/effect/test/PubSub.test.ts index 670e045dc1..d75c62b18f 100644 --- a/repos/effect/packages/effect/test/PubSub.test.ts +++ b/repos/effect/packages/effect/test/PubSub.test.ts @@ -450,6 +450,22 @@ describe("PubSub", () => { assert.deepStrictEqual(yield* PubSub.takeAll(sub), [3, 4, 5]) })) + it.effect("unbounded rounds up fractional replay", () => + Effect.gen(function*() { + const pubsub = yield* PubSub.unbounded({ replay: 1.5 }) + yield* PubSub.publishAll(pubsub, [1, 2, 3, 4]) + const sub = yield* PubSub.subscribe(pubsub) + assert.deepStrictEqual(yield* PubSub.takeAll(sub), [3, 4]) + })) + + it.effect("unbounded disables non-positive replay", () => + Effect.gen(function*() { + const pubsub = yield* PubSub.unbounded({ replay: -1 }) + yield* PubSub.publishAll(pubsub, [1, 2]) + const sub = yield* PubSub.subscribe(pubsub) + assert.deepStrictEqual(yield* PubSub.takeUpTo(sub, 2), []) + })) + it.effect("unbounded takeUpTo", () => { const messages = [1, 2, 3, 4, 5] return PubSub.unbounded({ replay: 3 }).pipe( diff --git a/repos/effect/packages/effect/test/Pull.test.ts b/repos/effect/packages/effect/test/Pull.test.ts new file mode 100644 index 0000000000..848308bab8 --- /dev/null +++ b/repos/effect/packages/effect/test/Pull.test.ts @@ -0,0 +1,40 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause, Exit, Result } from "effect" +import * as Pull from "effect/Pull" + +describe("Pull", () => { + describe("filterDone", () => { + it("succeeds when the done signal is the only failure", () => { + const result = Pull.filterDone(Cause.fail(Cause.Done("leftover"))) + assert.deepStrictEqual(result, Result.succeed(Cause.Done("leftover"))) + }) + + it("strips the done signal when merged with other failures", () => { + const cause = Cause.combine(Cause.fail(Cause.Done("leftover")), Cause.die("boom")) + const result = Pull.filterDone(cause) + assert.deepStrictEqual(result, Result.fail(Cause.die("boom"))) + }) + + it("fails with the original cause when no done signal is present", () => { + const cause = Cause.fail("error") + assert.deepStrictEqual(Pull.filterDone(cause), Result.fail(cause)) + }) + + it("succeeds when the done signal is merged only with interruptions", () => { + const cause = Cause.combine(Cause.interrupt(1), Cause.fail(Cause.Done("leftover"))) + assert.deepStrictEqual(Pull.filterDone(cause), Result.succeed(Cause.Done("leftover"))) + }) + }) + + describe("doneExitFromCause", () => { + it("treats a pure done cause as success", () => { + const exit = Pull.doneExitFromCause(Cause.fail(Cause.Done("leftover"))) + assert.deepStrictEqual(exit, Exit.succeed("leftover")) + }) + + it("fails with the stripped cause when the done signal was merged with a failure", () => { + const cause = Cause.combine(Cause.fail(Cause.Done("leftover")), Cause.die("boom")) + assert.deepStrictEqual(Pull.doneExitFromCause(cause), Exit.failCause(Cause.die("boom"))) + }) + }) +}) diff --git a/repos/effect/packages/effect/test/Queue.test.ts b/repos/effect/packages/effect/test/Queue.test.ts index 2b266d237a..3806e0b822 100644 --- a/repos/effect/packages/effect/test/Queue.test.ts +++ b/repos/effect/packages/effect/test/Queue.test.ts @@ -319,6 +319,28 @@ describe("Queue", () => { assert.isNotNull(fiber.pollUnsafe()) })) + it.effect("end preserves Done for take and excludes it from await", () => + Effect.gen(function*() { + const queue = yield* Queue.unbounded() + const takeFiber = yield* Queue.take(queue).pipe(Effect.forkChild) + const awaitFiber = yield* Queue.await(queue).pipe(Effect.forkChild) + yield* Effect.yieldNow + yield* Queue.end(queue) + + assert.deepStrictEqual(yield* Fiber.await(takeFiber), Exit.fail(Cause.Done())) + assert.strictEqual(yield* Fiber.join(awaitFiber), void 0) + })) + + it.effect("await preserves non-Done failures", () => + Effect.gen(function*() { + const queue = yield* Queue.unbounded() + const fiber = yield* Queue.await(queue).pipe(Effect.exit, Effect.forkChild) + yield* Effect.yieldNow + yield* Queue.fail(queue, "boom") + + assert.deepStrictEqual(yield* Fiber.join(fiber), Exit.fail("boom")) + })) + it.effect("bounded 0 capacity", () => Effect.gen(function*() { const queue = yield* Queue.bounded(0) diff --git a/repos/effect/packages/effect/test/RcMap.test.ts b/repos/effect/packages/effect/test/RcMap.test.ts index 8163192db0..415616a1f5 100644 --- a/repos/effect/packages/effect/test/RcMap.test.ts +++ b/repos/effect/packages/effect/test/RcMap.test.ts @@ -1,8 +1,178 @@ import { assert, describe, it } from "@effect/vitest" -import { Cause, Data, Effect, Exit, RcMap, Scope } from "effect" +import { Cause, Data, Deferred, Effect, Exit, Fiber, Option, RcMap, Ref, Scope } from "effect" import { TestClock } from "effect/testing" describe("RcMap", () => { + describe("getOption", () => { + it.effect("returns None without lookup or capacity acquisition when missing", () => + Effect.gen(function*() { + const lookups = yield* Ref.make(0) + const map = yield* RcMap.make({ + lookup: (key: string) => Ref.update(lookups, (n) => n + 1).pipe(Effect.as(key)), + capacity: 0 + }) + + assert.deepStrictEqual(yield* RcMap.getOption(map, "missing"), Option.none()) + assert.deepStrictEqual(yield* RcMap.getOption("missing")(map), Option.none()) + assert.strictEqual(yield* Ref.get(lookups), 0) + })) + + it.effect("retains a ready entry until the caller scope closes", () => + Effect.gen(function*() { + const released = yield* Ref.make(0) + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease( + Effect.succeed(key), + () => Ref.update(released, (n) => n + 1) + ) + }) + const ownerScope = yield* Scope.make() + const borrowerScope = yield* Scope.make() + + yield* RcMap.get(map, "ready").pipe(Scope.provide(ownerScope)) + assert.deepStrictEqual( + yield* RcMap.getOption("ready")(map).pipe(Scope.provide(borrowerScope)), + Option.some("ready") + ) + + yield* Scope.close(ownerScope, Exit.void) + assert.strictEqual(yield* Ref.get(released), 0) + yield* Scope.close(borrowerScope, Exit.void) + assert.strictEqual(yield* Ref.get(released), 1) + })) + + it.effect("shares an in-flight entry", () => + Effect.gen(function*() { + const started = yield* Deferred.make() + const complete = yield* Deferred.make() + const released = yield* Ref.make(0) + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease( + Deferred.succeed(started, void 0).pipe( + Effect.andThen(Deferred.await(complete)), + Effect.as(key) + ), + () => Ref.update(released, (n) => n + 1) + ) + }) + const ownerScope = yield* Scope.make() + const borrowerScope = yield* Scope.make() + const owner = yield* RcMap.get(map, "pending").pipe( + Scope.provide(ownerScope), + Effect.forkChild({ startImmediately: true }) + ) + yield* Deferred.await(started) + const borrower = yield* RcMap.getOption(map, "pending").pipe( + Scope.provide(borrowerScope), + Effect.forkChild({ startImmediately: true }) + ) + + assert.strictEqual(map.state._tag === "Open" ? Array.from(map.state.map)[0][1].refCount : 0, 2) + yield* Deferred.succeed(complete, void 0) + assert.strictEqual(yield* Fiber.join(owner), "pending") + assert.deepStrictEqual(yield* Fiber.join(borrower), Option.some("pending")) + + yield* Scope.close(ownerScope, Exit.void) + assert.strictEqual(yield* Ref.get(released), 0) + yield* Scope.close(borrowerScope, Exit.void) + assert.strictEqual(yield* Ref.get(released), 1) + })) + + it.effect("propagates a cached failure", () => + Effect.gen(function*() { + const lookups = yield* Ref.make(0) + const map = yield* RcMap.make({ + lookup: (_key: string) => Ref.update(lookups, (n) => n + 1).pipe(Effect.andThen(Effect.fail("boom"))) + }) + const ownerScope = yield* Scope.make() + const borrowerScope = yield* Scope.make() + + assert.deepStrictEqual( + yield* RcMap.get(map, "failed").pipe(Scope.provide(ownerScope), Effect.exit), + Exit.fail("boom") + ) + assert.deepStrictEqual( + yield* RcMap.getOption(map, "failed").pipe(Scope.provide(borrowerScope), Effect.exit), + Exit.fail("boom") + ) + assert.strictEqual(yield* Ref.get(lookups), 1) + + yield* Scope.close(ownerScope, Exit.void) + yield* Scope.close(borrowerScope, Exit.void) + })) + + it.effect("retains an idle entry and restarts its TTL on release", () => + Effect.gen(function*() { + const released = yield* Ref.make(0) + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease( + Effect.succeed(key), + () => Ref.update(released, (n) => n + 1) + ), + idleTimeToLive: 1000 + }) + const ownerScope = yield* Scope.make() + yield* RcMap.get(map, "ttl").pipe(Scope.provide(ownerScope)) + yield* Scope.close(ownerScope, Exit.void) + + yield* TestClock.adjust(500) + const borrowerScope = yield* Scope.make() + assert.deepStrictEqual( + yield* RcMap.getOption(map, "ttl").pipe(Scope.provide(borrowerScope)), + Option.some("ttl") + ) + yield* TestClock.adjust(500) + assert.strictEqual(yield* Ref.get(released), 0) + + yield* Scope.close(borrowerScope, Exit.void) + yield* TestClock.adjust(999) + assert.strictEqual(yield* Ref.get(released), 0) + yield* TestClock.adjust(1) + assert.strictEqual(yield* Ref.get(released), 1) + assert.deepStrictEqual(yield* RcMap.getOption(map, "ttl"), Option.none()) + })) + + it.effect("linearizes retention before invalidation", () => + Effect.gen(function*() { + const released = yield* Ref.make(0) + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease( + Effect.succeed(key), + () => Ref.update(released, (n) => n + 1) + ) + }) + const ownerScope = yield* Scope.make() + const borrowerScope = yield* Scope.make() + yield* RcMap.get(map, "key").pipe(Scope.provide(ownerScope)) + assert.deepStrictEqual( + yield* RcMap.getOption(map, "key").pipe(Scope.provide(borrowerScope)), + Option.some("key") + ) + + yield* RcMap.invalidate(map, "key") + assert.isFalse(yield* RcMap.has(map, "key")) + yield* Scope.close(ownerScope, Exit.void) + assert.strictEqual(yield* Ref.get(released), 0) + yield* Scope.close(borrowerScope, Exit.void) + assert.strictEqual(yield* Ref.get(released), 1) + })) + + it.effect("returns None when the map is closed", () => + Effect.gen(function*() { + const mapScope = yield* Scope.make() + const map = yield* RcMap.make({ + lookup: (key: string) => Effect.succeed(key) + }).pipe(Scope.provide(mapScope)) + yield* Scope.close(mapScope, Exit.void) + + assert.deepStrictEqual(yield* RcMap.getOption(map, "key"), Option.none()) + })) + }) + it.effect("deallocation", () => Effect.gen(function*() { const acquired: Array = [] diff --git a/repos/effect/packages/effect/test/TestClock.test.ts b/repos/effect/packages/effect/test/TestClock.test.ts index d53df56a86..54fa0ddfbe 100644 --- a/repos/effect/packages/effect/test/TestClock.test.ts +++ b/repos/effect/packages/effect/test/TestClock.test.ts @@ -78,6 +78,15 @@ describe("TestClock", () => { assert.strictEqual(yield* testClock.monotonicTimeNanos, 1_000_000_000n) })) + it.effect("adjust - preserves precision for nanosecond durations beyond Number.MAX_SAFE_INTEGER", () => + Effect.gen(function*() { + const testClock = yield* TestClock.make() + const nanos = 999_999_999_999_999_000n + yield* testClock.adjust(Duration.nanos(nanos)) + assert.strictEqual(testClock.monotonicTimeNanosUnsafe(), nanos) + assert.strictEqual(testClock.currentTimeNanosUnsafe(), nanos) + })) + it.effect("adjust - keeps nanosecond access total after infinite durations", () => Effect.gen(function*() { for (const duration of [Duration.infinity, Duration.negativeInfinity]) { diff --git a/repos/effect/packages/effect/test/Tracer.test.ts b/repos/effect/packages/effect/test/Tracer.test.ts index ebd59d9408..30dfa9f447 100644 --- a/repos/effect/packages/effect/test/Tracer.test.ts +++ b/repos/effect/packages/effect/test/Tracer.test.ts @@ -1,5 +1,5 @@ -import { describe, it } from "@effect/vitest" -import { assertInclude, assertNone, deepStrictEqual, strictEqual } from "@effect/vitest/utils" +import { assert, describe, it } from "@effect/vitest" +import { assertNone, deepStrictEqual, strictEqual } from "@effect/vitest/utils" import { Cause, Context, Duration, Effect, Fiber, Layer, Tracer } from "effect" import { TestClock } from "effect/testing" import type { Span } from "effect/Tracer" @@ -13,11 +13,56 @@ const getParent = (span: Tracer.Span): Tracer.AnySpan => { return span.parent.value } +const otlpTracerLayer = OtlpTracer.layer({ + url: "http://localhost:4318/v1/traces", + resource: { + serviceName: "test-service" + } +}).pipe( + Layer.provide(OtlpSerialization.layerJson), + Layer.provide(Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response()))) + )) +) + +const makeRootAndChildSpans = Effect.gen(function*() { + const root = yield* Effect.withSpan(Effect.currentSpan, "root") + const child = yield* Effect.withSpan(Effect.currentSpan, "child", { parent: root }) + return [root, child] as const +}) + +const assertSpanIdentifiers = (root: Tracer.Span, child: Tracer.Span) => { + assert.match(root.traceId, /^[0-9a-f]{32}$/) + assert.match(root.spanId, /^[0-9a-f]{16}$/) + strictEqual(child.traceId, root.traceId) + assert.match(child.spanId, /^[0-9a-f]{16}$/) +} + describe("Tracer", () => { + describe("span identifiers", () => { + it.effect("generates native span identifiers", () => + Effect.gen(function*() { + const [root, child] = yield* makeRootAndChildSpans + assertSpanIdentifiers(root, child) + })) + + it.effect("generates OTLP span identifiers", () => + Effect.gen(function*() { + const [root, child] = yield* makeRootAndChildSpans + assert.notInstanceOf(root, Tracer.NativeSpan) + assert.notInstanceOf(child, Tracer.NativeSpan) + assertSpanIdentifiers(root, child) + }).pipe(Effect.provide(otlpTracerLayer))) + }) + describe("Effect.withSpan", () => { it.effect("should capture the stack trace", () => Effect.gen(function*() { - const cause = yield* Effect.die(new Error("boom")).pipe( + const error = new Error("boom") + const errorSite = error.stack?.split("\n")[1]?.trim() + assert.isDefined(errorSite) + const cause = yield* Effect.die(error).pipe( Effect.withSpan("C", { annotations: Tracer.DisablePropagation.context(true) }), @@ -25,7 +70,7 @@ describe("Tracer", () => { Effect.flip ) - assertInclude(Cause.pretty(cause), "Tracer.test.ts:20:41") + assert.include(Cause.pretty(cause), errorSite) })) it.effect("should set the parent span", () => @@ -114,20 +159,7 @@ describe("Tracer", () => { strictEqual(result, 42) }).pipe( - Effect.provide( - OtlpTracer.layer({ - url: "http://localhost:4318/v1/traces", - resource: { - serviceName: "test-service" - } - }).pipe( - Layer.provide(OtlpSerialization.layerJson), - Layer.provide(Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response()))) - )) - ) - ) + Effect.provide(otlpTracerLayer) )) it.effect("should set the correct start and end time", () => diff --git a/repos/effect/packages/effect/test/Tuple.test.ts b/repos/effect/packages/effect/test/Tuple.test.ts index 8110ccace9..0f03e62931 100644 --- a/repos/effect/packages/effect/test/Tuple.test.ts +++ b/repos/effect/packages/effect/test/Tuple.test.ts @@ -1,4 +1,4 @@ -import { Number, pipe, Schema, String, Tuple } from "effect" +import { Equivalence, Number, pipe, Schema, String, Tuple } from "effect" import { TestSchema } from "effect/testing" import { deepStrictEqual, strictEqual } from "node:assert" import { describe, it } from "vitest" @@ -124,4 +124,34 @@ describe("Tuple", () => { deepStrictEqual(R.initialValue, [0, ""]) }) + + it("appendElement appends a single element in both data-first and data-last forms", () => { + deepStrictEqual(pipe(Tuple.make(1, 2), Tuple.appendElement("end")), [1, 2, "end"]) + deepStrictEqual(Tuple.appendElement(Tuple.make(1, 2), "end"), [1, 2, "end"]) + }) + + it("appendElements concatenates a tuple in both data-first and data-last forms", () => { + deepStrictEqual(pipe(Tuple.make(1, 2), Tuple.appendElements(["a", "b"] as const)), [1, 2, "a", "b"]) + deepStrictEqual(Tuple.appendElements(Tuple.make(1, 2), ["a", "b"] as const), [1, 2, "a", "b"]) + }) + + it("makeEquivalence compares tuple positions independently", () => { + const eq = Tuple.makeEquivalence([ + Equivalence.strictEqual(), + Equivalence.strictEqual() + ]) + + strictEqual(eq(["Alice", 30], ["Alice", 30]), true) + strictEqual(eq(["Alice", 30], ["Bob", 30]), false) + strictEqual(eq(["Alice", 30], ["Alice", 31]), false) + }) + + it("makeOrder orders tuple positions lexicographically", () => { + const ord = Tuple.makeOrder([String.Order, Number.Order]) + + strictEqual(ord(["Alice", 30], ["Bob", 25]), -1) + strictEqual(ord(["Alice", 30], ["Alice", 30]), 0) + strictEqual(ord(["Bob", 25], ["Alice", 30]), 1) + strictEqual(ord(["Alice", 30], ["Alice", 25]), 1) + }) }) diff --git a/repos/effect/packages/effect/test/cluster/ClusterWorkflowEngine.test.ts b/repos/effect/packages/effect/test/cluster/ClusterWorkflowEngine.test.ts index 3604caebce..7b4864df47 100644 --- a/repos/effect/packages/effect/test/cluster/ClusterWorkflowEngine.test.ts +++ b/repos/effect/packages/effect/test/cluster/ClusterWorkflowEngine.test.ts @@ -176,6 +176,17 @@ describe.concurrent("ClusterWorkflowEngine", () => { expect(flags.get("interrupt3")).toBeFalsy() }).pipe(Effect.provide(TestWorkflowLayer))) + it.effect("Activity.raceAll ignores a failure when another activity can succeed", () => + Effect.gen(function*() { + const fiber = yield* FailureRaceWorkflow.execute({ + id: "failure-race" + }).pipe(Effect.forkChild({ startImmediately: true })) + + yield* TestClock.adjust("1 second") + + expect(yield* Fiber.join(fiber)).toEqual("slow") + }).pipe(Effect.provide(TestWorkflowLayer))) + it.effect("Activity.raceAll replays the first durable activity", () => Effect.gen(function*() { const flags = yield* Flags @@ -204,6 +215,307 @@ describe.concurrent("ClusterWorkflowEngine", () => { expect(result).toEqual("Activity3") }).pipe(Effect.provide(TestWorkflowLayer))) + it.effect("DurableDeferred.raceAll lets a deferred win while another branch is active", () => + Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const executionId = yield* MixedRaceWorkflow.executionId({ id: "mixed-race" }) + const fiber = yield* MixedRaceWorkflow.execute({ id: "mixed-race" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(1) + const token = DurableDeferred.tokenFromExecutionId(MixedRaceGate, { + workflow: MixedRaceWorkflow, + executionId + }) + yield* DurableDeferred.succeed(MixedRaceGate, { token, value: "signal" }) + yield* sharding.pollStorage + yield* TestClock.adjust("1 second") + + expect(yield* Fiber.join(fiber)).toEqual("signal") + }).pipe(Effect.provide(TestWorkflowLayer)), 20_000) + + it.effect( + "DurableDeferred.raceAll lets an active branch win while the deferred stays pending", + () => + Effect.gen(function*() { + const fiber = yield* MixedRaceWorkflow.execute({ id: "mixed-race-activity" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + for (let i = 0; i < 4; i++) { + yield* TestClock.adjust("1 second") + } + + expect(yield* Fiber.join(fiber)).toEqual("activity") + }).pipe(Effect.provide(TestWorkflowLayer)), + 20_000 + ) + + it.effect( + "DurableDeferred.raceAll replays the run when a losing deferred completes late", + () => + Effect.gen(function*() { + const flags = yield* Flags + const sharding = yield* Sharding.Sharding + const executionId = yield* LosingDeferredWorkflow.executionId({ id: "losing-deferred" }) + const fiber = yield* LosingDeferredWorkflow.execute({ id: "losing-deferred" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(1) + yield* TestClock.adjust("1 second") + while (flags.get("losing-deferred-tail-runs") !== 1) { + yield* TestClock.adjust("1 second") + } + + const token = DurableDeferred.tokenFromExecutionId(LosingDeferredGate, { + workflow: LosingDeferredWorkflow, + executionId + }) + yield* DurableDeferred.succeed(LosingDeferredGate, { token, value: "signal" }) + for (let i = 0; i < 4; i++) { + yield* sharding.pollStorage + yield* TestClock.adjust("10 seconds") + } + + expect(yield* Fiber.join(fiber)).toEqual("activity:tail") + // The late completion preempts the tail; the replay re-executes it. + expect(flags.get("losing-deferred-tail-runs")).toEqual(2) + }).pipe(Effect.provide(TestWorkflowLayer)), + 20_000 + ) + + it.effect("DurableDeferred.raceAll wakes the active run by replaying it", () => + Effect.gen(function*() { + const flags = yield* Flags + const sharding = yield* Sharding.Sharding + const executionId = yield* InPlaceWakeWorkflow.executionId({ id: "in-place-wake" }) + const fiber = yield* InPlaceWakeWorkflow.execute({ id: "in-place-wake" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(1) + const token = DurableDeferred.tokenFromExecutionId(InPlaceWakeGate, { + workflow: InPlaceWakeWorkflow, + executionId + }) + yield* DurableDeferred.succeed(InPlaceWakeGate, { token, value: "signal" }) + yield* sharding.pollStorage + yield* TestClock.adjust("1 second") + + expect(yield* Fiber.join(fiber)).toEqual("signal") + // Usually the completion preempts the parked run (2 runs); under load + // it can land before the branch parks and is read directly (1 run). + assert([1, 2].includes(flags.get("in-place-wake-runs") as number)) + }).pipe(Effect.provide(TestWorkflowLayer)), 20_000) + + it.effect("DurableDeferred.raceAll wakes a branch wrapped in DurableDeferred.into", () => + Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const executionId = yield* IntoWrapWorkflow.executionId({ id: "into-wrap" }) + const fiber = yield* IntoWrapWorkflow.execute({ id: "into-wrap" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(1) + const token = DurableDeferred.tokenFromExecutionId(IntoWrapGate, { + workflow: IntoWrapWorkflow, + executionId + }) + yield* DurableDeferred.succeed(IntoWrapGate, { token, value: "signal" }) + yield* sharding.pollStorage + yield* TestClock.adjust("1 second") + + expect(yield* Fiber.join(fiber)).toEqual("signal") + }).pipe(Effect.provide(TestWorkflowLayer)), 20_000) + + it.effect( + "DurableDeferred.raceAll does not preempt for deferreds awaited inside activity bodies", + () => + Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const fiber = yield* ClockCaptureWorkflow.execute({ id: "clock-capture" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + // Only workflow-level awaits preempt; the clock inside the activity + // does not, so the other branch wins. + yield* TestClock.adjust(1) + yield* TestClock.adjust(5000) + yield* sharding.pollStorage + yield* TestClock.adjust(1000) + yield* TestClock.adjust("60 seconds") + + expect(yield* Fiber.join(fiber)).toEqual("slow") + }).pipe(Effect.provide(TestWorkflowLayer)), + 20_000 + ) + + it.effect( + "DurableDeferred.raceAll lets a bare durable clock branch win while another branch is active", + () => + Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const fiber = yield* BareClockWorkflow.execute({ id: "bare-clock" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(1) + for (let i = 0; i < 8; i++) { + yield* sharding.pollStorage + yield* TestClock.adjust("5 seconds") + } + + expect(yield* Fiber.join(fiber)).toEqual("clock") + }).pipe(Effect.provide(TestWorkflowLayer)), + 20_000 + ) + + it.effect("DurableDeferred.raceAll runs a branch's transformations on a deferred wake", () => + Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const executionId = yield* MappedGateWorkflow.executionId({ id: "mapped-gate" }) + const fiber = yield* MappedGateWorkflow.execute({ id: "mapped-gate" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(1) + const token = DurableDeferred.tokenFromExecutionId(MappedGate, { + workflow: MappedGateWorkflow, + executionId + }) + yield* DurableDeferred.succeed(MappedGate, { token, value: "signal" }) + yield* sharding.pollStorage + yield* TestClock.adjust("1 second") + + expect(yield* Fiber.join(fiber)).toEqual("signal!") + }).pipe(Effect.provide(TestWorkflowLayer)), 20_000) + + it.effect( + "DurableDeferred.raceAll wakes a branch that ran an activity before its await", + () => + Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const executionId = yield* PreGateWorkflow.executionId({ id: "pre-gate" }) + const fiber = yield* PreGateWorkflow.execute({ id: "pre-gate" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(1) + yield* TestClock.adjust(1) + const token = DurableDeferred.tokenFromExecutionId(PreGate, { + workflow: PreGateWorkflow, + executionId + }) + yield* DurableDeferred.succeed(PreGate, { token, value: "signal" }) + yield* sharding.pollStorage + yield* TestClock.adjust("1 second") + + expect(yield* Fiber.join(fiber)).toEqual("act:signal") + }).pipe(Effect.provide(TestWorkflowLayer)), + 20_000 + ) + + it.effect("DurableDeferred.raceAll re-runs a multi-await branch once per completion", () => + Effect.gen(function*() { + const flags = yield* Flags + const sharding = yield* Sharding.Sharding + const executionId = yield* TwoStepWorkflow.executionId({ id: "two-step" }) + const fiber = yield* TwoStepWorkflow.execute({ id: "two-step" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(1) + const tokenA = DurableDeferred.tokenFromExecutionId(TwoStepGateA, { + workflow: TwoStepWorkflow, + executionId + }) + yield* DurableDeferred.succeed(TwoStepGateA, { token: tokenA, value: "a" }) + yield* sharding.pollStorage + yield* TestClock.adjust("1 second") + + // The run parks on the second gate, usually after one wake replay; + // under load the first completion can be read directly (1 run). + assert([1, 2].includes(flags.get("two-step-branch-runs") as number)) + + const tokenB = DurableDeferred.tokenFromExecutionId(TwoStepGateB, { + workflow: TwoStepWorkflow, + executionId + }) + yield* DurableDeferred.succeed(TwoStepGateB, { token: tokenB, value: "b" }) + yield* sharding.pollStorage + yield* TestClock.adjust("1 second") + + expect(yield* Fiber.join(fiber)).toEqual("a:b") + assert([2, 3].includes(flags.get("two-step-branch-runs") as number)) + }).pipe(Effect.provide(TestWorkflowLayer)), 20_000) + + it.effect( + "DurableDeferred.raceAll delivers a completion that lands while a suspension commits", + () => + Effect.gen(function*() { + const flags = yield* Flags + const sharding = yield* Sharding.Sharding + const executionId = yield* SlowUnwindWorkflow.executionId({ id: "slow-unwind" }) + const fiber = yield* SlowUnwindWorkflow.execute({ id: "slow-unwind" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + // Wait until both branches have parked and the ensuring sleep has + // started, so the completion deterministically lands during unwind. + while (flags.get("slow-unwind-started") !== true) { + yield* Effect.yieldNow + } + + const token = DurableDeferred.tokenFromExecutionId(SlowUnwindGateB, { + workflow: SlowUnwindWorkflow, + executionId + }) + yield* DurableDeferred.succeed(SlowUnwindGateB, { token, value: "signal-b" }) + // Finish the unwind, then the replay and its own ensuring sleep. + for (let i = 0; i < 4; i++) { + yield* TestClock.adjust("10 seconds") + yield* sharding.pollStorage + } + + expect(yield* Fiber.join(fiber)).toEqual("signal-b") + expect(flags.get("slow-unwind-runs")).toEqual(2) + }).pipe(Effect.provide(TestWorkflowLayer)), + 20_000 + ) + + it.effect( + "DurableDeferred.raceAll suspends when every branch is pending and resumes with the winner", + () => + Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const executionId = yield* TwoGateWorkflow.executionId({ id: "two-gates" }) + const fiber = yield* TwoGateWorkflow.execute({ id: "two-gates" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + // Wait for the race to suspend with both gates pending. + yield* TestClock.adjust(1) + let polled = yield* TwoGateWorkflow.poll(executionId) + while (Option.isNone(polled) || polled.value._tag !== "Suspended") { + yield* Effect.yieldNow + polled = yield* TwoGateWorkflow.poll(executionId) + } + + const token = DurableDeferred.tokenFromExecutionId(TwoGateB, { + workflow: TwoGateWorkflow, + executionId + }) + yield* DurableDeferred.succeed(TwoGateB, { token, value: "signal-b" }) + yield* sharding.pollStorage + yield* TestClock.adjust("5 seconds") + + expect(yield* Fiber.join(fiber)).toEqual("signal-b") + }).pipe(Effect.provide(TestWorkflowLayer)), + 20_000 + ) + it.effect("nested workflows", () => Effect.gen(function*() { const flags = yield* Flags @@ -214,6 +526,9 @@ describe.concurrent("ClusterWorkflowEngine", () => { id: "123" }).pipe(Effect.forkChild) yield* TestClock.adjust(1000) + while (flags.get("parent-suspended") === undefined) { + yield* Effect.yieldNow + } assert.isUndefined(flags.get("parent-end")) assert.isUndefined(flags.get("child-end")) @@ -422,24 +737,26 @@ describe.concurrent("ClusterWorkflowEngine", () => { }).pipe(Effect.provide(TestWorkflowLayer))) }) -const TestShardingConfig = ShardingConfig.layer({ - shardsPerGroup: 300, - availableShardGroups: ["default", "workflow"], - assignedShardGroups: ["default", "workflow"], - entityMailboxCapacity: 10, - entityTerminationTimeout: 0, - entityMessagePollInterval: 5000, - sendRetryInterval: 100 -}) +const makeTestWorkflowEngine = (config?: Partial) => + ClusterWorkflowEngine.layer.pipe( + Layer.provideMerge(Sharding.layer), + Layer.provide(Runners.layerNoop), + Layer.provideMerge(MessageStorage.layerMemory), + Layer.provide(RunnerStorage.layerMemory), + Layer.provide(RunnerHealth.layerNoop), + Layer.provide(ShardingConfig.layer({ + shardsPerGroup: 300, + availableShardGroups: ["default", "workflow"], + assignedShardGroups: ["default", "workflow"], + entityMailboxCapacity: 10, + entityTerminationTimeout: 0, + entityMessagePollInterval: 5000, + sendRetryInterval: 100, + ...config + })) + ) -const TestWorkflowEngine = ClusterWorkflowEngine.layer.pipe( - Layer.provideMerge(Sharding.layer), - Layer.provide(Runners.layerNoop), - Layer.provideMerge(MessageStorage.layerMemory), - Layer.provide(RunnerStorage.layerMemory), - Layer.provide(RunnerHealth.layerNoop), - Layer.provide(TestShardingConfig) -) +const TestWorkflowEngine = makeTestWorkflowEngine() class SendEmailError extends Schema.Error("SendEmailError")({ _tag: Schema.tag("SendEmailError"), @@ -458,7 +775,7 @@ const EmailWorkflow = Workflow.make("EmailWorkflow", { }) class Flags extends Context.Service()("Flags", { - make: Effect.sync(() => new Map()) + make: Effect.sync(() => new Map()) }) { static readonly layer = Layer.effect(Flags, this.make) } @@ -578,6 +895,30 @@ const RaceWorkflowLayer = RaceWorkflow.toLayer(Effect.fnUntraced(function*() { ]) })) +const FailureRaceWorkflow = Workflow.make("FailureRaceWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + error: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const FailureRaceWorkflowLayer = FailureRaceWorkflow.toLayer(() => + Activity.raceAll("failure-race", [ + Activity.make({ + name: "failure-race-fast", + success: Schema.String, + error: Schema.String, + execute: Effect.fail("boom") + }), + Activity.make({ + name: "failure-race-slow", + success: Schema.String, + error: Schema.String, + execute: Effect.sleep("1 second").pipe(Effect.as("slow")) + }) + ]) +) + const DurableRaceWorkflow = Workflow.make("DurableRaceWorkflow", { payload: { id: Schema.String @@ -640,6 +981,336 @@ const DurableRaceWorkflowLayer = DurableRaceWorkflow.toLayer(Effect.fnUntraced(f return result })) +const MixedRaceWorkflow = Workflow.make("MixedRaceWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const MixedRaceGate = DurableDeferred.make("MixedRaceGate", { + success: Schema.String +}) + +const MixedRaceWorkflowLayer = MixedRaceWorkflow.toLayer(() => + DurableDeferred.raceAll({ + name: "mixed-race", + success: Schema.String, + error: Schema.Never, + effects: [ + DurableDeferred.await(MixedRaceGate), + Activity.make({ + name: "mixed-race-activity", + success: Schema.String, + execute: Effect.sleep("1 second").pipe(Effect.as("activity")) + }) + ] + }) +) + +const LosingDeferredWorkflow = Workflow.make("LosingDeferredWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const LosingDeferredGate = DurableDeferred.make("LosingDeferredGate", { + success: Schema.String +}) + +const LosingDeferredWorkflowLayer = LosingDeferredWorkflow.toLayer(Effect.fnUntraced(function*() { + const flags = yield* Flags + const winner = yield* DurableDeferred.raceAll({ + name: "losing-deferred", + success: Schema.String, + error: Schema.Never, + effects: [ + DurableDeferred.await(LosingDeferredGate), + Activity.make({ + name: "losing-deferred-activity", + success: Schema.String, + execute: Effect.sleep("1 second").pipe(Effect.as("activity")) + }) + ] + }) + const tail = yield* Activity.make({ + name: "losing-deferred-tail", + success: Schema.String, + execute: Effect.suspend(() => { + const runs = flags.get("losing-deferred-tail-runs") + flags.set("losing-deferred-tail-runs", typeof runs === "number" ? runs + 1 : 1) + return Effect.sleep("10 seconds").pipe(Effect.as("tail")) + }) + }) + return `${winner}:${tail}` +})) + +const InPlaceWakeWorkflow = Workflow.make("InPlaceWakeWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const InPlaceWakeGate = DurableDeferred.make("InPlaceWakeGate", { + success: Schema.String +}) + +const InPlaceWakeWorkflowLayer = InPlaceWakeWorkflow.toLayer(Effect.fnUntraced(function*() { + const flags = yield* Flags + const runs = flags.get("in-place-wake-runs") + flags.set("in-place-wake-runs", typeof runs === "number" ? runs + 1 : 1) + return yield* DurableDeferred.raceAll({ + name: "in-place-wake", + success: Schema.String, + error: Schema.Never, + effects: [ + DurableDeferred.await(InPlaceWakeGate), + Activity.make({ + name: "in-place-wake-activity", + success: Schema.String, + execute: Effect.sleep("5 seconds").pipe(Effect.as("activity")) + }) + ] + }) +})) + +const IntoWrapWorkflow = Workflow.make("IntoWrapWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const IntoWrapGate = DurableDeferred.make("IntoWrapGate", { + success: Schema.String +}) + +const IntoWrapAux = DurableDeferred.make("IntoWrapAux", { + success: Schema.String +}) + +const IntoWrapWorkflowLayer = IntoWrapWorkflow.toLayer(() => + DurableDeferred.raceAll({ + name: "into-wrap", + success: Schema.String, + error: Schema.Never, + effects: [ + DurableDeferred.into(DurableDeferred.await(IntoWrapGate), IntoWrapAux), + Activity.make({ + name: "into-wrap-activity", + success: Schema.String, + execute: Effect.sleep("30 seconds").pipe(Effect.as("activity")) + }) + ] + }) +) + +const ClockCaptureWorkflow = Workflow.make("ClockCaptureWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const ClockCaptureWorkflowLayer = ClockCaptureWorkflow.toLayer(() => + Activity.raceAll("clock-capture", [ + Activity.make({ + name: "clock-capture-durable", + success: Schema.String, + execute: DurableClock.sleep({ + name: "clock-capture-durable", + duration: 5000, + inMemoryThreshold: Duration.zero + }).pipe(Effect.as("clock")) + }), + Activity.make({ + name: "clock-capture-slow", + success: Schema.String, + execute: Effect.sleep("30 seconds").pipe(Effect.as("slow")) + }) + ]) +) + +const BareClockWorkflow = Workflow.make("BareClockWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const BareClockWorkflowLayer = BareClockWorkflow.toLayer(() => + DurableDeferred.raceAll({ + name: "bare-clock", + success: Schema.String, + error: Schema.Never, + effects: [ + DurableClock.sleep({ + name: "bare-clock-timer", + duration: 5000, + inMemoryThreshold: Duration.zero + }).pipe(Effect.as("clock")), + Activity.make({ + name: "bare-clock-slow", + success: Schema.String, + execute: Effect.sleep("30 seconds").pipe(Effect.as("slow")) + }) + ] + }) +) + +const MappedGateWorkflow = Workflow.make("MappedGateWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const MappedGate = DurableDeferred.make("MappedGate", { + success: Schema.String +}) + +const MappedGateWorkflowLayer = MappedGateWorkflow.toLayer(() => + DurableDeferred.raceAll({ + name: "mapped-gate", + success: Schema.String, + error: Schema.Never, + effects: [ + DurableDeferred.await(MappedGate).pipe(Effect.map((s) => `${s}!`)), + Activity.make({ + name: "mapped-gate-slow", + success: Schema.String, + execute: Effect.sleep("30 seconds").pipe(Effect.as("slow")) + }) + ] + }) +) + +const PreGateWorkflow = Workflow.make("PreGateWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const PreGate = DurableDeferred.make("PreGate", { + success: Schema.String +}) + +const PreGateWorkflowLayer = PreGateWorkflow.toLayer(() => + DurableDeferred.raceAll({ + name: "pre-gate", + success: Schema.String, + error: Schema.Never, + effects: [ + Effect.gen(function*() { + const a = yield* Activity.make({ + name: "pre-gate-activity", + success: Schema.String, + execute: Effect.succeed("act") + }) + const s = yield* DurableDeferred.await(PreGate) + return `${a}:${s}` + }), + Activity.make({ + name: "pre-gate-slow", + success: Schema.String, + execute: Effect.sleep("30 seconds").pipe(Effect.as("slow")) + }) + ] + }) +) + +const TwoStepWorkflow = Workflow.make("TwoStepWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const TwoStepGateA = DurableDeferred.make("TwoStepGateA", { + success: Schema.String +}) + +const TwoStepGateB = DurableDeferred.make("TwoStepGateB", { + success: Schema.String +}) + +const TwoStepWorkflowLayer = TwoStepWorkflow.toLayer(() => + DurableDeferred.raceAll({ + name: "two-step", + success: Schema.String, + error: Schema.Never, + effects: [ + Effect.gen(function*() { + const flags = yield* Flags + const runs = flags.get("two-step-branch-runs") + flags.set("two-step-branch-runs", typeof runs === "number" ? runs + 1 : 1) + const a = yield* DurableDeferred.await(TwoStepGateA) + const b = yield* DurableDeferred.await(TwoStepGateB) + return `${a}:${b}` + }), + // a live branch that holds no activity slot, so wake re-runs are + // processed while the race stays active + Effect.never + ] + }) +) + +const SlowUnwindWorkflow = Workflow.make("SlowUnwindWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const SlowUnwindGateA = DurableDeferred.make("SlowUnwindGateA", { + success: Schema.String +}) + +const SlowUnwindGateB = DurableDeferred.make("SlowUnwindGateB", { + success: Schema.String +}) + +const SlowUnwindWorkflowLayer = SlowUnwindWorkflow.toLayer(Effect.fnUntraced(function*() { + const flags = yield* Flags + const runs = flags.get("slow-unwind-runs") + flags.set("slow-unwind-runs", typeof runs === "number" ? runs + 1 : 1) + return yield* DurableDeferred.raceAll({ + name: "slow-unwind", + success: Schema.String, + error: Schema.Never, + effects: [ + DurableDeferred.await(SlowUnwindGateA), + DurableDeferred.await(SlowUnwindGateB) + ] + }).pipe( + // slows the unwind so completions can land while a suspension commits + Effect.ensuring( + Effect.sync(() => flags.set("slow-unwind-started", true)).pipe( + Effect.andThen(Effect.sleep("10 seconds")) + ) + ) + ) +})) + +const TwoGateWorkflow = Workflow.make("TwoGateWorkflow", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id +}) + +const TwoGateA = DurableDeferred.make("TwoGateA", { + success: Schema.String +}) + +const TwoGateB = DurableDeferred.make("TwoGateB", { + success: Schema.String +}) + +const TwoGateWorkflowLayer = TwoGateWorkflow.toLayer(() => + DurableDeferred.raceAll({ + name: "two-gates", + success: Schema.String, + error: Schema.Never, + effects: [ + DurableDeferred.await(TwoGateA), + DurableDeferred.await(TwoGateB) + ] + }) +) + const ParentWorkflow = Workflow.make("ParentWorkflow", { payload: { id: Schema.String @@ -782,9 +1453,24 @@ const makeBatchRequestError = () => { return error } -const TestWorkflowLayer = EmailWorkflowLayer.pipe( - Layer.merge(RaceWorkflowLayer), +const RaceWorkflowLayers = RaceWorkflowLayer.pipe( + Layer.merge(FailureRaceWorkflowLayer), Layer.merge(DurableRaceWorkflowLayer), + Layer.merge(MixedRaceWorkflowLayer), + Layer.merge(LosingDeferredWorkflowLayer), + Layer.merge(InPlaceWakeWorkflowLayer), + Layer.merge(IntoWrapWorkflowLayer), + Layer.merge(ClockCaptureWorkflowLayer), + Layer.merge(BareClockWorkflowLayer), + Layer.merge(MappedGateWorkflowLayer), + Layer.merge(TwoStepWorkflowLayer), + Layer.merge(PreGateWorkflowLayer), + Layer.merge(SlowUnwindWorkflowLayer), + Layer.merge(TwoGateWorkflowLayer) +) + +const TestWorkflowLayer = EmailWorkflowLayer.pipe( + Layer.merge(RaceWorkflowLayers), Layer.merge(ParentWorkflowLayer), Layer.merge(ChildWorkflowLayer), Layer.merge(ShardedClockWorkflowLayer), diff --git a/repos/effect/packages/effect/test/cluster/Envelope.test.ts b/repos/effect/packages/effect/test/cluster/Envelope.test.ts new file mode 100644 index 0000000000..46f535636f --- /dev/null +++ b/repos/effect/packages/effect/test/cluster/Envelope.test.ts @@ -0,0 +1,58 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema } from "effect" +import { EntityAddress, EntityId, EntityType, Envelope, Reply, ShardId, Snowflake } from "effect/unstable/cluster" +import { SchemaBinary } from "effect/unstable/encoding" +import { Headers } from "effect/unstable/http" + +const request = { + _tag: "Request", + requestId: Snowflake.Snowflake(BigInt(1)), + address: EntityAddress.make({ + shardId: ShardId.make("default", 1), + entityType: EntityType.make("TestEntity"), + entityId: EntityId.make("1") + }), + tag: "Test", + payload: { id: 1 }, + headers: Headers.empty +} as Envelope.PartialRequest + +describe("Envelope.OpaqueHole", () => { + it("leaves the already-encoded payload untouched under the JSON codec", () => { + const encoded = Schema.encodeSync(Envelope.PartialJson)(request) as Envelope.PartialRequestEncoded + assert.deepStrictEqual(encoded.payload, { id: 1 }) + + const decoded = Schema.decodeSync(Envelope.PartialJson)(encoded) + assert.strictEqual(decoded._tag, "Request") + assert.deepStrictEqual((decoded as Envelope.PartialRequest).payload, { id: 1 }) + }) + + it("carries a non-JSON payload through the hole", () => { + const bytes = Uint8Array.of(1, 2, 3) + const encoded = Schema.encodeSync(Envelope.PartialJson)( + { ...request, payload: bytes } as Envelope.PartialRequest + ) as Envelope.PartialRequestEncoded + assert.strictEqual(encoded.payload, bytes) + }) + + it("compiles the hole and Reply.Encoded as a bytes leaf under SchemaBinary", () => { + const bytes = Uint8Array.of(1, 2, 3) + const encodedBytes = Schema.encodeSync(SchemaBinary.toCodec(Schema.Uint8Array))(bytes) + const encodedHole = Schema.encodeSync(SchemaBinary.toCodec(Envelope.OpaqueHole))(bytes) + const encodedReply = Schema.encodeSync(SchemaBinary.toCodec(Reply.Encoded))(bytes as unknown as Reply.Encoded) + + assert.deepStrictEqual(encodedHole, encodedBytes) + assert.deepStrictEqual(encodedReply, encodedBytes) + assert.deepStrictEqual(Schema.decodeSync(SchemaBinary.toCodec(Envelope.OpaqueHole))(encodedHole), bytes) + assert.deepStrictEqual( + Schema.decodeSync(SchemaBinary.toCodec(Reply.Encoded))(encodedReply) as unknown, + bytes + ) + }) + + it("reports non-byte OpaqueHole inputs as schema failures", () => { + const encode = Schema.encodeUnknownSync(SchemaBinary.toCodec(Envelope.OpaqueHole)) + + assert.throws(() => encode({ id: 1 }), /Uint8Array/) + }) +}) diff --git a/repos/effect/packages/effect/test/cluster/MessageStorage.test.ts b/repos/effect/packages/effect/test/cluster/MessageStorage.test.ts index b0fdb941eb..e85096cf82 100644 --- a/repos/effect/packages/effect/test/cluster/MessageStorage.test.ts +++ b/repos/effect/packages/effect/test/cluster/MessageStorage.test.ts @@ -16,7 +16,7 @@ import { import { Headers } from "effect/unstable/http" import { Rpc, RpcSchema } from "effect/unstable/rpc" -const MemoryLive = MessageStorage.layerMemory.pipe( +const MemoryLayer = MessageStorage.layerMemory.pipe( Layer.provideMerge(Snowflake.layerGenerator), Layer.provide(ShardingConfig.layerDefaults) ) @@ -49,6 +49,41 @@ describe("MessageStorage", () => { expect(result._tag).toEqual("Success") }).pipe(Effect.provide(MessageStorage.MemoryDriver.layer))) + it.effect("encoded unprocessedMessages fails closed for an empty address filter", () => + Effect.gen(function*() { + const driver = yield* MessageStorage.MemoryDriver + const envelope: Envelope.PartialRequestEncoded = { + _tag: "Request", + requestId: "1", + address: { shardId: { group: "default", id: 1 }, entityType: "Repro", entityId: "one" }, + tag: "Repro", + payload: {}, + headers: {} + } + yield* driver.encoded.saveEnvelope({ envelope, primaryKey: null, deliverAt: null }) + const messages = yield* driver.encoded.unprocessedMessages(["default:1"], 0, { addresses: [] }) + expect(messages).toHaveLength(0) + }).pipe(Effect.provide(MessageStorage.MemoryDriver.layer))) + + it.effect("encoded resetAddresses fails closed for an empty address list", () => + Effect.gen(function*() { + const driver = yield* MessageStorage.MemoryDriver + const envelope: Envelope.PartialRequestEncoded = { + _tag: "Request", + requestId: "1", + address: { shardId: { group: "default", id: 1 }, entityType: "Repro", entityId: "one" }, + tag: "Repro", + payload: {}, + headers: {} + } + yield* driver.encoded.saveEnvelope({ envelope, primaryKey: null, deliverAt: null }) + const claimed = yield* driver.encoded.unprocessedMessages(["default:1"], 0) + expect(claimed).toHaveLength(1) + yield* driver.encoded.resetAddresses([]) + const messages = yield* driver.encoded.unprocessedMessages(["default:1"], 1) + expect(messages).toHaveLength(0) + }).pipe(Effect.provide(MessageStorage.MemoryDriver.layer))) + it.effect("saves a request", () => Effect.gen(function*() { const storage = yield* MessageStorage.MessageStorage @@ -57,7 +92,7 @@ describe("MessageStorage", () => { expect(result._tag).toEqual("Success") const messages = yield* storage.unprocessedMessages([request.envelope.address.shardId]) expect(messages).toHaveLength(1) - }).pipe(Effect.provide(MemoryLive))) + }).pipe(Effect.provide(MemoryLayer))) it.effect("detects duplicates", () => Effect.gen(function*() { @@ -75,7 +110,7 @@ describe("MessageStorage", () => { }) ) expect(result._tag).toEqual("Duplicate") - }).pipe(Effect.provide(MemoryLive))) + }).pipe(Effect.provide(MemoryLayer))) it.effect("unprocessedMessages excludes complete requests", () => Effect.gen(function*() { @@ -85,7 +120,62 @@ describe("MessageStorage", () => { yield* storage.saveReply(yield* makeReply(request)) const messages = yield* storage.unprocessedMessages([request.envelope.address.shardId]) expect(messages).toHaveLength(0) - }).pipe(Effect.provide(MemoryLive))) + }).pipe(Effect.provide(MemoryLayer))) + it.effect("unprocessedMessages honors the limit option", () => + Effect.gen(function*() { + const storage = yield* MessageStorage.MessageStorage + for (let i = 1; i <= 5; i++) { + yield* storage.saveRequest(yield* makeRequest({ payload: { id: i }, entityId: String(i) })) + } + const shardId = ShardId.make("default", 1) + const messages = yield* storage.unprocessedMessages([shardId], { limit: 3 }) + expect(messages).toHaveLength(3) + expect(messages.map((m: any) => m.envelope.payload.id)).toEqual([1, 2, 3]) + const remaining = yield* storage.unprocessedMessages([shardId]) + expect(remaining.map((m: any) => m.envelope.payload.id)).toEqual([4, 5]) + }).pipe(Effect.provide(MemoryLayer))) + + it.effect("unprocessedMessages filters by address", () => + Effect.gen(function*() { + const storage = yield* MessageStorage.MessageStorage + for (let i = 1; i <= 4; i++) { + yield* storage.saveRequest(yield* makeRequest({ payload: { id: i }, entityId: String(i) })) + } + const shardId = ShardId.make("default", 1) + const address = (entityId: string) => + EntityAddress.make({ + shardId, + entityType: EntityType.make("test"), + entityId: EntityId.make(entityId) + }) + const messages = yield* storage.unprocessedMessages([shardId], { + addresses: [address("2"), address("4")] + }) + expect(messages.map((m: any) => m.envelope.payload.id)).toEqual([2, 4]) + // an empty address filter returns nothing + const none = yield* storage.unprocessedMessages([shardId], { addresses: [] }) + expect(none).toHaveLength(0) + }).pipe(Effect.provide(MemoryLayer))) + + it.effect("resetAddresses makes claimed messages eligible again", () => + Effect.gen(function*() { + const storage = yield* MessageStorage.MessageStorage + for (let i = 1; i <= 4; i++) { + yield* storage.saveRequest(yield* makeRequest({ payload: { id: i }, entityId: String(i) })) + } + const shardId = ShardId.make("default", 1) + yield* storage.unprocessedMessages([shardId], { limit: 3 }) + yield* storage.resetAddresses(["1", "3"].map((entityId) => + EntityAddress.make({ + shardId, + entityType: EntityType.make("test"), + entityId: EntityId.make(entityId) + }) + )) + + const messages = yield* storage.unprocessedMessages([shardId]) + expect(messages.map((m: any) => m.envelope.payload.id)).toEqual([1, 3, 4]) + }).pipe(Effect.provide(MemoryLayer))) it.effect("repliesFor", () => Effect.gen(function*() { @@ -98,7 +188,7 @@ describe("MessageStorage", () => { replies = yield* storage.repliesFor([request]) expect(replies).toHaveLength(1) expect(replies[0].requestId).toEqual(request.envelope.requestId) - }).pipe(Effect.provide(MemoryLive))) + }).pipe(Effect.provide(MemoryLayer))) it.effect("registerReplyHandler", () => Effect.gen(function*() { @@ -116,7 +206,7 @@ describe("MessageStorage", () => { yield* storage.saveReply(yield* makeReply(request)) yield* latch.await yield* Fiber.await(fiber) - }).pipe(Effect.provide(MemoryLive))) + }).pipe(Effect.provide(MemoryLayer))) }) }) @@ -127,6 +217,7 @@ export const GetUserRpc = Rpc.make("GetUser", { export const makeRequest = Effect.fnUntraced(function*(options?: { readonly rpc?: Rpc.AnyWithProps readonly payload?: any + readonly entityId?: string }) { const snowflake = yield* Snowflake.Generator const rpc = options?.rpc ?? GetUserRpc @@ -136,7 +227,7 @@ export const makeRequest = Effect.fnUntraced(function*(options?: { address: EntityAddress.make({ shardId: ShardId.make("default", 1), entityType: EntityType.make("test"), - entityId: EntityId.make("1") + entityId: EntityId.make(options?.entityId ?? "1") }), tag: rpc._tag, payload: options?.payload ?? { id: 123 }, diff --git a/repos/effect/packages/effect/test/cluster/RunnerServer.test.ts b/repos/effect/packages/effect/test/cluster/RunnerServer.test.ts index b43f12274e..0b49574fa1 100644 --- a/repos/effect/packages/effect/test/cluster/RunnerServer.test.ts +++ b/repos/effect/packages/effect/test/cluster/RunnerServer.test.ts @@ -18,27 +18,70 @@ import { Snowflake } from "effect/unstable/cluster" import { Headers } from "effect/unstable/http" -import { Rpc, RpcTest } from "effect/unstable/rpc" +import { Rpc, type RpcSerialization, RpcServer, RpcTest } from "effect/unstable/rpc" const ReproEntity = Entity.make("ReproRunnerServer", [ Rpc.make("ReproStream", { success: Schema.Int, payload: { id: Schema.Number }, stream: true }) ]).annotateRpcs(ClusterSchema.Persisted, false) -const handlers = RunnerServer.layerHandlers.pipe( - Layer.provideMerge(ReproEntity.toLayer({ ReproStream: () => Stream.make(1) })), - Layer.provideMerge(Sharding.layer), - Layer.provideMerge(Snowflake.layerGenerator), - Layer.provide(RunnerStorage.layerMemory), - Layer.provide(RunnerHealth.layerNoop), - Layer.provide(Runners.layerNoop), - Layer.provideMerge(MessageStorage.layerMemory), - Layer.provide(ShardingConfig.layer({ - entityMailboxCapacity: 10, - entityTerminationTimeout: 0, - entityMessagePollInterval: 5000, - sendRetryInterval: 100, - refreshAssignmentsInterval: 0 - })) +const HoleCodecEntity = Entity.make("HoleCodecEntity", [ + Rpc.make("Double", { success: Schema.Int, payload: { id: Schema.Number } }) +]).annotateRpcs(ClusterSchema.Persisted, false) + +// A hole codec that is observably different from `Schema.toCodecJson`: the +// entity payload and the replies become JSON strings on the wire. +const codecForJsonString = + ((schema: S) => + Schema.fromJsonString(Schema.toCodecJson(schema as any))) as RpcSerialization.CodecFor + +const layerProtocol = (codecFor: RpcSerialization.CodecFor) => + Layer.effect(RpcServer.Protocol)( + Effect.map(Queue.unbounded(), (disconnects) => + RpcServer.Protocol.of({ + run: () => Effect.never, + disconnects, + send: () => Effect.void, + end: () => Effect.void, + clientIds: Effect.succeed(new Set()), + initialMessage: Effect.succeedNone, + supportsAck: false, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: false, + codecFor + })) + ) + +const makeHandlers = ( + entities: Layer.Layer, + codecFor: RpcSerialization.CodecFor +) => + RunnerServer.layerHandlers.pipe( + Layer.provide(layerProtocol(codecFor)), + Layer.provideMerge(entities), + Layer.provideMerge(Sharding.layer), + Layer.provideMerge(Snowflake.layerGenerator), + Layer.provide(RunnerStorage.layerMemory), + Layer.provide(RunnerHealth.layerNoop), + Layer.provide(Runners.layerNoop), + Layer.provideMerge(MessageStorage.layerMemory), + Layer.provide(ShardingConfig.layer({ + entityMailboxCapacity: 10, + entityTerminationTimeout: 0, + entityMessagePollInterval: 5000, + sendRetryInterval: 100, + refreshAssignmentsInterval: 0 + })) + ) + +const handlers = makeHandlers( + ReproEntity.toLayer({ ReproStream: () => Stream.make(1) }), + Schema.toCodecJson as RpcSerialization.CodecFor +) + +const holeCodecHandlers = makeHandlers( + HoleCodecEntity.toLayer({ Double: ({ payload }) => Effect.succeed(payload.id * 2) }), + codecForJsonString ) it.effect("completes a successful runner stream", () => @@ -93,3 +136,35 @@ it.effect("completes a successful runner stream", () => } assert.strictEqual(completion.value._tag, "WithExit") }).pipe(Effect.provide(handlers))) + +it.effect("fills the entity payload and reply holes with the serialization's codec", () => + Effect.gen(function*() { + yield* TestClock.adjust(1) + const sharding = yield* Sharding.Sharding + const snowflake = yield* Snowflake.Generator + const entityId = EntityId.make("hole") + const request = { + _tag: "Request", + requestId: snowflake.nextUnsafe(), + address: EntityAddress.make({ + shardId: sharding.getShardId(entityId, HoleCodecEntity.getShardGroup(entityId)), + entityType: EntityType.make("HoleCodecEntity"), + entityId + }), + tag: "Double", + // already encoded by the sender with the same hole codec + payload: JSON.stringify({ id: 21 }), + headers: Headers.empty + } as any as Envelope.PartialRequest + + const client = yield* RpcTest.makeClient(Runners.Rpcs) + const reply = yield* client.Effect({ request, persisted: false }).pipe( + Effect.timeout("1 second"), + TestClock.withLive + ) + + assert.strictEqual(typeof reply, "string", "the reply hole must carry the codec's output") + const decoded = JSON.parse(reply as any) + assert.strictEqual(decoded._tag, "WithExit") + assert.deepStrictEqual(decoded.exit, { _tag: "Success", value: 42 }) + }).pipe(Effect.provide(holeCodecHandlers))) diff --git a/repos/effect/packages/effect/test/cluster/Runners.test.ts b/repos/effect/packages/effect/test/cluster/Runners.test.ts index d7c40205fa..6bf033ee2e 100644 --- a/repos/effect/packages/effect/test/cluster/Runners.test.ts +++ b/repos/effect/packages/effect/test/cluster/Runners.test.ts @@ -23,7 +23,7 @@ import { Snowflake } from "effect/unstable/cluster" import { Headers } from "effect/unstable/http" -import { Rpc, RpcClient, RpcTest } from "effect/unstable/rpc" +import { Rpc, RpcClient, type RpcSerialization, RpcServer, RpcTest } from "effect/unstable/rpc" import { RpcClientError } from "effect/unstable/rpc/RpcClientError" import type { FromClientEncoded, FromServerEncoded } from "effect/unstable/rpc/RpcMessage" import { Socket } from "effect/unstable/socket" @@ -56,7 +56,26 @@ const TestShardingConfig = ShardingConfig.layer({ refreshAssignmentsInterval: 0 }) +const layerServerProtocol = (codecFor: RpcSerialization.CodecFor) => + Layer.effect(RpcServer.Protocol)( + Effect.map(Queue.unbounded(), (disconnects) => + RpcServer.Protocol.of({ + run: () => Effect.never, + disconnects, + send: () => Effect.void, + end: () => Effect.void, + clientIds: Effect.succeed(new Set()), + initialMessage: Effect.succeedNone, + supportsAck: false, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: false, + codecFor + })) + ) + const RunnerServerHandlers = RunnerServer.layerHandlers.pipe( + Layer.provide(layerServerProtocol(Schema.toCodecJson as RpcSerialization.CodecFor)), Layer.provideMerge(BadReplyEntityLayer), Layer.provideMerge(Sharding.layer), Layer.provideMerge(Snowflake.layerGenerator), @@ -186,28 +205,38 @@ describe.concurrent("Runners.makeRpc", () => { annotations: Context.empty() }) + // A hole codec that is observably different from `Schema.toCodecJson`: the + // entity payload becomes a JSON string inside the runner envelope. + const codecForJsonString = + ((schema: S) => + Schema.fromJsonString(Schema.toCodecJson(schema as any))) as RpcSerialization.CodecFor + const layerFakeProtocol = ( onRequest: ( request: FromClientEncoded, write: (data: FromServerEncoded) => Effect.Effect - ) => Effect.Effect + ) => Effect.Effect, + codecFor: RpcSerialization.CodecFor = Schema.toCodecJson as RpcSerialization.CodecFor ) => - Layer.succeed(Runners.RpcClientProtocol)(() => - Effect.sync(() => { - let write!: (data: FromServerEncoded) => Effect.Effect - return RpcClient.Protocol.of({ - run(_clientId, f) { - write = f - return Effect.never - }, - send(_clientId, request) { - return onRequest(request, write) - }, - supportsAck: true, - supportsTransferables: false + Layer.succeed(Runners.RpcClientProtocol)({ + codecFor, + make: () => + Effect.sync(() => { + let write!: (data: FromServerEncoded) => Effect.Effect + return RpcClient.Protocol.of({ + run(_clientId, f) { + write = f + return Effect.never + }, + send(_clientId, request) { + return onRequest(request, write) + }, + supportsAck: true, + supportsTransferables: false, + codecFor + }) }) - }) - ) + }) const layerRunners = (protocol: Layer.Layer) => Runners.layerRpc.pipe( @@ -217,9 +246,53 @@ describe.concurrent("Runners.makeRpc", () => { Layer.provide(TestShardingConfig) ) + it.effect("uses the client protocol codec for simulated remote serialization", () => { + let codecCalls = 0 + const codecFor = ((schema: S) => { + codecCalls++ + return Schema.fromJsonString(Schema.toCodecJson(schema as any)) + }) as RpcSerialization.CodecFor + const codecForJson = Schema.toCodecJson as RpcSerialization.CodecFor + return Effect.gen(function*() { + const runners = yield* Runners.Runners + const snowflake = yield* Snowflake.Generator + const message = makeOutgoingRequest(TestRpc, snowflake.nextUnsafe(), () => Effect.void) + + yield* runners.sendLocal({ + message, + simulateRemoteSerialization: true, + send: (incoming) => + Effect.sync(() => { + assert.strictEqual(incoming._tag, "IncomingRequestLocal") + if (incoming._tag === "IncomingRequestLocal") { + assert.strictEqual((incoming.envelope.payload as { readonly id: number }).id, 1) + } + }) + }) + + assert.strictEqual(codecCalls, 2) + assert.strictEqual(message.encodedCache?.codecFor, codecFor) + const encodedJson = yield* Message.serialize(message, codecForJson) + assert.strictEqual(encodedJson._tag, "Request") + if (encodedJson._tag === "Request") { + assert.deepStrictEqual(encodedJson.payload, { id: 1 }) + } + assert.strictEqual(message.encodedCache?.codecFor, codecForJson) + }).pipe( + Effect.provide(layerRunners(layerFakeProtocol(() => Effect.void, codecFor))) + ) + }) + const respondWithDefect = (request: FromClientEncoded, write: (data: FromServerEncoded) => Effect.Effect) => request._tag === "Request" ? write({ _tag: "Defect", defect: "boom" }) : Effect.void + const failTransport = () => + Effect.fail( + new RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006 }) + }) + ) + it.effect("a server-delivered defect resolves the request instead of RunnerUnavailable", () => Effect.gen(function*() { const runners = yield* Runners.Runners @@ -255,13 +328,37 @@ describe.concurrent("Runners.makeRpc", () => { return assert.fail("send must fail for a transport failure") } assert.instanceOf(Cause.squash(exit.cause), ClusterError.RunnerUnavailable) - }).pipe(Effect.provide(layerRunners(layerFakeProtocol(() => - Effect.fail( - new RpcClientError({ - reason: new Socket.SocketCloseError({ code: 1006 }) - }) - ) - ))))) + }).pipe(Effect.provide(layerRunners(layerFakeProtocol(failTransport))))) + + it.effect("volatile notification transport failures map to RunnerUnavailable", () => + Effect.gen(function*() { + const runners = yield* Runners.Runners + const snowflakeGen = yield* Snowflake.Generator + const message = makeOutgoingRequest(TestRpc, snowflakeGen.nextUnsafe(), () => Effect.void) + + const exit = yield* Effect.exit(runners.notify({ + address: Option.some(runnerAddress), + message, + discard: true + })) + if (!Exit.isFailure(exit)) { + return assert.fail("volatile notification must fail when delivery fails") + } + assert.instanceOf(Cause.squash(exit.cause), ClusterError.RunnerUnavailable) + }).pipe(Effect.provide(layerRunners(layerFakeProtocol(failTransport))))) + + it.effect("persisted notification transport failures are ignored", () => + Effect.gen(function*() { + const runners = yield* Runners.Runners + const snowflakeGen = yield* Snowflake.Generator + const message = makeOutgoingRequest(TestRpcPersisted, snowflakeGen.nextUnsafe(), () => Effect.void) + + yield* runners.notify({ + address: Option.some(runnerAddress), + message, + discard: true + }) + }).pipe(Effect.provide(layerRunners(layerFakeProtocol(failTransport))))) it.effect("a delivered defect for a persisted request maps to RunnerUnavailable for storage recovery", () => Effect.gen(function*() { @@ -275,4 +372,37 @@ describe.concurrent("Runners.makeRpc", () => { } assert.instanceOf(Cause.squash(exit.cause), ClusterError.RunnerUnavailable) }).pipe(Effect.provide(layerRunners(layerFakeProtocol(respondWithDefect))))) + + it.effect("encodes the entity payload with the protocol codec and leaves the hole untouched", () => { + const sent: Array = [] + const protocol = layerFakeProtocol( + (request, write) => { + sent.push(request) + // the defect hole is filled by the same codec + return request._tag === "Request" + ? write({ _tag: "Defect", defect: JSON.stringify("boom") }) + : Effect.void + }, + codecForJsonString + ) + return Effect.gen(function*() { + const runners = yield* Runners.Runners + const snowflakeGen = yield* Snowflake.Generator + const message = makeOutgoingRequest(TestRpc, snowflakeGen.nextUnsafe(), () => Effect.void) + + yield* runners.send({ address: runnerAddress, message }) + + assert.strictEqual(sent.length, 1) + const request = sent[0] + if (request._tag !== "Request") { + return assert.fail("expected a runner Request") + } + // the outer runner payload is filled by the protocol codec + assert.strictEqual(typeof request.payload, "string") + const outer = JSON.parse(request.payload as string) + // the entity payload was encoded with the same codec and carried through + // the opaque hole without being re-encoded + assert.strictEqual(outer.request.payload, JSON.stringify({ id: 1 })) + }).pipe(Effect.provide(layerRunners(protocol))) + }) }) diff --git a/repos/effect/packages/effect/test/cluster/Sharding.test.ts b/repos/effect/packages/effect/test/cluster/Sharding.test.ts index 946e4f6668..f7ed7ec6eb 100644 --- a/repos/effect/packages/effect/test/cluster/Sharding.test.ts +++ b/repos/effect/packages/effect/test/cluster/Sharding.test.ts @@ -23,8 +23,12 @@ import { ClusterMetrics, ClusterSchema, Entity, + EntityAddress, EntityId, + EntityType, + Envelope, MachineId, + Message, MessageStorage, Runner, RunnerAddress, @@ -36,6 +40,7 @@ import { ShardingConfig, Snowflake } from "effect/unstable/cluster" +import { Headers } from "effect/unstable/http" import { Rpc } from "effect/unstable/rpc" import { CallerId, @@ -919,6 +924,73 @@ describe.concurrent("Sharding", () => { expect(driver.unprocessed.size).toEqual(1) }).pipe(Effect.provide(TestSharding))) + it.effect("client discard returns while the volatile request keeps processing", () => + Effect.gen(function*() { + yield* TestClock.adjust(1) + const state = yield* TestEntityState + const makeClient = yield* TestEntity.client + const client = makeClient("1") + + const result = yield* client.NeverVolatile(void 0, { discard: true }) + + assert.isUndefined(result) + yield* TestClock.adjust(1) + assert.strictEqual(Queue.sizeUnsafe(state.envelopes), 1) + }).pipe(Effect.provide(TestSharding))) + + it.effect("client volatile discard retries a failed delivery", () => + Effect.gen(function*() { + let attempts = 0 + + yield* Effect.gen(function*() { + yield* TestClock.adjust(1) + const config = yield* ShardingConfig.ShardingConfig + ;(config as any).runnerAddress = Option.some(RunnerAddress.make("localhost", 1234)) + const makeClient = yield* TestEntity.client + const client = makeClient("1") + const fiber = yield* client.NeverVolatile(void 0, { discard: true }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + assert.strictEqual(attempts, 1) + assert.isUndefined(fiber.pollUnsafe()) + yield* TestClock.adjust(100) + yield* Fiber.join(fiber) + assert.strictEqual(attempts, 2) + }).pipe( + Effect.provide(TestShardingWithoutRunners.pipe( + Layer.provide( + Layer.effect(Runners.Runners)( + Effect.gen(function*() { + const runners = yield* Runners.makeNoop + return { + ...runners, + notify(options) { + attempts++ + return attempts === 1 + ? Effect.fail( + new ClusterError.RunnerUnavailable({ + address: Option.getOrThrow(options.address) + }) + ) + : Effect.void + } + } + }) + ) + ), + Layer.provide([MessageStorage.layerMemory, Snowflake.layerGenerator]), + Layer.provideMerge(ShardingConfig.layer({ + entityMailboxCapacity: 10, + entityTerminationTimeout: 0, + entityMessagePollInterval: 5000, + sendRetryInterval: 100, + refreshAssignmentsInterval: 0 + })) + )) + ) + })) + it.effect("defects when a durable request has no MessageStorage", () => Effect.gen(function*() { const makeClient = yield* TestEntity.client @@ -1003,6 +1075,198 @@ describe.concurrent("Sharding", () => { })) }) +describe.concurrent("Sharding residency cap", () => { + it.effect("bounds resident entities to maxResidentEntities", () => + Effect.gen(function*() { + yield* TestClock.adjust(1) + const sharding = yield* Sharding.Sharding + const makeClient = yield* TestEntity.client + const fibers: Array> = [] + for (let i = 1; i <= 6; i++) { + fibers.push( + yield* makeClient(String(i)).GetUser({ id: i }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + ) + } + yield* TestClock.adjust(1) + assert.isAtMost(yield* sharding.activeEntityCount, 2) + + // idle entities are reaped over time, freeing slots for the backlog + for (let i = 0; i < 12; i++) { + yield* TestClock.adjust(5000) + assert.isAtMost(yield* sharding.activeEntityCount, 2) + } + + const users = yield* Fiber.joinAll(fibers) + assert.deepStrictEqual(users.map((user) => user.id), [1, 2, 3, 4, 5, 6]) + }).pipe(Effect.provide(CappedSharding({ maxResidentEntities: 2, entityMaxIdleTime: 1000 })))) + + it.effect("volatile sends to new entities fail with MailboxFull at the cap", () => + Effect.gen(function*() { + yield* TestClock.adjust(1) + const driver = yield* MessageStorage.MemoryDriver + const sharding = yield* Sharding.Sharding + const makeClient = yield* TestEntity.client + // occupy the only slot + yield* makeClient("1").NeverVolatile().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust(1) + assert.strictEqual(yield* sharding.activeEntityCount, 1) + + const error = yield* makeClient("2").GetUserVolatile({ id: 2 }).pipe(Effect.flip) + assert.strictEqual(error._tag, "MailboxFull") + + // persisted sends still succeed and wait in storage + yield* makeClient("2").GetUser({ id: 2 }, { discard: true }) + yield* TestClock.adjust(5000) + assert.strictEqual(yield* sharding.activeEntityCount, 1) + assert.strictEqual(driver.unprocessed.size, 1) + assert.strictEqual(driver.replyIds.size, 0) + }).pipe(Effect.provide(CappedSharding({ maxResidentEntities: 1 })))) + + it.effect("keeps delivering to resident entities at the cap", () => + Effect.gen(function*() { + yield* TestClock.adjust(1) + const driver = yield* MessageStorage.MemoryDriver + const state = yield* TestEntityState + const sharding = yield* Sharding.Sharding + const makeClient = yield* TestEntity.client + // make entity "1" resident and keep it busy + yield* makeClient("1").NeverFork().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust(1) + assert.strictEqual(Queue.sizeUnsafe(state.envelopes), 1) + + // a backlog of new entity ids in front of the resident's next message + for (let i = 2; i <= 4; i++) { + yield* makeClient(String(i)).GetUser({ id: i }, { discard: true }) + } + const fiber = yield* makeClient("1").GetUser({ id: 1 }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + yield* TestClock.adjust(5000) + + // the resident entity received its message despite the backlog in front + assert.deepStrictEqual(yield* Fiber.join(fiber), new User({ id: 1, name: "User 1" })) + // the new ids were not admitted and their requests stay in storage + assert.strictEqual(yield* sharding.activeEntityCount, 1) + assert.strictEqual(driver.unprocessed.size, 4) + assert.strictEqual(driver.replyIds.size, 1) + }).pipe(Effect.provide(CappedSharding({ + maxResidentEntities: 1, + unprocessedMessageBatchSize: 2 + })))) + + it.effect("drains a full batch without waiting for the poll interval", () => + Effect.gen(function*() { + yield* TestClock.adjust(1) + const driver = yield* MessageStorage.MemoryDriver + + for (let i = 1; i <= 5; i++) { + yield* saveGetUserRequest(String(i), i) + } + assert.strictEqual(driver.replyIds.size, 0) + + // a single poll drains the whole backlog in batches of 2 + yield* TestClock.adjust(5000) + assert.strictEqual(driver.replyIds.size, 5) + }).pipe(Effect.provide(CappedSharding({ unprocessedMessageBatchSize: 2 })))) + + it.effect("advances past in-flight memory requests when reading bounded batches", () => + Effect.gen(function*() { + yield* TestClock.adjust(1) + const makeClient = yield* TestEntity.client + yield* makeClient("1").Never().pipe(Effect.forkChild({ startImmediately: true })) + yield* makeClient("2").Never().pipe(Effect.forkChild({ startImmediately: true })) + const fiber = yield* makeClient("3").GetUser({ id: 3 }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(5000) + assert.deepStrictEqual(yield* Fiber.join(fiber), new User({ id: 3, name: "User 3" })) + }).pipe(Effect.provide(CappedSharding({ unprocessedMessageBatchSize: 2 })))) + + it.effect("keeps full-sized storage reads when approaching the entity cap", () => + Effect.gen(function*() { + const limits: Array = [] + yield* Effect.gen(function*() { + yield* TestClock.adjust(1) + const sharding = yield* Sharding.Sharding + const makeClient = yield* TestEntity.client + for (let i = 1; i <= 4; i++) { + yield* makeClient(String(i)).NeverVolatile().pipe(Effect.forkChild({ startImmediately: true })) + } + yield* TestClock.adjust(1) + assert.strictEqual(yield* sharding.activeEntityCount, 4) + limits.length = 0 + + for (let i = 0; i < 10; i++) { + yield* saveGetUserRequest(String(i % 4 + 1), i) + } + yield* TestClock.adjust(5000) + assert.isNotEmpty(limits) + assert.deepStrictEqual([...new Set(limits)], [100]) + }).pipe(Effect.provide(CappedSharding({ + maxResidentEntities: 5, + unprocessedMessageBatchSize: 100 + }, (storage) => ({ + ...storage, + unprocessedMessages(shardIds, options) { + if (options?.limit !== undefined) limits.push(options.limit) + return storage.unprocessedMessages(shardIds, options) + } + })))) + })) + + it.effect("normalizes programmatic batch sizes below one", () => + Effect.forEach([0, -1], (unprocessedMessageBatchSize) => + Effect.gen(function*() { + yield* TestClock.adjust(1) + const makeClient = yield* TestEntity.client + const fiber = yield* makeClient("1").GetUser({ id: 1 }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust(5000) + assert.isDefined(fiber.pollUnsafe()) + assert.deepStrictEqual(yield* Fiber.join(fiber), new User({ id: 1, name: "User 1" })) + }).pipe(Effect.provide(CappedSharding({ unprocessedMessageBatchSize }))), { discard: true })) + + it.effect("does not busy-poll storage at the cap", () => + Effect.gen(function*() { + let reads = 0 + yield* Effect.gen(function*() { + yield* TestClock.adjust(1) + const makeClient = yield* TestEntity.client + yield* makeClient("1").NeverVolatile().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust(1) + // a backlog for a new id that cannot be admitted + yield* makeClient("2").GetUser({ id: 2 }, { discard: true }) + yield* TestClock.adjust(1) + const before = reads + yield* TestClock.adjust(4000) // less than the poll interval + assert.isAtMost(reads - before, 1) + }).pipe(Effect.provide(CappedSharding({ maxResidentEntities: 1 }, (storage) => ({ + ...storage, + unprocessedMessages(shardIds, options) { + reads++ + return storage.unprocessedMessages(shardIds, options) + } + })))) + })) + + it.effect("unbounded maxResidentEntities does not limit spawning", () => + Effect.gen(function*() { + yield* TestClock.adjust(1) + const sharding = yield* Sharding.Sharding + const makeClient = yield* TestEntity.client + for (let i = 1; i <= 15; i++) { + yield* makeClient(String(i)).NeverVolatile().pipe(Effect.forkChild({ startImmediately: true })) + } + yield* TestClock.adjust(1) + assert.strictEqual(yield* sharding.activeEntityCount, 15) + }).pipe(Effect.provide(CappedSharding({ maxResidentEntities: "unbounded" })))) +}) + describe("Sharding shard lock failover", () => { it.effect("interrupts entities and reacquires shards after lock storage recovers", () => Effect.gen(function*() { @@ -1396,13 +1660,15 @@ const otherRunner = Runner.make({ weight: 1 }) -const TestShardingConfig = ShardingConfig.layer({ +const testConfigDefaults: Partial = { entityMailboxCapacity: 10, entityTerminationTimeout: 0, entityMessagePollInterval: 5000, sendRetryInterval: 100, refreshAssignmentsInterval: 0 -}) +} + +const TestShardingConfig = ShardingConfig.layer(testConfigDefaults) const TestShardingWithoutState = TestEntityNoState.pipe( Layer.provideMerge(Sharding.layer), @@ -1426,4 +1692,53 @@ const TestSharding = TestShardingWithoutStorage.pipe( Layer.provide(TestShardingConfig) ) +const CappedSharding = ( + config: Partial, + transformStorage?: ( + storage: MessageStorage.MessageStorage["Service"] + ) => MessageStorage.MessageStorage["Service"] +) => { + const configLayer = ShardingConfig.layer({ ...testConfigDefaults, ...config }) + let layer = TestShardingWithoutRunners.pipe( + Layer.provide(Runners.layerNoop), + Layer.provide(configLayer) + ) + if (transformStorage) { + layer = layer.pipe(Layer.updateService(MessageStorage.MessageStorage, transformStorage)) + } + return layer.pipe( + Layer.provideMerge(MessageStorage.layerMemory), + Layer.provide(configLayer) + ) +} + const ContextBleedSharding = ContextBleedLayer.pipe(Layer.provideMerge(TestSharding)) + +// saves a persisted GetUser request directly to storage, bypassing the client, +// so the storage read loop only learns about it from the next poll +const saveGetUserRequest = Effect.fnUntraced(function*(entityId: string, id: number) { + const storage = yield* MessageStorage.MessageStorage + const sharding = yield* Sharding.Sharding + const rpc = TestEntity.protocol.requests.get("GetUser")! as any + const entity = EntityId.make(entityId) + yield* storage.saveRequest( + new Message.OutgoingRequest({ + envelope: Envelope.makeRequest({ + requestId: yield* sharding.getSnowflake, + address: EntityAddress.make({ + shardId: sharding.getShardId(entity, "default"), + entityType: EntityType.make(TestEntity.type), + entityId: entity + }), + tag: "GetUser", + payload: { id }, + headers: Headers.empty + }), + annotations: rpc.annotations, + context: Context.empty() as any, + rpc, + lastReceivedReply: Option.none(), + respond: () => Effect.void + }) + ) +}) diff --git a/repos/effect/packages/effect/test/cluster/ShardingConfig.test.ts b/repos/effect/packages/effect/test/cluster/ShardingConfig.test.ts index 44ef9a893f..bf8762f39b 100644 --- a/repos/effect/packages/effect/test/cluster/ShardingConfig.test.ts +++ b/repos/effect/packages/effect/test/cluster/ShardingConfig.test.ts @@ -3,6 +3,33 @@ import { ConfigProvider, Effect, Option } from "effect" import { RunnerAddress, ShardingConfig } from "effect/unstable/cluster" describe("ShardingConfig", () => { + it.effect("bounds runner residency and storage reads by default", () => + Effect.gen(function*() { + assert.strictEqual(ShardingConfig.defaults.maxResidentEntities, 10_000) + assert.strictEqual(ShardingConfig.defaults.unprocessedMessageBatchSize, 1024) + + const defaults = yield* ShardingConfig.config.parse(ConfigProvider.fromUnknown({})) + assert.strictEqual(defaults.maxResidentEntities, 10_000) + assert.strictEqual(defaults.unprocessedMessageBatchSize, 1024) + + // environment values are integers only + const fromEnv = yield* ShardingConfig.config.parse( + ConfigProvider.fromUnknown({ maxResidentEntities: "500", unprocessedMessageBatchSize: "128" }) + ) + assert.strictEqual(fromEnv.maxResidentEntities, 500) + assert.strictEqual(fromEnv.unprocessedMessageBatchSize, 128) + + const invalid = yield* ShardingConfig.config.parse( + ConfigProvider.fromUnknown({ maxResidentEntities: "unbounded" }) + ).pipe(Effect.flip) + assert.strictEqual(invalid._tag, "ConfigError") + + // "unbounded" is only available programmatically + const layer = ShardingConfig.layer({ maxResidentEntities: "unbounded" }) + const config = yield* Effect.provide(ShardingConfig.ShardingConfig, layer) + assert.strictEqual(config.maxResidentEntities, "unbounded") + })) + it.effect("treats the optional listen address as an atomic group", () => Effect.gen(function*() { const defaults = yield* ShardingConfig.config.parse(ConfigProvider.fromUnknown({})) @@ -34,4 +61,19 @@ describe("ShardingConfig", () => { at ["listenPort"]` ) })) + + it.effect("rejects non-positive residency and batch limits", () => + Effect.gen(function*() { + for ( + const values of [ + { maxResidentEntities: "0" }, + { maxResidentEntities: "-1" }, + { unprocessedMessageBatchSize: "0" }, + { unprocessedMessageBatchSize: "-1" } + ] + ) { + const error = yield* ShardingConfig.config.parse(ConfigProvider.fromUnknown(values)).pipe(Effect.flip) + assert.strictEqual(error._tag, "ConfigError") + } + })) }) diff --git a/repos/effect/packages/effect/test/internal/Uuid.test.ts b/repos/effect/packages/effect/test/internal/Uuid.test.ts new file mode 100644 index 0000000000..6a723ff966 --- /dev/null +++ b/repos/effect/packages/effect/test/internal/Uuid.test.ts @@ -0,0 +1,26 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Uuid from "effect/internal/uuid" + +const bytes = () => Uint8Array.from({ length: 16 }, (_, i) => i) + +describe("Uuid", () => { + it("stringifies UUID bytes", () => { + assert.strictEqual(Uuid.stringify(bytes()), "00010203-0405-0607-0809-0a0b0c0d0e0f") + }) + + it("generates UUID v4 bytes and strings", () => { + assert.deepStrictEqual( + Uuid.v4Bytes(bytes()), + Uint8Array.of(0, 1, 2, 3, 4, 5, 0x46, 7, 0x88, 9, 10, 11, 12, 13, 14, 15) + ) + assert.strictEqual(Uuid.v4String(bytes()), "00010203-0405-4607-8809-0a0b0c0d0e0f") + }) + + it("generates UUID v7 bytes and strings", () => { + assert.deepStrictEqual( + Uuid.v7Bytes(0x0123456789ab, bytes()), + Uint8Array.of(1, 0x23, 0x45, 0x67, 0x89, 0xab, 0x76, 7, 0x88, 9, 10, 11, 12, 13, 14, 15) + ) + assert.strictEqual(Uuid.v7String(0x0123456789ab, bytes()), "01234567-89ab-7607-8809-0a0b0c0d0e0f") + }) +}) diff --git a/repos/effect/packages/effect/test/reactivity/Atom.test.ts b/repos/effect/packages/effect/test/reactivity/Atom.test.ts index 2f49bd3a4d..79177ec8f0 100644 --- a/repos/effect/packages/effect/test/reactivity/Atom.test.ts +++ b/repos/effect/packages/effect/test/reactivity/Atom.test.ts @@ -2784,7 +2784,7 @@ interface BuildCounter { readonly inc: Effect.Effect } const BuildCounter = Context.Service("BuildCounter") -const BuildCounterLive = Layer.sync(BuildCounter, () => { +const BuildCounterLayer = Layer.sync(BuildCounter, () => { let count = 0 return BuildCounter.of({ get: Effect.sync(() => count), @@ -2799,7 +2799,7 @@ interface Counter { readonly inc: Effect.Effect } const Counter = Context.Service("Counter") -const CounterLive = Layer.effect( +const CounterLayer = Layer.effect( Counter, Effect.gen(function*() { const buildCounter = yield* BuildCounter @@ -2813,7 +2813,7 @@ const CounterLive = Layer.effect( }) }) ).pipe( - Layer.provide(BuildCounterLive) + Layer.provide(BuildCounterLayer) ) const CounterTest = Layer.effect( @@ -2830,14 +2830,14 @@ const CounterTest = Layer.effect( }) }) ).pipe( - Layer.provide(BuildCounterLive) + Layer.provide(BuildCounterLayer) ) interface Multiplier { readonly times: (n: number) => Effect.Effect } const Multiplier = Context.Service("Multiplier") -const MultiplierLive = Layer.effect( +const MultiplierLayer = Layer.effect( Multiplier, Effect.gen(function*() { const counter = yield* Counter @@ -2847,9 +2847,9 @@ const MultiplierLive = Layer.effect( }) }) ).pipe( - Layer.provideMerge(CounterLive) + Layer.provideMerge(CounterLayer) ) -const buildCounterRuntime = Atom.runtime(BuildCounterLive) -const counterRuntime = Atom.runtime(CounterLive) -const multiplierRuntime = Atom.runtime(MultiplierLive) +const buildCounterRuntime = Atom.runtime(BuildCounterLayer) +const counterRuntime = Atom.runtime(CounterLayer) +const multiplierRuntime = Atom.runtime(MultiplierLayer) diff --git a/repos/effect/packages/effect/test/rpc/RpcSerialization.test.ts b/repos/effect/packages/effect/test/rpc/RpcSerialization.test.ts index 831d661a24..648e7f1d42 100644 --- a/repos/effect/packages/effect/test/rpc/RpcSerialization.test.ts +++ b/repos/effect/packages/effect/test/rpc/RpcSerialization.test.ts @@ -1,6 +1,10 @@ import { afterEach, assert, describe, it } from "@effect/vitest" -import { Effect } from "effect" -import { RpcSerialization } from "effect/unstable/rpc" +import { Effect, Exit, Layer, Schema, Stream } from "effect" +import { SchemaBinary } from "effect/unstable/encoding" +import { HttpRouter } from "effect/unstable/http" +import * as HttpClient from "effect/unstable/http/HttpClient" +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" +import { Rpc, RpcClient, RpcGroup, type RpcMessage, RpcSchema, RpcSerialization, RpcServer } from "effect/unstable/rpc" const responseExitSuccess = (requestId: string | number, value: unknown) => ({ _tag: "Exit", @@ -42,6 +46,119 @@ const assertMaxBufferSizeExceeded = (f: () => unknown, maxBufferSize: number) => } } +// A hole codec that is observably different from `Schema.toCodecJson`: every +// hole is JSON-lowered and then written as a JSON string. Anything that still +// hardcodes `Schema.toCodecJson` fails to round-trip against it. +const codecForJsonString = + ((schema: S) => + Schema.fromJsonString(Schema.toCodecJson(schema as any))) as RpcSerialization.CodecFor + +const serialization: RpcSerialization.RpcSerialization["Service"] = RpcSerialization.RpcSerialization.of({ + ...RpcSerialization.ndjson, + codecFor: codecForJsonString +}) + +const layerSerialization = Layer.succeed(RpcSerialization.RpcSerialization)(serialization) + +class EchoError extends Schema.Error("EchoError")({ + at: Schema.Date +}) {} + +const Rpcs = RpcGroup.make( + Rpc.make("Echo", { payload: { value: Schema.String }, success: Schema.String }), + Rpc.make("Counts", { payload: { to: Schema.Number }, success: RpcSchema.Stream(Schema.Number, Schema.Never) }), + Rpc.make("Fail", { payload: {}, success: Schema.Void, error: EchoError }), + Rpc.make("Boom", { payload: {}, success: Schema.Void }) +) + +const failedAt = new Date(0) + +const Handlers = Rpcs.toLayer({ + Echo: ({ value }) => Effect.succeed(`${value}!`), + Counts: ({ to }) => Stream.range(1, to), + Fail: () => Effect.fail(new EchoError({ at: failedAt })), + Boom: () => Effect.die("boom") +}) + +const Server = RpcServer.layerHttp({ + group: Rpcs, + path: "/rpc", + protocol: "http" +}).pipe( + Layer.provide(Handlers), + Layer.provide(layerSerialization) +) + +const makeClient = Effect.fnUntraced(function*() { + const requests: Array = [] + const { dispose, handler } = HttpRouter.toWebHandler(Server) + yield* Effect.addFinalizer(() => Effect.promise(dispose)) + + const httpClient = HttpClient.make((request) => { + const raw = (request.body as any).body as Uint8Array | string + const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw) + requests.push(text) + return Effect.map( + Effect.promise(() => handler(new Request("http://test/rpc", { method: "POST", body: text }))), + (response) => HttpClientResponse.fromWeb(request, response) + ) + }) + + const client = yield* RpcClient.make(Rpcs).pipe( + Effect.provide( + RpcClient.layerProtocolHttp({ url: "http://test/rpc" }).pipe( + Layer.provide(layerSerialization), + Layer.provide(Layer.succeed(HttpClient.HttpClient)(httpClient)) + ) + ) + ) + + return { client, requests } as const +}) + +const BinaryServer = RpcServer.layerHttp({ + group: Rpcs, + path: "/rpc", + protocol: "http" +}).pipe( + Layer.provide(Handlers), + Layer.provide(RpcSerialization.layerSchemaBinary()) +) + +const makeBinaryClient = Effect.fnUntraced(function*() { + const { dispose, handler } = HttpRouter.toWebHandler(BinaryServer) + yield* Effect.addFinalizer(() => Effect.promise(dispose)) + + const httpClient = HttpClient.make((request) => { + const body = (request.body as any).body as Uint8Array + return Effect.map( + Effect.promise(() => + handler(new Request("http://test/rpc", { method: "POST", body: new Uint8Array(body).buffer })) + ), + (response) => HttpClientResponse.fromWeb(request, response) + ) + }) + + return yield* RpcClient.make(Rpcs).pipe( + Effect.provide( + RpcClient.layerProtocolHttp({ url: "http://test/rpc" }).pipe( + Layer.provide(RpcSerialization.layerSchemaBinary()), + Layer.provide(Layer.succeed(HttpClient.HttpClient)(httpClient)) + ) + ) + ) +}) + +const uvarint = (value: number): Uint8Array => { + const bytes: Array = [] + while (value >= 0x80) { + bytes.push((value & 0x7f) | 0x80) + value = Math.floor(value / 0x80) + } + bytes.push(value) + return Uint8Array.from(bytes) +} + describe("RpcSerialization", () => { describe.sequential("jsonRpc inherited properties", () => { afterEach(() => { @@ -204,7 +321,7 @@ describe("RpcSerialization", () => { }) assert.strictEqual( encoded, - "{\"jsonrpc\":\"2.0\",\"method\":\"users.get\",\"params\":null,\"id\":0,\"headers\":[]}" + "{\"jsonrpc\":\"2.0\",\"method\":\"users.get\",\"params\":null,\"id\":0}" ) }) @@ -240,7 +357,36 @@ describe("RpcSerialization", () => { }) assert.strictEqual( encoded, - "{\"jsonrpc\":\"2.0\",\"method\":\"users.get\",\"params\":null,\"id\":\"\",\"headers\":[]}" + "{\"jsonrpc\":\"2.0\",\"method\":\"users.get\",\"params\":null,\"id\":\"\"}" + ) + }) + + it("jsonRpc encodes a notification without an id", () => { + const parser = RpcSerialization.jsonRpc().makeUnsafe() + const decoded = parser.decode("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\"}") + assert.deepStrictEqual(decoded, [{ + _tag: "Request", + id: "", + tag: "notifications/message", + payload: null, + headers: [], + isNotification: true + }]) + + const encoded = parser.encode({ + _tag: "Request", + id: "", + tag: "notifications/message", + payload: { level: "info" }, + headers: [["x-test", "value"]], + traceId: "trace", + spanId: "span", + sampled: true, + isNotification: true + }) + assert.strictEqual( + encoded, + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{\"level\":\"info\"},\"headers\":[[\"x-test\",\"value\"]],\"traceId\":\"trace\",\"spanId\":\"span\",\"sampled\":true}" ) }) @@ -310,4 +456,184 @@ describe("RpcSerialization", () => { }).pipe( Effect.provide(RpcSerialization.layerMsgPackWith({ maxBufferSize: 2 })) )) + + describe("SchemaBinary", () => { + it.effect("roundtrips requests and streamed responses over HTTP", () => + Effect.gen(function*() { + const client = yield* makeBinaryClient() + + assert.strictEqual(yield* client.Echo({ value: "hi" }), "hi!") + assert.deepStrictEqual(yield* Stream.runCollect(client.Counts({ to: 3 })), [1, 2, 3]) + + const error = yield* Effect.flip(client.Fail({})) + assert.instanceOf(error, EchoError) + assert.strictEqual(error.at.getTime(), failedAt.getTime()) + + const exit = yield* Effect.exit(client.Boom({})) + assert(Exit.isFailure(exit)) + assert.include(String(exit.cause), "boom") + })) + + it.effect("fingerprints envelopes", () => + Effect.gen(function*() { + const serialization = yield* RpcSerialization.RpcSerialization + const parser = serialization.makeUnsafe() + const incompatibleEnvelope = Schema.Struct({ + _tag: Schema.tag("Request"), + id: Schema.Union([Schema.String, Schema.Number]), + tag: Schema.String, + payload: Schema.Uint8Array, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + added: Schema.optional(Schema.String) + }) + const frame = Schema.encodeSync(SchemaBinary.toCodec(incompatibleEnvelope, { fingerprint: true }))({ + _tag: "Request", + id: 1, + tag: "Echo", + payload: Uint8Array.of(1), + headers: [] + }) + + assert.throws(() => parser.decode(frame), /matching layout fingerprint/) + }).pipe(Effect.provide(RpcSerialization.layerSchemaBinary()))) + + it.effect("does not fingerprint payloads by default", () => + Effect.gen(function*() { + const serialization = yield* RpcSerialization.RpcSerialization + const Writer = Schema.Struct({ + value: Schema.String, + added: Schema.optional(Schema.String) + }) + const Reader = Schema.Struct({ value: Schema.String }) + const encoded = Schema.encodeSync(serialization.codecFor(Writer))({ value: "ok", added: "new" }) + + assert.instanceOf(encoded, Uint8Array) + assert.deepStrictEqual(Schema.decodeSync(serialization.codecFor(Reader))(encoded), { value: "ok" }) + }).pipe(Effect.provide(RpcSerialization.layerSchemaBinary()))) + + it.effect("layerSchemaBinary fingerprints payloads when enabled", () => + Effect.gen(function*() { + const serialization = yield* RpcSerialization.RpcSerialization + const Writer = Schema.Struct({ value: Schema.String }) + const Reader = Schema.Struct({ value: Schema.Number }) + const encoded = Schema.encodeSync(serialization.codecFor(Writer))({ value: "ok" }) + + assert.deepStrictEqual(Schema.decodeSync(serialization.codecFor(Writer))(encoded), { value: "ok" }) + assert.throws( + () => Schema.decodeSync(serialization.codecFor(Reader))(encoded), + /matching layout fingerprint/ + ) + }).pipe(Effect.provide(RpcSerialization.layerSchemaBinary({ fingerprintPayloads: true })))) + + it.effect("uses fingerprinted envelope framing and the binary content type", () => + Effect.gen(function*() { + const serialization = yield* RpcSerialization.RpcSerialization + const parser = serialization.makeUnsafe() + const request: RpcMessage.RequestEncoded = { + _tag: "Request", + id: 1, + tag: "Echo", + payload: Uint8Array.of(1, 2, 3), + headers: [] + } + const frame = parser.encode(request) + + assert.strictEqual(serialization.contentType, "application/vnd.effect.rpc+schema-binary") + assert.strictEqual(serialization.includesFraming, true) + assert.instanceOf(frame, Uint8Array) + assert.deepStrictEqual(parser.decode(frame), [request]) + }).pipe(Effect.provide(RpcSerialization.layerSchemaBinary()))) + + it.effect("owns encoded frames without copying envelope holes", () => + Effect.gen(function*() { + const serialization = yield* RpcSerialization.RpcSerialization + const encoder = serialization.makeUnsafe() + const payload = Uint8Array.of(1, 2, 3) + const request: RpcMessage.RequestEncoded = { + _tag: "Request", + id: 1, + tag: "Echo", + payload, + headers: [] + } + const frame = encoder.encode(request) + assert(frame instanceof Uint8Array) + const expected = frame.slice() + + payload.fill(9) + for (let i = 0; i < 1_000; i++) encoder.encode({ ...request, id: i }) + + assert.deepStrictEqual(frame, expected) + assert.deepStrictEqual(serialization.makeUnsafe().decode(frame), [{ + ...request, + payload: Uint8Array.of(1, 2, 3) + }]) + }).pipe(Effect.provide(RpcSerialization.layerSchemaBinary()))) + + it.effect("defaults maxFrameSize to 16 MiB", () => + Effect.gen(function*() { + const serialization = yield* RpcSerialization.RpcSerialization + assert.deepStrictEqual(serialization.makeUnsafe().decode(uvarint(16 * 1024 * 1024)), []) + assert.throws( + () => serialization.makeUnsafe().decode(uvarint(16 * 1024 * 1024 + 1)), + /frame within maxFrameSize/ + ) + }).pipe(Effect.provide(RpcSerialization.layerSchemaBinary()))) + + it.effect("layerSchemaBinary overrides maxFrameSize", () => + Effect.gen(function*() { + const serialization = yield* RpcSerialization.RpcSerialization + assert.deepStrictEqual(serialization.makeUnsafe().decode(uvarint(4)), []) + assert.throws(() => serialization.makeUnsafe().decode(uvarint(5)), /frame within maxFrameSize/) + }).pipe(Effect.provide(RpcSerialization.layerSchemaBinary({ maxFrameSize: 4 })))) + }) + + describe("codecFor", () => { + it("built-in serializations JSON-lower the hole", () => { + const encode = Schema.encodeSync(RpcSerialization.json.codecFor(Schema.Date)) + assert.strictEqual(encode(new Date(0)), "1970-01-01T00:00:00.000Z") + assert.strictEqual(RpcSerialization.ndjson.codecFor, RpcSerialization.json.codecFor) + assert.strictEqual(RpcSerialization.msgPack.codecFor, RpcSerialization.json.codecFor) + assert.strictEqual(RpcSerialization.jsonRpc().codecFor, RpcSerialization.json.codecFor) + }) + + it.effect("fills the request payload hole with the serialization's codec", () => + Effect.gen(function*() { + const { client, requests } = yield* makeClient() + + assert.strictEqual(yield* client.Echo({ value: "hi" }), "hi!") + + assert.strictEqual(requests.length, 1) + const frame = JSON.parse(requests[0]) + assert.strictEqual(typeof frame.payload, "string", "the payload hole must carry the codec's output") + assert.deepStrictEqual(JSON.parse(frame.payload), { value: "hi" }) + })) + + it.effect("fills the stream chunk holes with the serialization's codec", () => + Effect.gen(function*() { + const { client } = yield* makeClient() + + assert.deepStrictEqual(yield* Stream.runCollect(client.Counts({ to: 3 })), [1, 2, 3]) + })) + + it.effect("fills the exit hole with the serialization's codec", () => + Effect.gen(function*() { + const { client } = yield* makeClient() + + const error = yield* Effect.flip(client.Fail({})) + assert.instanceOf(error, EchoError) + assert.strictEqual(error.at.getTime(), failedAt.getTime()) + })) + + it.effect("fills the defect hole with the serialization's codec", () => + Effect.gen(function*() { + const { client } = yield* makeClient() + + const exit = yield* Effect.exit(client.Boom({})) + if (Exit.isSuccess(exit)) { + return assert.fail("expected the handler defect to reach the client") + } + assert.include(String(exit.cause), "boom") + })) + }) }) diff --git a/repos/effect/packages/effect/test/rpc/RpcServer.test.ts b/repos/effect/packages/effect/test/rpc/RpcServer.test.ts index e3a40b7b3d..ddb4e0712c 100644 --- a/repos/effect/packages/effect/test/rpc/RpcServer.test.ts +++ b/repos/effect/packages/effect/test/rpc/RpcServer.test.ts @@ -1,9 +1,110 @@ import { assert, describe, it } from "@effect/vitest" -import { Deferred, Effect, Layer } from "effect" -import { RpcSerialization, RpcServer } from "effect/unstable/rpc" +import { Deferred, Effect, Layer, Schema, Stream } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { Rpc, RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc" import { Socket, SocketServer } from "effect/unstable/socket" +const producedWithoutReadingFramedBody = Effect.fnUntraced(function*( + streamBufferSize?: number | "unbounded" +) { + let produced = 0 + const Rpcs = RpcGroup.make(Rpc.make("events", { + payload: Schema.Struct({}), + success: Schema.Number, + stream: true + })) + const Handlers = Rpcs.toLayerHandler( + "events", + () => Stream.fromEffectRepeat(Effect.sync(() => ++produced)).pipe(Stream.take(10_000)) + ) + const Server = RpcServer.layerHttp({ + group: Rpcs, + path: "/rpc", + protocol: "http", + ...(streamBufferSize === undefined ? {} : { streamBufferSize }) + }).pipe( + Layer.provide(Handlers), + Layer.provide(RpcSerialization.layerNdjson) + ) + const { dispose, handler } = HttpRouter.toWebHandler(Server) + yield* Effect.addFinalizer(() => Effect.promise(dispose)) + + const response = yield* Effect.promise(() => + handler( + new Request("http://test/rpc", { + method: "POST", + body: `{"_tag":"Request","id":1,"tag":"events","payload":{},"headers":[]}\n` + }) + ) + ) + yield* Effect.addFinalizer(() => response.body === null ? Effect.void : Effect.promise(() => response.body!.cancel())) + for (let i = 0; i < 100; i++) { + yield* Effect.yieldNow + } + + return produced +}) + describe("RpcServer", () => { + it.effect("applies backpressure to framed HTTP responses by default", () => + Effect.gen(function*() { + const produced = yield* producedWithoutReadingFramedBody() + + assert.isAbove(produced, 0) + assert.isBelow(produced, 10_000) + })) + + it.effect("uses the configured framed HTTP response buffer size", () => + Effect.gen(function*() { + const produced = yield* producedWithoutReadingFramedBody(1) + + assert.isAbove(produced, 0) + assert.isBelow(produced, 16) + })) + + it.effect("allows unbounded framed HTTP response buffering", () => + Effect.gen(function*() { + const produced = yield* producedWithoutReadingFramedBody("unbounded") + + assert.strictEqual(produced, 10_000) + })) + + it.effect("keeps non-framed HTTP responses unbounded", () => + Effect.gen(function*() { + let produced = 0 + const Rpcs = RpcGroup.make(Rpc.make("events", { + payload: Schema.Struct({}), + success: Schema.Number, + stream: true + })) + const Handlers = Rpcs.toLayerHandler( + "events", + () => Stream.fromEffectRepeat(Effect.sync(() => ++produced)).pipe(Stream.take(100)) + ) + const Server = RpcServer.layerHttp({ + group: Rpcs, + path: "/rpc", + protocol: "http", + streamBufferSize: 1 + }).pipe( + Layer.provide(Handlers), + Layer.provide(RpcSerialization.layerJson) + ) + const { dispose, handler } = HttpRouter.toWebHandler(Server) + yield* Effect.addFinalizer(() => Effect.promise(dispose)) + + yield* Effect.promise(() => + handler( + new Request("http://test/rpc", { + method: "POST", + body: `{"_tag":"Request","id":1,"tag":"events","payload":{},"headers":[]}` + }) + ) + ) + + assert.strictEqual(produced, 100) + })) + it.effect("closes a socket when the serialization buffer limit is exceeded", () => Effect.gen(function*() { const handledChunks: Array = [] diff --git a/repos/effect/packages/effect/test/schema/Graph.test.ts b/repos/effect/packages/effect/test/schema/Graph.test.ts new file mode 100644 index 0000000000..1972b3fabc --- /dev/null +++ b/repos/effect/packages/effect/test/schema/Graph.test.ts @@ -0,0 +1,300 @@ +import { assert, describe, it } from "@effect/vitest" +import { throws } from "@effect/vitest/utils" +import { Equivalence, Graph, Option, Schema } from "effect" +import { TestSchema } from "effect/testing" + +const directedCodec = Schema.toCodecJson(Schema.Graph("directed", Schema.String, Schema.Number)) +const undirectedCodec = Schema.toCodecJson(Schema.Graph("undirected", Schema.String, Schema.Number)) +const decodeDirected = Schema.decodeUnknownSync(directedCodec) +const encodeDirected = Schema.encodeSync(directedCodec) + +const empty = (type: Graph.Kind) => ({ type, nodes: [], edges: [] }) + +const assertDecodeFailure = (input: unknown, path: string, message?: string) => { + throws(() => decodeDirected(input), (error) => { + const formatted = globalThis.String(error) + assert.include(formatted, path) + if (message !== undefined) assert.include(formatted, message) + }) +} + +describe("Schema.Graph", () => { + it("derives valid arbitrary graphs", () => { + new TestSchema.Asserts(Schema.Graph("directed", Schema.String, Schema.Number)).arbitrary().verifyGeneration() + new TestSchema.Asserts(Schema.Graph("undirected", Schema.String, Schema.Number)).arbitrary().verifyGeneration() + }) + + it("derives active-structure equivalence from payload schemas", () => { + const modulo = Schema.Number.annotate({ + toEquivalence: (): Equivalence.Equivalence => Equivalence.make((a, b) => a % 2 === b % 2) + }) + const equivalence = Schema.toEquivalence(Schema.Graph("directed", modulo, modulo)) + const left = Graph.directed((mutable) => { + const a = Graph.addNode(mutable, 0) + const b = Graph.addNode(mutable, 1) + Graph.addEdge(mutable, a, b, 2) + }) + const right = Graph.directed((mutable) => { + const a = Graph.addNode(mutable, 2) + const b = Graph.addNode(mutable, 3) + Graph.addEdge(mutable, a, b, 4) + }) + + assert.strictEqual(equivalence(left, right), true) + + const undirectedEquivalence = Schema.toEquivalence(Schema.Graph("undirected", Schema.Number, Schema.Number)) + const forward = Graph.undirected((mutable) => { + Graph.addNode(mutable, 0) + Graph.addNode(mutable, 1) + Graph.addEdge(mutable, 0, 1, 2) + }) + const reversed = Graph.undirected((mutable) => { + Graph.addNode(mutable, 0) + Graph.addNode(mutable, 1) + Graph.addEdge(mutable, 1, 0, 2) + }) + assert.strictEqual(undirectedEquivalence(forward, reversed), true) + }) + + it("encodes and decodes empty directed and undirected graphs", () => { + assert.deepStrictEqual(encodeDirected(Graph.directed()), empty("directed")) + assert.deepStrictEqual(Schema.encodeSync(undirectedCodec)(Graph.undirected()), empty("undirected")) + assert.strictEqual(decodeDirected(empty("directed")).type, "directed") + assert.strictEqual(Schema.decodeUnknownSync(undirectedCodec)(empty("undirected")).type, "undirected") + }) + + it("preserves indexed directed structure, isolated nodes, loops, parallel edges, and gaps", () => { + const graph = Graph.directed((mutable) => { + const removed = Graph.addNode(mutable, "removed") + const source = Graph.addNode(mutable, "source") + const target = Graph.addNode(mutable, "target") + Graph.addNode(mutable, "isolated") + Graph.removeNode(mutable, removed) + const removedEdge = Graph.addEdge(mutable, source, target, -1) + Graph.addEdge(mutable, source, source, 1) + Graph.addEdge(mutable, source, target, 2) + Graph.addEdge(mutable, source, target, 3) + Graph.removeEdge(mutable, removedEdge) + }) + const encoded = { + type: "directed", + nodes: [ + { index: 1, data: "source" }, + { index: 2, data: "target" }, + { index: 3, data: "isolated" } + ], + edges: [ + { index: 1, source: 1, target: 1, data: 1 }, + { index: 2, source: 1, target: 2, data: 2 }, + { index: 3, source: 1, target: 2, data: 3 } + ] + } satisfies Graph.Snapshot + + assert.deepStrictEqual(encodeDirected(graph), encoded) + const decoded = decodeDirected(encoded) + assert.deepStrictEqual(Array.from(decoded), encoded.nodes.map(({ index, data }) => [index, data] as const)) + assert.deepStrictEqual( + Array.from(Graph.edges(decoded), ([index, edge]) => ({ index, ...edge })), + encoded.edges + ) + assert.deepStrictEqual(encodeDirected(decoded), encoded) + }) + + it("preserves stored orientation for undirected edges", () => { + const graph = Graph.undirected((mutable) => { + const a = Graph.addNode(mutable, "A") + const b = Graph.addNode(mutable, "B") + Graph.addEdge(mutable, b, a, 1) + }) + const encoded = Schema.encodeSync(undirectedCodec)(graph) + + assert.deepStrictEqual(encoded, { + type: "undirected", + nodes: [{ index: 0, data: "A" }, { index: 1, data: "B" }], + edges: [{ index: 0, source: 1, target: 0, data: 1 }] + }) + assert.deepStrictEqual( + Schema.encodeSync(undirectedCodec)(Schema.decodeUnknownSync(undirectedCodec)(encoded)), + encoded + ) + }) + + it("starts a new allocation lineage after the highest active indexes", () => { + const decoded = decodeDirected({ + type: "directed", + nodes: [{ index: 2, data: "A" }, { index: 5, data: "B" }], + edges: [{ index: 4, source: 2, target: 5, data: 1 }] + }) + let nodeIndex: Graph.NodeIndex | undefined + let edgeIndex: Graph.EdgeIndex | undefined + Graph.mutate(decoded, (mutable) => { + nodeIndex = Graph.addNode(mutable, "C") + edgeIndex = Graph.addEdge(mutable, 5, nodeIndex, 2) + }) + + assert.strictEqual(nodeIndex, 6) + assert.strictEqual(edgeIndex, 5) + }) + + it("does not serialize removed trailing allocation history", () => { + const graph = Graph.directed((mutable) => { + const a = Graph.addNode(mutable, "A") + const b = Graph.addNode(mutable, "B") + const trailing = Graph.addNode(mutable, "removed") + const active = Graph.addEdge(mutable, a, b, 1) + const removed = Graph.addEdge(mutable, a, b, 2) + Graph.removeEdge(mutable, removed) + Graph.removeNode(mutable, trailing) + assert.strictEqual(active, 0) + }) + const decoded = decodeDirected(encodeDirected(graph)) + let nodeIndex: Graph.NodeIndex | undefined + let edgeIndex: Graph.EdgeIndex | undefined + Graph.mutate(decoded, (mutable) => { + nodeIndex = Graph.addNode(mutable, "next") + edgeIndex = Graph.addEdge(mutable, 0, 1, 3) + }) + + assert.strictEqual(nodeIndex, 2) + assert.strictEqual(edgeIndex, 1) + }) + + it("rebuilds adjacency and initializes derived caches for cyclic graphs", () => { + const graph = decodeDirected({ + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 2, data: "B" }, { index: 4, data: "C" }], + edges: [ + { index: 1, source: 0, target: 2, data: 1 }, + { index: 3, source: 2, target: 4, data: 2 }, + { index: 5, source: 4, target: 0, data: 3 } + ] + }) + + assert.deepStrictEqual(Graph.neighbors(graph, 0), [2]) + assert.deepStrictEqual(Graph.predecessors(graph, 0), [4]) + assert.strictEqual(Graph.isAcyclic(graph), false) + }) + + it("applies node and edge payload transformations", () => { + const codec = Schema.toCodecJson(Schema.Graph("directed", Schema.FiniteFromString, Schema.FiniteFromString)) + const encoded = { + type: "directed", + nodes: [{ index: 0, data: "1" }, { index: 1, data: "2" }], + edges: [{ index: 0, source: 0, target: 1, data: "3" }] + } as const + const graph = Schema.decodeUnknownSync(codec)(encoded) + + assert.deepStrictEqual(Array.from(graph), [[0, 1], [1, 2]]) + assert.deepStrictEqual(Option.getOrThrow(Graph.getEdge(graph, 0)).data, 3) + assert.deepStrictEqual(Schema.encodeSync(codec)(graph), encoded) + }) + + it("rejects mutable graphs and the wrong graph kind", () => { + const mutable = Graph.beginMutation(Graph.directed()) + assert.throws(() => encodeDirected(mutable as never), /immutable directed Graph/) + assert.throws(() => encodeDirected(Graph.undirected() as never), /immutable directed Graph/) + assertDecodeFailure(empty("undirected"), "[\"type\"]", "Expected \"directed\"") + }) + + it("rejects invalid node and edge indexes at precise paths", () => { + const nodeCases = [-1, 0.5, Number.MAX_SAFE_INTEGER + 1] + for (const index of nodeCases) { + assertDecodeFailure( + { type: "directed", nodes: [{ index, data: "A" }], edges: [] }, + "[\"nodes\"][0][\"index\"]" + ) + } + const edgeCases = [-1, 0.5, Number.MAX_SAFE_INTEGER + 1] + for (const index of edgeCases) { + assertDecodeFailure({ + type: "directed", + nodes: [{ index: 0, data: "A" }], + edges: [{ index, source: 0, target: 0, data: 1 }] + }, "[\"edges\"][0][\"index\"]") + } + assertDecodeFailure( + { + type: "directed", + nodes: [{ index: 0, data: "A" }, { index: 0, data: "B" }], + edges: [] + }, + "[\"nodes\"][1][\"index\"]", + "strictly increasing" + ) + assertDecodeFailure( + { + type: "directed", + nodes: [{ index: 1, data: "A" }, { index: 0, data: "B" }], + edges: [] + }, + "[\"nodes\"][1][\"index\"]", + "strictly increasing" + ) + assertDecodeFailure( + { + type: "directed", + nodes: [{ index: 0, data: "A" }], + edges: [ + { index: 1, source: 0, target: 0, data: 1 }, + { index: 1, source: 0, target: 0, data: 2 } + ] + }, + "[\"edges\"][1][\"index\"]", + "strictly increasing" + ) + assertDecodeFailure( + { + type: "directed", + nodes: [{ index: 0, data: "A" }], + edges: [ + { index: 2, source: 0, target: 0, data: 1 }, + { index: 1, source: 0, target: 0, data: 2 } + ] + }, + "[\"edges\"][1][\"index\"]", + "strictly increasing" + ) + }) + + it("rejects dangling endpoints without leaking GraphError", () => { + assertDecodeFailure( + { + type: "directed", + nodes: [{ index: 1, data: "A" }], + edges: [{ index: 0, source: 0, target: 1, data: 1 }] + }, + "[\"edges\"][0][\"source\"]", + "encoded node index" + ) + assertDecodeFailure( + { + type: "directed", + nodes: [{ index: 1, data: "A" }], + edges: [{ index: 0, source: 1, target: 2, data: 1 }] + }, + "[\"edges\"][0][\"target\"]", + "encoded node index" + ) + }) + + it("reports payload failures at array and property paths", () => { + assertDecodeFailure( + { + type: "directed", + nodes: [{ index: 0, data: null }], + edges: [] + }, + "[\"nodes\"][0][\"data\"]", + "Expected string" + ) + assertDecodeFailure( + { + type: "directed", + nodes: [{ index: 0, data: "A" }], + edges: [{ index: 0, source: 0, target: 0, data: null }] + }, + "[\"edges\"][0][\"data\"]", + "Expected number" + ) + }) +}) diff --git a/repos/effect/packages/effect/test/schema/Schema.test.ts b/repos/effect/packages/effect/test/schema/Schema.test.ts index a3608990cf..3f28fe5fe9 100644 --- a/repos/effect/packages/effect/test/schema/Schema.test.ts +++ b/repos/effect/packages/effect/test/schema/Schema.test.ts @@ -34,7 +34,14 @@ import { import { TestSchema } from "effect/testing" import { produce } from "immer" import { deepStrictEqual, fail, strictEqual } from "node:assert" -import { assertFalse, assertInclude, assertSchemaIssueError, assertTrue, throws } from "../utils/assert.ts" +import { + assertExitSuccess, + assertFalse, + assertInclude, + assertSchemaIssueError, + assertTrue, + throws +} from "../utils/assert.ts" const verifyGeneration = true @@ -48,6 +55,30 @@ const SnakeToCamel = Schema.String.pipe( ) describe("Schema", () => { + it("keeps synchronous decode and encode effects eager", () => { + // A synchronous parser already produces an `Exit`, so the adapter must hand + // it back as-is instead of wrapping it in something a fiber has to run. + const eagerExit = (effect: Effect.Effect): Exit.Exit => { + assertTrue(Exit.isExit(effect), "expected the adapter to return an Exit") + return effect as Exit.Exit + } + const schemaError = (exit: Exit.Exit) => + Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none() + + assertExitSuccess(eagerExit(Schema.decodeUnknownEffect(Schema.String)("a")), "a") + assertExitSuccess(eagerExit(Schema.encodeUnknownEffect(Schema.String)("a")), "a") + + for ( + const effect of [ + Schema.decodeUnknownEffect(Schema.String)(null), + Schema.encodeUnknownEffect(Schema.String)(null) + ] + ) { + const error = schemaError(eagerExit(effect)) + assertTrue(Option.isSome(error) && Schema.isSchemaError(error.value)) + } + }) + it("isSchema", () => { class A extends Schema.Class("A")(Schema.Struct({ a: Schema.String @@ -81,6 +112,18 @@ describe("Schema", () => { strictEqual(error.message, "Expected string") strictEqual(String(error), "SchemaError(Expected string)") }) + + it("does not capture stack frames", () => { + const result = SchemaParser.decodeUnknownResult(Schema.String)(null) + assertTrue(Result.isFailure(result)) + const ErrorWithLimit = Error as typeof Error & { stackTraceLimit?: number | undefined } + const stackTraceLimit = ErrorWithLimit.stackTraceLimit + + const error = new Schema.SchemaError(result.failure) + + strictEqual(error.stack, "SchemaError: Expected string") + strictEqual(ErrorWithLimit.stackTraceLimit, stackTraceLimit) + }) }) describe("parseOptions annotation", () => { @@ -175,14 +218,17 @@ Missing key describe("Literal", () => { it("should throw an error if the literal is not a finite number", () => { throws( + // @effect-diagnostics-next-line schemaLiteralNonFinite:off () => Schema.Literal(Infinity), new Error("A numeric literal must be finite, got Infinity") ) throws( + // @effect-diagnostics-next-line schemaLiteralNonFinite:off () => Schema.Literal(-Infinity), new Error("A numeric literal must be finite, got -Infinity") ) throws( + // @effect-diagnostics-next-line schemaLiteralNonFinite:off () => Schema.Literal(NaN), new Error("A numeric literal must be finite, got NaN") ) @@ -4132,6 +4178,32 @@ Expected a value between -2147483648 and 2147483647` ) }) + it(`mode: "oneOf" with nested and contradicted sentinels`, async () => { + const nested = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a"), variant: Schema.Literal("x") }), + Schema.Struct({ kind: Schema.Literal("a"), variant: Schema.Literal("y") }) + ]) + const schema = Schema.Struct({ + block: Schema.Union([ + nested, + Schema.Struct({ kind: Schema.Literal("b") }) + ], { mode: "oneOf" }) + }) + const decoding = new TestSchema.Asserts(schema).decoding() + + await decoding.succeed({ block: { kind: "a", variant: "x" } }) + await decoding.fail( + { block: { kind: "a", variant: "z" } }, + `Expected { readonly "kind": "a", readonly "variant": "x", ... } | { readonly "kind": "a", readonly "variant": "y", ... } + at ["block"]` + ) + await decoding.fail( + { block: { kind: "a", variant: undefined } }, + `Expected { readonly "kind": "a", readonly "variant": "x", ... } | { readonly "kind": "a", readonly "variant": "y", ... } + at ["block"]` + ) + }) + it(`mode: "oneOf" counts repeated member occurrences`, async () => { const member = Schema.Struct({ kind: Schema.Literal("a") }) const schema = Schema.Union([member, member], { mode: "oneOf" }) @@ -9082,6 +9154,25 @@ pointed message ), "D" ) + + // matchOrElse + deepStrictEqual( + schema.matchOrElse({ _tag: "A", a: "a" }, { A: () => "A" }, () => "fallback"), + "A" + ) + deepStrictEqual( + schema.matchOrElse({ _tag: b, b: 1 }, { A: () => "A" }, (value) => value._tag), + b + ) + deepStrictEqual( + pipe({ _tag: b, b: 1 }, schema.matchOrElse({ A: () => "A" }, (value) => value._tag)), + b + ) + const undefinedCases = { A: undefined } as unknown as { A?: () => string } + deepStrictEqual( + schema.matchOrElse({ _tag: "A", a: "a" }, undefinedCases, () => "fallback"), + "fallback" + ) }) it("should support multiple tags", () => { @@ -9208,6 +9299,20 @@ pointed message pipe({ _tag: "C", c: true }, schema.match({ A: () => "A", B: () => "B", C: () => "C" })), "C" ) + + // matchOrElse + deepStrictEqual( + schema.matchOrElse({ _tag: "A", a: "a" }, { A: (value) => value.a }, () => "fallback"), + "a" + ) + deepStrictEqual( + schema.matchOrElse({ _tag: "B", b: 1 }, { A: () => "A" }, (value) => value._tag), + "B" + ) + deepStrictEqual( + pipe({ _tag: "B", b: 1 }, schema.matchOrElse({ A: () => "A" }, (value) => value._tag)), + "B" + ) }) }) }) diff --git a/repos/effect/packages/effect/test/schema/SchemaAST.test.ts b/repos/effect/packages/effect/test/schema/SchemaAST.test.ts index fc83a80672..014944ba1b 100644 --- a/repos/effect/packages/effect/test/schema/SchemaAST.test.ts +++ b/repos/effect/packages/effect/test/schema/SchemaAST.test.ts @@ -316,6 +316,27 @@ describe("SchemaAST", () => { const ast = E.ast deepStrictEqual(SchemaAST.collectSentinels(ast), [{ key: "_tag", literal: "E" }]) }) + + it("Union: the sentinels common to every member", () => { + const shared = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a"), variant: Schema.Literal("x") }), + Schema.Struct({ kind: Schema.Literal("a"), variant: Schema.Literal("y") }) + ]) + deepStrictEqual(SchemaAST.collectSentinels(shared.ast), [{ key: "kind", literal: "a" }]) + + const disjoint = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a") }), + Schema.Struct({ kind: Schema.Literal("b") }) + ]) + deepStrictEqual(SchemaAST.collectSentinels(disjoint.ast), []) + + // A suspended member stays opaque, so the intersection is conservative. + const withSuspend = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a") }), + Schema.suspend(() => Schema.Struct({ kind: Schema.Literal("a") })) + ]) + deepStrictEqual(SchemaAST.collectSentinels(withSuspend.ast), []) + }) }) describe("getCandidates", () => { @@ -455,7 +476,7 @@ describe("SchemaAST", () => { deepStrictEqual(SchemaAST.getCandidates(input, ast.types), [ast.types[0]]) }) - it("should collect matches from different sentinel keys without duplicates", () => { + it("should handle candidates with different sentinel keys", () => { const schema = Schema.Union([ Schema.Struct({ kind: Schema.Literal("a"), @@ -469,6 +490,14 @@ describe("SchemaAST", () => { SchemaAST.getCandidates({ kind: "a", status: "ready", value: "value" }, ast.types), [ast.types[0], ast.types[1]] ) + deepStrictEqual( + SchemaAST.getCandidates({ kind: "b", status: "ready", value: "value" }, ast.types), + [ast.types[1]] + ) + deepStrictEqual( + SchemaAST.getCandidates({ kind: undefined, status: "ready", value: "value" }, ast.types), + [ast.types[1]] + ) }) it("should handle tagged tuples", () => { @@ -513,6 +542,31 @@ describe("SchemaAST", () => { const input = { kind: "a" } strictEqual(Schema.decodeUnknownSync(schema)(input), input) }) + + it("should dispatch a nested union member by its common sentinel", () => { + const hosted = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a"), variant: Schema.Literal("x") }), + Schema.Struct({ kind: Schema.Literal("a"), variant: Schema.Literal("y") }) + ]) + const flat = Schema.Struct({ kind: Schema.Literal("b") }) + const ast = Schema.Union([hosted, flat]).ast + deepStrictEqual(SchemaAST.getCandidates({ kind: "a" }, ast.types), [ast.types[0]]) + deepStrictEqual(SchemaAST.getCandidates({ kind: "b" }, ast.types), [ast.types[1]]) + }) + + it("should exclude members whose sentinel the input contradicts", () => { + const schema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a"), variant: Schema.Literal("x"), value: Schema.String }), + Schema.Struct({ kind: Schema.Literal("a"), variant: Schema.Literal("y"), value: Schema.Number }) + ]) + const ast = schema.ast + deepStrictEqual(SchemaAST.getCandidates({ kind: "a", variant: "x" }, ast.types), [ast.types[0]]) + deepStrictEqual(SchemaAST.getCandidates({ kind: "a", variant: "z" }, ast.types), []) + deepStrictEqual(SchemaAST.getCandidates({ kind: "a", variant: undefined }, ast.types), []) + // A missing sentinel key does not exclude: the member still owes the error. + deepStrictEqual(SchemaAST.getCandidates({ kind: "a" }, ast.types), [ast.types[0], ast.types[1]]) + deepStrictEqual(SchemaAST.getCandidates({ kind: "a", variant: undefined }, ast.types, true), ast.types) + }) }) describe("getIndexSignatureKeys", () => { diff --git a/repos/effect/packages/effect/test/schema/SchemaParser.test.ts b/repos/effect/packages/effect/test/schema/SchemaParser.test.ts index eabfed067a..990a064994 100644 --- a/repos/effect/packages/effect/test/schema/SchemaParser.test.ts +++ b/repos/effect/packages/effect/test/schema/SchemaParser.test.ts @@ -528,6 +528,18 @@ describe("SchemaParser", () => { strictEqual(calls.join(","), "a,b,c") }) + it("converts synchronous property parser throws into defects", () => { + const field = Schema.declareConstructor()([], () => () => { + throw new Error("property defect") + }) + const schema = Schema.Struct({ field }) + + const exit = SchemaParser.decodeUnknownExit(schema)({ field: "value" }) + + assertTrue(Exit.isFailure(exit)) + assertTrue(Cause.hasDies(exit.cause)) + }) + it("keeps encoding-link parser compilation lazy for Suspend", () => { let evaluations = 0 const schema = Schema.suspend(() => { diff --git a/repos/effect/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts b/repos/effect/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts new file mode 100644 index 0000000000..a57717b2f2 --- /dev/null +++ b/repos/effect/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts @@ -0,0 +1,265 @@ +import { assert, describe, it } from "@effect/vitest" +import type { Options as AjvOptions } from "ajv" +import { Exit, JsonSchema, Schema, SchemaRepresentation } from "effect" + +// oxlint-disable-next-line @typescript-eslint/no-require-imports +const Ajv2020 = require("ajv/dist/2020") + +const ajv = new Ajv2020.default( + { + allErrors: true, + strict: false, + validateSchema: true, + code: { esm: true } + } satisfies AjvOptions +) + +function compile(document: JsonSchema.Document<"draft-2020-12">) { + return ajv.compile({ + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + ...document.schema, + $defs: document.definitions + }) +} + +function assertSameAcceptedValues( + left: (input: unknown) => unknown, + right: (input: unknown) => unknown, + inputs: ReadonlyArray +): void { + for (const input of inputs) { + assert.strictEqual( + left(input) === true, + right(input) === true, + `Validation differs for ${JSON.stringify(input)}` + ) + } +} + +function assertJsonSchemaEquivalent( + left: JsonSchema.Document<"draft-2020-12">, + right: JsonSchema.Document<"draft-2020-12">, + inputs: ReadonlyArray +): void { + assertSameAcceptedValues(compile(left), compile(right), inputs) +} + +function assertJsonSchemaImportRoundTrip( + schema: JsonSchema.JsonSchema, + inputs: ReadonlyArray +): void { + const source = JsonSchema.fromSchemaDraft2020_12(schema) + const imported = SchemaRepresentation.fromJsonSchemaDocument(source, { patterns: "apply" }) + const representation = SchemaRepresentation.toRepresentation(imported.ast) + const emitted = SchemaRepresentation.toJsonSchemaDocument(representation) + assertJsonSchemaEquivalent(source, emitted, inputs) + + const validate = compile(source) + const decode = Schema.decodeUnknownExit(imported as unknown as Schema.ConstraintDecoder, { + onExcessProperty: "error" + }) + assertSameAcceptedValues((input) => Exit.isSuccess(decode(input)), validate, inputs) +} + +function assertRepresentationRoundTrip( + schema: Schema.ConstraintDecoder, + inputs: ReadonlyArray +): void { + const emitted = Schema.toJsonSchemaDocument(schema) + const imported = SchemaRepresentation.fromJsonSchemaDocument(emitted, { patterns: "apply" }) + const decodeSource = Schema.decodeUnknownExit(schema, { onExcessProperty: "error" }) + const decodeImported = Schema.decodeUnknownExit(imported as unknown as Schema.ConstraintDecoder, { + onExcessProperty: "error" + }) + assertSameAcceptedValues( + (input) => Exit.isSuccess(decodeImported(input)), + (input) => Exit.isSuccess(decodeSource(input)), + inputs + ) + assertJsonSchemaEquivalent(emitted, Schema.toJsonSchemaDocument(imported), inputs) +} + +describe("JSON Schema round-trip laws", () => { + describe("toJsonSchema(fromJsonSchema(A))", () => { + it("applies type-specific keywords only to matching instances", () => { + assertJsonSchemaImportRoundTrip( + { minLength: 2 }, + ["", "a", "ab", 0, true, null, [], {}] + ) + assertJsonSchemaImportRoundTrip( + { minimum: 1 }, + [0, 1, "a", true, null, [], {}] + ) + assertJsonSchemaImportRoundTrip( + { minItems: 1 }, + [[], [1], "a", 0, true, null, {}] + ) + assertJsonSchemaImportRoundTrip( + { required: ["a"] }, + [{}, { a: 1 }, [], "a", 0, true, null] + ) + }) + + it("conjoins enum with its sibling constraints", () => { + assertJsonSchemaImportRoundTrip( + { enum: ["a", "ab", 1], minLength: 2 }, + ["a", "ab", 1, true] + ) + }) + + it("conjoins const with its sibling constraints", () => { + assertJsonSchemaImportRoundTrip( + { const: "a", minLength: 2 }, + ["a", "aa", 1, null] + ) + }) + + it("intersects finite enums as sets", () => { + assertJsonSchemaImportRoundTrip( + { allOf: [{ enum: ["a", "b", 1] }, { enum: ["b", 1, true] }] }, + ["a", "b", 1, true, null] + ) + }) + + it("preserves oneOf branch multiplicity", () => { + assertJsonSchemaImportRoundTrip( + { oneOf: [{ const: "a" }, { const: "a" }] }, + ["a", "b", 1] + ) + }) + + it("preserves minItems after prefixItems", () => { + assertJsonSchemaImportRoundTrip( + { + type: "array", + prefixItems: [{ type: "string" }], + items: { type: "number" }, + minItems: 2 + }, + [[], ["a"], ["a", 1], ["a", 1, 2], ["a", "b"]] + ) + }) + + it("intersects one constraint with multiple alternatives linearly", () => { + assertJsonSchemaImportRoundTrip( + { + allOf: [ + { + anyOf: [ + { type: "string", minLength: 2, maxLength: 3 }, + { type: "string", minLength: 5, maxLength: 6 } + ] + }, + { type: "string", pattern: "^a" } + ] + }, + ["a", "aa", "bb", "aaaa", "aaaaa", "bbbbb", 1] + ) + }) + + it("preserves empty closed objects", () => { + assertJsonSchemaImportRoundTrip( + { type: "object", additionalProperties: false }, + [{}, { a: 1 }, [], [1], null, 1] + ) + }) + + it("applies additionalProperties to required-only names", () => { + assertJsonSchemaImportRoundTrip( + { type: "object", required: ["a"], additionalProperties: false }, + [{}, { a: 1 }, { a: "a" }, { b: 1 }, []] + ) + }) + + it("applies an additionalProperties schema to required-only names", () => { + assertJsonSchemaImportRoundTrip( + { type: "object", required: ["a"], additionalProperties: { type: "string" } }, + [{}, { a: 1 }, { a: "a" }, { a: "a", b: "b" }, { a: "a", b: 1 }, []] + ) + }) + + it("preserves closed object scopes", () => { + assertJsonSchemaImportRoundTrip( + { + type: "object", + additionalProperties: false, + allOf: [{ properties: { a: { type: "string" } } }] + }, + [{}, { a: "a" }, { a: 1 }, { b: 1 }, { a: "a", b: 1 }, []] + ) + }) + + it("preserves sibling additionalProperties schemas", () => { + assertJsonSchemaImportRoundTrip( + { + type: "object", + properties: { a: { type: "string" } }, + allOf: [{ additionalProperties: { type: "boolean" } }] + }, + [{}, { a: "a" }, { b: true }, { b: "b" }, { a: "a", b: true }, []] + ) + }) + + it("preserves open patternProperties", () => { + assertJsonSchemaImportRoundTrip( + { + type: "object", + patternProperties: { "^a": { type: "string" } } + }, + [{}, { a: "a" }, { a: 1 }, { ab: "a", b: 1 }, { b: 1 }, []] + ) + }) + }) + + describe("fromJsonSchema(toJsonSchema(X))", () => { + it("preserves structs", () => { + assertRepresentationRoundTrip( + Schema.Struct({ a: Schema.String }), + [{}, { a: "a" }, { a: 1 }, { a: "a", b: 1 }, []] + ) + }) + + it("preserves string indexes", () => { + assertRepresentationRoundTrip( + Schema.Record(Schema.String, Schema.Union([Schema.Finite, Schema.String])), + [{}, { a: "a" }, { a: 1 }, { a: true }, []] + ) + }) + + it("preserves pattern indexes", () => { + assertRepresentationRoundTrip( + Schema.Record(Schema.String.check(Schema.isUppercased()), Schema.Finite), + [{}, { A: 1 }, { A: "a" }, { a: 1 }, { a: "a" }, []] + ) + }) + + it("preserves pattern and string indexes", () => { + assertRepresentationRoundTrip( + Schema.StructWithRest(Schema.Struct({}), [ + Schema.Record(Schema.String.check(Schema.isUppercased()), Schema.Finite), + Schema.Record(Schema.String, Schema.Boolean) + ]), + [{}, { A: 1 }, { A: true }, { a: 1 }, { a: true }, []] + ) + }) + + it("preserves multiple string indexes", () => { + assertRepresentationRoundTrip( + Schema.StructWithRest(Schema.Struct({}), [ + Schema.Record(Schema.String, Schema.Union([Schema.Boolean, Schema.String])), + Schema.Record(Schema.String, Schema.Union([Schema.Boolean, Schema.Finite])) + ]), + [{}, { a: true }, { a: false }, { a: 1 }, { a: "a" }, []] + ) + }) + + it("applies string indexes to explicit properties", () => { + assertRepresentationRoundTrip( + Schema.StructWithRest(Schema.Struct({ a: Schema.Union([Schema.String, Schema.Boolean]) }), [ + Schema.Record(Schema.String, Schema.Boolean) + ]), + [{}, { a: true }, { a: false }, { a: "a" }, { a: 1 }, { a: true, b: false }, []] + ) + }) + }) +}) diff --git a/repos/effect/packages/effect/test/schema/representation/builtInRevivers.test.ts b/repos/effect/packages/effect/test/schema/representation/builtInRevivers.test.ts index 76f6e830e5..cc2bc205ec 100644 --- a/repos/effect/packages/effect/test/schema/representation/builtInRevivers.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/builtInRevivers.test.ts @@ -846,6 +846,15 @@ describe("SchemaRepresentation built-in declaration revivers", () => { }) }) + it("revives Graph", () => { + assertDeclarationReviver({ + schema: Schema.Graph("directed", Schema.String, Schema.Number), + id: "effect/schema/Graph", + payload: "directed", + reviver: Schema.GraphReviver + }) + }) + it("revives ReadonlySet", () => { assertDeclarationReviver({ schema: Schema.ReadonlySet(Schema.String), 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 c1f6347ec2..cc26e02ef8 100644 --- a/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -87,6 +87,14 @@ describe("fromJsonSchemaDocument", () => { ) }) + it("keeps annotation-only schemas unconstrained", () => { + const representation = fromJsonSchemaRepresentation( + JsonSchema.fromSchemaDraft2020_12({ format: "email" }) + ).representation + assertTrue(representation._tag === "Declaration") + strictEqual(representation.annotations?.format, "email") + }) + describe("const", () => { it("string literal", () => { assertFromJsonSchema( @@ -182,25 +190,13 @@ describe("fromJsonSchemaDocument", () => { ) }) - it("const: non-literal", () => { - assertFromJsonSchema( - { schema: { const: {} } }, - { - "representation": { - "_tag": "Declaration", - "representation": { - "id": "effect/schema/Json", - "payload": null - }, - "annotations": { - "expected": "JSON value" - }, - "typeParameters": [], - "checks": [] - }, - "references": {} - } - ) + it("rejects structured values", () => { + for (const value of [{}, []]) { + throws( + () => toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ const: value })), + `Unsupported structured JSON Schema value for "const"\n at ["schema"]["const"]` + ) + } }) }) @@ -879,39 +875,14 @@ describe("fromJsonSchemaDocument", () => { ) }) - it("pattern infers the string type", () => { - assertFromJsonSchema( - { schema: { pattern: "a*" } }, - { - "representation": { - "_tag": "String", - "checks": [ - { - "_tag": "Filter", - "representation": { - "id": "effect/schema/isPattern", - "payload": { - "source": "a*", - "flags": "" - } - }, - "annotations": { - "expected": "a string matching the RegExp a*", - "arbitrary": { - "constraint": { - "patterns": [ - "a*" - ] - } - } - }, - "aborted": false - } - ] - }, - "references": {} - } - ) + it("pattern only constrains strings", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ pattern: "^a+$" }) + )) + assertTrue(is("a")) + assertFalse(is("b")) + assertTrue(is(1)) + assertTrue(is(null)) }) }) }) @@ -1980,7 +1951,18 @@ describe("fromJsonSchemaDocument", () => { "_tag": "Objects", "checks": [], "propertySignatures": [], - "indexSignatures": [] + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Never", + "checks": [] + } + } + ] }, "references": {} } @@ -2066,211 +2048,44 @@ describe("fromJsonSchemaDocument", () => { }) it("properties & additionalProperties", () => { - assertFromJsonSchema( - { - schema: { + throws( + () => + toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ type: "object", properties: { a: { type: "string" } }, required: ["a"], additionalProperties: { type: "boolean" } - } - }, - { - "representation": { - "_tag": "Objects", - "checks": [], - "propertySignatures": [ - { - "name": { - "type": "string", - "value": "a" - }, - "type": { - "_tag": "String", - "checks": [] - }, - "isOptional": false, - "isMutable": false - } - ], - "indexSignatures": [ - { - "parameter": { - "_tag": "String", - "checks": [] - }, - "type": { - "_tag": "Boolean", - "checks": [] - } - } - ] - }, - "references": {} - } + })), + `Unsupported object keyword scopes\n at ["schema"]` ) }) - it("imports a single pattern property", () => { - assertFromJsonSchema( - { - schema: { + it("rejects a closed single pattern property", () => { + throws( + () => + toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ type: "object", patternProperties: { "a*": { type: "string" } }, additionalProperties: false - } - }, - { - "representation": { - "_tag": "Objects", - "checks": [], - "propertySignatures": [], - "indexSignatures": [ - { - "parameter": { - "_tag": "String", - "checks": [ - { - "_tag": "Filter", - "representation": { - "id": "effect/schema/isPattern", - "payload": { - "source": "a*", - "flags": "" - } - }, - "annotations": { - "expected": "a string matching the RegExp a*", - "arbitrary": { - "constraint": { - "patterns": [ - "a*" - ] - } - } - }, - "aborted": false - } - ] - }, - "type": { - "_tag": "String", - "checks": [] - } - } - ] - }, - "references": {} - } + })), + `Unsupported object keyword scopes\n at ["schema"]` ) }) - it("imports multiple pattern properties", () => { - assertFromJsonSchema( - { - schema: { + it("rejects closed multiple pattern properties", () => { + throws( + () => + toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ type: "object", patternProperties: { "a*": { type: "string" }, "b*": { type: "number" } }, additionalProperties: false - } - }, - { - "representation": { - "_tag": "Objects", - "checks": [], - "propertySignatures": [], - "indexSignatures": [ - { - "parameter": { - "_tag": "String", - "checks": [ - { - "_tag": "Filter", - "representation": { - "id": "effect/schema/isPattern", - "payload": { - "source": "a*", - "flags": "" - } - }, - "annotations": { - "expected": "a string matching the RegExp a*", - "arbitrary": { - "constraint": { - "patterns": [ - "a*" - ] - } - } - }, - "aborted": false - } - ] - }, - "type": { - "_tag": "String", - "checks": [] - } - }, - { - "parameter": { - "_tag": "String", - "checks": [ - { - "_tag": "Filter", - "representation": { - "id": "effect/schema/isPattern", - "payload": { - "source": "b*", - "flags": "" - } - }, - "annotations": { - "expected": "a string matching the RegExp b*", - "arbitrary": { - "constraint": { - "patterns": [ - "b*" - ] - } - } - }, - "aborted": false - } - ] - }, - "type": { - "_tag": "Number", - "checks": [ - { - "_tag": "Filter", - "representation": { - "id": "effect/schema/isFinite", - "payload": null - }, - "annotations": { - "expected": "a finite number", - "arbitrary": { - "constraint": { - "noInfinity": true, - "noNaN": true - } - } - }, - "aborted": false - } - ] - } - } - ] - }, - "references": {} - } + })), + `Unsupported object keyword scopes\n at ["schema"]` ) }) @@ -2713,43 +2528,16 @@ describe("fromJsonSchemaDocument", () => { ) }) - it("imports structured enum members as JSON", () => { - assertFromJsonSchema( - { schema: { enum: [[], {}] } }, - { - "representation": { - "_tag": "Union", - "checks": [], - "types": [ - { - "_tag": "Declaration", - "representation": { - "id": "effect/schema/Json", - "payload": null - }, - "annotations": { - "expected": "JSON value" - }, - "typeParameters": [], - "checks": [] - }, - { - "_tag": "Declaration", - "representation": { - "id": "effect/schema/Json", - "payload": null - }, - "annotations": { - "expected": "JSON value" - }, - "typeParameters": [], - "checks": [] - } - ], - "mode": "anyOf" - }, - "references": {} - } + it("rejects structured enum members", () => { + for (const value of [[], {}, { not: "data" }]) { + throws( + () => toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ enum: ["a", value] })), + `Unsupported structured JSON Schema value for "enum"\n at ["schema"]["enum"][1]` + ) + } + throws( + () => toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ const: "a", enum: [{}] })), + `Unsupported structured JSON Schema value for "enum"\n at ["schema"]["enum"][0]` ) }) @@ -2781,27 +2569,93 @@ describe("fromJsonSchemaDocument", () => { }) describe("$ref", () => { - it("treats a reference with an empty token as unconstrained", () => { - assertFromJsonSchema( - { schema: { $ref: "" } }, - { - "representation": { - "_tag": "Declaration", - "representation": { - "id": "effect/schema/Json", - "payload": null - }, - "annotations": { - "expected": "JSON value" - }, - "typeParameters": [], - "checks": [] - }, - "references": {} - } + it("rejects a reference below a definition instead of resolving its final token", () => { + throws( + () => + toSchemaFromJsonSchemaDocument( + JsonSchema.fromSchemaDraft07({ + definitions: { + inner: { type: "number" }, + outer: { + type: "object", + properties: { + inner: { type: "string" } + } + } + }, + type: "object", + properties: { + copy: { $ref: "#/definitions/outer/properties/inner" } + } + }) + ), + `Unsupported reference "#/$defs/outer/properties/inner"\n at ["schema"]["properties"]["copy"]["$ref"]` + ) + }) + + it("rejects an empty reference", () => { + throws( + () => toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ $ref: "" })), + `Unsupported reference ""\n at ["schema"]["$ref"]` + ) + }) + + it("rejects an external reference instead of aliasing a local definition", () => { + throws( + () => + toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + $ref: "https://example.com/schema#/$defs/A", + $defs: { + A: { type: "string" } + } + })), + `Unsupported reference "https://example.com/schema#/$defs/A"\n at ["schema"]["$ref"]` + ) + }) + + it("reports the full reference when a direct definition is missing", () => { + throws( + () => toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ $ref: "#/$defs/Missing" })), + `Invalid reference "#/$defs/Missing"\n at ["schema"]["$ref"]` ) }) + it("unescapes a direct definition reference", () => { + const schema = toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + $ref: "#/$defs/A~1B~0C", + $defs: { + "A/B~C": { type: "string" } + } + })) + const is = Schema.is(schema) + assertTrue(is("a")) + assertFalse(is(1)) + }) + + it("decodes a direct definition reference", () => { + const schema = toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + $ref: "#/$defs/A%20B", + $defs: { + "A B": { type: "string" } + } + })) + const is = Schema.is(schema) + assertTrue(is("a")) + assertFalse(is(1)) + }) + + it("resolves a direct reference to an empty definition key", () => { + const schema = toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + $ref: "#/$defs/", + $defs: { + "": { type: "string" } + } + })) + const is = Schema.is(schema) + assertTrue(is("a")) + assertFalse(is(1)) + }) + it("should create a Reference and a definition", () => { assertFromJsonSchema( { @@ -3119,6 +2973,121 @@ describe("fromJsonSchemaDocument", () => { }) describe("allOf", () => { + it("prunes disjoint lanes before intersecting unions", () => { + const representation = fromJsonSchemaRepresentation( + JsonSchema.fromSchemaDraft2020_12({ + allOf: [ + { anyOf: [{ type: "string" }, { type: "number" }] }, + { anyOf: [{ type: "number" }, { type: "boolean" }] } + ] + }) + ).representation + strictEqual(representation._tag, "Number") + }) + + it("rejects intersections requiring a Cartesian product", () => { + throws( + () => + toSchemaFromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ + allOf: [ + { + anyOf: [ + { type: "string", minLength: 2, maxLength: 3 }, + { type: "string", minLength: 5, maxLength: 6 } + ] + }, + { + anyOf: [ + { type: "string", pattern: "^a" }, + { type: "string", pattern: "z$" } + ] + } + ] + }) + ), + `Unsupported intersection of overlapping unions\n at ["schema"]["allOf"][1]` + ) + }) + + it("rejects distributions that duplicate a nested choice", () => { + throws( + () => + toSchemaFromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ + allOf: [ + { + anyOf: [ + { type: "object", properties: { tag: { const: "a" } } }, + { type: "object", properties: { tag: { const: "b" } } } + ] + }, + { + type: "object", + properties: { + value: { anyOf: [{ type: "string" }, { type: "number" }] } + } + } + ] + }) + ), + `Unsupported intersection of overlapping unions\n at ["schema"]["allOf"][1]` + ) + }) + + it("distributes across a nested reference without choices", () => { + const schema = toSchemaFromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ + allOf: [ + { + anyOf: [ + { type: "object", required: ["a"] }, + { type: "object", required: ["b"] } + ] + }, + { + type: "object", + properties: { + value: { $ref: "#/$defs/Value" } + }, + required: ["value"] + } + ], + $defs: { + Value: { type: "string" } + } + }) + ) + const is = Schema.is(schema) + assertTrue(is({ a: true, value: "a" })) + assertTrue(is({ b: true, value: "b" })) + assertFalse(is({ a: true, value: 1 })) + assertFalse(is({ value: "a" })) + }) + + it("reports unsupported keywords after a false schema", () => { + throws( + () => + toSchemaFromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ + allOf: [false, { contains: {} }] + }) + ), + `Unsupported JSON Schema keyword "contains"\n at ["schema"]["allOf"][1]["contains"]` + ) + }) + + it("intersects one constraint with many alternatives linearly", () => { + const representation = fromJsonSchemaRepresentation( + JsonSchema.fromSchemaDraft2020_12({ + pattern: "^value-", + anyOf: Array.from({ length: 16 }, (_, index) => ({ const: `value-${index}` })) + }) + ).representation + assertTrue(representation._tag === "Union") + strictEqual(representation.types.length, 16) + }) + it("resolves a root reference before intersecting allOf", () => { const definition: JsonSchema.JsonSchema = { type: "string", minLength: 1 } assertFromJsonSchema({ @@ -3283,7 +3252,18 @@ describe("fromJsonSchemaDocument", () => { }, "checks": [], "propertySignatures": [], - "indexSignatures": [] + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [] + }, + "type": { + "_tag": "Never", + "checks": [] + } + } + ] }, "references": {} } @@ -3390,19 +3370,12 @@ describe("fromJsonSchemaDocument", () => { { schema: { type: "string", minLength: 2, allOf: [{ enum: ["a", "ab"] }] } }, { "representation": { - "_tag": "Union", + "_tag": "Literal", "checks": [], - "types": [ - { - "_tag": "Literal", - "checks": [], - "literal": { - "type": "string", - "value": "ab" - } - } - ], - "mode": "anyOf" + "literal": { + "type": "string", + "value": "ab" + } }, "references": {} } @@ -3414,19 +3387,12 @@ describe("fromJsonSchemaDocument", () => { { schema: { enum: ["a", "ab"], allOf: [{ type: "string", minLength: 2 }] } }, { "representation": { - "_tag": "Union", + "_tag": "Literal", "checks": [], - "types": [ - { - "_tag": "Literal", - "checks": [], - "literal": { - "type": "string", - "value": "ab" - } - } - ], - "mode": "anyOf" + "literal": { + "type": "string", + "value": "ab" + } }, "references": {} } @@ -4191,19 +4157,12 @@ describe("fromJsonSchemaDocument", () => { }, { "representation": { - "_tag": "Union", + "_tag": "Literal", "checks": [], - "types": [ - { - "_tag": "Literal", - "checks": [], - "literal": { - "type": "string", - "value": "a" - } - } - ], - "mode": "anyOf" + "literal": { + "type": "string", + "value": "a" + } }, "references": {} } @@ -5424,28 +5383,128 @@ describe("fromJsonSchemaDocument", () => { } }) - it("merges object index signatures", () => { - const indexes = fromJsonSchemaRepresentation( - JsonSchema.fromSchemaDraft2020_12({ - type: "object", - additionalProperties: false, - patternProperties: { "^a": { type: "string" } }, - allOf: [ - { type: "object", additionalProperties: true }, - { - type: "object", - additionalProperties: false, - patternProperties: { "^b": { type: "number" } } - } - ] - }) - ) - strictEqual(indexes.representation._tag, "Objects") - if (indexes.representation._tag === "Objects") { - strictEqual(indexes.representation.indexSignatures.length, 3) + it("keeps additionalProperties scopes separate", () => { + for ( + const schema of [ + { + type: "object", + additionalProperties: false, + allOf: [{ properties: { a: { type: "string" } } }] + }, + { + type: "object", + properties: { a: { type: "string" } }, + allOf: [{ additionalProperties: false }] + } + ] + ) { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema))) + assertTrue(is({})) + assertFalse(is({ a: "a" })) } }) + it("does not move sibling properties into a closed scope", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + allOf: [{ properties: { b: { type: "number" } } }] + }))) + assertTrue(is({ a: "a" })) + assertFalse(is({ b: 1 })) + assertFalse(is({ a: "a", b: 1 })) + }) + + it("rejects an object when a required sibling property is outside a closed scope", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + additionalProperties: false, + allOf: [{ properties: { a: { type: "string" } }, required: ["a"] }] + }))) + assertFalse(is({})) + assertFalse(is({ a: "a" })) + }) + + it("keeps object keyword scopes through references", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + $ref: "#/$defs/Closed", + allOf: [{ properties: { a: { type: "string" } } }], + $defs: { + Closed: { type: "object", additionalProperties: false } + } + }))) + assertTrue(is({})) + assertFalse(is({ a: "a" })) + }) + + it("applies a sibling additionalProperties schema to fixed properties", () => { + const schema = toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + properties: { a: { type: "string" } }, + allOf: [{ additionalProperties: { type: "boolean" } }] + })) + const is = Schema.is(schema) + assertTrue(is({ b: true })) + assertFalse(is({ a: "a" })) + assertFalse(is({ b: "b" })) + deepStrictEqual(Schema.toJsonSchemaDocument(schema).schema, { + type: "object", + properties: { a: { not: {} } }, + allOf: [{ type: "object", additionalProperties: { type: "boolean" } }] + }) + }) + + it("lowers open patterns over a finite object domain", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + allOf: [{ patternProperties: { "^a$": { minLength: 2 } } }] + }))) + assertTrue(is({ a: "aa" })) + assertFalse(is({ a: "a" })) + }) + + it("preserves open pattern scopes", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + patternProperties: { "^a": { type: "string" } } + }))) + assertTrue(is({ a: "a", b: 1 })) + assertFalse(is({ a: 1 })) + }) + + it("applies open patterns to fixed properties", () => { + const is = Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + properties: { a: { type: "string" } }, + patternProperties: { "^a$": { minLength: 2 } } + }))) + assertTrue(is({ a: "aa" })) + assertFalse(is({ a: "a" })) + }) + + it("rejects object scopes that require pattern complements", () => { + throws( + () => + fromJsonSchemaRepresentation(JsonSchema.fromSchemaDraft2020_12({ + type: "object", + additionalProperties: false, + patternProperties: { "^a": { type: "string" } }, + allOf: [ + { type: "object", additionalProperties: true }, + { + type: "object", + additionalProperties: false, + patternProperties: { "^b": { type: "number" } } + } + ] + })), + `Unsupported object keyword scopes\n at ["schema"]` + ) + }) + describe("type: object", () => { it("add properties", () => { assertFromJsonSchema( @@ -5469,7 +5528,7 @@ describe("fromJsonSchemaDocument", () => { "value": "a" }, "type": { - "_tag": "String", + "_tag": "Never", "checks": [] }, "isOptional": true, @@ -5518,6 +5577,71 @@ describe("fromJsonSchemaDocument", () => { }) }) + describe("unsupported validation keywords", () => { + it("not", () => { + throws( + () => + toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft07({ + type: "object", + properties: { + value: { not: { type: "string" } } + } + })), + `Unsupported JSON Schema keyword "not"\n at ["schema"]["properties"]["value"]["not"]` + ) + }) + + for ( + const [keyword, value] of [ + ["$dynamicRef", "#node"], + ["contains", { type: "string" }], + ["dependentRequired", { a: ["b"] }], + ["dependentSchemas", { a: { required: ["b"] } }], + ["unevaluatedItems", false], + ["unevaluatedProperties", false] + ] as const + ) { + it(keyword, () => { + throws( + () => toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ [keyword]: value })), + `Unsupported JSON Schema keyword "${keyword}"\n at ["schema"][${JSON.stringify(keyword)}]` + ) + }) + } + + for (const branch of ["then", "else"] as const) { + it(`if/${branch}`, () => { + throws( + () => + toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ + if: { type: "string" }, + [branch]: false + })), + `Unsupported JSON Schema keyword "if"\n at ["schema"]["if"]` + ) + }) + } + + it("ignores inactive conditional and contains cardinality keywords", () => { + for ( + const [keyword, value] of [ + ["if", false], + ["then", false], + ["else", false], + ["minContains", 1], + ["maxContains", 2] + ] as const + ) { + const document = JsonSchema.fromSchemaDraft2020_12({ [keyword]: value }) + assertTrue(Schema.is(toSchemaFromJsonSchemaDocument(document))({})) + } + }) + + it("ignores custom keywords", () => { + assertTrue(Schema.is(toSchemaFromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12({ custom: false })))({})) + }) + }) + describe("options", () => { describe("patterns", () => { it("rejects patterns by default", () => { diff --git a/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts b/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts index 2dc625f3c4..540fe6d793 100644 --- a/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts @@ -233,7 +233,7 @@ describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => { schemas: [{ $ref: "#/$defs/Missing", description: "resolve" }], definitions: {} }), - "Invalid reference Missing\n at [\"schemas\"][0][\"$ref\"]" + `Invalid reference "#/$defs/Missing"\n at ["schemas"][0]["$ref"]` ) }) diff --git a/repos/effect/packages/effect/test/schema/representation/schemaToJsonSchemaDocument.test.ts b/repos/effect/packages/effect/test/schema/representation/schemaToJsonSchemaDocument.test.ts index c52e818821..792a5e66a8 100644 --- a/repos/effect/packages/effect/test/schema/representation/schemaToJsonSchemaDocument.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/schemaToJsonSchemaDocument.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest" -import { Schema } from "effect" +import { Schema, type SchemaRepresentation } from "effect" describe("Schema.toJsonSchemaDocument", () => { it("uses the encoded side for representations and JSON Schema", () => { @@ -16,6 +16,59 @@ describe("Schema.toJsonSchemaDocument", () => { }) }) + it("inlines repeated anonymous schemas by default", () => { + const shared = Schema.Struct({ value: Schema.String }) + + assert.deepStrictEqual( + Schema.toJsonSchemaDocument(Schema.Struct({ first: shared, second: shared })), + { + dialect: "draft-2020-12", + schema: { + type: "object", + properties: { + first: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false + }, + second: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false + } + }, + required: ["first", "second"], + additionalProperties: false + }, + definitions: {} + } + ) + }) + + it("forwards the reference policy to representation and JSON Schema generation", () => { + const shared = Schema.String.annotate({ identifier: "Shared" }) + const options = { referencePolicy: () => undefined } + + assert.deepStrictEqual(Schema.toRepresentation(shared, options).references, {}) + assert.deepStrictEqual(Schema.toJsonSchemaDocument(shared, options), { + dialect: "draft-2020-12", + schema: { type: "string" }, + definitions: {} + }) + + const inputs: Array = [] + Schema.toJsonSchemaDocument(Schema.Date.annotate({ identifier: "Date" }), { + referencePolicy: (input) => { + inputs.push(input) + return undefined + } + }) + assert.strictEqual(inputs[0].ast._tag, "String") + assert.strictEqual(inputs[0].identifier, "DateEncoded") + }) + it("projects encoded tuple elements for JSON Schema", () => { assert.deepStrictEqual(Schema.toJsonSchemaDocument(Schema.Tuple([Schema.NumberFromString])).schema, { type: "array", @@ -25,17 +78,14 @@ describe("Schema.toJsonSchemaDocument", () => { }) }) - it("preserves Number checks on the finite encoded branch", () => { + it("does not project checks through an artificial JSON encoding", () => { assert.deepStrictEqual( Schema.toJsonSchemaDocument(Schema.Number.check(Schema.isGreaterThan(0))), { dialect: "draft-2020-12", schema: { anyOf: [ - { - type: "number", - allOf: [{ exclusiveMinimum: 0 }] - }, + { type: "number" }, { type: "string", enum: ["Infinity", "-Infinity", "NaN"] @@ -47,6 +97,37 @@ describe("Schema.toJsonSchemaDocument", () => { ) }) + it("preserves checks when no artificial JSON encoding is needed", () => { + assert.deepStrictEqual( + Schema.toJsonSchemaDocument(Schema.Number.check(Schema.isFinite(), Schema.isGreaterThan(0))), + { + dialect: "draft-2020-12", + schema: { + type: "number", + exclusiveMinimum: 0 + }, + definitions: {} + } + ) + }) + + it("does not project annotations through an artificial JSON encoding", () => { + const schema = Schema.Number.annotate({ description: "source description" }) + const expected = { + dialect: "draft-2020-12" as const, + schema: { + anyOf: [ + { type: "number" }, + { type: "string", enum: ["Infinity", "-Infinity", "NaN"] } + ] + }, + definitions: {} + } + + assert.deepStrictEqual(Schema.toJsonSchemaDocument(schema), expected) + assert.deepStrictEqual(Schema.toJsonSchemaDocument(Schema.toCodecJson(schema)), expected) + }) + it("preserves output, references and generation options", () => { const shared = Schema.String.check(Schema.isMinLength(2)).annotate({ identifier: "Shared", @@ -106,7 +187,7 @@ describe("Schema.toJsonSchemaDocument", () => { dialect: "draft-2020-12", schema: { type: "string", - allOf: [{ minLength: 2 }] + minLength: 2 }, definitions: {} }) diff --git a/repos/effect/packages/effect/test/schema/representation/toCodeDocument.test.ts b/repos/effect/packages/effect/test/schema/representation/toCodeDocument.test.ts index 77d4674efa..206ae1c9e5 100644 --- a/repos/effect/packages/effect/test/schema/representation/toCodeDocument.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/toCodeDocument.test.ts @@ -1308,7 +1308,7 @@ describe("toCodeDocument", () => { { codes: makeCode( `Schema.StructWithRest(Schema.Struct({ "a": Schema.Number }), [Schema.Record(Schema.String, Schema.Number)])`, - `{ readonly "a": number, readonly [x: string]: number }` + `{ readonly "a": number } & { readonly [x: string]: number }` ) } ) @@ -1321,7 +1321,7 @@ describe("toCodeDocument", () => { { codes: makeCode( `Schema.StructWithRest(Schema.Struct({ "a": Schema.Number }), [Schema.Record(Schema.String, Schema.Number)]).annotate({ "description": "a" })`, - `{ readonly "a": number, readonly [x: string]: number }` + `{ readonly "a": number } & { readonly [x: string]: number }` ) } ) @@ -1824,7 +1824,8 @@ describe("toCodeDocument", () => { properties: { a: { type: "string" - } + }, + b: {} }, required: ["a"] } @@ -1835,7 +1836,8 @@ describe("toCodeDocument", () => { properties: { b: { type: "number" - } + }, + a: {} }, required: ["b"] } diff --git a/repos/effect/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts b/repos/effect/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts index 15d9906e5f..5a82c5a370 100644 --- a/repos/effect/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts @@ -516,7 +516,7 @@ describe("SchemaRepresentation.toJsonSchemaDocument", () => { assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, { type: "integer", - allOf: [{ minimum: 0 }] + minimum: 0 }) assert.strictEqual(receivedType, "integer") }) @@ -529,7 +529,7 @@ describe("SchemaRepresentation.toJsonSchemaDocument", () => { dialect: "draft-2020-12", schema: { type: "string", - allOf: [{ pattern: "^[a-z]+$" }] + pattern: "^[a-z]+$" }, definitions: {} }) @@ -593,7 +593,7 @@ describe("SchemaRepresentation.toJsonSchemaDocument", () => { assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, { anyOf: [{ type: "object" }, { type: "array" }], - allOf: [{ propertyNames: { type: "string" } }] + propertyNames: { type: "string" } }) }) }) @@ -725,6 +725,263 @@ describe("SchemaRepresentation.toJsonSchemaDocument", () => { }) describe("normalization", () => { + describe("conservative check compaction", () => { + function check( + fragment: JsonSchema.JsonSchema, + annotations?: SchemaRepresentation.Filter["annotations"] + ): SchemaRepresentation.Filter { + return { + _tag: "Filter", + aborted: false, + annotations: { ...annotations, toJsonSchema: () => fragment } + } + } + + function compileChecks(...fragments: ReadonlyArray) { + return compile({ + _tag: "Any", + checks: fragments.map((fragment) => check(fragment)) + }) + } + + it("keeps colliding keywords in separate schema objects", () => { + assert.deepStrictEqual( + compile({ + _tag: "String", + checks: [check({ minLength: 1 }), check({ minLength: 2 })] + }), + { + type: "string", + minLength: 1, + allOf: [{ minLength: 2 }] + } + ) + }) + + it("inlines pure annotations on a check", () => { + assert.deepStrictEqual( + compile({ + _tag: "String", + checks: [check({ minLength: 1 }, { + title: "title", + description: "description", + default: "default", + examples: ["example"], + readOnly: true, + writeOnly: false + })] + }), + { + type: "string", + minLength: 1, + title: "title", + description: "description", + default: "default", + examples: ["example"], + readOnly: true, + writeOnly: false + } + ) + }) + + it("inlines callback metadata with pure check annotations", () => { + assert.deepStrictEqual( + compile({ + _tag: "String", + checks: [check({ minLength: 1, title: "callback" }, { description: "annotation" })] + }), + { + type: "string", + minLength: 1, + title: "callback", + description: "annotation" + } + ) + }) + + it("keeps colliding annotations in a separate schema object", () => { + assert.deepStrictEqual( + compile({ + _tag: "String", + annotations: { description: "string" }, + checks: [check({ minLength: 1 }, { description: "check" })] + }), + { + type: "string", + description: "string", + allOf: [{ minLength: 1, description: "check" }] + } + ) + }) + + it("keeps format annotations in a separate schema object", () => { + assert.deepStrictEqual( + compile({ + _tag: "String", + checks: [check({ minLength: 1 }, { format: "annotated-check" })] + }), + { + type: "string", + allOf: [{ minLength: 1, format: "annotated-check" }] + } + ) + }) + + it("inlines propertyNames without a collision", () => { + assert.deepStrictEqual( + compile({ + _tag: "Objects", + propertySignatures: [], + indexSignatures: [], + checks: [check({ propertyNames: { minLength: 1 } })] + }), + { + anyOf: [{ type: "object" }, { type: "array" }], + propertyNames: { minLength: 1 } + } + ) + }) + + it("keeps colliding propertyNames in separate schema objects", () => { + assert.deepStrictEqual( + compile({ + _tag: "Objects", + propertySignatures: [], + indexSignatures: [], + checks: [ + check({ propertyNames: { minLength: 1 } }), + check({ propertyNames: { maxLength: 2 } }) + ] + }), + { + anyOf: [{ type: "object" }, { type: "array" }], + propertyNames: { minLength: 1 }, + allOf: [{ propertyNames: { maxLength: 2 } }] + } + ) + }) + + it("keeps a reference separate from contextual annotations", () => { + const document = SchemaRepresentation.toJsonSchemaDocument({ + representation: { + _tag: "Objects", + propertySignatures: [{ + name: "value", + type: { _tag: "Reference", $ref: "Value" }, + isOptional: false, + isMutable: false, + annotations: { description: "contextual" } + }], + indexSignatures: [], + checks: [] + }, + references: { Value: StringRepresentation } + }) + + assert.deepStrictEqual(document.schema, { + type: "object", + properties: { + value: { + allOf: [ + { $ref: "#/$defs/Value" }, + { description: "contextual" } + ] + } + }, + required: ["value"], + additionalProperties: false + }) + }) + + const coupledKeywords = [ + [ + "contains and minContains", + { contains: { const: "a" } }, + { minContains: 2 } + ], + [ + "properties and additionalProperties", + { properties: { a: true } }, + { additionalProperties: false } + ], + [ + "prefixItems and items", + { prefixItems: [true] }, + { items: false } + ], + [ + "if and else", + { if: { required: ["a"] } }, + { else: { required: ["b"] } } + ] + ] satisfies ReadonlyArray + + for (const [name, first, second] of coupledKeywords) { + it(`keeps ${name} in separate schema objects`, () => { + for (const [left, right] of [[first, second], [second, first]]) { + assert.deepStrictEqual(compileChecks(left, right), { + ...left, + allOf: [right] + }) + } + }) + } + + it("keeps a fragment containing a custom keyword intact", () => { + assert.deepStrictEqual( + compile({ + _tag: "String", + checks: [check({ minLength: 1, "x-check": true })] + }), + { + type: "string", + allOf: [{ minLength: 1, "x-check": true }] + } + ) + }) + + it("keeps format scoped after absorbing a number type", () => { + assert.deepStrictEqual( + compile({ + _tag: "Number", + checks: [check({ type: "number" }, { format: "finite" })] + }), + { + type: "number", + allOf: [{ format: "finite" }] + } + ) + }) + + it("inlines callback metadata after absorbing a number type", () => { + assert.deepStrictEqual( + compile({ + _tag: "Number", + checks: [check({ type: "integer", description: "integer" })] + }), + { + type: "integer", + description: "integer" + } + ) + }) + + it("does not overwrite an annotation after absorbing a number type", () => { + assert.deepStrictEqual( + compile({ + _tag: "Number", + annotations: { description: "number" }, + checks: [check({ type: "integer" }, { description: "integer" })] + }), + { + type: "integer", + description: "number", + allOf: [{ description: "integer" }] + } + ) + }) + }) + it("extracts nested number types without losing other allOf members", () => { const output = SchemaRepresentation.toJsonSchemaDocument({ representation: { diff --git a/repos/effect/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts b/repos/effect/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts index cf279e58d7..af9cf09a1a 100644 --- a/repos/effect/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts @@ -189,7 +189,8 @@ describe("SchemaRepresentation.toJsonSchemaMultiDocument", () => { assert.deepStrictEqual(output.definitions, { CallbackEncoded: { type: "string", - allOf: [{ minLength: 1, maxLength: 10 }] + minLength: 1, + maxLength: 10 } }) }) diff --git a/repos/effect/packages/effect/test/schema/representation/toRepresentation.test.ts b/repos/effect/packages/effect/test/schema/representation/toRepresentation.test.ts index 4c8ae33a13..6cd171bdfa 100644 --- a/repos/effect/packages/effect/test/schema/representation/toRepresentation.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/toRepresentation.test.ts @@ -419,7 +419,9 @@ describe("SchemaRepresentation.toRepresentation", () => { schemas: [shared.ast, shared.ast] } }) - const document = SchemaRepresentation.toRepresentation(Schema.String.check(filter).ast) + const document = SchemaRepresentation.toRepresentation(Schema.String.check(filter).ast, { + referencePolicy: ({ ast, occurrences }) => occurrences > 1 ? `${ast._tag}_` : undefined + }) const representation = document.representation assert.strictEqual(representation._tag, "String") @@ -521,211 +523,53 @@ describe("SchemaRepresentation.toRepresentation", () => { }) }) - describe("shared references", () => { - it("extracts shared Objects, Arrays, and Union schemas into references", () => { - const object = Schema.Struct({ value: Schema.String }) - const array = Schema.Array(Schema.Number) - const union = Schema.Union([Schema.Struct({ value: Schema.String }), Schema.Null]) + describe("reference policies", () => { + it("extracts only candidates with identifiers by default", () => { + const anonymous = Schema.Struct({ value: Schema.String }) + const identified = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Identified" }) const document = SchemaRepresentation.toRepresentation( - Schema.Tuple([object, object, array, array, union, union]).ast + Schema.Tuple([anonymous, identified]).ast ) - assert.deepStrictEqual(document, { - representation: { - _tag: "Arrays", - elements: [ - { type: { _tag: "Reference", $ref: "Objects_" }, isOptional: false }, - { type: { _tag: "Reference", $ref: "Objects_" }, isOptional: false }, - { type: { _tag: "Reference", $ref: "Arrays_" }, isOptional: false }, - { type: { _tag: "Reference", $ref: "Arrays_" }, isOptional: false }, - { type: { _tag: "Reference", $ref: "Union_" }, isOptional: false }, - { type: { _tag: "Reference", $ref: "Union_" }, isOptional: false } - ], - rest: [], - checks: [] - }, - references: { - Objects_: { - _tag: "Objects", - propertySignatures: [{ - name: "value", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - }], - indexSignatures: [], - checks: [] - }, - Arrays_: { - _tag: "Arrays", - elements: [], - rest: [{ _tag: "Number", checks: [] }], - checks: [] - }, - Union_: { - _tag: "Union", - types: [ - { - _tag: "Objects", - propertySignatures: [{ - name: "value", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - }], - indexSignatures: [], - checks: [] - }, - { _tag: "Null", checks: [] } - ], - mode: "anyOf", - checks: [] - } - } - }) - }) - - it("does not extract shared unions of leaf schemas", () => { - const union = Schema.Union([Schema.String, Schema.Number]) - const document = SchemaRepresentation.toRepresentation(Schema.Tuple([union, union]).ast) - - assert.deepStrictEqual(document.references, {}) + assert.deepStrictEqual(Object.keys(document.references), ["Identified"]) assert.strictEqual(document.representation._tag, "Arrays") if (document.representation._tag === "Arrays") { - assert.deepStrictEqual(document.representation.elements.map((element) => element.type._tag), ["Union", "Union"]) + assert.deepStrictEqual(document.representation.elements.map((element) => element.type._tag), [ + "Objects", + "Reference" + ]) } }) - it("extracts leaf schemas only when references are estimated to be cheaper", () => { - const smallUnion = Schema.Union([Schema.String, Schema.Number]) - const smallEnum = Schema.Enum({ A: "a", B: "b" }) - const largeEnum = Schema.Enum({ A: "a", B: "b", C: "c" }) - const smallTemplateLiteral = Schema.TemplateLiteral(["a", Schema.String]) - const largeTemplateLiteral = Schema.TemplateLiteral(["a", Schema.String, "b"]) - const shortLiteral = Schema.Literal("a".repeat(64)) - const longLiteral = Schema.Literal("a".repeat(65)) + it("allows a policy to inline an explicitly identified schema", () => { + const schema = Schema.String.annotate({ identifier: "Value" }) assert.deepStrictEqual( - Object.keys(SchemaRepresentation.toRepresentations([smallUnion.ast, smallUnion.ast]).references), - [] - ) - assert.deepStrictEqual( - Object.keys( - SchemaRepresentation.toRepresentations([smallUnion.ast, smallUnion.ast, smallUnion.ast]).references - ), - ["Union_"] - ) - assert.deepStrictEqual( - Object.keys(SchemaRepresentation.toRepresentations([smallEnum.ast, smallEnum.ast]).references), - [] - ) - assert.deepStrictEqual( - Object.keys(SchemaRepresentation.toRepresentations([largeEnum.ast, largeEnum.ast]).references), - ["Enum_"] - ) - assert.deepStrictEqual( - Object.keys( - SchemaRepresentation.toRepresentations([smallTemplateLiteral.ast, smallTemplateLiteral.ast]).references - ), - [] - ) - assert.deepStrictEqual( - Object.keys( - SchemaRepresentation.toRepresentations([largeTemplateLiteral.ast, largeTemplateLiteral.ast]).references - ), - ["TemplateLiteral_"] - ) - assert.deepStrictEqual( - Object.keys(SchemaRepresentation.toRepresentations([shortLiteral.ast, shortLiteral.ast]).references), - [] - ) - assert.deepStrictEqual( - Object.keys(SchemaRepresentation.toRepresentations([longLiteral.ast, longLiteral.ast]).references), - ["Literal_"] - ) - }) - - it("does not extract structurally equivalent schemas with distinct ASTs", () => { - const first = Schema.Struct({ value: Schema.String }) - const second = Schema.Struct({ value: Schema.String }) - const document = SchemaRepresentation.toRepresentation(Schema.Tuple([first, second]).ast) - - assert.deepStrictEqual(document.references, {}) - }) - - it("does not extract a child solely because its shared parent is reused", () => { - const child = Schema.Struct({ value: Schema.String }) - const parent = Schema.Struct({ child }) - const document = SchemaRepresentation.toRepresentation(Schema.Tuple([parent, parent]).ast) - - assert.deepStrictEqual(document.references, { - Objects_: { - _tag: "Objects", - propertySignatures: [{ - name: "child", - type: { - _tag: "Objects", - propertySignatures: [{ - name: "value", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - }], - indexSignatures: [], - checks: [] - }, - isOptional: false, - isMutable: false - }], - indexSignatures: [], - checks: [] + SchemaRepresentation.toRepresentation(schema.ast, { referencePolicy: () => undefined }), + { + representation: { + _tag: "String", + annotations: { identifier: "Value" }, + checks: [] + }, + references: {} } - }) - }) - - it("extracts shared Suspend schemas but not shared trivial schemas", () => { - const suspend = Schema.suspend(() => Schema.String) - const document = SchemaRepresentation.toRepresentation( - Schema.Tuple([Schema.String, Schema.String, suspend, suspend]).ast ) - - assert.deepStrictEqual(document.references, { - Suspend_: { - _tag: "Suspend", - checks: [], - thunk: { _tag: "String", checks: [] } - } - }) }) - it("extracts shared Declaration schemas", () => { - const declaration = Schema.declare((input): input is string => typeof input === "string") - const document = SchemaRepresentation.toRepresentation(Schema.Tuple([declaration, declaration]).ast) - - assert.deepStrictEqual(document, { - representation: { - _tag: "Arrays", - elements: [ - { - isOptional: false, - type: { _tag: "Reference", $ref: "Declaration_" } - }, - { - isOptional: false, - type: { _tag: "Reference", $ref: "Declaration_" } - } - ], - rest: [], - checks: [] - }, - references: { - Declaration_: { - _tag: "Declaration", - typeParameters: [], - checks: [] - } - } + it("forces a synthetic reference when a policy inlines a recursive schema", () => { + interface Node { + readonly next?: Node + } + const Node = Schema.Struct({ + next: Schema.optionalKey(Schema.suspend((): Schema.Codec => Node)) + }).annotate({ identifier: "Node" }) + const document = SchemaRepresentation.toRepresentation(Node.ast, { + referencePolicy: () => undefined }) + + assert.deepStrictEqual(document.representation, { _tag: "Reference", $ref: "Objects_" }) + assert.deepStrictEqual(Object.keys(document.references), ["Objects_"]) }) }) diff --git a/repos/effect/packages/effect/test/schema/representation/toRepresentations.test.ts b/repos/effect/packages/effect/test/schema/representation/toRepresentations.test.ts index 7897184e11..e55001ab5b 100644 --- a/repos/effect/packages/effect/test/schema/representation/toRepresentations.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/toRepresentations.test.ts @@ -135,30 +135,66 @@ describe("SchemaRepresentation.toRepresentations", () => { }) assert.deepStrictEqual(reversed.references, forward.references) }) + }) - it("shares an anonymous non-trivial schema between roots", () => { + describe("reference policies", () => { + it("supports policies based on occurrence counts", () => { const shared = Schema.Struct({ value: Schema.String }) - const document = SchemaRepresentation.toRepresentations([shared.ast, shared.ast]) + const equivalent = Schema.Struct({ value: Schema.String }) + const referencePolicy: SchemaRepresentation.ReferencePolicy = ({ ast, occurrences }) => + occurrences > 1 ? `${ast._tag}_` : undefined - assert.deepStrictEqual(document, { - representations: [ - { _tag: "Reference", $ref: "Objects_" }, - { _tag: "Reference", $ref: "Objects_" } - ], - references: { - Objects_: { - _tag: "Objects", - propertySignatures: [{ - name: "value", - type: { _tag: "String", checks: [] }, - isOptional: false, - isMutable: false - }], - indexSignatures: [], - checks: [] - } + const single = SchemaRepresentation.toRepresentations([shared.ast], { referencePolicy }) + const distinct = SchemaRepresentation.toRepresentations([shared.ast, equivalent.ast], { + referencePolicy: ({ ast, occurrences }) => + ast._tag === "Objects" && occurrences > 1 ? `${ast._tag}_` : undefined + }) + const repeated = SchemaRepresentation.toRepresentations([shared.ast, shared.ast], { referencePolicy }) + + assert.deepStrictEqual(single.references, {}) + assert.deepStrictEqual(distinct.references, {}) + assert.deepStrictEqual(repeated.representations, [ + { _tag: "Reference", $ref: "Objects_" }, + { _tag: "Reference", $ref: "Objects_" } + ]) + assert.deepStrictEqual(Object.keys(repeated.references), ["Objects_"]) + }) + + it("suffixes policy name collisions", () => { + const first = Schema.Struct({ first: Schema.String }) + const second = Schema.Struct({ second: Schema.String }) + const document = SchemaRepresentation.toRepresentations([first.ast, second.ast], { + referencePolicy: ({ ast }) => ast._tag === "Objects" ? "Model" : undefined + }) + + assert.deepStrictEqual(document.representations, [ + { _tag: "Reference", $ref: "Model" }, + { _tag: "Reference", $ref: "Model_1" } + ]) + assert.deepStrictEqual(Object.keys(document.references), ["Model", "Model_1"]) + }) + + it("distinguishes identifiers that share the same encoded AST owner", () => { + const first = Schema.NumberFromString.annotate({ identifier: "First" }) + const second = Schema.NumberFromString.annotate({ identifier: "Second" }) + const inputs: Array = [] + const document = SchemaRepresentation.toRepresentations([first.ast, second.ast], { + referencePolicy: (input) => { + inputs.push(input) + return input.identifier } }) + + assert.deepStrictEqual(document.representations, [ + { _tag: "Reference", $ref: "FirstEncoded" }, + { _tag: "Reference", $ref: "SecondEncoded" } + ]) + assert.deepStrictEqual(Object.keys(document.references), ["FirstEncoded", "SecondEncoded"]) + assert.deepStrictEqual(inputs.map(({ identifier, occurrences }) => ({ identifier, occurrences })), [ + { identifier: "FirstEncoded", occurrences: 1 }, + { identifier: "SecondEncoded", occurrences: 1 } + ]) + assert.strictEqual(inputs[0].ast, inputs[1].ast) }) }) diff --git a/repos/effect/packages/effect/test/schema/toCodec.test.ts b/repos/effect/packages/effect/test/schema/toCodec.test.ts index 0a854d4151..8e32c0626d 100644 --- a/repos/effect/packages/effect/test/schema/toCodec.test.ts +++ b/repos/effect/packages/effect/test/schema/toCodec.test.ts @@ -376,6 +376,21 @@ describe("Serializers", () => { }) describe("checks", () => { + it("runs source checks once per direction", () => { + let executions = 0 + const codec = Schema.toCodecJson(Schema.Number.check(Schema.makeFilter(() => { + executions++ + return undefined + }))) + + Schema.decodeUnknownSync(codec)(1) + strictEqual(executions, 1) + + executions = 0 + Schema.encodeUnknownSync(codec)(1) + strictEqual(executions, 1) + }) + it("Finite", async () => { const schema = Schema.Finite const asserts = new TestSchema.Asserts(Schema.toCodecJson(schema)) @@ -2747,6 +2762,18 @@ Expected "Infinity" | "-Infinity" | "NaN"` }) describe("toCodecArrayFromSingle", () => { + it("preserves union fallback when a singleton fails array element checks", async () => { + const schema = Schema.toCodecArrayFromSingle(Schema.toCodecStringTree(Schema.Union([ + Schema.Array(Schema.String.check(Schema.isMinLength(2))), + Schema.String + ]))) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.succeed("a", "a") + await decoding.succeed("ab", ["ab"]) + }) + it("accepts string and array inputs for a top-level array", async () => { const serializer = Schema.toCodecArrayFromSingle(Schema.toCodecStringTree(Schema.Array(Schema.Finite))) strictEqual(serializer.ast._tag, "Arrays") diff --git a/repos/effect/packages/effect/test/schema/toJsonSchemaDocument.test.ts b/repos/effect/packages/effect/test/schema/toJsonSchemaDocument.test.ts index 333e73b057..82fb3edece 100644 --- a/repos/effect/packages/effect/test/schema/toJsonSchemaDocument.test.ts +++ b/repos/effect/packages/effect/test/schema/toJsonSchemaDocument.test.ts @@ -99,97 +99,6 @@ describe("toJsonSchemaDocument", () => { ) }) - describe("reference extraction", () => { - it("preserves shared non-trivial schemas with references", () => { - const shared = Schema.Struct({ value: Schema.String }) - - assertJsonSchemaDocument( - Schema.Struct({ left: shared, right: shared }), - { - schema: { - type: "object", - properties: { - left: { $ref: "#/$defs/Objects_" }, - right: { $ref: "#/$defs/Objects_" } - }, - required: ["left", "right"], - additionalProperties: false - }, - definitions: { - Objects_: { - type: "object", - properties: { - value: { type: "string" } - }, - required: ["value"], - additionalProperties: false - } - } - } - ) - }) - - it("preserves repeated optional structural schemas with references", () => { - const shared = Schema.Struct({ value: Schema.String }) - - assertJsonSchemaDocument( - Schema.Struct({ left: Schema.optional(shared), right: Schema.optional(shared) }), - { - schema: { - type: "object", - properties: { - left: { $ref: "#/$defs/Union_" }, - right: { $ref: "#/$defs/Union_" } - }, - additionalProperties: false - }, - definitions: { - Union_: { - anyOf: [ - { - type: "object", - properties: { - value: { type: "string" } - }, - required: ["value"], - additionalProperties: false - }, - { type: "null" } - ] - } - } - } - ) - }) - - it("inlines shared canonical unions of leaf schemas", () => { - assertJsonSchemaDocument( - Schema.Struct({ left: Schema.Number, right: Schema.Number }), - { - schema: { - type: "object", - properties: { - left: { - anyOf: [ - { type: "number" }, - { type: "string", enum: ["Infinity", "-Infinity", "NaN"] } - ] - }, - right: { - anyOf: [ - { type: "number" }, - { type: "string", enum: ["Infinity", "-Infinity", "NaN"] } - ] - } - }, - required: ["left", "right"], - additionalProperties: false - } - } - ) - }) - }) - describe("options", () => { it("generateDescriptions: true", () => { assertJsonSchemaDocument( @@ -674,9 +583,7 @@ describe("toJsonSchemaDocument", () => { }, "value": { "type": "string", - "allOf": [ - { "pattern": "^-?\\d+$" } - ] + "pattern": "^-?\\d+$" } }, "required": ["_tag", "value"], @@ -838,9 +745,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "allOf": [ - { "pattern": "^-?\\d+$" } - ] + "pattern": "^-?\\d+$" } } ) @@ -853,9 +758,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "allOf": [ - { "pattern": "^Symbol\\((.*)\\)$" } - ] + "pattern": "^Symbol\\((.*)\\)$" } } ) @@ -868,9 +771,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "allOf": [ - { "pattern": "^Symbol\\((.*)\\)$" } - ] + "pattern": "^Symbol\\((.*)\\)$" } } ) @@ -961,9 +862,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "allOf": [ - { "minLength": 2 } - ] + "minLength": 2 } } ) @@ -987,9 +886,7 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "string", "description": "a", - "allOf": [ - { "minLength": 2 } - ] + "minLength": 2 } } ) @@ -1003,9 +900,8 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "allOf": [ - { "minLength": 2, "description": "a" } - ] + "minLength": 2, + "description": "a" } } ) @@ -1017,10 +913,8 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "allOf": [ - { "minLength": 2 }, - { "maxLength": 3 } - ] + "minLength": 2, + "maxLength": 3 } } ) @@ -1033,10 +927,8 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "string", "description": "a", - "allOf": [ - { "minLength": 2 }, - { "maxLength": 3 } - ] + "minLength": 2, + "maxLength": 3 } } ) @@ -1050,15 +942,9 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "allOf": [ - { - "minLength": 2 - }, - { - "maxLength": 3, - "description": "a" - } - ] + "minLength": 2, + "maxLength": 3, + "description": "a" } } ) @@ -1074,10 +960,8 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "string", "description": "a", + "minLength": 2, "allOf": [ - { - "minLength": 2 - }, { "maxLength": 3, "description": "c" @@ -1097,11 +981,9 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", + "minLength": 2, + "description": "b", "allOf": [ - { - "minLength": 2, - "description": "b" - }, { "maxLength": 3, "description": "c" @@ -1142,9 +1024,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument(Schema.String.check(Schema.isPattern(/^abb+$/)), { schema: { "type": "string", - "allOf": [ - { "pattern": "^abb+$" } - ] + "pattern": "^abb+$" } }) }) @@ -1160,7 +1040,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument(Schema.String.check(check), { schema: { "type": "string", - "allOf": [{ pattern }] + pattern } }) } @@ -1171,9 +1051,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument(schema, { schema: { "type": "string", - "allOf": [ - { "pattern": "^\\S[\\s\\S]*\\S$|^\\S$|^$" } - ] + "pattern": "^\\S[\\s\\S]*\\S$|^\\S$|^$" } }) }) @@ -1183,9 +1061,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument(schema, { schema: { "type": "string", - "allOf": [ - { "pattern": "^[^A-Z]*$" } - ] + "pattern": "^[^A-Z]*$" } }) }) @@ -1195,9 +1071,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument(schema, { schema: { "type": "string", - "allOf": [ - { "pattern": "^[^a-z]*$" } - ] + "pattern": "^[^a-z]*$" } }) }) @@ -1207,9 +1081,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument(schema, { schema: { "type": "string", - "allOf": [ - { "pattern": "^[^a-z]?.*$" } - ] + "pattern": "^[^a-z]?.*$" } }) }) @@ -1219,9 +1091,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument(schema, { schema: { "type": "string", - "allOf": [ - { "pattern": "^[^A-Z]?.*$" } - ] + "pattern": "^[^A-Z]?.*$" } }) }) @@ -1290,9 +1160,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "allOf": [ - { "minLength": 2 } - ] + "minLength": 2 } } ) @@ -1307,9 +1175,7 @@ describe("toJsonSchemaDocument", () => { "items": { "type": "string" }, - "allOf": [ - { "minItems": 2 } - ] + "minItems": 2 } } ) @@ -1344,9 +1210,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "string", - "allOf": [ - { "maxLength": 2 } - ] + "maxLength": 2 } } ) @@ -1361,9 +1225,7 @@ describe("toJsonSchemaDocument", () => { "items": { "type": "string" }, - "allOf": [ - { "maxItems": 2 } - ] + "maxItems": 2 } } ) @@ -1375,16 +1237,14 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "array", + "maxItems": 2, "minItems": 1, "prefixItems": [{ "type": "string" }], "items": { "type": "string" - }, - "allOf": [ - { "maxItems": 2 } - ] + } } } ) @@ -1398,13 +1258,9 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "string", "description": "description", - "allOf": [ - { - "format": "uuid", - "pattern": - "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$" - } - ] + "format": "uuid", + "pattern": + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$" } } ) @@ -1417,11 +1273,7 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "string", "description": "description", - "allOf": [ - { - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" - } - ] + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" } } ) @@ -1434,9 +1286,7 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "string", "description": "description", - "allOf": [ - { "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$" } - ] + "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$" } } ) @@ -1449,9 +1299,7 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "string", "description": "description", - "allOf": [ - { "pattern": "^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$" } - ] + "pattern": "^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$" } } ) @@ -1526,9 +1374,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "number", - "allOf": [{ - "description": "a" - }] + "description": "a" } } ) @@ -1614,9 +1460,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "number", - "allOf": [ - { "exclusiveMinimum": 1 } - ] + "exclusiveMinimum": 1 } } ) @@ -1628,9 +1472,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "number", - "allOf": [ - { "minimum": 1 } - ] + "minimum": 1 } } ) @@ -1640,9 +1482,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument(Schema.Finite.check(Schema.isLessThan(1)), { schema: { "type": "number", - "allOf": [ - { "exclusiveMaximum": 1 } - ] + "exclusiveMaximum": 1 } }) }) @@ -1651,9 +1491,7 @@ describe("toJsonSchemaDocument", () => { assertJsonSchemaDocument(Schema.Finite.check(Schema.isLessThanOrEqualTo(1)), { schema: { "type": "number", - "allOf": [ - { "maximum": 1 } - ] + "maximum": 1 } }) }) @@ -1664,9 +1502,8 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "number", - "allOf": [ - { "minimum": 1, "maximum": 10 } - ] + "minimum": 1, + "maximum": 10 } } ) @@ -1677,9 +1514,8 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "number", - "allOf": [ - { "exclusiveMinimum": 1, "maximum": 10 } - ] + "exclusiveMinimum": 1, + "maximum": 10 } } ) @@ -1690,9 +1526,8 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "number", - "allOf": [ - { "minimum": 1, "exclusiveMaximum": 10 } - ] + "minimum": 1, + "exclusiveMaximum": 10 } } ) @@ -1703,9 +1538,8 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "number", - "allOf": [ - { "exclusiveMinimum": 1, "exclusiveMaximum": 10 } - ] + "exclusiveMinimum": 1, + "exclusiveMaximum": 10 } } ) @@ -1717,9 +1551,7 @@ describe("toJsonSchemaDocument", () => { { schema: { "type": "integer", - "allOf": [ - { "multipleOf": 2 } - ] + "multipleOf": 2 } } ) @@ -2906,9 +2738,7 @@ describe("toJsonSchemaDocument", () => { "type": "object", "additionalProperties": { "type": "number", - "allOf": [{ - "description": "v" - }] + "description": "v" }, "description": "r" } @@ -2916,8 +2746,8 @@ describe("toJsonSchemaDocument", () => { ) }) - it("Record(String, Json)", () => { - const schema = Schema.Record(Schema.String, Schema.Json) + it("JsonObject", () => { + const schema = Schema.JsonObject assertJsonSchemaDocument( schema, { @@ -2985,9 +2815,7 @@ describe("toJsonSchemaDocument", () => { "additionalProperties": { "type": "number" }, - "allOf": [ - { "minProperties": 2 } - ] + "minProperties": 2 } } ) @@ -3000,7 +2828,7 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "object", "additionalProperties": { "type": "number" }, - "allOf": [{ "maxProperties": 2 }] + "maxProperties": 2 } } ) @@ -3013,7 +2841,8 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "object", "additionalProperties": { "type": "number" }, - "allOf": [{ "minProperties": 2, "maxProperties": 2 }] + "minProperties": 2, + "maxProperties": 2 } } ) @@ -3021,27 +2850,50 @@ describe("toJsonSchemaDocument", () => { }) }) - it("StructWithRest", () => { - assertJsonSchemaDocument( - Schema.StructWithRest(Schema.Struct({ a: Schema.String }), [ - Schema.Record(Schema.String, Schema.Union([Schema.Finite, Schema.String])) - ]), - { - schema: { - "type": "object", - "properties": { - "a": { "type": "string" } - }, - "additionalProperties": { - "anyOf": [ - { "type": "number" }, - { "type": "string" } - ] - }, - "required": ["a"] + describe("StructWithRest", () => { + it("property and string index", () => { + assertJsonSchemaDocument( + Schema.StructWithRest(Schema.Struct({ a: Schema.String }), [ + Schema.Record(Schema.String, Schema.Union([Schema.Finite, Schema.String])) + ]), + { + schema: { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "allOf": [{ + "type": "object", + "additionalProperties": { + "anyOf": [ + { "type": "number" }, + { "type": "string" } + ] + } + }], + "required": ["a"] + } } - } - ) + ) + }) + + it("pattern and string indexes", () => { + assertJsonSchemaDocument( + Schema.StructWithRest(Schema.Struct({}), [ + Schema.Record(Schema.String.check(Schema.isUppercased()), Schema.Finite), + Schema.Record(Schema.String, Schema.Boolean) + ]), + { + schema: { + type: "object", + patternProperties: { + "^[^a-z]*$": { type: "number" } + }, + allOf: [{ type: "object", additionalProperties: { type: "boolean" } }] + } + } + ) + }) }) describe("Tuple", () => { @@ -3292,9 +3144,7 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "array", "items": { "type": "string" }, - "allOf": [ - { "minItems": 2 } - ] + "minItems": 2 } } ) @@ -3307,9 +3157,7 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "array", "items": { "type": "string" }, - "allOf": [ - { "maxItems": 2 } - ] + "maxItems": 2 } } ) @@ -3338,9 +3186,7 @@ describe("toJsonSchemaDocument", () => { schema: { "type": "array", "items": { "type": "string" }, - "allOf": [ - { "uniqueItems": true } - ] + "uniqueItems": true } } ) @@ -3460,9 +3306,7 @@ describe("toJsonSchemaDocument", () => { "anyOf": [ { "type": "string", - "allOf": [ - { "pattern": "^-?\\d+$" } - ] + "pattern": "^-?\\d+$" }, { "type": "string" } ] diff --git a/repos/effect/packages/effect/test/schema/toStandardJSONSchemaV1.test.ts b/repos/effect/packages/effect/test/schema/toStandardJSONSchemaV1.test.ts index 05e2a0ef95..f57a476e75 100644 --- a/repos/effect/packages/effect/test/schema/toStandardJSONSchemaV1.test.ts +++ b/repos/effect/packages/effect/test/schema/toStandardJSONSchemaV1.test.ts @@ -1,6 +1,6 @@ import { assertTrue, deepStrictEqual } from "@effect/vitest/utils" -import type { StandardJSONSchemaV1 } from "@standard-schema/spec" import { Schema } from "effect" +import type { StandardJSONSchemaV1 } from "effect/StandardSchema" import { describe, it } from "vitest" function standardConvertToJSONSchemaInput( diff --git a/repos/effect/packages/effect/test/schema/toStandardSchemaV1.test.ts b/repos/effect/packages/effect/test/schema/toStandardSchemaV1.test.ts index b4d7b949a9..88ee43577f 100644 --- a/repos/effect/packages/effect/test/schema/toStandardSchemaV1.test.ts +++ b/repos/effect/packages/effect/test/schema/toStandardSchemaV1.test.ts @@ -1,6 +1,6 @@ import { assertTrue, deepStrictEqual, strictEqual } from "@effect/vitest/utils" -import type { StandardSchemaV1 } from "@standard-schema/spec" import { Context, Effect, Option, Predicate, Schema, SchemaGetter, SchemaIssue } from "effect" +import type { StandardSchemaV1 } from "effect/StandardSchema" import { describe, it } from "vitest" function validate( diff --git a/repos/effect/packages/effect/test/unstable/ai/AiError.test.ts b/repos/effect/packages/effect/test/unstable/ai/AiError.test.ts index 5d751a2e4a..4e0e367ad4 100644 --- a/repos/effect/packages/effect/test/unstable/ai/AiError.test.ts +++ b/repos/effect/packages/effect/test/unstable/ai/AiError.test.ts @@ -64,6 +64,36 @@ describe("AiError", () => { const error = new AiError.AuthenticationError({ kind: "Unknown" }) assert.strictEqual(error._tag, "AuthenticationError") }) + + it("should render the description after the kind suggestion when present", () => { + const error = new AiError.AuthenticationError({ + kind: "InsufficientPermissions", + description: "anthropic.claude-sonnet-5 is not available for this account" + }) + assert.strictEqual( + error.message, + "InsufficientPermissions: Your API key lacks required permissions. anthropic.claude-sonnet-5 is not available for this account" + ) + }) + + it("should render the message unchanged when description is absent", () => { + const error = new AiError.AuthenticationError({ kind: "InsufficientPermissions" }) + assert.strictEqual( + error.message, + "InsufficientPermissions: Your API key lacks required permissions" + ) + assert.isUndefined(error.description) + }) + + it("should round-trip the description through the schema", () => { + const error = new AiError.AuthenticationError({ + kind: "ExpiredKey", + description: "The security token included in the request is expired" + }) + const encoded = Schema.encodeUnknownSync(AiError.AuthenticationError)(error) + const decoded = Schema.decodeUnknownSync(AiError.AuthenticationError)(encoded) + assert.strictEqual(decoded.description, "The security token included in the request is expired") + }) }) describe("ContentPolicyError", () => { @@ -552,6 +582,18 @@ describe("AiError", () => { } }) + it("should thread the description into 401/403 AuthenticationError", () => { + const reason = AiError.reasonFromHttpStatus({ + status: 403, + description: "User is not authorized to perform: bedrock:InvokeModel" + }) + assert.strictEqual(reason._tag, "AuthenticationError") + if (reason._tag === "AuthenticationError") { + assert.strictEqual(reason.description, "User is not authorized to perform: bedrock:InvokeModel") + assert.include(reason.message, "User is not authorized to perform: bedrock:InvokeModel") + } + }) + it("should map 429 to RateLimitError", () => { const reason = AiError.reasonFromHttpStatus({ status: 429 }) assert.strictEqual(reason._tag, "RateLimitError") diff --git a/repos/effect/packages/effect/test/unstable/ai/AnthropicStructuredOutput.test.ts b/repos/effect/packages/effect/test/unstable/ai/AnthropicStructuredOutput.test.ts index 3df2d58d3e..c856acc037 100644 --- a/repos/effect/packages/effect/test/unstable/ai/AnthropicStructuredOutput.test.ts +++ b/repos/effect/packages/effect/test/unstable/ai/AnthropicStructuredOutput.test.ts @@ -116,9 +116,7 @@ describe("toCodecAnthropic", () => { assertJsonSchema(Schema.String.check(Schema.isStartsWith("a")), { "type": "string", "description": `a string starting with "a"`, - "allOf": [ - { "pattern": "^a" } - ] + "pattern": "^a" }) }) @@ -126,8 +124,8 @@ describe("toCodecAnthropic", () => { assertJsonSchema(Schema.String.check(Schema.isStartsWith("a"), Schema.isEndsWith("b")), { "type": "string", "description": `a string starting with "a" and a string ending with "b"`, + "pattern": "^a", "allOf": [ - { "pattern": "^a" }, { "pattern": "b$" } ] }) @@ -137,9 +135,7 @@ describe("toCodecAnthropic", () => { assertJsonSchema(Schema.String.check(Schema.isPattern(/^a/)), { "type": "string", "description": `a string matching the RegExp ^a`, - "allOf": [ - { "pattern": "^a" } - ] + "pattern": "^a" }) }) @@ -147,9 +143,7 @@ describe("toCodecAnthropic", () => { assertJsonSchema(Schema.String.check(Schema.isPattern(/^a/, { description: "description" })), { "type": "string", "description": "description", - "allOf": [ - { "pattern": "^a" } - ] + "pattern": "^a" }) }) }) diff --git a/repos/effect/packages/effect/test/unstable/ai/AnthropicStructuredOutputRepresentation.test.ts b/repos/effect/packages/effect/test/unstable/ai/AnthropicStructuredOutputRepresentation.test.ts index aaf5840090..4a9bc7ec9e 100644 --- a/repos/effect/packages/effect/test/unstable/ai/AnthropicStructuredOutputRepresentation.test.ts +++ b/repos/effect/packages/effect/test/unstable/ai/AnthropicStructuredOutputRepresentation.test.ts @@ -21,7 +21,7 @@ describe("AnthropicStructuredOutput representation v2", () => { assert.deepStrictEqual(toCodecAnthropic(schema).jsonSchema, { type: "string", description: "starts with a", - allOf: [{ pattern: "^a" }] + pattern: "^a" }) }) @@ -57,7 +57,7 @@ describe("AnthropicStructuredOutput representation v2", () => { assert.deepStrictEqual(toCodecAnthropic(schema).jsonSchema, { type: "string", - allOf: [{ pattern: "^a" }] + pattern: "^a" }) }) @@ -94,7 +94,7 @@ describe("AnthropicStructuredOutput representation v2", () => { assert.deepStrictEqual(toCodecAnthropic(schema).jsonSchema, { type: "string", - allOf: [{ pattern: "^a" }] + pattern: "^a" }) assert.strictEqual(invocations, 1) }) diff --git a/repos/effect/packages/effect/test/unstable/ai/LanguageModel.test.ts b/repos/effect/packages/effect/test/unstable/ai/LanguageModel.test.ts index d43c682829..3aa389221d 100644 --- a/repos/effect/packages/effect/test/unstable/ai/LanguageModel.test.ts +++ b/repos/effect/packages/effect/test/unstable/ai/LanguageModel.test.ts @@ -19,6 +19,17 @@ const MyToolkitLayer = MyToolkit.toLayer({ ) }) +const TransformTool = Tool.make("TransformTool", { + parameters: Schema.FiniteFromString, + success: Schema.Finite +}) + +const TransformToolkit = Toolkit.make(TransformTool) + +const TransformToolkitLayer = TransformToolkit.toLayer({ + TransformTool: (value) => Effect.succeed(value * 2) +}) + const ApprovalTool = Tool.make("ApprovalTool", { parameters: Schema.Struct({ action: Schema.String }), success: Schema.Struct({ result: Schema.String }), @@ -49,7 +60,112 @@ describe("LanguageModel", () => { response: undefined } + describe("generateText", () => { + it.effect("validates encoded tool parameters when tool call resolution is disabled", () => + Effect.gen(function*() { + const error = yield* LanguageModel.generateText({ + prompt: [], + toolkit: TransformToolkit, + disableToolCallResolution: true + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "tool-invalid-transform", + name: "TransformTool", + params: { invalid: true } + }] + }), + Effect.provide(TransformToolkitLayer), + Effect.flip + ) + + strictEqual(error.reason._tag, "InvalidOutputError") + })) + + it.effect("preserves encoded tool parameters when tool call resolution is disabled", () => + Effect.gen(function*() { + const response = yield* LanguageModel.generateText({ + prompt: [], + toolkit: TransformToolkit, + disableToolCallResolution: true + }) + const toolCall = response.toolCalls[0]! + + strictEqual(toolCall.params, "21") + + const toolkit = yield* TransformToolkit + const results = yield* toolkit.handle(toolCall.name, toolCall.params).pipe( + Effect.flatMap(Stream.runCollect) + ) + + strictEqual(results[0].result, 42) + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "tool-transform", + name: "TransformTool", + params: "21" + }] + }), + Effect.provide(TransformToolkitLayer) + )) + }) + describe("streamText", () => { + it.effect("validates encoded tool parameters when tool call resolution is disabled", () => + Effect.gen(function*() { + const error = yield* LanguageModel.streamText({ + prompt: [], + toolkit: TransformToolkit, + disableToolCallResolution: true + }).pipe( + Stream.runDrain, + TestUtils.withLanguageModel({ + streamText: [{ + type: "tool-call", + id: "tool-invalid-transform", + name: "TransformTool", + params: { invalid: true } + }] + }), + Effect.provide(TransformToolkitLayer), + Effect.flip + ) + + strictEqual(error.reason._tag, "InvalidOutputError") + })) + + it.effect("preserves encoded tool parameters when tool call resolution is disabled", () => + Effect.gen(function*() { + const parts = yield* LanguageModel.streamText({ + prompt: [], + toolkit: TransformToolkit, + disableToolCallResolution: true + }).pipe(Stream.runCollect) + const toolCall = parts.find((part) => part.type === "tool-call")! + + strictEqual(toolCall.params, "21") + + const toolkit = yield* TransformToolkit + const results = yield* toolkit.handle(toolCall.name, toolCall.params).pipe( + Effect.flatMap(Stream.runCollect) + ) + + strictEqual(results[0].result, 42) + }).pipe( + TestUtils.withLanguageModel({ + streamText: [{ + type: "tool-call", + id: "tool-transform", + name: "TransformTool", + params: "21" + }] + }), + Effect.provide(TransformToolkitLayer) + )) + it.effect("should emit tool calls before executing tool handlers", () => Effect.gen(function*() { const parts: Array>> = [] diff --git a/repos/effect/packages/effect/test/unstable/ai/LanguageModelRepresentation.test.ts b/repos/effect/packages/effect/test/unstable/ai/LanguageModelRepresentation.test.ts index 69e5936f84..124a829642 100644 --- a/repos/effect/packages/effect/test/unstable/ai/LanguageModelRepresentation.test.ts +++ b/repos/effect/packages/effect/test/unstable/ai/LanguageModelRepresentation.test.ts @@ -18,7 +18,7 @@ describe("LanguageModel representation v2", () => { assert.deepStrictEqual(LanguageModel.defaultCodecTransformer(schema).jsonSchema, { type: "string", - allOf: [{ minLength: 2 }] + minLength: 2 }) }) }) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/Lifecycle.test.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/Lifecycle.test.ts deleted file mode 100644 index 67e4f5857e..0000000000 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/Lifecycle.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { assert, describe, it } from "@effect/vitest" -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as Schema from "effect/Schema" -import * as McpSchema from "effect/unstable/ai/McpSchema" -import * as McpServer from "effect/unstable/ai/McpServer" -import * as Tool from "effect/unstable/ai/Tool" -import * as Toolkit from "effect/unstable/ai/Toolkit" -import { makeRawHttpHarness, makeServerLayer } from "./utils.ts" - -const ServerLayer = makeServerLayer({ name: "LifecycleServer" }) -const makeHarness = makeRawHttpHarness(ServerLayer) - -const initializeRequest = (protocolVersion: string, id = 1) => ({ - jsonrpc: "2.0", - id, - method: "initialize", - params: { - protocolVersion, - capabilities: {}, - clientInfo: { - name: "LifecycleClient", - version: "1.0.0" - } - } -}) - -const initializedNotification = { - jsonrpc: "2.0", - method: "notifications/initialized" -} - -const pingRequest = { - jsonrpc: "2.0", - id: 2, - method: "ping", - params: {} -} - -const InitializeResponse = Schema.Struct({ - jsonrpc: Schema.Literal("2.0"), - id: Schema.Number, - result: McpSchema.InitializeResult -}) - -const ErrorResponse = Schema.Struct({ - jsonrpc: Schema.Literal("2.0"), - id: Schema.NullOr(Schema.Number), - error: McpSchema.McpError -}) - -const decodeInitializeResponse = Schema.decodeUnknownEffect(InitializeResponse) -const decodeErrorResponse = Schema.decodeUnknownEffect(ErrorResponse) - -type Post = (body: unknown, headers?: HeadersInit) => Effect.Effect - -const initialize = Effect.fnUntraced(function*( - post: Post, - protocolVersion: string, - id = 1 -) { - const response = yield* post(initializeRequest(protocolVersion, id)) - const body = yield* Effect.promise(() => response.json()) - return { - response, - message: yield* decodeInitializeResponse(body) - } as const -}) - -const TestTool = Tool.make("TestTool", { - success: Schema.String -}) -const TestToolkit = Toolkit.make(TestTool) -const TestToolkitLayer = McpServer.toolkit(TestToolkit).pipe( - Layer.provide(TestToolkit.toLayer({ - TestTool: () => Effect.succeed("ok") - })) -) -const FeaturesServerLayer = Layer.mergeAll( - TestToolkitLayer, - McpServer.resource({ - uri: "file:///test", - name: "TestResource", - content: Effect.succeed("test") - }), - McpServer.prompt({ - name: "TestPrompt", - content: () => Effect.succeed("test") - }) -).pipe( - Layer.provide(makeServerLayer({ - name: "LifecycleServer", - extensions: { "example/lifecycle": { enabled: true } } - })) -) - -describe("McpServer initialization", () => { - describe("2025-11-25", () => { - describe("Lifecycle", () => { - describe("1. Lifecycle Phases", () => { - describe("1.1 Initialization", () => { - it.effect("requires initialize to be the first request", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const response = yield* post(pingRequest) - - assert.isAtLeast(response.status, 400) - })) - - it.effect("rejects initialized notifications before initialize", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const response = yield* post(initializedNotification) - - assert.isAtLeast(response.status, 400) - })) - - it.effect("requires protocolVersion, capabilities, and clientInfo", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const invalidParams = [ - { - capabilities: {}, - clientInfo: { name: "LifecycleClient", version: "1.0.0" } - }, - { - protocolVersion: "2025-11-25", - clientInfo: { name: "LifecycleClient", version: "1.0.0" } - }, - { - protocolVersion: "2025-11-25", - capabilities: {} - } - ] - - for (let i = 0; i < invalidParams.length; i++) { - const response = yield* post({ - jsonrpc: "2.0", - id: i + 1, - method: "initialize", - params: invalidParams[i] - }) - const body = yield* Effect.promise(() => response.json()) - const error = yield* decodeErrorResponse(body) - - assert.strictEqual(error.id, i + 1) - assert.isNumber(error.error.code) - assert.isNull(response.headers.get("Mcp-Session-Id")) - } - })) - - it.effect("returns server capabilities and implementation information", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const { message, response } = yield* initialize(post, "2025-11-25") - - assert.strictEqual(response.status, 200) - assert.strictEqual(message.id, 1) - assert.deepStrictEqual(message.result.capabilities, { - completions: {}, - logging: {} - }) - assert.deepStrictEqual(message.result.serverInfo, { - name: "LifecycleServer", - version: "1.0.0" - }) - const sessionId = response.headers.get("Mcp-Session-Id") - assert.isNotNull(sessionId) - assert.match(sessionId, /^[\x21-\x7e]+$/) - })) - - it.effect("accepts initialized after a successful initialize response", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const initialized = yield* initialize(post, "2025-11-25") - const sessionId = initialized.response.headers.get("Mcp-Session-Id") - assert.isNotNull(sessionId) - - const response = yield* post(initializedNotification, { - "Mcp-Session-Id": sessionId, - "Mcp-Protocol-Version": initialized.message.result.protocolVersion - }) - - assert.strictEqual(response.status, 202) - assert.strictEqual(yield* Effect.promise(() => response.text()), "") - })) - }) - - describe("1.1.1 Version Negotiation", () => { - it.effect("echoes a requested version supported by the server", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const { message } = yield* initialize(post, "2025-06-18") - - assert.strictEqual(message.result.protocolVersion, "2025-06-18") - })) - - it.effect("negotiates an unsupported requested version to the latest supported version", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const { message } = yield* initialize(post, "2025-11-25") - - assert.strictEqual(message.result.protocolVersion, "2025-06-18") - })) - }) - - describe("1.1.2 Capability Negotiation", () => { - it.effect("advertises the capabilities provided by the server", () => - Effect.gen(function*() { - const { post } = yield* makeRawHttpHarness(FeaturesServerLayer) - const { message } = yield* initialize(post, "2025-11-25") - - assert.deepStrictEqual(message.result.capabilities, { - completions: {}, - extensions: { "example/lifecycle": { enabled: true } }, - logging: {}, - prompts: { listChanged: true }, - resources: { listChanged: true, subscribe: false }, - tools: { listChanged: true } - }) - })) - }) - - describe("1.2 Operation", () => { - it.effect("continues to use the version negotiated during initialization", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const initialized = yield* initialize(post, "2025-06-18") - const sessionId = initialized.response.headers.get("Mcp-Session-Id") - assert.isNotNull(sessionId) - - const response = yield* post(pingRequest, { - "Mcp-Session-Id": sessionId, - "Mcp-Protocol-Version": initialized.message.result.protocolVersion - }) - - assert.strictEqual(response.status, 200) - assert.strictEqual(response.headers.get("Mcp-Protocol-Version"), "2025-06-18") - })) - }) - }) - - describe("3. Error Handling", () => { - it.effect("handles protocol version mismatch through version negotiation", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const { message } = yield* initialize(post, "invalid-version") - - assert.strictEqual(message.result.protocolVersion, "2025-06-18") - })) - }) - }) - }) -}) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts index c2eaa2059e..9218120ca4 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts @@ -47,16 +47,19 @@ const completeRaw = ( export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Completion", () => { - // Shared by the 2024-11-05, 2025-03-26, and 2025-06-18 specifications, - // except completion context, which was added in 2025-06-18. + // Shared by the 2025-03-26 and 2025-06-18 specifications, except + // completion context, which was added in 2025-06-18. describe("Capabilities", () => { - it.effect("MUST advertise completions when argument completion is supported", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - - assert.property(initialized.message.result.capabilities, "completions") - })) + it.effect.skipIf(["2024-11-05"].includes(protocol.protocolVersion))( + "MUST advertise completions when argument completion is supported", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + + assert.property(initialized.message.result.capabilities, "completions") + }) + ) }) describe("Requesting Completions", () => { @@ -79,16 +82,19 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.deepStrictEqual(result.completion.values, ["alpha", "beta"]) })) - it.effect("MUST pass previously resolved argument context to the completion handler", () => - Effect.gen(function*() { - const result = yield* complete( - { type: "ref/prompt", name: "ContextCompletionPrompt" }, - { name: "value", value: "c" }, - { arguments: { locale: "en" } } - ) - - assert.deepStrictEqual(result.completion.values, ["context received"]) - })) + it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "MUST pass previously resolved argument context to the completion handler", + () => + Effect.gen(function*() { + const result = yield* complete( + { type: "ref/prompt", name: "ContextCompletionPrompt" }, + { name: "value", value: "c" }, + { arguments: { locale: "en" } } + ) + + assert.deepStrictEqual(result.completion.values, ["context received"]) + }) + ) it.effect("SHOULD reject an unknown prompt reference with Invalid Params", () => Effect.gen(function*() { const test = yield* McpConformance diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts index 4addf97962..6c35780853 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts @@ -39,7 +39,7 @@ const request = { } as const const runElicitation = , unknown>>( - client: McpTestPeer["client"], + client: McpTestPeer["reverseClient"], protocolVersion: McpProtocol.ProtocolVersion, schema: S ) => @@ -52,6 +52,11 @@ const runElicitation = { // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation describe("Capabilities", () => { + it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( + "MUST treat an empty elicitation capability as form support", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const peer = yield* test.makePeer({ + capabilities: { elicitation: {} }, + handlers: { + "elicitation/create": () => Effect.succeed({ action: "accept", content: { name: "Ada" } }) + } + }) + + yield* peer.reverseClient.elicit(Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)(request)) + + assert.deepStrictEqual((yield* peer.takeRequest).payload, request) + }).pipe(Effect.scoped) + ) + it.effect("MUST send elicitation requests when the client advertises elicitation", () => Effect.gen(function*() { const test = yield* McpConformance @@ -84,10 +107,33 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - yield* peer.client["elicitation/create"](request) + yield* peer.wireClient["elicitation/create"](request) assert.strictEqual((yield* peer.takeRequest).method, "elicitation/create") })) + + it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( + "MUST gate form and URL modes on their independent nested capabilities", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const formOnly = yield* test.makePeer({ capabilities: { elicitation: { form: {} } } }) + const urlOnly = yield* test.makePeer({ capabilities: { elicitation: { url: {} } } }) + + const formOnUrlOnly = yield* Effect.exit(urlOnly.reverseClient.elicit(request)) + const urlOnFormOnly = yield* Effect.exit(formOnly.reverseClient.elicit({ + mode: "url", + message: "Authorize access", + url: "https://example.com/authorize", + elicitationId: "authorization-1" + })) + + assert.strictEqual(formOnUrlOnly._tag, "Failure") + assert.strictEqual(urlOnFormOnly._tag, "Failure") + assert.deepStrictEqual(yield* formOnly.requests, []) + assert.deepStrictEqual(yield* urlOnly.requests, []) + }) + ) }) describe("Form Mode", () => { @@ -105,7 +151,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const result = yield* peer.client["elicitation/create"](request) + const result = yield* peer.wireClient["elicitation/create"](request) const recorded = yield* peer.takeRequest const payload = yield* decodeElicitationRequest(recorded.payload) @@ -121,6 +167,28 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } })) + it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( + "MUST preserve omitted and explicit form modes", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const peer = yield* test.makePeer({ + capabilities: { elicitation: { form: {} } }, + handlers: { + "elicitation/create": () => Effect.succeed({ action: "accept", content: { name: "Ada" } }) + } + }) + + yield* peer.reverseClient.elicit(Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)(request)) + yield* peer.reverseClient.elicit( + Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)({ ...request, mode: "form" }) + ) + + const requests = yield* peer.requests + assert.deepStrictEqual(requests.map((_) => _.payload), [request, { ...request, mode: "form" }]) + }).pipe(Effect.scoped) + ) + it.effect("MUST decode accepted content against the requested schema", () => Effect.gen(function*() { const test = yield* McpConformance @@ -136,7 +204,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) const result = yield* runElicitation( - peer.client, + peer.reverseClient, protocol.protocolVersion, Schema.Struct({ name: Schema.String, @@ -147,6 +215,24 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.deepStrictEqual(result, { name: "Ada", age: 37 }) })) + it.effect("MUST reject schemas outside the elicitation subset before sending", () => + Effect.gen(function*() { + const test = yield* McpConformance + const peer = yield* test.makePeer({ capabilities: { elicitation: {} } }) + + const exit = yield* Effect.exit(runElicitation( + peer.reverseClient, + protocol.protocolVersion, + Schema.Struct({ nested: Schema.Struct({ value: Schema.String }) }) + )) + + assert.isTrue(Exit.isFailure(exit)) + if (Exit.isFailure(exit)) { + assert.isTrue(Cause.hasDies(exit.cause)) + } + assert.deepStrictEqual(yield* peer.requests, []) + }).pipe(Effect.scoped)) + it.effect("SCENARIO returns a typed failure when the user declines", () => Effect.gen(function*() { const test = yield* McpConformance @@ -158,7 +244,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) const error = yield* runElicitation( - peer.client, + peer.reverseClient, protocol.protocolVersion, Schema.Struct({ name: Schema.String }) ).pipe(Effect.flip) @@ -177,7 +263,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) const exit = yield* Effect.exit(runElicitation( - peer.client, + peer.reverseClient, protocol.protocolVersion, Schema.Struct({ name: Schema.String }) )) @@ -203,7 +289,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) const exit = yield* Effect.exit(runElicitation( - peer.client, + peer.reverseClient, protocol.protocolVersion, Schema.Struct({ name: Schema.String }) )) @@ -214,5 +300,32 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } })) }) + + describe("URL Mode", () => { + it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( + "MUST round-trip URL elicitation", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const request = { + mode: "url" as const, + message: "Authorize access", + url: "https://example.com/authorize", + elicitationId: "authorization-1" + } + const peer = yield* test.makePeer({ + capabilities: { elicitation: { url: {} } }, + handlers: { "elicitation/create": () => Effect.succeed({ action: "accept" }) } + }) + + const result = yield* peer.reverseClient.elicit( + Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)(request) + ) + + assert.deepStrictEqual((yield* peer.takeRequest).payload, request) + assert.strictEqual(result.action, "accept") + }).pipe(Effect.scoped) + ) + }) }) }) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/McpTestPeer.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/McpTestPeer.ts index 1b13ae3eea..25342ffdc5 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/McpTestPeer.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/McpTestPeer.ts @@ -4,11 +4,13 @@ import * as Exit from "effect/Exit" import * as Option from "effect/Option" import * as Queue from "effect/Queue" import * as Ref from "effect/Ref" +import * as Schema from "effect/Schema" import type * as McpProtocol from "effect/unstable/ai/McpProtocol" -import * as McpSchema from "effect/unstable/ai/McpSchema" +import type * as McpSchema from "effect/unstable/ai/McpSchema" import * as RpcClient from "effect/unstable/rpc/RpcClient" import type * as RpcClientError from "effect/unstable/rpc/RpcClientError" import type * as RpcGroup from "effect/unstable/rpc/RpcGroup" +import type * as RpcSerialization from "effect/unstable/rpc/RpcSerialization" export type ReverseMethod = "roots/list" | "sampling/createMessage" | "elicitation/create" @@ -29,10 +31,11 @@ export interface McpTestPeerOptions { } export interface McpTestPeer { - readonly client: RpcClient.RpcClient< + readonly wireClient: RpcClient.RpcClient< RpcGroup.Rpcs, RpcClientError.RpcClientError > + readonly reverseClient: McpSchema.McpReverseClient readonly requests: Effect.Effect> readonly takeRequest: Effect.Effect } @@ -41,7 +44,7 @@ const isReverseMethod = (method: string): method is ReverseMethod => ["roots/list", "sampling/createMessage", "elicitation/create"].includes(method) export const makeMcpTestPeer = Effect.fn("McpTestPeer.make")(function*( - _protocol: McpProtocol.ProtocolAdapter, + protocol: McpProtocol.ProtocolAdapter, options: McpTestPeerOptions = {} ) { const requests = yield* Ref.make>([]) @@ -104,16 +107,24 @@ export const makeMcpTestPeer = Effect.fn("McpTestPeer.make")(function*( }, supportsAck: true, supportsTransferables: false, - supportsStructuredClone: false + codecFor: Schema.toCodecJson as RpcSerialization.CodecFor }) ) - const client = yield* RpcClient.make(McpSchema.ServerRequestRpcs).pipe( + const wireClient = yield* RpcClient.make( + protocol.serverRequestRpcs as unknown as typeof McpSchema.ServerRequestRpcs + ).pipe( Effect.provideService(RpcClient.Protocol, rpcProtocol) ) + const reverseClient = yield* protocol.makeReverseClient({ + protocolVersion: protocol.protocolVersion, + clientCapabilities: options.capabilities ?? {}, + clientInfo: options.clientInfo ?? { name: "McpTestPeer", version: "1.0.0" } + }).pipe(Effect.provideService(RpcClient.Protocol, rpcProtocol)) return { - client, + wireClient, + reverseClient, requests: Ref.get(requests), takeRequest: Queue.take(inbox) } satisfies McpTestPeer diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts index ead7f43245..a96ce34067 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts @@ -318,18 +318,21 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }]) })) - it.effect("MUST return audio message content", () => - Effect.gen(function*() { - const result = yield* getPromptWire("AudioPrompt") - assert.deepStrictEqual(result.result.messages, [{ - role: "user", - content: { - type: "audio", - data: "BAUG", - mimeType: "audio/wav" - } - }]) - })) + it.effect.skipIf(["2024-11-05"].includes(protocol.protocolVersion))( + "MUST return audio message content", + () => + Effect.gen(function*() { + const result = yield* getPromptWire("AudioPrompt") + assert.deepStrictEqual(result.result.messages, [{ + role: "user", + content: { + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + } + }]) + }) + ) it.effect("MUST return embedded resource message content", () => Effect.gen(function*() { const result = yield* getPrompt("EmbeddedResourcePrompt") diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/RootsTest.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/RootsTest.ts index a5deec1ca5..6151349406 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/RootsTest.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/RootsTest.ts @@ -23,7 +23,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - yield* peer.client["roots/list"](undefined) + yield* peer.wireClient["roots/list"](undefined) assert.strictEqual((yield* peer.takeRequest).method, "roots/list") })) @@ -38,7 +38,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - yield* peer.client["roots/list"](undefined) + yield* peer.wireClient["roots/list"](undefined) assert.strictEqual((yield* peer.takeRequest).method, "roots/list") })) @@ -58,7 +58,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const result = yield* peer.client["roots/list"](undefined) + const result = yield* peer.wireClient["roots/list"](undefined) assert.deepStrictEqual( result.roots.map((root) => ({ @@ -82,7 +82,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const result = yield* peer.client["roots/list"](undefined) + const result = yield* peer.wireClient["roots/list"](undefined) assert.deepStrictEqual(result.roots, []) })) @@ -102,9 +102,10 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const error = yield* peer.client["roots/list"](undefined).pipe(Effect.flip) + const error = yield* peer.wireClient["roots/list"](undefined).pipe(Effect.flip) - assert.instanceOf(error, McpSchema.InternalError) + assert.isTrue("code" in error) + if ("code" in error) assert.strictEqual(error.code, McpSchema.INTERNAL_ERROR_CODE) assert.strictEqual(error.message, "Roots unavailable") })) }) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/SamplingTest.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/SamplingTest.ts index 8323da893a..076fed99c3 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/SamplingTest.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/SamplingTest.ts @@ -84,10 +84,147 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - yield* peer.client["sampling/createMessage"](samplingRequest) + yield* peer.wireClient["sampling/createMessage"](samplingRequest) assert.strictEqual((yield* peer.takeRequest).method, "sampling/createMessage") })) + + it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( + "SHOULD reject context-enabled requests before sending without sampling.context", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const peer = yield* test.makePeer({ capabilities: { sampling: {} } }) + const request = McpSchema.CreateMessage.payloadSchema.make({ + ...samplingRequest, + includeContext: "thisServer" + }) + + const exit = yield* Effect.exit(peer.reverseClient.createMessage(request)) + + assert.strictEqual(exit._tag, "Failure") + assert.deepStrictEqual(yield* peer.requests, []) + }) + ) + + it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( + "MUST reject every tool-enabled request shape before sending without sampling.tools", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const requests = [ + { + name: "tools", + request: { + messages: [{ role: "user", content: { type: "text", text: "Weather?" } }], + tools: [{ name: "weather", inputSchema: { type: "object" } }], + maxTokens: 64 + } + }, + { + name: "toolChoice", + request: { + messages: [{ role: "user", content: { type: "text", text: "Weather?" } }], + toolChoice: { mode: "required" }, + maxTokens: 64 + } + }, + { + name: "tool_use content", + request: { + messages: [{ + role: "assistant", + content: { type: "tool_use", id: "call-1", name: "weather", input: { city: "Zurich" } } + }], + maxTokens: 64 + } + }, + { + name: "tool_result content", + request: { + messages: [{ + role: "user", + content: { + type: "tool_result", + toolUseId: "call-1", + content: [{ type: "text", text: "Sunny" }] + } + }], + maxTokens: 64 + } + } + ] as const + + for (const testCase of requests) { + const peer = yield* test.makePeer({ capabilities: { sampling: {} } }) + const request = Schema.decodeUnknownSync(McpSchema.CreateMessage.payloadSchema)(testCase.request) + const exit = yield* Effect.exit(peer.reverseClient.createMessage(request)) + + assert.strictEqual(exit._tag, "Failure", testCase.name) + assert.deepStrictEqual(yield* peer.requests, [], testCase.name) + } + }).pipe(Effect.scoped) + ) + + it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( + "MUST allow text-only content arrays without sampling.tools", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const peer = yield* test.makePeer({ + capabilities: { sampling: {} }, + handlers: { + "sampling/createMessage": () => + Effect.succeed({ + role: "assistant", + content: [{ type: "text", text: "First" }, { type: "text", text: "Second" }], + model: "text-model" + }) + } + }) + + const result = yield* peer.reverseClient.createMessage( + Schema.decodeUnknownSync(McpSchema.CreateMessage.payloadSchema)({ + messages: [{ + role: "user", + content: [{ type: "text", text: "First" }, { type: "text", text: "Second" }] + }], + maxTokens: 64 + }) + ) + + assert.strictEqual((yield* peer.requests).length, 1) + assert.deepStrictEqual(JSON.parse(JSON.stringify(result.content)), [ + { type: "text", text: "First" }, + { type: "text", text: "Second" } + ]) + }) + ) + + it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( + "SCENARIO rejects unexpected tool-enabled responses when sampling.tools was not advertised", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const peer = yield* test.makePeer({ + capabilities: { sampling: {} }, + handlers: { + "sampling/createMessage": () => + Effect.succeed({ + role: "assistant", + content: { type: "tool_use", id: "call-1", name: "weather", input: {} }, + model: "tool-model", + stopReason: "toolUse" + }) + } + }) + + const exit = yield* Effect.exit(peer.reverseClient.createMessage(samplingRequest)) + + assert.strictEqual(exit._tag, "Failure") + assert.strictEqual((yield* peer.requests).length, 1) + }) + ) }) describe("Creating Messages", () => { @@ -95,13 +232,15 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman Effect.gen(function*() { const test = yield* McpConformance const peer = yield* test.makePeer({ - capabilities: { sampling: {} }, + capabilities: { + sampling: protocol.protocolVersion === "2025-11-25" ? { context: {} } : {} + }, handlers: { "sampling/createMessage": () => Effect.succeed(textResponse) } }) - yield* peer.client["sampling/createMessage"](samplingRequestWithOptions) + yield* peer.wireClient["sampling/createMessage"](samplingRequestWithOptions) const recorded = yield* peer.takeRequest const payload = yield* decodeSamplingRequest(recorded.payload) @@ -138,7 +277,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const result = yield* peer.client["sampling/createMessage"](samplingRequest).pipe( + const result = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe( Effect.flatMap(decodeSamplingResult) ) @@ -170,46 +309,49 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const result = yield* peer.client["sampling/createMessage"](samplingRequest).pipe( + const result = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe( Effect.flatMap(decodeSamplingResult) ) assert.deepStrictEqual(result.content, { type: "image", - data: new Uint8Array([1, 2, 3]), + data: "AQID", mimeType: "image/png" }) })) - it.effect("MUST accept audio sampling content", () => - Effect.gen(function*() { - const test = yield* McpConformance - const peer = yield* test.makePeer({ - capabilities: { sampling: {} }, - handlers: { - "sampling/createMessage": () => - Effect.succeed({ - role: "assistant", - content: { - type: "audio", - data: "BAUG", - mimeType: "audio/wav" - }, - model: "audio-model" - }) - } - }) + it.effect.skipIf(["2024-11-05"].includes(protocol.protocolVersion))( + "MUST accept audio sampling content", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const peer = yield* test.makePeer({ + capabilities: { sampling: {} }, + handlers: { + "sampling/createMessage": () => + Effect.succeed({ + role: "assistant", + content: { + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + }, + model: "audio-model" + }) + } + }) - const result = yield* peer.client["sampling/createMessage"](samplingRequest).pipe( - Effect.flatMap(decodeSamplingResult) - ) + const result = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe( + Effect.flatMap(decodeSamplingResult) + ) - assert.deepStrictEqual(result.content, { - type: "audio", - data: new Uint8Array([4, 5, 6]), - mimeType: "audio/wav" + assert.deepStrictEqual(result.content, { + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + }) }) - })) + ) it.effect("MUST surface sampling errors returned by the client", () => Effect.gen(function*() { @@ -226,11 +368,100 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const error = yield* peer.client["sampling/createMessage"](samplingRequest).pipe(Effect.flip) + const error = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe(Effect.flip) - assert.instanceOf(error, McpSchema.InternalError) + assert.isTrue("code" in error) + if ("code" in error) assert.strictEqual(error.code, McpSchema.INTERNAL_ERROR_CODE) assert.strictEqual(error.message, "Sampling failed") })) + + it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( + "MUST round-trip tool-enabled sampling requests and results", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const response = { + role: "assistant", + content: [{ + type: "tool_use", + id: "call-2", + name: "weather", + input: { city: "Geneva" } + }], + model: "tool-model", + stopReason: "toolUse" + } as const + const peer = yield* test.makePeer({ + capabilities: { sampling: { tools: {} } }, + handlers: { "sampling/createMessage": () => Effect.succeed(response) } + }) + const request = McpSchema.CreateMessage.payloadSchema.make({ + messages: [ + McpSchema.SamplingMessage.make({ + role: "assistant", + content: McpSchema.ToolUseContent.make({ + id: "call-1", + name: "weather", + input: { city: "Zurich" } + }) + }), + McpSchema.SamplingMessage.make({ + role: "user", + content: McpSchema.ToolResultContent.make({ + toolUseId: "call-1", + content: [McpSchema.TextContent.make({ text: "Sunny" })], + structuredContent: { temperature: 24 } + }) + }) + ], + tools: [{ + name: "weather", + description: "Get weather", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"] + } + }], + toolChoice: new McpSchema.ToolChoice({ mode: "required" }), + maxTokens: 64 + }) + + const result = yield* peer.reverseClient.createMessage(request) + const recorded = yield* peer.takeRequest + + assert.strictEqual(recorded.method, "sampling/createMessage") + assert.deepStrictEqual(recorded.payload, { + messages: [ + { + role: "assistant", + content: { type: "tool_use", id: "call-1", name: "weather", input: { city: "Zurich" } } + }, + { + role: "user", + content: { + type: "tool_result", + toolUseId: "call-1", + content: [{ type: "text", text: "Sunny" }], + structuredContent: { temperature: 24 } + } + } + ], + tools: [{ + name: "weather", + description: "Get weather", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"] + } + }], + toolChoice: { mode: "required" }, + maxTokens: 64 + }) + assert.deepStrictEqual(JSON.parse(JSON.stringify(result)), response) + }) + ) }) }) }) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts index f50e794b70..9c711f423c 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts @@ -139,29 +139,32 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.isTrue(result.tools.every((tool) => tool.inputSchema.type === "object")) })) - it.effect("MUST return each declared tool output schema", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - yield* test.notifyInitialized(initialized) - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - id: 2, - method: "tools/list", - params: {} + it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "MUST return each declared tool output schema", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeTools(message.result)) + ) + + assert.strictEqual( + result.tools.find((tool) => tool.name === "StructuredTool")?.outputSchema?.type, + "object" + ) + const scalarTool = result.tools.find((tool) => tool.name === "TestTool") + assert.isDefined(scalarTool) + assert.notProperty(scalarTool, "outputSchema") }) - const result = yield* test.decodeResult(response).pipe( - Effect.flatMap((message) => decodeTools(message.result)) - ) - - assert.strictEqual( - result.tools.find((tool) => tool.name === "StructuredTool")?.outputSchema?.type, - "object" - ) - const scalarTool = result.tools.find((tool) => tool.name === "TestTool") - assert.isDefined(scalarTool) - assert.notProperty(scalarTool, "outputSchema") - })) + ) }) describe("Calling Tools", () => { @@ -190,6 +193,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman it.effect("MUST reject an unknown tool name with a protocol error", () => Effect.gen(function*() { const test = yield* McpConformance + const before = (yield* test.observations).toolInvocations const initialized = yield* test.initialize({ server: "features" }) yield* test.notifyInitialized(initialized) const response = yield* test.send(initialized, { @@ -204,9 +208,28 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman const error = yield* test.decodeError(response) assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + assert.strictEqual((yield* test.observations).toolInvocations, before) + })) + + it.effect("MUST reject malformed tool params without invoking a handler", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const before = (yield* test.observations).toolInvocations + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { arguments: { value: "called" } } + }) + const error = yield* test.decodeError(response) + + assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + assert.strictEqual((yield* test.observations).toolInvocations, before) })) - it.effect("MUST reject arguments that do not match the input schema with a protocol error", () => + it.effect("MUST handle arguments that do not match the input schema for the revision", () => Effect.gen(function*() { const test = yield* McpConformance const initialized = yield* test.initialize({ server: "features" }) @@ -220,9 +243,16 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman arguments: { value: 123 } } }) - const error = yield* test.decodeError(response) - - assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + if (protocol.protocolVersion === "2025-11-25") { + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeCallTool(message.result)) + ) + assert.strictEqual(result.isError, true) + assert.strictEqual(result.content[0]?.type, "text") + } else { + const error = yield* test.decodeError(response) + assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + } })) it.effect("MUST not invoke a tool handler when argument validation fails", () => Effect.gen(function*() { @@ -271,25 +301,31 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman mimeType: "image/png" }]) })) - it.effect("SCHEMA returns audio content", () => - Effect.gen(function*() { - const result = yield* callToolWire("AudioTool") - assert.deepStrictEqual(result.result.content, [{ - type: "audio", - data: "BAUG", - mimeType: "audio/wav" - }]) - })) - it.effect("SCHEMA returns resource links", () => - Effect.gen(function*() { - const result = yield* callTool("ResourceLinkTool") - assert.deepStrictEqual(result.content, [{ - type: "resource_link", - uri: "file:///test", - name: "TestResource", - mimeType: "text/plain" - }]) - })) + it.effect.skipIf(["2024-11-05"].includes(protocol.protocolVersion))( + "SCHEMA returns audio content", + () => + Effect.gen(function*() { + const result = yield* callToolWire("AudioTool") + assert.deepStrictEqual(result.result.content, [{ + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + }]) + }) + ) + it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "SCHEMA returns resource links", + () => + Effect.gen(function*() { + const result = yield* callTool("ResourceLinkTool") + assert.deepStrictEqual(result.content, [{ + type: "resource_link", + uri: "file:///test", + name: "TestResource", + mimeType: "text/plain" + }]) + }) + ) it.effect("SCHEMA returns embedded resources", () => Effect.gen(function*() { const result = yield* callTool("EmbeddedResourceTool") @@ -310,11 +346,14 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman { type: "text", text: "second" } ]) })) - it.effect("SCHEMA returns structured content", () => - Effect.gen(function*() { - const result = yield* callTool("StructuredTool") - assert.deepStrictEqual(result.structuredContent, { value: "structured" }) - })) + it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "SCHEMA returns structured content", + () => + Effect.gen(function*() { + const result = yield* callTool("StructuredTool") + assert.deepStrictEqual(result.structuredContent, { value: "structured" }) + }) + ) it.effect("MUST return tool execution failures with isError", () => Effect.gen(function*() { const result = yield* callTool("ErrorTool") diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts index 686df560a9..9593fec3a6 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts @@ -28,6 +28,11 @@ const jsonRequest = (method: string, body?: unknown, headers?: HeadersInit) => { }) } +const httpTransportSuiteName = (protocol: McpProtocol.ProtocolAdapter) => + protocol.protocolVersion === "2024-11-05" + ? "Single-endpoint HTTP compatibility extension" + : "Streamable HTTP" + export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Transports", () => { @@ -110,28 +115,31 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) })) - it.effect("SCENARIO applies the revision-specific stdio batch policy", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - yield* fixture.sendRaw({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: protocol.protocolVersion, - capabilities: {}, - clientInfo: { name: "stdio-client", version: "1.0.0" } - } + it.effect.skipIf(["2025-03-26"].includes(protocol.protocolVersion))( + "MUST reject JSON-RPC batches over stdio", + () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.sendRaw({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: protocol.protocolVersion, + capabilities: {}, + clientInfo: { name: "stdio-client", version: "1.0.0" } + } + }) + yield* fixture.takeFrame + yield* fixture.sendRaw([ + { jsonrpc: "2.0", id: 2, method: "ping", params: {} }, + { jsonrpc: "2.0", id: 3, method: "ping", params: {} } + ]) + const error = yield* fixture.takeFrame.pipe(Effect.flatMap(decodeErrorFrame)) + assert.strictEqual(error.id, null) + assert.strictEqual(error.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE) }) - yield* fixture.takeFrame - yield* fixture.sendRaw([ - { jsonrpc: "2.0", id: 2, method: "ping", params: {} }, - { jsonrpc: "2.0", id: 3, method: "ping", params: {} } - ]) - const response = yield* fixture.takeFrame.pipe(Effect.flatMap(decodeErrorFrame)) - assert.strictEqual(response.id, null) - assert.strictEqual(response.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE) - })) + ) it.effect("MUST shut down when the client closes stdin", () => Effect.gen(function*() { @@ -145,8 +153,19 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman })) }) - describe("Streamable HTTP", () => { + describe(httpTransportSuiteName(protocol), () => { describe("Sending Messages to the Server", () => { + it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "MUST reject JSON-RPC batches", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const response = yield* test.post([test.initializeRequest()]) + + assert.isAtLeast(response.status, 400) + }) + ) + it.effect("MUST accept JSON-RPC requests through POST on the MCP endpoint", () => Effect.gen(function*() { const test = yield* McpConformance @@ -179,6 +198,25 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.strictEqual(yield* Effect.promise(() => response.text()), "") })) + it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "MUST require the negotiated protocol-version header after initialization", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + assert.isNotNull(initialized.sessionId) + + const missing = yield* test.ping(initialized, { includeProtocolVersion: false }) + const mismatched = yield* test.ping(initialized, { + id: 3, + protocolVersion: protocol.protocolVersion === "2025-11-25" ? "2025-06-18" : "2025-03-26" + }) + + assert.isAtLeast(missing.status, 400) + assert.isAtLeast(mismatched.status, 400) + }) + ) + it.effect("MUST require the application/json content type for POST requests", () => Effect.gen(function*() { const test = yield* McpConformance @@ -341,48 +379,51 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman })) }) - describe("Protocol Version Header", () => { - it.effect("MUST apply the revision-specific protocol header requirement", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - const response = yield* test.ping(initialized, { - includeProtocolVersion: false - }) - assert.strictEqual(response.status, 400) - })) - it.effect("MUST accept the negotiated protocol version", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - const response = yield* test.ping(initialized, { - includeProtocolVersion: true, - protocolVersion: protocol.protocolVersion - }) - assert.strictEqual(response.status, 200) - })) - it.effect("MUST reject an unsupported protocol version with bad request", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - const response = yield* test.ping(initialized, { - includeProtocolVersion: true, - protocolVersion: "2099-01-01" - }) - assert.strictEqual(response.status, 400) - })) - it.effect("SCENARIO replays the selected protocol version on HTTP responses", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - assert.strictEqual( - initialized.response.headers.get("Mcp-Protocol-Version"), - protocol.protocolVersion - ) - const response = yield* test.ping(initialized, { includeProtocolVersion: true }) - assert.strictEqual(response.headers.get("Mcp-Protocol-Version"), protocol.protocolVersion) - })) - }) + describe.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "Protocol Version Header", + () => { + it.effect("MUST apply the revision-specific protocol header requirement", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + const response = yield* test.ping(initialized, { + includeProtocolVersion: false + }) + assert.strictEqual(response.status, 400) + })) + it.effect("MUST accept the negotiated protocol version", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + const response = yield* test.ping(initialized, { + includeProtocolVersion: true, + protocolVersion: protocol.protocolVersion + }) + assert.strictEqual(response.status, 200) + })) + it.effect("MUST reject an unsupported protocol version with bad request", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + const response = yield* test.ping(initialized, { + includeProtocolVersion: true, + protocolVersion: "2099-01-01" + }) + assert.strictEqual(response.status, 400) + })) + it.effect("SCENARIO replays the selected protocol version on HTTP responses", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + assert.strictEqual( + initialized.response.headers.get("Mcp-Protocol-Version"), + protocol.protocolVersion + ) + const response = yield* test.ping(initialized, { includeProtocolVersion: true }) + assert.strictEqual(response.headers.get("Mcp-Protocol-Version"), protocol.protocolVersion) + })) + } + ) describe("Security", () => { it.effect("MUST validate the Origin header before every MCP route", () => diff --git a/repos/effect/packages/effect/test/unstable/ai/McpProtocol.test.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts similarity index 65% rename from repos/effect/packages/effect/test/unstable/ai/McpProtocol.test.ts rename to repos/effect/packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts index be950fc52f..4db2240b52 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpProtocol.test.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts @@ -2,6 +2,9 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Schema } from "effect" import * as McpProtocol from "effect/unstable/ai/internal/mcpProtocol" import * as McpProtocolRegistry from "effect/unstable/ai/internal/mcpProtocolRegistry" +import * as McpSchema2025_06_18 from "effect/unstable/ai/internal/mcpSchema/v2025_06_18" +import type * as PublicMcpProtocol from "effect/unstable/ai/McpProtocol" +import * as McpSchema from "effect/unstable/ai/McpSchema" import * as Rpc from "effect/unstable/rpc/Rpc" import * as RpcGroup from "effect/unstable/rpc/RpcGroup" @@ -27,13 +30,41 @@ const makeTestProtocol = < return McpProtocol.make({ protocolVersion, transport: { - acceptsJsonRpcBatches: true, - requiresVersionHeader: false + acceptsJsonRpcBatches: false, + requiresVersionHeader: true }, clientRpcs: RpcGroup.make(TestRequest), clientNotificationRpcs: RpcGroup.make(), serverRequestRpcs: RpcGroup.make(), - serverNotificationRpcs: RpcGroup.make() + serverNotificationRpcs: RpcGroup.make(), + toReverseClient: () => ({ + listRoots: () => + Effect.fail( + new McpSchema.McpReverseOperationUnsupported({ + operation: "roots/list", + protocolVersion: "2025-06-18", + reason: "Synthetic test adapter" + }) + ), + createMessage: () => + Effect.fail( + new McpSchema.McpReverseOperationUnsupported({ + operation: "sampling/createMessage", + protocolVersion: "2025-06-18", + reason: "Synthetic test adapter" + }) + ), + elicit: () => + Effect.fail( + new McpSchema.McpReverseOperationUnsupported({ + operation: "elicitation/create", + protocolVersion: "2025-06-18", + reason: "Synthetic test adapter" + }) + ) + }), + projectNotification: () => Effect.succeed(undefined), + normalizeCancellation: () => Effect.succeed({ requestId: "" }) }) } @@ -90,7 +121,7 @@ describe("McpProtocolRegistry", () => { Effect.gen(function*() { const first = makeTestProtocol("test-a", "a") const second = makeTestProtocol("test-b", "b") - const protocols: [McpProtocol.AnyProtocolAdapter, McpProtocol.AnyProtocolAdapter] = [ + const protocols: [PublicMcpProtocol.AnyProtocolAdapter, PublicMcpProtocol.AnyProtocolAdapter] = [ first, second ] @@ -121,8 +152,8 @@ describe("McpProtocolRegistry", () => { assert.notStrictEqual(selectedRequest.tag, unselectedRequest.tag) assert.notStrictEqual(selectedRequest.tag, "test/shape") - const selectedRpc = registry.clientRpcs.requests.get(selectedRequest.tag) - const unselectedRpc = registry.clientRpcs.requests.get(unselectedRequest.tag) + const selectedRpc = first.clientRpcs.requests.get("test/shape") + const unselectedRpc = second.clientRpcs.requests.get("test/shape") assert.isDefined(selectedRpc) assert.isDefined(unselectedRpc) @@ -138,3 +169,32 @@ describe("McpProtocolRegistry", () => { }) })) }) + +describe("MCP v2025-06-18 schema", () => { + it("accepts resource links in prompt messages", () => { + const message = Schema.decodeUnknownSync(McpSchema2025_06_18.PromptMessage)({ + role: "user", + content: { + type: "resource_link", + uri: "file:///example.txt", + name: "example" + } + }) + + assert.strictEqual(message.content.type, "resource_link") + }) + + it("does not expose future annotations or named extension capabilities", () => { + 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/unstable/ai/McpServer/McpServer.test.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts index 7a0ad15a5a..786dd9f2ca 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts @@ -80,6 +80,8 @@ const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal se const TestServerLayer = makeServerLayer({ name: "TestServer" }) +const LatestProtocolServerLayer = makeServerLayer({ name: "TestServer", protocols: [McpProtocol.v2025_11_25] }) + const initializePayload = { protocolVersion: "2025-06-18", capabilities: {}, @@ -96,6 +98,15 @@ const pingBody = { id: 0 } +const directClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: initializePayload.clientInfo, + initializePayload, + getClient: Effect.die("not used") +}) + const makeTestClientWith = Effect.fnUntraced(function*( serverLayer: Layer.Layer, options?: { @@ -154,6 +165,68 @@ const toolResultText = (result: McpSchema.CallToolResult): string => { } describe("McpServer", () => { + describe("direct service", () => { + it.effect("should fail when a resource URI is unknown", () => + Effect.gen(function*() { + const server = yield* McpServer.McpServer.make + + const error = yield* server.findResource("file:///unknown").pipe( + Effect.provideService(McpSchema.McpServerClient, directClient), + Effect.flip + ) + + assertTrue(error instanceof McpSchema.InvalidParams) + assert.strictEqual(error.message, "Resource 'file:///unknown' not found") + })) + + it.effect("should preserve a registered resource handler's typed failure", () => + Effect.gen(function*() { + const server = yield* McpServer.McpServer.make + const failure = new McpSchema.InternalError({ message: "resource failed" }) + yield* server.addResource({ + resource: new McpSchema.Resource({ + uri: "file:///failure", + name: "failure" + }), + annotations: Context.empty(), + handle: Effect.fail(failure) + }) + + const error = yield* server.findResource("file:///failure").pipe( + Effect.provideService(McpSchema.McpServerClient, directClient), + Effect.flip + ) + + assert.strictEqual(error, failure) + })) + + it.effect("should pass undefined to a low-level tool handler when arguments are omitted", () => + Effect.gen(function*() { + const server = yield* McpServer.McpServer.make + let received: unknown = "not called" + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "arguments-omitted", + inputSchema: { + type: "object", + properties: {} + } + }), + annotations: Context.empty(), + handle: (payload) => { + received = payload + return Effect.succeed(new McpSchema.CallToolResult({ content: [] })) + } + }) + + yield* server.callTool({ name: "arguments-omitted" }).pipe( + Effect.provideService(McpSchema.McpServerClient, directClient) + ) + + assert.isUndefined(received) + })) + }) + it.effect("should reject browser Origins by default while accepting Origin-less clients", () => Effect.gen(function*() { const harness = yield* makeHttpHarness(TestServerLayer) @@ -209,6 +282,22 @@ describe("McpServer", () => { strictEqual(response.status, 400) })) + it.effect("negotiates an initialize request from a client on an unsupported protocol version", () => + Effect.gen(function*() { + const { httpClient } = yield* makeTestClientWith(LatestProtocolServerLayer) + + const response = yield* HttpClientRequest.post("http://localhost/mcp").pipe( + HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + HttpClientRequest.setHeader("Mcp-Protocol-Version", "2025-06-18"), + HttpClientRequest.bodyJsonUnsafe({ jsonrpc: "2.0", id: 1, method: "initialize", params: initializePayload }), + httpClient.execute + ) + + strictEqual(response.status, 200) + strictEqual(response.headers["mcp-protocol-version"], "2025-11-25") + assertTrue(response.headers["mcp-session-id"] !== undefined) + })) + describe("registerToolkit", () => { it.effect("lists output schemas only for structured tool results", () => Effect.gen(function*() { @@ -251,7 +340,8 @@ describe("McpServer", () => { }).pipe(Effect.flip) assert.isFalse(handlerInvoked) - assert.instanceOf(error, McpSchema.InvalidParams) + assert.isTrue("code" in error) + if ("code" in error) assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) assert.match(error.message, /Invalid parameters for tool 'OptionalStringTool'/) assert.match(error.message, /Expected string \| undefined/) assert.match(error.message, /at \["signature"\]/) @@ -352,9 +442,11 @@ describe("McpServer", () => { arguments: {} }).pipe(Effect.flip) - assert.instanceOf(error, McpSchema.InvalidParams) - assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) - assert.strictEqual(error.message, "Tool 'UnknownTool' not found") + assert.isTrue("code" in error) + if ("code" in error) { + assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + assert.strictEqual(error.message, "Tool 'UnknownTool' not found") + } })) }) @@ -414,6 +506,54 @@ describe("McpServer", () => { strictEqual(pingResponseBody.length > 0, true) })) + it.effect("drops server notifications from buffered JSON-RPC responses", () => + Effect.gen(function*() { + const serverLayer = Layer.effectDiscard(Effect.gen(function*() { + const router = yield* HttpRouter.HttpRouter + const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffect() + yield* protocol.run((clientId, message) => { + if (message._tag !== "Request") { + return Effect.void + } + return Effect.gen(function*() { + yield* protocol.send(clientId, { + _tag: "Request", + id: "", + tag: "notifications/message", + payload: { level: "info" }, + headers: [], + isNotification: true + }) + yield* protocol.send(clientId, { + _tag: "Exit", + requestId: message.id, + exit: { _tag: "Success", value: { ok: true } } + }) + yield* protocol.end(clientId) + }) + }).pipe(Effect.forkScoped) + yield* router.add("POST", "/mcp", () => httpEffect) + })).pipe( + Layer.provideMerge(HttpRouter.layer), + Layer.provide(RpcSerialization.layerJsonRpc()) + ) + const harness = yield* makeHttpHarness(serverLayer) + + const response = yield* harness.post({ + jsonrpc: "2.0", + method: "ping", + params: {}, + id: 1 + }) + + assert.strictEqual(response.status, 200) + assert.deepStrictEqual(yield* Effect.promise(() => response.json()), { + jsonrpc: "2.0", + id: 1, + result: { ok: true } + }) + })) + it.effect("validates supplied protocol versions on POST", () => Effect.gen(function*() { const { client, httpClient } = yield* makeRouterTestClient(HttpRouter.cors()) @@ -430,6 +570,55 @@ describe("McpServer", () => { strictEqual(yield* unsupportedResponse.text, "") strictEqual(unsupportedResponse.headers["access-control-allow-origin"], "*") + const malformedResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe( + HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + HttpClientRequest.setHeader("Mcp-Protocol-Version", "9999-01-01"), + HttpClientRequest.bodyText("{"), + HttpClientRequest.setHeader("content-type", "application/json"), + httpClient.execute + ) + strictEqual(malformedResponse.status, 400) + strictEqual(yield* malformedResponse.text, "") + + const malformedNoVersionResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe( + HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + HttpClientRequest.bodyText("{"), + HttpClientRequest.setHeader("content-type", "application/json"), + httpClient.execute + ) + strictEqual(malformedNoVersionResponse.status, 200) + const malformedNoVersionBody = JSON.parse(yield* malformedNoVersionResponse.text) + strictEqual(malformedNoVersionBody.id, null) + strictEqual(malformedNoVersionBody.error.code, McpSchema.PARSE_ERROR_CODE) + + const invalidRequestResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe( + HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + HttpClientRequest.setHeader("Mcp-Protocol-Version", "9999-01-01"), + HttpClientRequest.bodyJsonUnsafe({ hello: "world" }), + httpClient.execute + ) + strictEqual(invalidRequestResponse.status, 400) + strictEqual(yield* invalidRequestResponse.text, "") + + const invalidRequestNoVersionResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe( + HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + HttpClientRequest.bodyJsonUnsafe({ hello: "world" }), + httpClient.execute + ) + strictEqual(invalidRequestNoVersionResponse.status, 200) + const invalidRequestNoVersionBody = JSON.parse(yield* invalidRequestNoVersionResponse.text) + strictEqual(invalidRequestNoVersionBody.id, null) + strictEqual(invalidRequestNoVersionBody.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE) + + const invalidInitializeResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe( + HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + HttpClientRequest.setHeader("Mcp-Protocol-Version", "9999-01-01"), + HttpClientRequest.bodyJsonUnsafe({ method: "initialize", id: 7 }), + httpClient.execute + ) + strictEqual(invalidInitializeResponse.status, 400) + strictEqual(yield* invalidInitializeResponse.text, "") + const responseOnly = yield* HttpClientRequest.post("http://localhost/mcp").pipe( HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), HttpClientRequest.bodyJsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), @@ -487,12 +676,8 @@ describe("McpServer", () => { it.effect("should isolate resource update subscriptions between sessions", () => Effect.gen(function*() { const clientIds = new Set([1, 2]) - const client1Outbound = yield* Queue.unbounded< - RpcMessage.FromServerEncoded | RpcMessage.RequestEncoded - >() - const client2Outbound = yield* Queue.unbounded< - RpcMessage.FromServerEncoded | RpcMessage.RequestEncoded - >() + const client1Outbound = yield* Queue.unbounded() + const client2Outbound = yield* Queue.unbounded() const disconnects = yield* Queue.unbounded() const writeRequest = yield* Deferred.make< (clientId: number, message: RpcMessage.FromClientEncoded) => Effect.Effect @@ -508,7 +693,9 @@ describe("McpServer", () => { initialMessage: Effect.succeedNone, supportsAck: false, supportsTransferables: false, - supportsSpanPropagation: false + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: Schema.toCodecJson as RpcSerialization.CodecFor }) ) ) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts new file mode 100644 index 0000000000..ab7f03d673 --- /dev/null +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts @@ -0,0 +1,1290 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import { CurrentLogLevel } from "effect/References" +import * as Schema from "effect/Schema" +import * as McpCore from "effect/unstable/ai/internal/mcpCore" +import * as McpProtocol from "effect/unstable/ai/McpProtocol" +import * as McpSchema from "effect/unstable/ai/McpSchema" +import * as McpServer from "effect/unstable/ai/McpServer" +import * as Tool from "effect/unstable/ai/Tool" +import * as Toolkit from "effect/unstable/ai/Toolkit" +import * as RpcClient from "effect/unstable/rpc/RpcClient" +import type * as RpcSerialization from "effect/unstable/rpc/RpcSerialization" +import { makeHttpHarness } from "./TestUtils/McpHttpHarness.ts" + +const ServerIcon = McpSchema.Icon.make({ + src: "https://example.com/server.svg", + mimeType: "image/svg+xml", + sizes: ["48x48", "any"], + theme: "dark" +}) +const ResourceIcon = McpSchema.Icon.make({ src: "https://example.com/resource.svg" }) +const PromptIcon = McpSchema.Icon.make({ src: "https://example.com/prompt.svg" }) +const ToolIcon = McpSchema.Icon.make({ src: "https://example.com/tool.svg" }) + +const SharedTool = Tool.make("shared", { + parameters: Tool.EmptyParams, + success: Schema.String +}).annotate(Tool.Title, "Shared tool title") + +const StructuredOnlyTool = Tool.make("structured-only", { + parameters: Tool.EmptyParams, + success: Schema.Struct({ value: Schema.String }) +}).annotate( + McpSchema.EnabledWhen, + (client) => client.protocolVersion === "2025-06-18" +) + +const ValidatedTool = Tool.make("validated", { + parameters: Schema.Struct({ + value: Schema.String + }), + success: Schema.String +}) + +const CapabilityTool = Tool.make("capability", { + parameters: Tool.EmptyParams, + success: Schema.String, + dependencies: [McpSchema.McpServerClient] +}) + +const InitializeMetadataTool = Tool.make("initialize-metadata", { + parameters: Tool.EmptyParams, + success: Schema.String, + dependencies: [McpSchema.McpServerClient] +}) + +const LogLevelTool = Tool.make("log-level", { + parameters: Tool.EmptyParams, + success: Schema.String, + dependencies: [CurrentLogLevel] +}) + +const CapabilityGatedTool = Tool.make("capability-gated", { + parameters: Tool.EmptyParams, + success: Schema.String +}).annotate( + McpSchema.EnabledWhen, + (client) => + client.clientInfo.name === "allowed-client" && + client.capabilities.roots !== undefined +) + +const TestToolkit = Toolkit.make( + SharedTool, + StructuredOnlyTool, + ValidatedTool, + CapabilityTool, + InitializeMetadataTool, + LogLevelTool, + CapabilityGatedTool +) + +const FamilyResource = McpServer.resource({ + uri: "file:///canonical.txt", + name: "canonical", + description: "Canonical resource", + mimeType: "text/plain", + content: Effect.succeed("canonical resource") +}) + +const FamilyPrompt = McpServer.prompt({ + name: "canonical-prompt", + description: "Canonical prompt", + parameters: { + style: Schema.String + }, + completion: { + style: () => Effect.succeed(["short", "long"]) + }, + content: ({ style }) => Effect.succeed(`Use the ${style} style`) +}) + +const AudioPrompt = McpServer.prompt({ + name: "audio-prompt", + content: () => + Effect.succeed([{ + role: "user", + content: McpSchema.AudioContent.make({ + data: new Uint8Array([1, 2, 3]), + mimeType: "audio/wav" + }) + }]) +}) + +const ResourceLinkPrompt = McpServer.prompt({ + name: "resource-link-prompt", + content: () => + Effect.succeed([{ + role: "user", + content: McpSchema.ResourceLink.make({ + uri: "file:///canonical.txt", + name: "canonical" + }) + }]) +}) + +interface TestState { + sharedInvocations: number + structuredInvocations: number + capabilityInvocations: number +} + +const makeFixture = Effect.fnUntraced(function*() { + const state: TestState = { + sharedInvocations: 0, + structuredInvocations: 0, + capabilityInvocations: 0 + } + const toolkitLayer = McpServer.toolkit(TestToolkit).pipe( + Layer.provideMerge(TestToolkit.toLayer(TestToolkit.of({ + shared: () => + Effect.sync(() => { + state.sharedInvocations++ + return "shared-result" + }), + "structured-only": () => + Effect.sync(() => { + state.structuredInvocations++ + return { value: "structured-result" } + }), + validated: ({ value }) => Effect.succeed(value), + capability: () => + McpServer.clientCapabilities.pipe( + Effect.map((capabilities) => { + state.capabilityInvocations++ + return JSON.stringify(capabilities) + }) + ), + "initialize-metadata": () => + McpSchema.McpServerClient.useSync((client) => JSON.stringify(client.initializePayload._meta)), + "log-level": () => CurrentLogLevel, + "capability-gated": () => Effect.succeed("visible") + }))) + ) + const serverLayer = Layer.mergeAll( + toolkitLayer, + FamilyResource, + FamilyPrompt, + AudioPrompt, + ResourceLinkPrompt + ).pipe( + Layer.provide(McpServer.layerHttp({ + name: "ProtocolAdapterServer", + version: "1.0.0", + description: "Protocol adapter fixture", + websiteUrl: "https://example.com/mcp", + icons: [McpSchema.Icon.make({ + src: "https://example.com/server.svg", + mimeType: "image/svg+xml", + sizes: ["any"] + })], + path: "/mcp", + protocols: [ + McpProtocol.v2025_11_25, + McpProtocol.v2025_06_18, + McpProtocol.v2025_03_26, + McpProtocol.v2024_11_05 + ] + })) + ) + const harness = yield* makeHttpHarness(serverLayer) + return { ...harness, state } +}) + +interface LowLevelTestState { + imageInvocations: number + audioInvocations: number + resourceLinkInvocations: number + projectionMismatchInvocations: number + argumentObservations: Array + duplicateInvocations: Array<"first" | "second"> +} + +const makeLowLevelFixture = Effect.fnUntraced(function*() { + const state: LowLevelTestState = { + imageInvocations: 0, + audioInvocations: 0, + resourceLinkInvocations: 0, + projectionMismatchInvocations: 0, + argumentObservations: [], + duplicateInvocations: [] + } + const registrations = Layer.effectDiscard( + Effect.gen(function*() { + const server = yield* McpServer.McpServer + const makeTool = (name: string, description: string) => + new McpSchema.Tool({ + name, + description, + inputSchema: { + type: "object", + properties: {} + } + }) + + yield* server.addResource({ + resource: new McpSchema.Resource({ + uri: "file:///metadata.txt", + name: "metadata-resource", + icons: [ResourceIcon], + _meta: { descriptor: "fixture" } + }), + annotations: Context.empty(), + handle: Effect.succeed( + McpSchema.ReadResourceResult.make({ + contents: [{ uri: "file:///metadata.txt", text: "metadata" }], + _meta: { result: "fixture" } + }) + ) + }) + yield* server.addPrompt({ + prompt: new McpSchema.Prompt({ + name: "metadata-prompt", + icons: [PromptIcon], + _meta: { descriptor: "fixture" } + }), + annotations: Context.empty(), + completions: { + value: () => + Effect.succeed( + McpSchema.CompleteResult.make({ + completion: { values: ["metadata"] }, + _meta: { result: "fixture" } + }) + ) + }, + handle: () => + Effect.succeed( + McpSchema.GetPromptResult.make({ + messages: [{ + role: "user", + content: { type: "text", text: "metadata", _meta: { content: "fixture" } } + }], + _meta: { result: "fixture" } + }) + ) + }) + + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "title-precedence", + title: "Canonical title", + icons: [ToolIcon], + inputSchema: { + type: "object", + properties: {} + }, + annotations: { + title: "Legacy annotation title" + } + }), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ type: "text", text: "title" }] + }) + ) + }) + yield* server.addTool({ + tool: makeTool("projection-mismatch", "Claims old support but returns audio"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.projectionMismatchInvocations++ + return new McpSchema.CallToolResult({ + content: [{ + type: "audio", + data: new Uint8Array([1]), + mimeType: "audio/wav" + }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("arguments", "Argument normalization probe"), + annotations: Context.empty(), + handle: (payload) => + Effect.sync(() => { + state.argumentObservations.push(payload) + return new McpSchema.CallToolResult({ + content: [{ type: "text", text: "arguments" }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("image", "Image for both revisions"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.imageInvocations++ + return new McpSchema.CallToolResult({ + content: [{ + type: "image", + data: new Uint8Array([1, 2, 3]), + mimeType: "image/png" + }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("binary-resource", "Binary embedded resource"), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ + type: "resource", + resource: { + uri: "file:///binary.dat", + mimeType: "application/octet-stream", + blob: new Uint8Array([7, 8, 9]) + } + }] + }) + ) + }) + yield* server.addTool({ + tool: makeTool("annotated-resource", "Annotated embedded resource"), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ + type: "resource", + resource: { + uri: "file:///annotated.txt", + mimeType: "text/plain", + text: "annotated", + _meta: { source: "fixture" } + }, + annotations: { + audience: ["assistant"], + priority: 0.75, + lastModified: "2026-08-13T00:00:00Z" + }, + _meta: { content: "fixture" } + }], + _meta: { result: "fixture" } + }) + ) + }) + yield* server.addTool({ + tool: makeTool("audio", "Current-revision audio"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.audioInvocations++ + return new McpSchema.CallToolResult({ + content: [{ + type: "audio", + data: new Uint8Array([4, 5, 6]), + mimeType: "audio/wav" + }] + }) + }) + }) + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "schema-output", + description: "Tool with an output schema", + inputSchema: { + type: "object", + properties: {} + }, + outputSchema: { + type: "object", + properties: { + value: { type: "string" } + }, + required: ["value"] + }, + _meta: { descriptor: "fixture" } + }), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ type: "text", text: "schema" }] + }) + ) + }) + yield* server.addTool({ + tool: makeTool("structured-object", "Object structured content"), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ type: "text", text: "structured" }], + structuredContent: { value: "fixture" } + }) + ) + }) + yield* server.addTool({ + tool: makeTool("structured-scalar", "Scalar structured content"), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ type: "text", text: "structured" }], + structuredContent: "fixture" + }) + ) + }) + yield* server.addTool({ + tool: makeTool("resource-link", "Current-revision resource link"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.resourceLinkInvocations++ + return new McpSchema.CallToolResult({ + content: [{ + type: "resource_link", + name: "linked", + title: "Linked resource", + description: "A linked fixture", + uri: "file:///linked.txt", + mimeType: "text/plain", + annotations: { + audience: ["user"], + priority: 0.5 + }, + size: 42, + _meta: { source: "fixture" } + }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("duplicate", "first descriptor"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.duplicateInvocations.push("first") + return new McpSchema.CallToolResult({ + content: [{ type: "text", text: "first" }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("duplicate", "second descriptor"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.duplicateInvocations.push("second") + return new McpSchema.CallToolResult({ + content: [{ type: "text", text: "second" }] + }) + }) + }) + }) + ) + const serverLayer = registrations.pipe( + Layer.provide(McpServer.layerHttp({ + name: "LowLevelProtocolAdapterServer", + version: "1.0.0", + description: "Low-level protocol adapter fixture", + websiteUrl: "https://example.com/low-level-mcp", + icons: [ServerIcon], + path: "/mcp", + protocols: [ + McpProtocol.v2025_11_25, + McpProtocol.v2025_06_18, + McpProtocol.v2025_03_26, + McpProtocol.v2024_11_05 + ] + })) + ) + const harness = yield* makeHttpHarness(serverLayer) + return { ...harness, state } +}) + +const JsonRpcResponse = Schema.Union([ + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.Number, + result: Schema.Record(Schema.String, Schema.Unknown) + }), + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.NullOr(Schema.Number), + error: Schema.Struct({ + code: Schema.Number, + message: Schema.String + }) + }) +]) +type JsonRpcResponse = typeof JsonRpcResponse.Type + +const decodeJsonRpcResponse = Schema.decodeUnknownEffect(JsonRpcResponse) + +const initialize = Effect.fnUntraced(function*( + post: Effect.Success>["post"], + protocolVersion: "2025-11-25" | "2025-06-18" | "2025-03-26" | "2024-11-05", + options?: { + readonly capabilities?: Record + readonly clientInfo?: { + readonly name: string + readonly version: string + } + } +) { + const response = yield* post({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion, + capabilities: options?.capabilities ?? {}, + clientInfo: options?.clientInfo ?? { + name: "ProtocolAdapterClient", + version: "1.0.0" + } + } + }) + const body = yield* Effect.promise(() => response.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + if (!("result" in body)) { + return yield* Effect.die(`Initialization failed: ${body.error.message}`) + } + const sessionId = response.headers.get("Mcp-Session-Id") + assert.isNotNull(sessionId) + assert.strictEqual(body.result.protocolVersion, protocolVersion) + + let requestId = 1 + const request = Effect.fnUntraced(function*(method: string, params?: unknown) { + const response = yield* post({ + jsonrpc: "2.0", + id: ++requestId, + method, + ...(params === undefined ? {} : { params }) + }, { + "Mcp-Protocol-Version": protocolVersion, + "Mcp-Session-Id": sessionId + }) + return yield* Effect.promise(() => response.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + }) + + return { initializeResult: body.result, protocolVersion, request } +}) + +const resultOf = (message: JsonRpcResponse): Record => { + if (!("result" in message)) { + assert.fail(`Expected result, received error ${message.error.code}: ${message.error.message}`) + } + return message.result +} + +const errorOf = (message: JsonRpcResponse): { + readonly code: number + readonly message: string +} => { + if (!("error" in message)) { + assert.fail("Expected error, received result") + } + return message.error +} + +const listedTools = (message: JsonRpcResponse): ReadonlyArray> => { + const tools = resultOf(message).tools + return Schema.decodeUnknownSync(Schema.Array(Schema.Record(Schema.String, Schema.Unknown)))(tools) +} + +const textResult = (message: JsonRpcResponse): string => { + const content = resultOf(message).content + const [first] = Schema.decodeUnknownSync( + Schema.NonEmptyArray(Schema.Record(Schema.String, Schema.Unknown)) + )(content) + assert.strictEqual(first.type, "text") + const text = Schema.decodeUnknownSync(Schema.String)(first.text) + return JSON.parse(text) +} + +describe("McpServer protocol adapters", () => { + it.effect("should keep log levels isolated when one session updates its level", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const first = yield* initialize(fixture.post, "2025-06-18") + const second = yield* initialize(fixture.post, "2025-06-18") + + resultOf(yield* first.request("logging/setLevel", { level: "debug" })) + + assert.strictEqual( + textResult(yield* first.request("tools/call", { name: "log-level" })), + "Debug" + ) + assert.strictEqual( + textResult(yield* second.request("tools/call", { name: "log-level" })), + "Info" + ) + })) + + it.effect("should reject prompt content when the negotiated schema cannot represent it", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const oldClient = yield* initialize(fixture.post, "2024-11-05") + const marchClient = yield* initialize(fixture.post, "2025-03-26") + const juneClient = yield* initialize(fixture.post, "2025-06-18") + + assert.strictEqual( + errorOf(yield* oldClient.request("prompts/get", { name: "audio-prompt" })).code, + McpSchema.INVALID_PARAMS_ERROR_CODE + ) + assert.property( + resultOf(yield* marchClient.request("prompts/get", { name: "audio-prompt" })), + "messages" + ) + assert.strictEqual( + errorOf(yield* marchClient.request("prompts/get", { name: "resource-link-prompt" })).code, + McpSchema.INVALID_PARAMS_ERROR_CODE + ) + assert.property( + resultOf(yield* juneClient.request("prompts/get", { name: "resource-link-prompt" })), + "messages" + ) + })) + + it.effect("should project canonical notifications when encoding each protocol revision", () => + Effect.gen(function*() { + for ( + const protocol of [ + McpProtocol.v2024_11_05, + McpProtocol.v2025_03_26, + McpProtocol.v2025_06_18 + ] + ) { + const metadata = { source: "test" } + const progress = yield* protocol.projectNotification(McpCore.ServerNotification.Progress({ + progressToken: "task-1", + progress: 1, + total: 2, + message: "half way", + metadata + })) + assert.isDefined(progress) + assert.strictEqual(progress.tag, "notifications/progress") + assert.deepStrictEqual(progress.payload, { + _meta: metadata, + progressToken: "task-1", + progress: 1, + total: 2, + message: protocol.protocolVersion === "2024-11-05" ? undefined : "half way" + }) + + for ( + const [notification, expected] of [ + [ + McpCore.ServerNotification.Cancelled({ requestId: 1, reason: "done", metadata }), + { tag: "notifications/cancelled", payload: { _meta: metadata, requestId: 1, reason: "done" } } + ], + [ + McpCore.ServerNotification.LoggingMessage({ + level: "info", + logger: "test", + data: { ready: true }, + metadata + }), + { + tag: "notifications/message", + payload: { _meta: metadata, level: "info", logger: "test", data: { ready: true } } + } + ], + [ + McpCore.ServerNotification.ResourceUpdated({ uri: "test://resource", metadata }), + { + tag: "notifications/resources/updated", + payload: { _meta: metadata, uri: "test://resource" } + } + ], + [ + McpCore.ServerNotification.ResourcesChanged({ metadata }), + { tag: "notifications/resources/list_changed", payload: { _meta: metadata } } + ], + [ + McpCore.ServerNotification.ToolsChanged({ metadata }), + { tag: "notifications/tools/list_changed", payload: { _meta: metadata } } + ], + [ + McpCore.ServerNotification.PromptsChanged({ metadata }), + { tag: "notifications/prompts/list_changed", payload: { _meta: metadata } } + ] + ] as const + ) { + assert.deepStrictEqual(yield* protocol.projectNotification(notification), expected) + } + } + + const elicitationComplete = McpCore.ServerNotification.ElicitationComplete({ elicitationId: "elicitation-1" }) + assert.deepStrictEqual(yield* McpProtocol.v2025_11_25.projectNotification(elicitationComplete), { + tag: "notifications/elicitation/complete", + payload: { elicitationId: "elicitation-1" } + }) + for ( + const protocol of [ + McpProtocol.v2024_11_05, + McpProtocol.v2025_03_26, + McpProtocol.v2025_06_18 + ] + ) { + assert.isUndefined(yield* protocol.projectNotification(elicitationComplete)) + } + })) + it.effect("should omit elicitation when the negotiated protocol predates v2025-06-18", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26"] as const) { + const client = yield* initialize(fixture.post, protocolVersion, { + capabilities: { elicitation: {} } + }) + assert.notProperty(client.initializeResult.capabilities, "elicitation") + assert.strictEqual( + textResult(yield* client.request("tools/call", { name: "capability" })), + "{}" + ) + } + })) + + it.effect("should preserve initialize metadata when creating the public request context", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18"] as const) { + const response = yield* fixture.post({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion, + capabilities: {}, + clientInfo: { name: "metadata-client", version: "1.0.0" }, + _meta: { progressToken: protocolVersion } + } + }) + const sessionId = response.headers.get("Mcp-Session-Id") + assert.isNotNull(sessionId) + const toolResponse = yield* fixture.post({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "initialize-metadata" } + }, { + "Mcp-Protocol-Version": protocolVersion, + "Mcp-Session-Id": sessionId + }) + const body = yield* Effect.promise(() => toolResponse.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + assert.strictEqual(textResult(body), JSON.stringify({ progressToken: protocolVersion })) + } + })) + + it.effect("should reject elicitation before transport when the protocol predates v2025-06-18", () => + Effect.gen(function*() { + let sends = 0 + const reverseProtocol = yield* RpcClient.Protocol.make(() => + Effect.succeed({ + send: () => + Effect.sync(() => { + sends++ + }), + supportsAck: true, + supportsTransferables: false, + codecFor: Schema.toCodecJson as RpcSerialization.CodecFor + }) + ) + for ( + const protocol of [ + McpProtocol.v2024_11_05, + McpProtocol.v2025_03_26 + ] + ) { + const client = yield* protocol.makeReverseClient({ + protocolVersion: protocol.protocolVersion, + clientCapabilities: { elicitation: {} }, + clientInfo: { name: "test", version: "1.0.0" } + }).pipe( + Effect.provideService(RpcClient.Protocol, reverseProtocol) + ) + const error = yield* client.elicit({ + message: "test", + requestedSchema: { + type: "object", + properties: {} + } + }).pipe(Effect.flip) + assert.instanceOf(error, McpSchema.McpReverseOperationUnsupported) + } + assert.strictEqual(sends, 0) + }).pipe(Effect.scoped)) + + it.effect("should omit June fields when projecting a March tool descriptor", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const client = yield* initialize(fixture.post, "2025-03-26") + const shared = listedTools(yield* client.request("tools/list")) + .find((tool) => tool.name === "shared") + + assert.isDefined(shared) + assert.deepStrictEqual(shared.inputSchema, { type: "object" }) + assert.deepStrictEqual(shared.annotations, { + title: "Shared tool title", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + }) + assert.notProperty(shared, "title") + assert.notProperty(shared, "outputSchema") + assert.notProperty(shared, "_meta") + })) + + it.effect("should prefer the canonical title when projecting March annotations", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const marchClient = yield* initialize(fixture.post, "2025-03-26") + const tool = listedTools(yield* marchClient.request("tools/list")) + .find((tool) => tool.name === "title-precedence") + + assert.isDefined(tool) + assert.deepStrictEqual(tool.annotations, { + title: "Canonical title", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + }) + })) + + it.effect("should return a typed protocol error when a result cannot be projected", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const client = yield* initialize(fixture.post, "2024-11-05") + const error = errorOf( + yield* client.request("tools/call", { + name: "projection-mismatch" + }) + ) + + assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + assert.match(error.message, /audio tool content is not supported by MCP 2024-11-05/) + assert.strictEqual(fixture.state.projectionMismatchInvocations, 1) + })) + + it.effect("should route each session through its negotiated RPC group when revisions coexist", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const oldClient = yield* initialize(fixture.post, "2024-11-05") + const currentClient = yield* initialize(fixture.post, "2025-06-18") + + const oldShared = listedTools(yield* oldClient.request("tools/list")) + .find((tool) => tool.name === "shared") + const currentShared = listedTools(yield* currentClient.request("tools/list")) + .find((tool) => tool.name === "shared") + + assert.isDefined(oldShared) + assert.isDefined(currentShared) + assert.notProperty(oldShared, "title") + assert.strictEqual(currentShared.title, "Shared tool title") + })) + + for (const protocolVersion of ["2025-06-18", "2024-11-05"] as const) { + it.effect(`should expose the negotiated client profile when serving ${protocolVersion} requests`, () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const advertisedCapabilities = { + roots: { listChanged: true }, + sampling: {} + } + const allowed = yield* initialize(fixture.post, protocolVersion, { + capabilities: advertisedCapabilities, + clientInfo: { name: "allowed-client", version: "2.0.0" } + }) + const denied = yield* initialize(fixture.post, protocolVersion, { + capabilities: {}, + clientInfo: { name: "other-client", version: "1.0.0" } + }) + + const allowedTools = listedTools(yield* allowed.request("tools/list")) + const deniedTools = listedTools(yield* denied.request("tools/list")) + assert.isTrue(allowedTools.some((tool) => tool.name === "capability-gated")) + assert.isFalse(deniedTools.some((tool) => tool.name === "capability-gated")) + + const observed = JSON.parse(textResult( + yield* allowed.request("tools/call", { name: "capability" }) + )) + assert.deepStrictEqual(observed, advertisedCapabilities) + assert.strictEqual(fixture.state.capabilityInvocations, 1) + })) + } + + it.effect("should project the exact descriptor shape when listing tools for each revision", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + + const currentTools = listedTools(yield* currentClient.request("tools/list")) + const oldTools = listedTools(yield* oldClient.request("tools/list")) + const currentShared = currentTools.find((tool) => tool.name === "shared") + const oldShared = oldTools.find((tool) => tool.name === "shared") + + assert.isDefined(currentShared) + assert.isDefined(oldShared) + assert.strictEqual(currentShared.title, "Shared tool title") + assert.deepStrictEqual(currentShared.annotations, { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + }) + assert.notProperty(oldShared, "title") + assert.notProperty(oldShared, "annotations") + assert.notProperty(oldShared, "_meta") + assert.notProperty(oldShared, "outputSchema") + })) + + it.effect("should project outputSchema only when the protocol revision supports it", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + const currentTools = listedTools(yield* currentClient.request("tools/list")) + const oldTools = listedTools(yield* oldClient.request("tools/list")) + const currentSchemaOutput = currentTools.find((tool) => tool.name === "schema-output") + const oldSchemaOutput = oldTools.find((tool) => tool.name === "schema-output") + assert.isDefined(currentSchemaOutput) + assert.isDefined(oldSchemaOutput) + assert.deepStrictEqual(currentSchemaOutput.outputSchema, { + type: "object", + properties: { + value: { type: "string" } + }, + required: ["value"] + }) + assert.deepStrictEqual(currentSchemaOutput._meta, { descriptor: "fixture" }) + assert.notProperty(oldSchemaOutput, "outputSchema") + assert.notProperty(oldSchemaOutput, "_meta") + })) + + it.effect("should encode binary content for every revision that can represent it", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const image = resultOf(yield* client.request("tools/call", { name: "image" })) + assert.deepStrictEqual(image.content, [{ + type: "image", + data: "AQID", + mimeType: "image/png" + }]) + + const embedded = resultOf( + yield* client.request("tools/call", { name: "binary-resource" }) + ) + assert.deepStrictEqual(embedded.content, [{ + type: "resource", + resource: { + uri: "file:///binary.dat", + mimeType: "application/octet-stream", + blob: "BwgJ" + } + }]) + } + + for (const protocolVersion of ["2025-03-26", "2025-06-18"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const audio = resultOf(yield* client.request("tools/call", { name: "audio" })) + assert.deepStrictEqual(audio.content, [{ + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + }]) + } + })) + + it.effect("should project structured content according to the negotiated revision", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const objectResult = resultOf( + yield* client.request("tools/call", { name: "structured-object" }) + ) + const scalarResult = resultOf( + yield* client.request("tools/call", { name: "structured-scalar" }) + ) + assert.notProperty(objectResult, "structuredContent") + assert.notProperty(scalarResult, "structuredContent") + } + + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const objectResult = resultOf( + yield* currentClient.request("tools/call", { name: "structured-object" }) + ) + assert.deepStrictEqual(objectResult.structuredContent, { value: "fixture" }) + + const scalarError = errorOf( + yield* currentClient.request("tools/call", { name: "structured-scalar" }) + ) + assert.strictEqual(scalarError.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + assert.match(scalarError.message, /non-object structured tool content is not supported by MCP 2025-06-18/) + })) + + it.effect("should preserve only supported metadata when projecting embedded resource content", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + + for (const protocolVersion of ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const result = resultOf( + yield* client.request("tools/call", { name: "annotated-resource" }) + ) + const [content] = Schema.decodeUnknownSync( + Schema.NonEmptyArray(Schema.Record(Schema.String, Schema.Unknown)) + )(result.content) + assert.deepStrictEqual(result._meta, { result: "fixture" }) + const resource = Schema.decodeUnknownSync( + Schema.Record(Schema.String, Schema.Unknown) + )(content.resource) + assert.deepStrictEqual( + content.annotations, + protocolVersion === "2025-11-25" + ? { + audience: ["assistant"], + priority: 0.75, + lastModified: "2026-08-13T00:00:00Z" + } + : { + audience: ["assistant"], + priority: 0.75 + } + ) + assert.strictEqual(resource.uri, "file:///annotated.txt") + assert.strictEqual(resource.mimeType, "text/plain") + assert.strictEqual(resource.text, "annotated") + if (protocolVersion === "2025-11-25" || protocolVersion === "2025-06-18") { + assert.deepStrictEqual(resource._meta, { source: "fixture" }) + assert.deepStrictEqual(content._meta, { content: "fixture" }) + } else { + assert.notProperty(resource, "_meta") + assert.notProperty(content, "_meta") + } + } + })) + + it.effect("should preserve result metadata while omitting June-only metadata for older revisions", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const resources = resultOf(yield* client.request("resources/list")) + const resource = (resources.resources as Array>).find( + (_) => _.uri === "file:///metadata.txt" + )! + const read = resultOf( + yield* client.request("resources/read", { uri: "file:///metadata.txt" }) + ) + const prompts = resultOf(yield* client.request("prompts/list")) + const promptDescriptor = (prompts.prompts as Array>).find( + (_) => _.name === "metadata-prompt" + )! + const prompt = resultOf( + yield* client.request("prompts/get", { name: "metadata-prompt" }) + ) + const completion = resultOf( + yield* client.request("completion/complete", { + ref: { type: "ref/prompt", name: "metadata-prompt" }, + argument: { name: "value", value: "m" } + }) + ) + + assert.deepStrictEqual(read._meta, { result: "fixture" }) + assert.deepStrictEqual(prompt._meta, { result: "fixture" }) + assert.deepStrictEqual(completion._meta, { result: "fixture" }) + const content = (prompt.messages as Array>)[0].content + if (protocolVersion === "2025-06-18") { + assert.deepStrictEqual(resource._meta, { descriptor: "fixture" }) + assert.deepStrictEqual(promptDescriptor._meta, { descriptor: "fixture" }) + assert.deepStrictEqual(content._meta, { content: "fixture" }) + } else { + assert.notProperty(resource, "_meta") + assert.notProperty(promptDescriptor, "_meta") + assert.notProperty(content, "_meta") + } + } + })) + + it.effect("should expose implementation metadata and descriptor icons when supported by the protocol", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const november = yield* initialize(fixture.post, "2025-11-25") + const june = yield* initialize(fixture.post, "2025-06-18") + + assert.deepStrictEqual(november.initializeResult.serverInfo, { + name: "LowLevelProtocolAdapterServer", + version: "1.0.0", + description: "Low-level protocol adapter fixture", + websiteUrl: "https://example.com/low-level-mcp", + icons: [{ + src: "https://example.com/server.svg", + mimeType: "image/svg+xml", + sizes: ["48x48", "any"], + theme: "dark" + }] + }) + assert.deepStrictEqual(june.initializeResult.serverInfo, { + name: "LowLevelProtocolAdapterServer", + version: "1.0.0" + }) + + const resources = resultOf(yield* november.request("resources/list")) + const resource = (resources.resources as Array>).find( + (_) => _.uri === "file:///metadata.txt" + ) + assert.isDefined(resource) + assert.deepStrictEqual(resource.icons, [{ src: "https://example.com/resource.svg" }]) + + const prompts = resultOf(yield* november.request("prompts/list")) + const prompt = (prompts.prompts as Array>).find((_) => _.name === "metadata-prompt") + assert.isDefined(prompt) + assert.deepStrictEqual(prompt.icons, [{ src: "https://example.com/prompt.svg" }]) + + const tool = listedTools(yield* november.request("tools/list")).find((_) => _.name === "title-precedence") + assert.isDefined(tool) + assert.deepStrictEqual(tool.icons, [{ src: "https://example.com/tool.svg" }]) + })) + + it.effect("should hide and reject a tool when its declaration is incompatible with the revision", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + + const currentTools = listedTools(yield* currentClient.request("tools/list")) + const oldTools = listedTools(yield* oldClient.request("tools/list")) + + assert.isTrue(currentTools.some((tool) => tool.name === "structured-only")) + assert.isFalse(oldTools.some((tool) => tool.name === "structured-only")) + + const oldCall = yield* oldClient.request("tools/call", { + name: "structured-only", + arguments: {} + }) + assert.property(oldCall, "error") + assert.strictEqual(fixture.state.structuredInvocations, 0) + })) + + it.effect("should reject after invocation when a tool result cannot be projected", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + + const currentTools = listedTools(yield* currentClient.request("tools/list")) + const oldTools = listedTools(yield* oldClient.request("tools/list")) + assert.isTrue(currentTools.some((tool) => tool.name === "audio")) + assert.isTrue(currentTools.some((tool) => tool.name === "resource-link")) + assert.isTrue(oldTools.some((tool) => tool.name === "audio")) + assert.isTrue(oldTools.some((tool) => tool.name === "resource-link")) + + assert.property( + yield* oldClient.request("tools/call", { name: "audio" }), + "error" + ) + assert.property( + yield* oldClient.request("tools/call", { name: "resource-link" }), + "error" + ) + assert.strictEqual(fixture.state.audioInvocations, 1) + assert.strictEqual(fixture.state.resourceLinkInvocations, 1) + })) + + it.effect("should return a typed failure when structured tool content is not valid JSON", () => + Effect.gen(function*() { + const server = yield* McpServer.McpServer.make + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "invalid-structured-content", + inputSchema: { + type: "object", + properties: {} + } + }), + annotations: Context.empty(), + handle: () => + Effect.succeed({ + content: [{ type: "text", text: "invalid" }], + structuredContent: Symbol("not-json") + } as unknown as McpSchema.CallToolResult) + }) + + const error = yield* server.callTool({ + name: "invalid-structured-content", + arguments: {} + }).pipe( + Effect.provideService( + McpSchema.McpServerClient, + McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { + name: "direct-client", + version: "1.0.0" + }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { + name: "direct-client", + version: "1.0.0" + } + }, + getClient: Effect.die("not used") + }) + ), + Effect.flip + ) + + assert.instanceOf(error, McpSchema.InvalidParams) + assert.strictEqual( + error.message, + "Tool 'invalid-structured-content' returned structured content that is not valid JSON" + ) + })) + + it("should reject a tool schema when inputSchema is not an object", () => { + assert.throws(() => + Schema.decodeUnknownSync(McpSchema.Tool)({ + name: "invalid", + inputSchema: "anything" + }) + ) + }) + + it.effect("should list and invoke the latest tool when a name is registered twice", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + + for (const client of [currentClient, oldClient]) { + const duplicates = listedTools( + yield* client.request("tools/list") + ).filter((tool) => tool.name === "duplicate") + assert.lengthOf(duplicates, 1) + assert.strictEqual(duplicates[0]?.description, "second descriptor") + + const result = resultOf( + yield* client.request("tools/call", { name: "duplicate" }) + ) + assert.deepStrictEqual(result.content, [{ type: "text", text: "second" }]) + } + assert.deepStrictEqual(fixture.state.duplicateInvocations, ["second", "second"]) + })) +}) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/TestUtils/McpServerLayer.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/TestUtils/McpServerLayer.ts index 6d6e49f40f..78f29cca6d 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/TestUtils/McpServerLayer.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/TestUtils/McpServerLayer.ts @@ -2,6 +2,7 @@ import { constVoid } from "effect/Function" import * as Layer from "effect/Layer" import * as Logger from "effect/Logger" import * as References from "effect/References" +import type * as Schema from "effect/Schema" import * as McpProtocol from "effect/unstable/ai/McpProtocol" import * as McpServer from "effect/unstable/ai/McpServer" @@ -16,7 +17,8 @@ export const makeServerLayer = (options: { ...Array ] | undefined - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: Readonly> | undefined + readonly allowedOrigins?: ReadonlyArray | undefined }) => McpServer.layerHttp({ name: options.name, diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/utils.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/utils.ts deleted file mode 100644 index fc1aea5b46..0000000000 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/utils.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as Effect from "effect/Effect" -import { constVoid } from "effect/Function" -import * as Layer from "effect/Layer" -import * as Logger from "effect/Logger" -import * as References from "effect/References" -import * as McpProtocol from "effect/unstable/ai/McpProtocol" -import * as McpServer from "effect/unstable/ai/McpServer" -import * as HttpRouter from "effect/unstable/http/HttpRouter" - -export const MCP_ENDPOINT = "http://localhost/mcp" - -const noopLogger = Logger.make(constVoid) - -export const makeServerLayer = (options: { - readonly name: string - readonly version?: string | undefined - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined -}) => - McpServer.layerHttp({ - name: options.name, - version: options.version ?? "1.0.0", - path: "/mcp", - protocols: [McpProtocol.v2025_06_18], - extensions: options.extensions - }).pipe( - Layer.provideMerge(Layer.succeed( - References.CurrentLoggers, - new Set([noopLogger]) - )), - Layer.orDie - ) - -export const makeWebHandler = Effect.fnUntraced(function*( - serverLayer: Layer.Layer, - options?: { - readonly routerLayer?: Layer.Layer | undefined - } -) { - const appLayer = options?.routerLayer ? Layer.merge(serverLayer, options.routerLayer) : serverLayer - const { dispose, handler } = HttpRouter.toWebHandler(appLayer, { disableLogger: true }) - yield* Effect.addFinalizer(() => Effect.promise(() => dispose())) - return handler -}) - -export const makeRawHttpHarness = Effect.fnUntraced(function*( - serverLayer: Layer.Layer -) { - const handler = yield* makeWebHandler(serverLayer) - const post = (body: unknown, headers?: HeadersInit) => - Effect.promise(() => - handler( - new Request(MCP_ENDPOINT, { - method: "POST", - headers: { - accept: "application/json, text/event-stream", - "content-type": "application/json", - ...headers - }, - body: JSON.stringify(body) - }) - ) - ) - return { post } as const -}) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts new file mode 100644 index 0000000000..288e61c517 --- /dev/null +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts @@ -0,0 +1,44 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as McpProtocol from "effect/unstable/ai/McpProtocol" +import * as BaseProtocolTest from "./McpConformance/BaseProtocolTest.ts" +import * as CompletionTest from "./McpConformance/CompletionTest.ts" +import * as LifecycleTest from "./McpConformance/LifecycleTest.ts" +import * as LoggingTest from "./McpConformance/LoggingTest.ts" +import * as McpConformance from "./McpConformance/McpConformance.ts" +import * as PromptsTest from "./McpConformance/PromptsTest.ts" +import * as ResourcesTest from "./McpConformance/ResourcesTest.ts" +import * as RootsTest from "./McpConformance/RootsTest.ts" +import * as SamplingTest from "./McpConformance/SamplingTest.ts" +import * as ToolsTest from "./McpConformance/ToolsTest.ts" +import * as TransportsTest from "./McpConformance/TransportsTest.ts" +import * as UtilitiesTest from "./McpConformance/UtilitiesTest.ts" + +const protocol = McpProtocol.v2024_11_05 +const testLayer = McpConformance.layer(protocol) + +LifecycleTest.suite(protocol, testLayer) +BaseProtocolTest.suite(protocol, testLayer) +TransportsTest.suite(protocol, testLayer) +UtilitiesTest.suite(protocol, testLayer) +LoggingTest.suite(protocol, testLayer) +CompletionTest.suite(protocol, testLayer) +ToolsTest.suite(protocol, testLayer) +ResourcesTest.suite(protocol, testLayer) +PromptsTest.suite(protocol, testLayer) +RootsTest.suite(protocol, testLayer) +SamplingTest.suite(protocol, testLayer) + +it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Completion", () => { + describe("Capabilities", () => { + it.effect("MUST NOT advertise completions", () => + Effect.gen(function*() { + const test = yield* McpConformance.McpConformance + const initialized = yield* test.initialize({ server: "features" }) + + assert.notProperty(initialized.message.result.capabilities, "completions") + })) + }) + }) +}) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts new file mode 100644 index 0000000000..d30326ff7f --- /dev/null +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts @@ -0,0 +1,134 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as McpProtocol from "effect/unstable/ai/McpProtocol" +import * as McpSchema from "effect/unstable/ai/McpSchema" +import * as BaseProtocolTest from "./McpConformance/BaseProtocolTest.ts" +import * as CompletionTest from "./McpConformance/CompletionTest.ts" +import * as LifecycleTest from "./McpConformance/LifecycleTest.ts" +import * as LoggingTest from "./McpConformance/LoggingTest.ts" +import { layer as makeMcpConformanceLayer, McpConformance } from "./McpConformance/McpConformance.ts" +import * as PromptsTest from "./McpConformance/PromptsTest.ts" +import * as ResourcesTest from "./McpConformance/ResourcesTest.ts" +import * as RootsTest from "./McpConformance/RootsTest.ts" +import * as SamplingTest from "./McpConformance/SamplingTest.ts" +import * as ToolsTest from "./McpConformance/ToolsTest.ts" +import * as TransportsTest from "./McpConformance/TransportsTest.ts" +import * as UtilitiesTest from "./McpConformance/UtilitiesTest.ts" +import { makeMcpStdioHarness } from "./TestUtils/McpStdioHarness.ts" + +const protocol = McpProtocol.v2025_03_26 +const testLayer = makeMcpConformanceLayer(protocol) + +LifecycleTest.suite(protocol, testLayer) +BaseProtocolTest.suite(protocol, testLayer) +TransportsTest.suite(protocol, testLayer) +UtilitiesTest.suite(protocol, testLayer) +LoggingTest.suite(protocol, testLayer) +CompletionTest.suite(protocol, testLayer) +ToolsTest.suite(protocol, testLayer) +ResourcesTest.suite(protocol, testLayer) +PromptsTest.suite(protocol, testLayer) +RootsTest.suite(protocol, testLayer) +SamplingTest.suite(protocol, testLayer) + +it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Transport-specific behavior", () => { + it.effect("MUST accept operational JSON-RPC batches over stdio", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.sendRaw({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: protocol.protocolVersion, + capabilities: {}, + clientInfo: { name: "stdio-client", version: "1.0.0" } + } + }) + yield* fixture.takeFrame + yield* fixture.sendRaw([ + { jsonrpc: "2.0", id: 2, method: "ping", params: {} }, + { jsonrpc: "2.0", id: 3, method: "ping", params: {} } + ]) + + assert.deepStrictEqual(yield* fixture.takeFrame, [ + { jsonrpc: "2.0", id: 2, result: {} }, + { jsonrpc: "2.0", id: 3, result: {} } + ]) + })) + + it.effect("SERVER rejects initialize as part of a JSON-RPC batch", () => + Effect.gen(function*() { + const test = yield* McpConformance + const response = yield* test.post([test.initializeRequest()]) + + assert.isAtLeast(response.status, 400) + assert.isNull(response.headers.get("Mcp-Session-Id")) + })) + + it.effect("MUST reject an empty JSON-RPC batch", () => + Effect.gen(function*() { + const test = yield* McpConformance + const response = yield* test.post([]) + const error = yield* test.decodeError(response) + + assert.strictEqual(error.id, null) + assert.strictEqual(error.error.code, -32600) + })) + + it.effect("MUST accept an operational mixed batch and correlate responses by id", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + assert.isNotNull(initialized.sessionId) + + const response = yield* test.send(initialized, [ + test.pingRequest(41), + test.initializedNotification, + { jsonrpc: "2.0", id: 42, method: "unknown/method" } + ], { includeProtocolVersion: false }) + const messages = yield* Effect.promise< + ReadonlyArray<{ + readonly jsonrpc?: unknown + readonly id?: unknown + readonly result?: unknown + readonly error?: { readonly code?: unknown } + }> + >(() => response.json()) + const success = messages.find((message) => message.id === 41) + const failure = messages.find((message) => message.id === 42) + + assert.deepStrictEqual(success, { jsonrpc: "2.0", id: 41, result: {} }) + assert.strictEqual(failure?.error?.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE) + })) + + it.effect("MUST return 202 with no body for an accepted notification-only batch", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + assert.isNotNull(initialized.sessionId) + + const response = yield* test.send(initialized, [ + test.initializedNotification, + { jsonrpc: "2.0", method: "notifications/roots/list_changed" } + ], { includeProtocolVersion: false }) + + assert.strictEqual(response.status, 202) + assert.strictEqual(yield* Effect.promise(() => response.text()), "") + })) + + it.effect("MUST operate without the later protocol-version header", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + assert.isNotNull(initialized.sessionId) + + const response = yield* test.ping(initialized, { + includeProtocolVersion: false + }) + + assert.strictEqual(response.status, 200) + })) + }) +}) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts index 4f3bbf5071..93fd7adc09 100644 --- a/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts @@ -1,14 +1,10 @@ -import { assert, describe, it } from "@effect/vitest" -import * as Effect from "effect/Effect" -import * as Schema from "effect/Schema" import * as McpProtocol from "effect/unstable/ai/McpProtocol" -import * as McpSchema from "effect/unstable/ai/McpSchema" import * as BaseProtocolTest from "./McpConformance/BaseProtocolTest.ts" import * as CompletionTest from "./McpConformance/CompletionTest.ts" import * as ElicitationTest from "./McpConformance/ElicitationTest.ts" import * as LifecycleTest from "./McpConformance/LifecycleTest.ts" import * as LoggingTest from "./McpConformance/LoggingTest.ts" -import { layer as makeMcpConformanceLayer, McpConformance } from "./McpConformance/McpConformance.ts" +import { layer as makeMcpConformanceLayer } from "./McpConformance/McpConformance.ts" import * as PromptsTest from "./McpConformance/PromptsTest.ts" import * as ResourcesTest from "./McpConformance/ResourcesTest.ts" import * as RootsTest from "./McpConformance/RootsTest.ts" @@ -17,14 +13,6 @@ import * as ToolsTest from "./McpConformance/ToolsTest.ts" import * as TransportsTest from "./McpConformance/TransportsTest.ts" import * as UtilitiesTest from "./McpConformance/UtilitiesTest.ts" -it("accepts tools/call without optional arguments", () => { - const decoded = Schema.decodeUnknownExit(McpSchema.CallTool.payloadSchema)({ name: "ping" }) - assert.strictEqual(decoded._tag, "Success") - if (decoded._tag === "Success") { - assert.deepStrictEqual(decoded.value.arguments, {}) - } -}) - const protocol = McpProtocol.v2025_06_18 const testLayer = makeMcpConformanceLayer(protocol) @@ -40,60 +28,3 @@ LoggingTest.suite(protocol, testLayer) RootsTest.suite(protocol, testLayer) SamplingTest.suite(protocol, testLayer) ElicitationTest.suite(protocol, testLayer) - -it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { - describe("Utilities", () => { - describe("Progress", () => { - // NOTE: Smoke test only. The client capability accepts this one-way notification, - // but McpServer does not expose an observer for its decoded payload. - it.effect("SCHEMA accepts the optional progress message", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - yield* test.notifyInitialized(initialized) - - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - method: "notifications/progress", - params: { - progressToken: "task-with-message", - progress: 1, - message: "Working" - } - }) - - assert.strictEqual(response.status, 202) - assert.strictEqual(yield* Effect.promise(() => response.text()), "") - })) - }) - }) - - describe("Transport-specific behavior", () => { - it.effect("MUST reject JSON-RPC batches", () => - Effect.gen(function*() { - const test = yield* McpConformance - const response = yield* test.post([test.initializeRequest()]) - - assert.isAtLeast(response.status, 400) - })) - - it.effect("MUST require the negotiated protocol-version header after initialization", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - assert.isNotNull(initialized.sessionId) - - const missing = yield* test.ping(initialized, { - includeProtocolVersion: false - }) - const mismatched = yield* test.ping(initialized, { - id: 3, - protocolVersion: "2025-03-26" - }) - - assert.strictEqual(initialized.message.result.protocolVersion, protocol.protocolVersion) - assert.isAtLeast(missing.status, 400) - assert.isAtLeast(mismatched.status, 400) - })) - }) -}) diff --git a/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts b/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts new file mode 100644 index 0000000000..17ea38eda5 --- /dev/null +++ b/repos/effect/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts @@ -0,0 +1,53 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as McpProtocol from "effect/unstable/ai/McpProtocol" +import * as McpSchema from "effect/unstable/ai/McpSchema" +import * as BaseProtocolTest from "./McpConformance/BaseProtocolTest.ts" +import * as CompletionTest from "./McpConformance/CompletionTest.ts" +import * as ElicitationTest from "./McpConformance/ElicitationTest.ts" +import * as LifecycleTest from "./McpConformance/LifecycleTest.ts" +import * as LoggingTest from "./McpConformance/LoggingTest.ts" +import { layer as makeMcpConformanceLayer, McpConformance } from "./McpConformance/McpConformance.ts" +import * as PromptsTest from "./McpConformance/PromptsTest.ts" +import * as ResourcesTest from "./McpConformance/ResourcesTest.ts" +import * as SamplingTest from "./McpConformance/SamplingTest.ts" +import * as ToolsTest from "./McpConformance/ToolsTest.ts" +import * as TransportsTest from "./McpConformance/TransportsTest.ts" +import * as UtilitiesTest from "./McpConformance/UtilitiesTest.ts" + +const protocol = McpProtocol.v2025_11_25 +const testLayer = makeMcpConformanceLayer(protocol) + +LifecycleTest.suite(protocol, testLayer) +BaseProtocolTest.suite(protocol, testLayer) +TransportsTest.suite(protocol, testLayer) +UtilitiesTest.suite(protocol, testLayer) +ToolsTest.suite(protocol, testLayer) +ResourcesTest.suite(protocol, testLayer) +PromptsTest.suite(protocol, testLayer) +CompletionTest.suite(protocol, testLayer) +LoggingTest.suite(protocol, testLayer) +SamplingTest.suite(protocol, testLayer) +ElicitationTest.suite(protocol, testLayer) + +it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Tools", () => { + it.effect("does not expose experimental Tasks without an extension", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const before = (yield* test.observations).toolInvocations + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "tasks/get", + params: { taskId: "task-1" } + }) + const error = yield* test.decodeError(response) + + assert.strictEqual(error.error.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE) + assert.strictEqual((yield* test.observations).toolInvocations, before) + })) + }) +}) diff --git a/repos/effect/packages/effect/test/unstable/ai/ToolRepresentation.test.ts b/repos/effect/packages/effect/test/unstable/ai/ToolRepresentation.test.ts index 2f81f7d0b7..01d81b05d9 100644 --- a/repos/effect/packages/effect/test/unstable/ai/ToolRepresentation.test.ts +++ b/repos/effect/packages/effect/test/unstable/ai/ToolRepresentation.test.ts @@ -25,7 +25,7 @@ describe("Tool representation v2", () => { properties: { value: { type: "string", - allOf: [{ minLength: 2 }] + minLength: 2 } }, required: ["value"], diff --git a/repos/effect/packages/effect/test/unstable/cli/Command.test.ts b/repos/effect/packages/effect/test/unstable/cli/Command.test.ts index d6d1994a52..6dfe708fd7 100644 --- a/repos/effect/packages/effect/test/unstable/cli/Command.test.ts +++ b/repos/effect/packages/effect/test/unstable/cli/Command.test.ts @@ -1,5 +1,5 @@ import { assert, describe, expect, it } from "@effect/vitest" -import { Context, Effect, Fiber, FileSystem, Layer, Option, Path, Runtime, Stdio } from "effect" +import { Context, Effect, Fiber, FileSystem, Layer, Option, Path, Redacted, Runtime, Stdio } from "effect" import { TestConsole } from "effect/testing" import { Argument, CliConfig, CliError, CliOutput, Command, Flag, GlobalFlag } from "effect/unstable/cli" import { toImpl } from "effect/unstable/cli/internal/command" @@ -165,6 +165,38 @@ describe("Command", () => { assert.include(output, "Run this command?") }).pipe(Effect.provide(TestLayer))) + it.effect("should redact values in wizard command output", () => + Effect.gen(function*() { + const secret = "hunter2-secret" + const captured: Array = [] + const command = Command.make("login", { + password: Flag.redacted("password"), + account: Argument.string("account") + }, ({ account, password }) => + Effect.sync(() => { + captured.push([Redacted.value(password), account]) + })) + + const fiber = yield* Command.runWith(command, { version: "1.0.0" })(["--wizard"]).pipe(Effect.forkChild) + yield* MockTerminal.inputText(secret) + yield* MockTerminal.inputKey("enter") + yield* MockTerminal.inputText("alice") + yield* MockTerminal.inputKey("enter") + yield* MockTerminal.inputKey("enter") + yield* Fiber.join(fiber) + + const output = [...yield* TestConsole.logLines, ...yield* MockTerminal.displayLines].join("\n") + const currentCommand = output.slice(output.indexOf("Current command"), output.indexOf("Command ready")) + const commandReady = output.slice(output.indexOf("Command ready")) + + assert.deepStrictEqual(captured, [[secret, "alice"]]) + assert.include(currentCommand, "--password") + assert.include(currentCommand, "") + assert.include(commandReady, "--password") + assert.include(commandReady, "") + assert.notInclude(output, secret) + }).pipe(Effect.provide(TestLayer))) + it.effect("should print a message when wizard mode is cancelled", () => Effect.gen(function*() { let invoked = false @@ -1521,6 +1553,22 @@ describe("Command", () => { assert.deepStrictEqual(captured, [false, false]) }).pipe(Effect.provide(TestLayer))) + it.effect("should parse required boolean flags when explicitly enabled or disabled", () => + Effect.gen(function*() { + const captured: Array = [] + + const cmd = Command.make("tool", { + verbose: Flag.boolean("verbose") + }, (config) => Effect.sync(() => captured.push(config.verbose))) + + const runCmd = Command.runWith(cmd, { version: "1.0.0" }) + + yield* runCmd(["--verbose"]) + yield* runCmd(["--no-verbose"]) + + assert.deepStrictEqual(captured, [true, false]) + }).pipe(Effect.provide(TestLayer))) + it.effect("should support optional boolean flags and --no- negation", () => Effect.gen(function*() { const captured: Array> = [] @@ -1548,7 +1596,10 @@ describe("Command", () => { const cmd = Command.make("tool", { prompt: Flag.boolean("prompt"), - force: Flag.boolean("force").pipe(Flag.withAlias("no-prompt")) + force: Flag.boolean("force").pipe( + Flag.withAlias("no-prompt"), + Flag.withDefault(false) + ) }, (config) => Effect.sync(() => captured.push([config.prompt, config.force] as const))) const runCmd = Command.runWith(cmd, { version: "1.0.0" }) diff --git a/repos/effect/packages/effect/test/unstable/cli/Help.test.ts b/repos/effect/packages/effect/test/unstable/cli/Help.test.ts index fed72fdd84..bb5eff2bf1 100644 --- a/repos/effect/packages/effect/test/unstable/cli/Help.test.ts +++ b/repos/effect/packages/effect/test/unstable/cli/Help.test.ts @@ -130,6 +130,29 @@ describe("Command help output", () => { expect(shortLine!.indexOf("Short flag description")).toBe(longLine!.indexOf("Long flag description")) }).pipe(Effect.provide(TestLayer))) + it.effect("separates long subcommand and argument names from their descriptions", () => + Effect.gen(function*() { + const child = Command.make("account:set-password", { + account: Argument.string("existing-account-identifier").pipe( + Argument.withDescription("Account to update") + ) + }).pipe(Command.withDescription("Rewrite the credential hash")) + const command = Command.make("demo").pipe(Command.withSubcommands([child])) + const run = Command.runWith(command, { version: "1.0.0" }) + + yield* run(["--help"]) + const rootHelp = yield* TestConsole.logLines + + yield* run(["account:set-password", "--help"]) + const allHelp = yield* TestConsole.logLines + const childHelp = allHelp.slice(rootHelp.length) + + assert.isTrue(rootHelp.some((line) => String(line).includes("account:set-password Rewrite the credential hash"))) + assert.isTrue( + childHelp.some((line) => String(line).includes("existing-account-identifier string Account to update")) + ) + }).pipe(Effect.provide(TestLayer))) + it.effect("hides flags marked with withHidden from help output", () => Effect.gen(function*() { const command = Command.make("tool", { diff --git a/repos/effect/packages/effect/test/unstable/cli/Param.test.ts b/repos/effect/packages/effect/test/unstable/cli/Param.test.ts index ef3642fb25..10eee45ee3 100644 --- a/repos/effect/packages/effect/test/unstable/cli/Param.test.ts +++ b/repos/effect/packages/effect/test/unstable/cli/Param.test.ts @@ -27,6 +27,44 @@ const TestLayer = Layer.mergeAll( ) describe("Param", () => { + describe("boolean", () => { + it.effect("returns MissingOption when a boolean flag is omitted", () => + Effect.gen(function*() { + const error = yield* Effect.flip( + Flag.boolean("verbose").parse({ + flags: {}, + arguments: [] + }) + ) + + assert.instanceOf(error, CliError.MissingOption) + }).pipe(Effect.provide(TestLayer))) + + it.effect("uses the requested default when a boolean flag is omitted", () => + Effect.gen(function*() { + const flag = Flag.boolean("verbose").pipe(Flag.withDefault(true)) + + const [, value] = yield* flag.parse({ + flags: {}, + arguments: [] + }) + + assert.isTrue(value) + }).pipe(Effect.provide(TestLayer))) + + it.effect("restores switch behavior with a false default", () => + Effect.gen(function*() { + const flag = Flag.boolean("verbose").pipe(Flag.withDefault(false)) + + const [, value] = yield* flag.parse({ + flags: {}, + arguments: [] + }) + + assert.isFalse(value) + }).pipe(Effect.provide(TestLayer))) + }) + it.effect("recognizes the alternate flag declared by orElse", () => Effect.gen(function*() { const command = Command.make("app", { @@ -84,7 +122,7 @@ describe("Param", () => { assert.deepStrictEqual(value, Option.none()) }).pipe(Effect.provide(TestLayer))) - it.effect("returns some when an optional boolean flag is provided", () => + it.effect("returns some false when an optional boolean flag is explicitly disabled", () => Effect.gen(function*() { const flag = Flag.boolean("verbose").pipe(Flag.optional) @@ -271,17 +309,32 @@ describe("Param", () => { assert.instanceOf(error, CliError.InvalidValue) }).pipe(Effect.provide(TestLayer))) - it.effect("does not prompt for missing boolean flags", () => + it.effect("prompts for missing boolean flags", () => Effect.gen(function*() { - const prompt = Prompt.text({ message: "Verbose" }) + const prompt = Prompt.confirm({ message: "Verbose" }) const flag = Flag.boolean("verbose").pipe(Flag.withFallbackPrompt(prompt)) + yield* MockTerminal.inputKey("y") + const [, value] = yield* flag.parse({ flags: {}, arguments: [] }) - assert.strictEqual(value, false) + assert.isTrue(value) + }).pipe(Effect.provide(TestLayer))) + + it.effect("uses an explicitly disabled boolean before prompting", () => + Effect.gen(function*() { + const prompt = Prompt.confirm({ message: "Verbose" }) + const flag = Flag.boolean("verbose").pipe(Flag.withFallbackPrompt(prompt)) + + const [, value] = yield* flag.parse({ + flags: { verbose: ["false"] }, + arguments: [] + }) + + assert.isFalse(value) }).pipe(Effect.provide(TestLayer))) it.effect("returns MissingOption when prompt is cancelled", () => @@ -320,6 +373,54 @@ describe("Param", () => { }) describe("withFallbackConfig", () => { + it.effect("uses ConfigProvider when a boolean flag is missing", () => { + const provider = ConfigProvider.fromEnv({ + env: { + VERBOSE: "true" + } + }) + + return Effect.gen(function*() { + const flag = Flag.boolean("verbose").pipe( + Flag.withFallbackConfig(Config.boolean("VERBOSE")) + ) + + const [, value] = yield* flag.parse({ + flags: {}, + arguments: [] + }) + + assert.isTrue(value) + }).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + Effect.provide(TestLayer) + ) + }) + + it.effect("uses an explicitly disabled boolean before reading config fallbacks", () => { + const provider = ConfigProvider.fromEnv({ + env: { + VERBOSE: "true" + } + }) + + return Effect.gen(function*() { + const flag = Flag.boolean("verbose").pipe( + Flag.withFallbackConfig(Config.boolean("VERBOSE")) + ) + + const [, value] = yield* flag.parse({ + flags: { verbose: ["false"] }, + arguments: [] + }) + + assert.isFalse(value) + }).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + Effect.provide(TestLayer) + ) + }) + it.effect("uses ConfigProvider when a flag is missing", () => { const provider = ConfigProvider.fromEnv({ env: { diff --git a/repos/effect/packages/effect/test/unstable/cli/Prompt.test.ts b/repos/effect/packages/effect/test/unstable/cli/Prompt.test.ts index 5d1e6d82b9..095de78837 100644 --- a/repos/effect/packages/effect/test/unstable/cli/Prompt.test.ts +++ b/repos/effect/packages/effect/test/unstable/cli/Prompt.test.ts @@ -52,7 +52,10 @@ const findFrame = (frames: ReadonlyArray, text: string) => frames.find(( describe("Prompt.date", () => { it.effect("renders two-digit years, teen ordinals, and noon meridiem correctly", () => Effect.gen(function*() { - const initial = DateTime.toDateUtc(DateTime.makeUnsafe({ year: 2024, month: 1, day: 11, hour: 12 })) + const initial = DateTime.toDateUtc(DateTime.makeZonedUnsafe( + { year: 2024, month: 1, day: 11, hour: 12 }, + { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true } + )) yield* MockTerminal.inputKey("enter") yield* Prompt.run(Prompt.date({ message: "When", initial, dateMask: "YY Do A" })) @@ -155,6 +158,98 @@ describe("Prompt.float", () => { }) describe("Prompt.text", () => { + it.effect("renders the default prompt theme", () => + Effect.gen(function*() { + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.text({ message: "Name" })) + + const frames = toFrames(yield* MockTerminal.displayLines) + assert.include(frames[0] ?? "", "? Name") + }).pipe(Effect.provide(TestLayer))) + + it.effect("renders the prompt theme from context", () => + Effect.gen(function*() { + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.text({ message: "Name" })).pipe( + Effect.provideService( + Prompt.Theme, + Prompt.makeTheme({ + prefix: "!", + pointerSmall: ">", + tick: "+", + ellipsis: "~" + }) + ) + ) + + const frames = toFrames(yield* MockTerminal.displayLines) + assert.include(frames[0] ?? "", "! Name >") + assert.include(frames.at(-1) ?? "", "+ Name ~") + }).pipe(Effect.provide(TestLayer))) + + it.effect("renders theme colors from context with per-prompt overrides", () => + Effect.gen(function*() { + yield* MockTerminal.inputText("A") + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.text({ + message: "Name", + theme: { primaryColor: `${escape}[35m` } + })).pipe( + Effect.provideService( + Prompt.Theme, + Prompt.makeTheme({ + primaryColor: `${escape}[31m`, + mutedColor: `${escape}[34m`, + successColor: `${escape}[33m`, + submittedColor: `${escape}[36m` + }) + ) + ) + + const output = toRawFrames(yield* MockTerminal.displayLines).join("\n") + assert.include(output, `${escape}[35m?`) + assert.include(output, `${escape}[34m›`) + assert.include(output, `${escape}[33m✔`) + assert.include(output, `${escape}[36mA`) + assert.notInclude(output, `${escape}[31m?`) + }).pipe(Effect.provide(TestLayer))) + + it.effect("renders validation errors with the theme error color", () => + Effect.gen(function*() { + yield* MockTerminal.inputText("bad") + yield* MockTerminal.inputKey("enter") + yield* MockTerminal.inputKey("u", { ctrl: true }) + yield* MockTerminal.inputText("ok") + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.text({ + message: "Name", + validate: (value) => value === "bad" ? Effect.fail("Try again") : Effect.succeed(value), + theme: { errorColor: `${escape}[35m` } + })) + + const output = toRawFrames(yield* MockTerminal.displayLines).join("\n") + assert.include(output, `${escape}[35m›`) + assert.include(output, `${escape}[35mbad`) + assert.notInclude(output, `${escape}[31m›`) + }).pipe(Effect.provide(TestLayer))) + + it.effect("omits spacing for empty theme symbols", () => + Effect.gen(function*() { + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.text({ + message: "Name", + theme: { prefix: "", pointerSmall: "" } + })) + + const frames = toFrames(yield* MockTerminal.displayLines) + assert.strictEqual(frames[0], "Name") + }).pipe(Effect.provide(TestLayer))) + it.effect("starts from the default value so it can be edited", () => Effect.gen(function*() { const prompt = Prompt.text({ @@ -255,7 +350,127 @@ describe("Prompt.text", () => { }).pipe(Effect.provide(TestLayer))) }) +describe("Prompt.select", () => { + it.effect("renders a per-prompt theme across redraws", () => + Effect.gen(function*() { + yield* MockTerminal.inputKey("down") + yield* MockTerminal.inputKey("enter") + + const result = yield* Prompt.run(Prompt.select({ + message: "Pick item", + theme: { + prefix: "!", + pointer: ">", + descriptionSeparator: ": " + }, + choices: [ + { title: "First", value: "first", description: "one" }, + { title: "Second", value: "second", description: "two" } + ] + })) + + assert.strictEqual(result, "second") + const frames = toFrames(yield* MockTerminal.displayLines) + assert.strictEqual(frames.filter((frame) => frame.includes("! Pick item")).length, 2) + assert.isTrue(frames.some((frame) => frame.includes("> Second : two"))) + }).pipe(Effect.provide(TestLayer))) + + it.effect("renders selection colors from the prompt theme", () => + Effect.gen(function*() { + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.select({ + message: "Pick item", + theme: { + primaryColor: `${escape}[35m`, + mutedColor: `${escape}[34m`, + successColor: `${escape}[33m`, + submittedColor: `${escape}[36m` + }, + choices: [{ title: "First", value: "first", description: "one" }] + })) + + const output = toRawFrames(yield* MockTerminal.displayLines).join("\n") + assert.include(output, `${escape}[35m❯`) + assert.include(output, `${escape}[34m- one`) + assert.include(output, `${escape}[33m✔`) + assert.include(output, `${escape}[36mFirst`) + }).pipe(Effect.provide(TestLayer))) + + it.effect("keeps title and description separated when the theme separator is empty", () => + Effect.gen(function*() { + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.select({ + message: "Pick item", + theme: { + descriptionSeparator: "", + mutedColor: `${escape}[34m` + }, + choices: [{ title: "First", value: "first", description: "one" }] + })) + + const output = yield* MockTerminal.displayLines + const frames = toFrames(output) + const rawFrames = toRawFrames(output) + assert.isTrue(frames.some((frame) => frame.includes("First one"))) + assert.isFalse(frames.some((frame) => frame.includes("Firstone"))) + assert.isTrue(rawFrames.some((frame) => frame.includes(`${escape}[34mone${escape}[0m`))) + }).pipe(Effect.provide(TestLayer))) + + it.effect("aligns choices when paging arrows have different widths", () => + Effect.gen(function*() { + yield* MockTerminal.inputKey("down") + yield* MockTerminal.inputKey("down") + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.select({ + message: "Pick item", + maxPerPage: 2, + theme: { arrowUp: "", arrowDown: "vvv" }, + choices: [ + { title: "Alpha", value: "alpha" }, + { title: "Beta", value: "beta" }, + { title: "Gamma", value: "gamma" } + ] + })) + + const frames = toFrames(yield* MockTerminal.displayLines) + assert.isTrue(frames.some((frame) => frame.includes("❯ Alpha") && frame.includes("vvv Beta"))) + assert.isTrue(frames.some((frame) => frame.includes(" Beta") && frame.includes("❯ Gamma"))) + }).pipe(Effect.provide(TestLayer))) +}) + describe("Prompt.password", () => { + it.effect("renders the password mask from the prompt theme", () => + Effect.gen(function*() { + yield* MockTerminal.inputText("abc") + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.password({ message: "Password" })).pipe( + Effect.provideService(Prompt.Theme, Prompt.makeTheme({ passwordMask: "•" })) + ) + + const frames = toFrames(yield* MockTerminal.displayLines) + assert.isTrue(frames.some((frame) => frame.includes("•••"))) + assert.isFalse(frames.some((frame) => frame.includes("***"))) + }).pipe(Effect.provide(TestLayer))) + + it.effect("keeps cursor movement aligned with a multi-character password mask", () => + Effect.gen(function*() { + yield* MockTerminal.inputText("abc") + yield* MockTerminal.inputKey("left") + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.password({ + message: "Password", + theme: { passwordMask: "**" } + })) + + const frames = toRawFrames(yield* MockTerminal.displayLines) + assert.isTrue(frames.some((frame) => frame.includes("******") && frame.endsWith(`${escape}[2D`))) + }).pipe(Effect.provide(TestLayer))) + it.effect("starts from the default value so it can be edited", () => Effect.gen(function*() { const prompt = Prompt.password({ @@ -285,6 +500,36 @@ describe("Prompt.password", () => { }).pipe(Effect.provide(TestLayer))) }) +describe("Prompt.toggle", () => { + it.effect("renders the separator from the prompt theme", () => + Effect.gen(function*() { + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.toggle({ + message: "Enabled", + theme: { toggleSeparator: "|" } + })) + + const frames = toFrames(yield* MockTerminal.displayLines) + assert.isTrue(frames.some((frame) => frame.includes("on | off"))) + assert.isFalse(frames.some((frame) => frame.includes("on / off"))) + }).pipe(Effect.provide(TestLayer))) + + it.effect("keeps labels separated when the theme separator is empty", () => + Effect.gen(function*() { + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(Prompt.toggle({ + message: "Enabled", + theme: { toggleSeparator: "" } + })) + + const frames = toFrames(yield* MockTerminal.displayLines) + assert.isTrue(frames.some((frame) => frame.includes("on off"))) + assert.isFalse(frames.some((frame) => frame.includes("onoff"))) + }).pipe(Effect.provide(TestLayer))) +}) + describe("Prompt.autoComplete", () => { it.effect("filters choices as you type", () => Effect.gen(function*() { @@ -434,6 +679,98 @@ describe("Prompt.autoComplete", () => { assert.isTrue(findFrame(frames, "No matches") !== undefined) }).pipe(Effect.provide(TestLayer))) + + it.effect("keeps `j` and `k` in the filter query instead of moving the cursor", () => + Effect.gen(function*() { + const prompt = Prompt.autoComplete({ + message: "Pick a branch", + choices: [ + { title: "feat/jira-fetch-tool", value: "feat/jira-fetch-tool" }, + { title: "main", value: "main" }, + { title: "feat/worktree-tool", value: "feat/worktree-tool" } + ] + }) + + yield* MockTerminal.inputText("jira") + yield* MockTerminal.inputKey("enter") + + const result = yield* Prompt.run(prompt) + assert.strictEqual(result, "feat/jira-fetch-tool") + + const output = yield* MockTerminal.displayLines + const frames = toFrames(output) + const filteredFrame = findFrame(frames, "[filter: jira]") + + assert.isTrue(filteredFrame !== undefined) + assert.isTrue(filteredFrame?.includes("feat/jira-fetch-tool")) + assert.isFalse(filteredFrame?.includes("main")) + }).pipe(Effect.provide(TestLayer))) + + it.effect("filters a query that merely contains a `k`", () => + Effect.gen(function*() { + const prompt = Prompt.autoComplete({ + message: "Pick a branch", + choices: [ + { title: "main", value: "main" }, + { title: "feat/jira-fetch-tool", value: "feat/jira-fetch-tool" }, + { title: "feat/worktree-tool", value: "feat/worktree-tool" } + ] + }) + + yield* MockTerminal.inputText("worktree") + yield* MockTerminal.inputKey("enter") + + const result = yield* Prompt.run(prompt) + assert.strictEqual(result, "feat/worktree-tool") + + const output = yield* MockTerminal.displayLines + const frames = toFrames(output) + const filteredFrame = findFrame(frames, "[filter: worktree]") + + assert.isTrue(filteredFrame !== undefined) + assert.isTrue(filteredFrame?.includes("feat/worktree-tool")) + assert.isFalse(filteredFrame?.includes("feat/jira-fetch-tool")) + }).pipe(Effect.provide(TestLayer))) + + it.effect("moves the cursor with ctrl-n and ctrl-p", () => + Effect.gen(function*() { + const prompt = Prompt.autoComplete({ + message: "Pick item", + choices: [ + { title: "Alpha", value: "alpha" }, + { title: "Beta", value: "beta" }, + { title: "Delta", value: "delta" } + ] + }) + + yield* MockTerminal.inputKey("n", { ctrl: true }) + yield* MockTerminal.inputKey("n", { ctrl: true }) + yield* MockTerminal.inputKey("p", { ctrl: true }) + yield* MockTerminal.inputKey("enter") + + const result = yield* Prompt.run(prompt) + assert.strictEqual(result, "beta") + }).pipe(Effect.provide(TestLayer))) + + it.effect("moves the cursor up with ctrl-k", () => + Effect.gen(function*() { + const prompt = Prompt.autoComplete({ + message: "Pick item", + choices: [ + { title: "Alpha", value: "alpha" }, + { title: "Beta", value: "beta" }, + { title: "Delta", value: "delta" } + ] + }) + + yield* MockTerminal.inputKey("down") + yield* MockTerminal.inputKey("down") + yield* MockTerminal.inputKey("k", { ctrl: true }) + yield* MockTerminal.inputKey("enter") + + const result = yield* Prompt.run(prompt) + assert.strictEqual(result, "beta") + }).pipe(Effect.provide(TestLayer))) }) describe("Prompt.file", () => { @@ -517,9 +854,151 @@ describe("Prompt.file", () => { assert.isFalse(narrowedFrame?.includes("basket.txt")) assert.isTrue(expandedFrame?.includes("basket.txt")) }).pipe(Effect.provide(FilePromptLayer))) + + const JkFilePromptLayer = Layer.mergeAll( + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + readDirectory: (directory) => + Effect.succeed( + directory === "/workspace" + ? ["jest.config.ts", "package.json", "readme.md"] + : [] + ), + stat: () => Effect.succeed({ type: "File" } as any) + }), + PathLayer, + TerminalLayer + ) + + it.effect("keeps `j` in the filter query instead of moving the cursor", () => + Effect.gen(function*() { + const prompt = Prompt.file({ + message: "Pick file", + startingPath: "/workspace" + }) + + yield* MockTerminal.inputText("jest") + yield* MockTerminal.inputKey("enter") + + const result = yield* Prompt.run(prompt) + assert.strictEqual(result, "/workspace/jest.config.ts") + + const output = yield* MockTerminal.displayLines + const frames = toFrames(output) + const filteredFrame = findFrame(frames, "[filter: jest]") + + assert.isTrue(filteredFrame !== undefined) + assert.isTrue(filteredFrame?.includes("jest.config.ts")) + assert.isFalse(filteredFrame?.includes("readme.md")) + }).pipe(Effect.provide(JkFilePromptLayer))) + + it.effect("keeps `k` in the filter query instead of moving the cursor", () => + Effect.gen(function*() { + const prompt = Prompt.file({ + message: "Pick file", + startingPath: "/workspace" + }) + + yield* MockTerminal.inputText("package") + yield* MockTerminal.inputKey("enter") + + const result = yield* Prompt.run(prompt) + assert.strictEqual(result, "/workspace/package.json") + + const output = yield* MockTerminal.displayLines + const frames = toFrames(output) + const filteredFrame = findFrame(frames, "[filter: package]") + + assert.isTrue(filteredFrame !== undefined) + assert.isTrue(filteredFrame?.includes("package.json")) + assert.isFalse(filteredFrame?.includes("readme.md")) + }).pipe(Effect.provide(JkFilePromptLayer))) + + it.effect("moves the cursor with ctrl-n and ctrl-p", () => + Effect.gen(function*() { + const prompt = Prompt.file({ + message: "Pick file", + startingPath: "/workspace" + }) + + yield* MockTerminal.inputKey("n", { ctrl: true }) + yield* MockTerminal.inputKey("n", { ctrl: true }) + yield* MockTerminal.inputKey("p", { ctrl: true }) + yield* MockTerminal.inputKey("enter") + + const result = yield* Prompt.run(prompt) + assert.strictEqual(result, "/workspace/alpha.txt") + }).pipe(Effect.provide(FilePromptLayer))) + + it.effect("moves the cursor up with ctrl-k", () => + Effect.gen(function*() { + const prompt = Prompt.file({ + message: "Pick file", + startingPath: "/workspace" + }) + + yield* MockTerminal.inputKey("down") + yield* MockTerminal.inputKey("down") + yield* MockTerminal.inputKey("k", { ctrl: true }) + yield* MockTerminal.inputKey("enter") + + const result = yield* Prompt.run(prompt) + assert.strictEqual(result, "/workspace/alpha.txt") + }).pipe(Effect.provide(FilePromptLayer))) }) describe("Prompt.multiSelect", () => { + it.effect("renders paging and checkbox symbols from the prompt theme", () => + Effect.gen(function*() { + const prompt = Prompt.multiSelect({ + message: "Pick items", + maxPerPage: 3, + theme: { + arrowDown: "v", + checkboxOn: "[x]", + checkboxOff: "[ ]" + }, + choices: [ + { title: "Alpha", value: "alpha" }, + { title: "Beta", value: "beta" } + ] + }) + yield* MockTerminal.inputKey("down") + yield* MockTerminal.inputKey("down") + yield* MockTerminal.inputKey("space") + yield* MockTerminal.inputKey("enter") + + const value = yield* Prompt.run(prompt) + + assert.deepStrictEqual(value, ["alpha"]) + const frames = toFrames(yield* MockTerminal.displayLines) + assert.isTrue(frames.some((frame) => frame.includes("v [ ] Alpha"))) + assert.isTrue(frames.some((frame) => frame.includes("[x] Alpha"))) + }).pipe(Effect.provide(TestLayer))) + + it.effect("aligns choices when checkbox symbols have different widths", () => + Effect.gen(function*() { + const prompt = Prompt.multiSelect({ + message: "Pick items", + theme: { checkboxOn: "[x]", checkboxOff: "" }, + choices: [ + { title: "Alpha", value: "alpha", selected: true }, + { title: "Beta", value: "beta" } + ] + }) + yield* MockTerminal.inputKey("enter") + + yield* Prompt.run(prompt) + + const frame = toFrames(yield* MockTerminal.displayLines).find((frame) => + frame.includes("[x] Alpha") && frame.includes("Beta") + ) + assert.isTrue(frame !== undefined) + const alpha = frame?.split("\n").find((line) => line.includes("Alpha")) ?? "" + const beta = frame?.split("\n").find((line) => line.includes("Beta")) ?? "" + assert.strictEqual(alpha.indexOf("Alpha"), beta.indexOf("Beta")) + }).pipe(Effect.provide(TestLayer))) + it.effect("does not allow a disabled multi-select choice to be selected", () => Effect.gen(function*() { const prompt = Prompt.multiSelect({ diff --git a/repos/effect/packages/effect/test/unstable/cli/completions/completions.test.ts b/repos/effect/packages/effect/test/unstable/cli/completions/completions.test.ts index 474bd89a62..7d473f0460 100644 --- a/repos/effect/packages/effect/test/unstable/cli/completions/completions.test.ts +++ b/repos/effect/packages/effect/test/unstable/cli/completions/completions.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, it } from "@effect/vitest" +import { assert, describe, expect, it } from "@effect/vitest" import { Argument, Command, Flag } from "effect/unstable/cli" import * as Completions from "effect/unstable/cli/Completions" import * as Bash from "effect/unstable/cli/internal/completions/bash" @@ -61,6 +61,32 @@ const withChoices = Command.make("deploy", { ) }).pipe(Command.withDescription("Deploy application")) +const trickyValues = [ + "it's-fine", + "node:20", + "with space", + "(whoami)", + "#tag", + "$HOME", + "back\\slash", + `say"hi"`, + "a*b", + "a;b", + "~x", + "foo'", + "a!b", + "\u{1F680}" +] + +const withTrickyChoices = Command.make("deploy", { + mode: Flag.choice("mode", trickyValues).pipe( + Flag.withDescription("Deploy mode") + ), + target: Argument.choice("target", ["o'clock", "a:b", "{x,y}", "a\u{1F600}b"]).pipe( + Argument.withDescription("Deployment target") + ) +}).pipe(Command.withDescription("Deploy application")) + const withPaths = Command.make("process", { input: Flag.file("input").pipe(Flag.withDescription("Input file")), outDir: Flag.directory("output-dir").pipe(Flag.withDescription("Output directory")), @@ -69,6 +95,17 @@ const withPaths = Command.make("process", { ) }).pipe(Command.withDescription("Process files")) +const withOptionalDirectoryAndSubcommands = Command.make("example", { + directory: Argument.directory("directory").pipe( + Argument.withDescription("Directory to start in"), + Argument.optional + ) +}).pipe( + Command.withSubcommands([ + Command.make("serve").pipe(Command.withDescription("Start the server")) + ]) +) + const nested3Levels = (() => { const leaf = Command.make("action", { dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Dry run mode")) @@ -87,6 +124,15 @@ const emptyCmd = Command.make("noop").pipe( Command.withDescription("Does nothing") ) +const choicesHelperSource = (script: string): string => { + const start = script.indexOf("_deploy--choices()") + const end = script.indexOf("\n}\n", start) + return script.slice(start, end + 2) +} + +const linesWith = (script: string, needle: string): string => + script.split("\n").filter((line) => line.includes(needle)).map((line) => line.trim()).join("\n") + // --------------------------------------------------------------------------- // Bash completions // --------------------------------------------------------------------------- @@ -134,8 +180,8 @@ describe("Bash completions", () => { assert.include(script, `--verbose|-v|--no-verbose) ;;`) assert.include(script, `--format|-f) _skip_next=1 ;;`) assert.include(script, `--format=*|-f=*) ;;`) - assert.include(script, `0)\n COMPREPLY=( $(compgen -W 'one' -- "$cur") )`) - assert.include(script, `1)\n COMPREPLY=( $(compgen -W 'two' -- "$cur") )`) + assert.include(script, `0)\n _tool--choices "$cur" "$_comp_word" 'one'`) + assert.include(script, `1)\n _tool--choices "$cur" "$_comp_word" 'two'`) }) it("generates completion function for root command", () => { @@ -143,7 +189,7 @@ describe("Bash completions", () => { const script = Bash.generate("greet", desc) assert.include(script, "_greet()") assert.include(script, "complete -F _greet greet") - assert.include(script, "_init_completion || return") + assert.include(script, `_init_completion -n "$COMP_WORDBREAKS" || return`) }) it("includes subcommand names in word list", () => { @@ -204,7 +250,71 @@ describe("Bash completions", () => { it("inlines choice values for choice flags", () => { const desc = fromCommand(withChoices) const script = Bash.generate("deploy", desc) - assert.include(script, "dev staging prod") + assert.include(script, `_deploy--choices "$cur" "$_comp_word" 'dev' 'staging' 'prod'`) + }) + + it("quotes choice values instead of exposing them to compgen -W re-expansion", () => { + const script = Bash.generate("deploy", fromCommand(withTrickyChoices)) + expect(linesWith(script, `_deploy--choices "$cur"`)).toMatchInlineSnapshot(` + "_deploy--choices "$cur" "$_comp_word" 'it'\\''s-fine' 'node:20' 'with space' '(whoami)' '#tag' '$HOME' 'back\\slash' 'say"hi"' 'a*b' 'a;b' '~x' 'foo'\\''' 'a!b' '🚀' + _deploy--choices "$cur" "$_comp_word" 'o'\\''clock' 'a:b' '{x,y}' 'a😀b'" + `) + assert.notInclude(script, `compgen -W 'it`) + }) + + it("splits words on whitespace only, so a value holding a word-break character stays one word", () => { + const script = Bash.generate("deploy", fromCommand(withTrickyChoices)) + assert.include(script, `_init_completion -n "$COMP_WORDBREAKS" || return`) + }) + + it("emits the choice helper", () => { + const script = Bash.generate("deploy", fromCommand(withTrickyChoices)) + expect(choicesHelperSource(script)).toMatchInlineSnapshot(` + "_deploy--choices() + { + local _cur="$1" _word="$2"; shift 2 + + local _head="\${_cur%"$_word"}" + local _open="" + case "$_head" in + *\\') _open="'" ;; + *\\") _open='"' ;; + esac + + local _prefix="$_cur" _committed="$_head" + _prefix=\${_prefix//\\\\/}; _prefix=\${_prefix//\\"/}; _prefix=\${_prefix//\\'/} + _committed=\${_committed//\\\\/}; _committed=\${_committed//\\"/}; _committed=\${_committed//\\'/} + + COMPREPLY=() + local _choice _rest _match + for _choice in "$@"; do + [[ "$_choice" == "$_prefix"* ]] || continue + _rest="\${_choice#"$_committed"}" + case "$_open" in + "'") + if [[ "$_head" == "'" ]]; then + _match=\${_rest//\\'/\\'\\\\\\'\\'} + else + [[ "$_rest" == *\\'* ]] && continue + _match="$_rest" + fi + ;; + '"') + _match="\${_rest//\\\\/\\\\\\\\}" + _match="\${_match//\\$/\\\\$}" + _match="\${_match//\\\`/\\\\\\\`}" + _match="\${_match//\\"/\\\\\\"}" + ;; + *) + printf -v _match '%q' "$_rest" + [[ -z "$_head" && "$_match" == '~'* ]] && _match="\\\\$_match" + ;; + esac + [[ -n "$_open" && "$_match" == *"$_open" ]] && _match+="$_open" + COMPREPLY+=("$_match") + done + }" + `) }) it("generates separate functions for nested subcommands", () => { @@ -241,12 +351,10 @@ describe("Bash completions", () => { it("groups flag aliases for used-flag filtering", () => { const desc = fromCommand(simpleCmd) const script = Bash.generate("greet", desc) - // All forms of --loud share the same group index - assert.include(script, "_flag_groups[--loud]=0") - assert.include(script, "_flag_groups[-l]=0") - assert.include(script, "_flag_groups[--no-loud]=0") - // --times has a different group index - assert.include(script, "_flag_groups[--times]=1") + assert.include(script, "--loud|-l|--no-loud) _used_0=1 ;;") + assert.include(script, "--times) _used_1=1 ;;") + assert.include(script, `[[ -n "$_used_0" ]] || _filtered_flags+=" --loud -l --no-loud"`) + assert.include(script, `[[ -n "$_used_1" ]] || _filtered_flags+=" --times"`) // Uses _filtered_flags instead of a static word list assert.include(script, "compgen -W \"$_filtered_flags\"") }) @@ -254,18 +362,24 @@ describe("Bash completions", () => { it("does not generate flag groups for commands with no flags", () => { const desc = fromCommand(emptyCmd) const script = Bash.generate("noop", desc) - assert.notInclude(script, "_flag_groups") + assert.notInclude(script, "_used_0") assert.notInclude(script, "_filtered_flags") }) + it("uses no bash 4 syntax, so the script runs on the bash macOS ships", () => { + const script = Bash.generate("comprehensive", fromCommand(ComprehensiveCli)) + assert.notInclude(script, "local -A") + assert.notInclude(script, "declare -A") + }) + it("includes inline _init_completion fallback", () => { const desc = fromCommand(simpleCmd) const script = Bash.generate("greet", desc) assert.include(script, "if ! type _init_completion &>/dev/null; then") assert.include(script, "COMPREPLY=()") - assert.include(script, "cur=\"${COMP_WORDS[COMP_CWORD]}\"") - assert.include(script, "cword=$COMP_CWORD") - assert.include(script, "fi") + assert.include(script, `if [[ "$_line" == [[:blank:]]* ]]; then`) + assert.include(script, "((_i == COMP_CWORD)) && cword=$_j") + assert.include(script, `cur="\${words[cword]}"`) }) }) @@ -341,6 +455,37 @@ describe("Zsh completions", () => { assert.include(script, "(us-east eu-west ap-south)") }) + it("escapes choice values for both the spec quoting and the action list re-parse", () => { + const script = Zsh.generate("deploy", fromCommand(withTrickyChoices)) + expect(linesWith(script, ":value:(")).toMatchInlineSnapshot( + `"'(--mode)--mode[Deploy mode]:value:(it\\'\\''s-fine node\\:20 with\\ space \\(whoami\\) \\#tag \\$HOME back\\\\slash say\\"hi\\" a\\*b a\\;b \\~x foo\\'\\'' a\\!b \\🚀)'"` + ) + expect(linesWith(script, "Deployment target")).toMatchInlineSnapshot( + `"':Deployment target:(o\\'\\''clock a\\:b \\{x,y\\} a\\😀b)'"` + ) + }) + + it("uses alternative argument sets for positional arguments and subcommands", () => { + const desc = fromCommand(withOptionalDirectoryAndSubcommands) + const script = Zsh.generate("example", desc) + + assert.include( + script, + ` - + parent-arguments + ':Directory to start in:_directories' + - + subcommands + '1:command:->command' + '*::arg:->args'` + ) + assert.notInclude( + script, + ` ':Directory to start in:_directories' + '1:command:->command'` + ) + }) + it("starts with #compdef directive", () => { const desc = fromCommand(simpleCmd) const script = Zsh.generate("greet", desc) @@ -470,6 +615,22 @@ describe("Fish completions", () => { assert.include(script, "-r -f -a 'dev staging prod'") }) + it("escapes choice values for both the string quoting and the expansion of the -a list", () => { + const script = Fish.generate("deploy", fromCommand(withTrickyChoices)) + expect(linesWith(script, "-r -f -a")).toMatchInlineSnapshot(` + "complete -c deploy -n 'begin; not __fish_contains_opt mode; or contains -- (commandline -poc)[-1] --mode; end' -l mode -d 'Deploy mode' -r -f -a 'it\\\\\\'s-fine node\\\\:20 with\\\\ space \\\\(whoami\\\\) \\\\#tag \\\\$HOME back\\\\\\\\slash say\\\\"hi\\\\" a\\\\*b a\\\\;b \\\\~x foo\\\\\\' a\\\\!b \\\\🚀' + complete -c deploy -r -f -a 'o\\\\\\'clock a\\\\:b \\\\{x,y\\\\} a\\\\😀b' -d 'Deployment target'" + `) + }) + + it("escapes backslashes in descriptions before quotes", () => { + const trailingBackslash = Command.make("deploy", { + mode: Flag.choice("mode", ["a"]).pipe(Flag.withDescription("Path like C:\\")) + }) + const script = Fish.generate("deploy", fromCommand(trailingBackslash)) + assert.include(script, `-d 'Path like C:\\\\'`) + }) + it("uses -n conditions for nested subcommand flags", () => { const desc = fromCommand(withSubcommands) const script = Fish.generate("server", desc) @@ -511,31 +672,32 @@ describe("Fish completions", () => { assert.include(script, "###-end-greet-completions-###") }) - it("generates __fish_contains_opt conditions for boolean flags", () => { + it("generates used-flag dedup conditions", () => { const desc = fromCommand(simpleCmd) const script = Fish.generate("greet", desc) // --loud is boolean with alias -l — gets dedup condition on the -l entry assert.include(script, "not __fish_contains_opt -s l loud no-loud") - // --times is a value-taking flag — its -l entry has NO dedup (would suppress - // value completions), but its bare-TAB -a entry DOES use dedup const lines = script.split("\n") const timesLongEntry = lines.find((l) => l.includes("-l times"))! - assert.notInclude(timesLongEntry, "__fish_contains_opt") + assert.include( + timesLongEntry, + "-n 'begin; not __fish_contains_opt times; or contains -- (commandline -poc)[-1] --times; end'" + ) const timesArgEntry = lines.find((l) => l.includes("-a '--times'"))! assert.include(timesArgEntry, "not __fish_contains_opt times") }) - it("combines subcommand and boolean dedup conditions", () => { + it("combines subcommand and dedup conditions", () => { const desc = fromCommand(withSubcommands) const script = Fish.generate("server", desc) // daemon is boolean — gets subcommand + dedup condition on -l entry assert.include(script, "__fish_seen_subcommand_from start; and not __fish_contains_opt daemon no-daemon") - // port is value-taking — its -l entry has only subcommand condition (no dedup), - // but its bare-TAB -a entry DOES use dedup const lines = script.split("\n") const portLongEntry = lines.find((l) => l.includes("-l port"))! - assert.include(portLongEntry, "-n '__fish_seen_subcommand_from start'") - assert.notInclude(portLongEntry, "__fish_contains_opt") + assert.include( + portLongEntry, + "-n '__fish_seen_subcommand_from start; and begin; not __fish_contains_opt -s p port; or contains -- (commandline -poc)[-1] --port -p; end'" + ) const portArgEntry = lines.find((l) => l.includes("-a '--port'"))! assert.include(portArgEntry, "not __fish_contains_opt -s p port") }) diff --git a/repos/effect/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts b/repos/effect/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts index 1ef1110d8b..20b8649770 100644 --- a/repos/effect/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts +++ b/repos/effect/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts @@ -12,12 +12,14 @@ const usersList = Command.make("list", { ), // Boolean flag active: Flag.boolean("active").pipe( - Flag.withDescription("Show only active users") + Flag.withDescription("Show only active users"), + Flag.withDefault(false) ), // Option with both short and long aliases verbose: Flag.boolean("verbose").pipe( Flag.withAlias("v"), - Flag.withDescription("Show detailed information") + Flag.withDescription("Show detailed information"), + Flag.withDefault(false) ) }, (config) => logAction("users list", { @@ -45,7 +47,8 @@ const usersCreate = Command.make("create", { // Boolean with explicit value support notify: Flag.boolean("notify").pipe( Flag.withAlias("n"), - Flag.withDescription("Send notification email") + Flag.withDescription("Send notification email"), + Flag.withDefault(false) ) }, (config) => logAction("users create", { @@ -117,7 +120,8 @@ const admin = Command.make("admin").pipe( Command.withSharedFlags({ // Boolean that can be set to false explicitly sudo: Flag.boolean("sudo").pipe( - Flag.withDescription("Run with elevated privileges") + Flag.withDescription("Run with elevated privileges"), + Flag.withDefault(false) ) }), Command.withDescription("Administrative commands"), @@ -136,11 +140,13 @@ const copy = Command.make("copy", { // Boolean flags with short aliases recursive: Flag.boolean("recursive").pipe( Flag.withAlias("r"), - Flag.withDescription("Copy directories recursively") + Flag.withDescription("Copy directories recursively"), + Flag.withDefault(false) ), force: Flag.boolean("force").pipe( Flag.withAlias("f"), - Flag.withDescription("Overwrite existing files") + Flag.withDescription("Overwrite existing files"), + Flag.withDefault(false) ), // Integer option buffer: Flag.integer("buffer-size").pipe( @@ -167,7 +173,8 @@ const move = Command.make("move", { // Options interactive: Flag.boolean("interactive").pipe( Flag.withAlias("i"), - Flag.withDescription("Prompt before overwrite") + Flag.withDescription("Prompt before overwrite"), + Flag.withDefault(false) ) }, (config) => logAction("move", { @@ -186,15 +193,18 @@ const remove = Command.make("remove", { // Multiple boolean options recursive: Flag.boolean("recursive").pipe( Flag.withAlias("r"), - Flag.withDescription("Remove directories and contents") + Flag.withDescription("Remove directories and contents"), + Flag.withDefault(false) ), force: Flag.boolean("force").pipe( Flag.withAlias("f"), - Flag.withDescription("Force removal without prompts") + Flag.withDescription("Force removal without prompts"), + Flag.withDefault(false) ), verbose: Flag.boolean("verbose").pipe( Flag.withAlias("v"), - Flag.withDescription("Explain what is being done") + Flag.withDescription("Explain what is being done"), + Flag.withDefault(false) ) }, (config) => logAction("remove", { @@ -214,7 +224,8 @@ const build = Command.make("build", { ), verbose: Flag.boolean("verbose").pipe( Flag.withAlias("v"), - Flag.withDescription("Enable verbose output") + Flag.withDescription("Enable verbose output"), + Flag.withDefault(false) ), configFile: Flag.string("config-file").pipe( Flag.withAlias("f"), @@ -252,7 +263,8 @@ const gitAdd = Command.make("add", { Argument.withDescription("Files to add") ), update: Flag.boolean("update").pipe( - Flag.withDescription("Update tracked files") + Flag.withDescription("Update tracked files"), + Flag.withDefault(false) ) }, (config) => logAction("git add", { @@ -264,7 +276,8 @@ const gitAdd = Command.make("add", { const gitStatus = Command.make("status", { short: Flag.boolean("short").pipe( - Flag.withDescription("Show short format") + Flag.withDescription("Show short format"), + Flag.withDefault(false) ) }, (config) => logAction("git status", { @@ -276,7 +289,8 @@ const gitStatus = Command.make("status", { const git = Command.make("git").pipe( Command.withSharedFlags({ verbose: Flag.boolean("verbose").pipe( - Flag.withDescription("Enable verbose output") + Flag.withDescription("Enable verbose output"), + Flag.withDefault(false) ) }), Command.withHandler((config) => @@ -335,7 +349,8 @@ const deployCommand = Command.make("deploy", { ) }, dryRun: Flag.boolean("dry-run").pipe( - Flag.withDescription("Perform a dry run") + Flag.withDescription("Perform a dry run"), + Flag.withDefault(false) ) }, (config) => logAction("deploy", { @@ -401,7 +416,8 @@ export const ComprehensiveCli = Command.make("mycli").pipe( // Global options available to all subcommands debug: Flag.boolean("debug").pipe( Flag.withAlias("d"), - Flag.withDescription("Enable debug logging") + Flag.withDescription("Enable debug logging"), + Flag.withDefault(false) ), config: Flag.file("config").pipe( Flag.withAlias("c"), @@ -410,7 +426,8 @@ export const ComprehensiveCli = Command.make("mycli").pipe( ), quiet: Flag.boolean("quiet").pipe( Flag.withAlias("q"), - Flag.withDescription("Suppress non-error output") + Flag.withDescription("Suppress non-error output"), + Flag.withDefault(false) ) }), Command.withDescription("A comprehensive CLI tool demonstrating all features"), diff --git a/repos/effect/packages/effect/test/unstable/encoding/SchemaBinary.test.ts b/repos/effect/packages/effect/test/unstable/encoding/SchemaBinary.test.ts new file mode 100644 index 0000000000..4a8a46a32d --- /dev/null +++ b/repos/effect/packages/effect/test/unstable/encoding/SchemaBinary.test.ts @@ -0,0 +1,2953 @@ +/** @effect-diagnostics schemaStructWithTag:off */ +/** @effect-diagnostics schemaNumber:off */ +/** @effect-diagnostics preferTypedSchemaDecoder:off */ +import { assert, describe, it } from "@effect/vitest" +import { + Array as Arr, + BigDecimal, + Cause, + Channel, + Chunk, + DateTime, + Duration, + Effect, + Exit, + HashMap, + HashSet, + Option, + Redacted, + Result, + Schema, + SchemaIssue, + SchemaParser, + SchemaTransformation, + Stream +} from "effect" +import * as SchemaBinary from "effect/unstable/encoding/SchemaBinary" + +const encode = (schema: Schema.Codec, value: A): Uint8Array => + Schema.encodeUnknownSync(SchemaBinary.toCodec(schema))(value) + +const roundtrip = (schema: Schema.Codec, value: A): A => { + const codec = SchemaBinary.toCodec(schema) + return Schema.decodeUnknownSync(codec)(Schema.encodeUnknownSync(codec)(value)) +} + +const concat = (...chunks: ReadonlyArray): Uint8Array => { + const out = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.length, 0)) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + +const sameNumber = (actual: unknown, expected: number) => { + assert.isTrue( + Object.is(actual, expected), + `expected ${globalThis.String(actual)} to be exactly ${globalThis.String(expected)}` + ) +} + +const encodeFingerprint = (schema: Schema.Codec, value: A): Uint8Array => + Schema.encodeUnknownSync(SchemaBinary.toCodec(schema, { fingerprint: true }))(value) + +const roundtripFingerprint = (schema: Schema.Codec, value: A): A => { + const codec = SchemaBinary.toCodec(schema, { fingerprint: true }) + return Schema.decodeUnknownSync(codec)(Schema.encodeUnknownSync(codec)(value)) +} + +// Splits a fingerprint frame into its layout hash and the payload after it, +// both as comparable strings. +const fingerprintFrame = ( + schema: Schema.Codec, + value: A +): { readonly fingerprint: string; readonly payload: string } => { + const bytes = encodeFingerprint(schema, value) + let offset = 0 + while ((bytes[offset] & 0x80) !== 0) offset++ + offset++ + assert.strictEqual(bytes[offset], 0x21) + return { + fingerprint: Array.from(bytes.subarray(offset + 1, offset + 9)).join(","), + payload: Array.from(bytes.subarray(offset + 9)).join(",") + } +} + +const fingerprintOf = (schema: Schema.Codec, value: A): string => + fingerprintFrame(schema, value).fingerprint + +const schemaError = (f: () => unknown): Schema.SchemaError => { + try { + f() + } catch (error) { + if (Schema.isSchemaError(error)) return error + throw error + } + throw new Error("expected SchemaError") +} + +describe("SchemaBinary", () => { + // Rows whose present optional fields differ, shared by the row-run shape + // tests in both wire modes. + const Varying = Schema.Struct({ + id: Schema.String, + a: Schema.optionalKey(Schema.String), + b: Schema.optionalKey(Schema.Number) + }) + const varying = [ + { id: "0", a: "x" }, + { id: "1" }, + { id: "2", a: "x", b: 1 }, + { id: "3", a: "x" }, + { id: "4", b: 2 } + ] + + describe("wire layout", () => { + it("packs fixed-size array elements", () => { + const numbers = [1.5, Number.NaN, -0, Number.POSITIVE_INFINITY] + const numberBytes = encode(Schema.Array(Schema.Number), numbers) + const decoded = roundtrip(Schema.Array(Schema.Number), numbers) + // length, envelope, count, mode byte, four f64 elements + assert.strictEqual(numberBytes.length, 36) + assert.strictEqual(decoded[0], 1.5) + assert.isTrue(Number.isNaN(decoded[1])) + assert.isTrue(Object.is(decoded[2], -0)) + assert.strictEqual(decoded[3], Number.POSITIVE_INFINITY) + + const boolBytes = encode(Schema.Array(Schema.Boolean), [true, false, true]) + assert.strictEqual(boolBytes.length, 6) + assert.deepStrictEqual(roundtrip(Schema.Array(Schema.Boolean), [true, false, true]), [true, false, true]) + }) + + it("length-prefixes variable-size array elements and nested arrays", () => { + assert.deepStrictEqual( + roundtrip(Schema.Array(Schema.String), ["ab", "", "cdé"]), + ["ab", "", "cdé"] + ) + assert.deepStrictEqual( + roundtrip(Schema.Array(Schema.Array(Schema.Number)), [[1, 2], [], [3]]), + [[1, 2], [], [3]] + ) + }) + + it("round-trips zero-width array elements", () => { + assert.deepStrictEqual(roundtrip(Schema.Array(Schema.Null), [null, null, null]), [null, null, null]) + assert.deepStrictEqual( + roundtrip(Schema.Array(Schema.Undefined), [undefined, undefined]), + [undefined, undefined] + ) + }) + + it("distinguishes absent optionals from present zero-width values", () => { + const schema = Schema.Struct({ value: Schema.optionalKey(Schema.Undefined) }) + const absent = roundtrip(schema, {}) + const present = roundtrip(schema, { value: undefined }) + + assert.isFalse(Object.hasOwn(absent, "value")) + assert.isTrue(Object.hasOwn(present, "value")) + assert.strictEqual(present.value, undefined) + assert.strictEqual(roundtrip(Schema.Null, null), null) + assert.strictEqual(roundtrip(Schema.Void, undefined), undefined) + }) + + it("includes non-enumerable fields in row presence masks", () => { + const Row = Schema.Struct({ id: Schema.String, value: Schema.optionalKey(Schema.Undefined) }) + const row = Object.defineProperty({ id: "1" }, "value", { value: undefined }) + for (const decoded of [roundtrip(Schema.Array(Row), [row]), roundtripFingerprint(Schema.Array(Row), [row])]) { + assert.isTrue(Object.hasOwn(decoded[0], "value")) + } + }) + + it("round-trips every literal leaf kind and registered symbols", () => { + const literals = Schema.Union([ + Schema.Literal("text"), + Schema.Literal(1), + Schema.Literal(true), + Schema.Literal(2n) + ]) + for (const value of ["text", 1, true, 2n] as const) { + assert.strictEqual(roundtrip(literals, value), value) + } + + const registered = Symbol.for("SchemaBinary/registered") + assert.strictEqual(roundtrip(Schema.Symbol, registered), registered) + assert.strictEqual(roundtrip(Schema.UniqueSymbol(registered), registered), registered) + }) + + it("omits the count for fixed tuples and includes it for optional tuples", () => { + const pair = Schema.Tuple([Schema.String, Schema.Number]) + // length, envelope, then a length-prefixed slot each: "key" and varint 42 + assert.strictEqual(encode(pair, ["key", 42]).length, 8) + assert.deepStrictEqual(roundtrip(pair, ["key", 42]), ["key", 42]) + + const optional = Schema.Tuple([Schema.Number, Schema.optionalKey(Schema.Number)]) + assert.deepStrictEqual(roundtrip(optional, [1]), [1]) + assert.deepStrictEqual(roundtrip(optional, [1, 2]), [1, 2]) + }) + + it("supports tuple rest and trailing slots", () => { + const schema = Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]) + assert.deepStrictEqual( + roundtrip(schema, ["head", 1.5, 2.5, 3.5, true]), + ["head", 1.5, 2.5, 3.5, true] + ) + assert.deepStrictEqual(roundtrip(schema, ["head", false]), ["head", false]) + }) + + it("writes a length-first frame and rejects envelope or leftovers", () => { + const codec = SchemaBinary.toCodec(Schema.Number) + const bytes = Schema.encodeUnknownSync(codec)(1) + assert.deepStrictEqual(Array.from(bytes), [2, 0x20, 0x02]) + + const flags = bytes.slice() + flags[1] = 0x21 + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(flags)).message, /version 2 envelope, flags 0/) + + const version = bytes.slice() + version[1] = 0x30 + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(version)).message, /version 2 envelope, flags 0/) + assert.match( + schemaError(() => Schema.decodeUnknownSync(codec)(concat(bytes, bytes))).message, + /no leftover bytes/ + ) + }) + }) + + describe("output arena", () => { + it("keeps an earlier result stable through later encodes and arena rollover", () => { + const codec = SchemaBinary.toCodec(Schema.String) + const encode = Schema.encodeUnknownSync(codec) + const first = encode("first") + const expected = first.slice() + let rolledOver = false + + for (let i = 0; i < 500; i++) { + const later = encode(`later-${i}-${"x".repeat(512)}`) + if (later.buffer !== first.buffer) rolledOver = true + assert.deepStrictEqual(first, expected) + } + + assert.isTrue(rolledOver) + assert.strictEqual(Schema.decodeUnknownSync(codec)(first), "first") + }) + + it("keeps different results independent inside a shared arena", () => { + const codec = SchemaBinary.toCodec(Schema.String) + const encode = Schema.encodeUnknownSync(codec) + const decode = Schema.decodeUnknownSync(codec) + const results = [encode("alpha"), encode("bravo"), encode("charlie")] + + assert.deepStrictEqual(results.map((bytes) => decode(bytes)), ["alpha", "bravo", "charlie"]) + const shared = results.flatMap((left, index) => results.slice(index + 1).map((right) => [left, right] as const)) + .find(([left, right]) => left.buffer === right.buffer) + assert.isDefined(shared) + assert.notStrictEqual(shared![0].byteOffset, shared![1].byteOffset) + }) + + it("preserves nested two-phase codec composition", () => { + const Inner = Schema.Struct({ id: Schema.Number, label: Schema.String }) + const Outer = Schema.Struct({ id: Schema.String, inner: SchemaBinary.toCodec(Inner) }) + const codec = SchemaBinary.toCodec(Outer) + const value = { id: "outer", inner: { id: 1, label: "inner" } } + + assert.deepStrictEqual(Schema.decodeUnknownSync(codec)(Schema.encodeUnknownSync(codec)(value)), value) + }) + + it.effect("keeps concurrent fiber results readable after their producing turn", () => { + const Value = Schema.Struct({ id: Schema.Number, value: Schema.String }) + const codec = SchemaBinary.toCodec(Value) + const encode = Schema.encodeUnknownEffect(codec) + const decode = Schema.decodeUnknownSync(codec) + const values = Array.from({ length: 100 }, (_, id) => ({ id, value: `value-${id}` })) + + return Effect.gen(function*() { + const encoded = yield* Effect.forEach( + values, + (value) => encode(value).pipe(Effect.flatMap((bytes) => Effect.yieldNow.pipe(Effect.as(bytes)))), + { concurrency: "unbounded" } + ) + yield* Effect.yieldNow + assert.deepStrictEqual(encoded.map((bytes) => decode(bytes)), values) + }) + }) + }) + + describe("numbers", () => { + it("encodes integral values as sign-magnitude varints", () => { + assert.deepStrictEqual(Array.from(encode(Schema.Number, 0)), [2, 0x20, 0]) + assert.deepStrictEqual(Array.from(encode(Schema.Number, -0)), [2, 0x20, 1]) + assert.deepStrictEqual(Array.from(encode(Schema.Number, 1)), [2, 0x20, 2]) + assert.deepStrictEqual(Array.from(encode(Schema.Number, -1)), [2, 0x20, 3]) + for (const value of [0, -0, 1, -1, 42, -42, 127, -128]) { + sameNumber(roundtrip(Schema.Number, value), value) + } + }) + + it("covers every varint width up to the seven byte cap, both signs", () => { + for (let width = 1; width <= 7; width++) { + // the widest magnitude this many varint bytes can hold, and the first + // magnitude that needs one more + const last = 2 ** (7 * width - 1) - 1 + const next = 2 ** (7 * width - 1) + for (const sign of [1, -1]) { + const inside = sign * last + const outside = sign * next + assert.strictEqual(encode(Schema.Number, inside).length, 2 + width, `${inside}`) + sameNumber(roundtrip(Schema.Number, inside), inside) + // past the cap the value takes the f64 form instead of an eighth byte + assert.strictEqual(encode(Schema.Number, outside).length, width === 7 ? 10 : 3 + width, `${outside}`) + sameNumber(roundtrip(Schema.Number, outside), outside) + } + } + }) + + it("encodes short decimals as a mantissa varint plus one scale byte", () => { + assert.deepStrictEqual(Array.from(encode(Schema.Number, 1.5)), [3, 0x20, 30, 1]) + assert.deepStrictEqual(Array.from(encode(Schema.Number, -0.5)), [3, 0x20, 11, 1]) + assert.deepStrictEqual(Array.from(encode(Schema.Number, 0.001)), [3, 0x20, 2, 3]) + assert.deepStrictEqual(Array.from(encode(Schema.Number, 12.5)), [4, 0x20, 0xFA, 0x01, 1]) + for (const value of [0.1, 1.3, 4.7, 98.5, -123.456, 12345678.5, 0.00000001]) { + sameNumber(roundtrip(Schema.Number, value), value) + } + // the widest mantissa a decimal may carry, and the first one past it + const inside = 219902325555.1 // (2 ** 41 - 1) / 10 + assert.strictEqual(encode(Schema.Number, inside).length, 9) + sameNumber(roundtrip(Schema.Number, inside), inside) + const outside = 219902325555.3 + assert.strictEqual(encode(Schema.Number, outside).length, 10) + sameNumber(roundtrip(Schema.Number, outside), outside) + }) + + it("keeps f64 for values neither a capped varint nor a decimal can hold", () => { + const values = [ + Number.EPSILON, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + Number.MAX_VALUE, + Math.PI, + 1e-9, + 2 ** 48, + -(2 ** 48) + ] + for (const value of values) { + assert.strictEqual(encode(Schema.Number, value).length, 10, `${value}`) + sameNumber(roundtrip(Schema.Number, value), value) + } + }) + + it("discriminates struct number forms with the field wire kind", () => { + const schema = Schema.Struct({ n: Schema.Number }) + // length, envelope, five-byte field tag, payload + assert.strictEqual(encode(schema, { n: 7 }).length, 8) + assert.strictEqual(encode(schema, { n: 7.5 }).length, 10) + assert.strictEqual(encode(schema, { n: Math.PI }).length, 15) + assert.deepStrictEqual(roundtrip(schema, { n: 7 }), { n: 7 }) + assert.deepStrictEqual(roundtrip(schema, { n: 7.5 }), { n: 7.5 }) + assert.deepStrictEqual(roundtrip(schema, { n: Math.PI }), { n: Math.PI }) + sameNumber(roundtrip(schema, { n: -0 }).n, -0) + }) + + it("uses the decimal wire kind in struct fields and record pairs", () => { + const Field = Schema.Struct({ n: Schema.Number.pipe(SchemaBinary.fieldId(1)) }) + assert.deepStrictEqual([...encode(Field, { n: 7.5 })], [5, 0x20, 13, 0x96, 1, 1]) + + const Record = Schema.Record(Schema.String, Schema.Number) + assert.deepStrictEqual([...encode(Record, { x: 7.5 })], [8, 0x20, 0, 5, 13, 120, 0x96, 1, 1]) + assert.deepStrictEqual(roundtrip(Record, { x: 7.5 }), { x: 7.5 }) + }) + + it("packs a uniform number array behind one mode byte", () => { + const integers = [0, -0, 1, -1, 1000, -1000] + // length, envelope, count, mode, four one-byte and two two-byte varints + assert.strictEqual(encode(Schema.Array(Schema.Number), integers).length, 12) + const decoded = roundtrip(Schema.Array(Schema.Number), integers) + integers.forEach((value, index) => sameNumber(decoded[index], value)) + + // one short decimal moves the run to the packed decimal mode + const mixed = [1, 2.5] + // length, envelope, count, mode, a one-byte and a two-byte code + assert.strictEqual(encode(Schema.Array(Schema.Number), mixed).length, 7) + assert.deepStrictEqual(roundtrip(Schema.Array(Schema.Number), mixed), mixed) + const decimals = [-0, 0.25, 1e6, -12.75] + const decoded2 = roundtrip(Schema.Array(Schema.Number), decimals) + decimals.forEach((value, index) => sameNumber(decoded2[index], value)) + + // one value outside both varint and decimal moves the whole run to f64 + const wide = [1, Math.PI] + assert.strictEqual(encode(Schema.Array(Schema.Number), wide).length, 20) + assert.deepStrictEqual(roundtrip(Schema.Array(Schema.Number), wide), wide) + assert.deepStrictEqual(roundtrip(Schema.Array(Schema.Number), []), []) + assert.deepStrictEqual( + roundtrip(Schema.Array(Schema.Array(Schema.Number)), [[1, 2], [], [3.5]]), + [[1, 2], [], [3.5]] + ) + }) + + it("length-prefixes each number slot of a tuple", () => { + const pair = Schema.Tuple([Schema.Number, Schema.Number]) + assert.strictEqual(encode(pair, [1, 2]).length, 6) + assert.strictEqual(encode(pair, [1, 2.5]).length, 7) + assert.strictEqual(encode(pair, [1, Math.PI]).length, 13) + assert.deepStrictEqual(roundtrip(pair, [1, 2.5]), [1, 2.5]) + assert.deepStrictEqual(roundtrip(pair, [1, Math.PI]), [1, Math.PI]) + const rest = Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number]) + assert.deepStrictEqual(roundtrip(rest, ["head", 1, 2.5, 3]), ["head", 1, 2.5, 3]) + }) + + it("carries every form behind the union kind byte", () => { + const schema = Schema.Union([Schema.Number, Schema.String]) + assert.strictEqual(encode(schema, 5).length, 4) + assert.strictEqual(encode(schema, 5.5).length, 5) + assert.strictEqual(encode(schema, Math.PI).length, 11) + assert.strictEqual(roundtrip(schema, 5), 5) + assert.strictEqual(roundtrip(schema, 5.5), 5.5) + assert.strictEqual(roundtrip(schema, Math.PI), Math.PI) + assert.strictEqual(roundtrip(schema, "x"), "x") + sameNumber(roundtrip(schema, -0), -0) + + const tagged = Schema.Union([ + Schema.Struct({ _tag: Schema.Literal("A"), n: Schema.Number }), + Schema.Struct({ _tag: Schema.Literal("B"), n: Schema.Int }) + ]) + assert.deepStrictEqual(roundtrip(tagged, { _tag: "A", n: 1.5 }), { _tag: "A", n: 1.5 }) + assert.deepStrictEqual(roundtrip(tagged, { _tag: "B", n: -7 }), { _tag: "B", n: -7 }) + }) + + it("parses a stream whose numbers change width", () => { + const values = [0, -0, 1, -1, 63, 64, 1_000_000, -1_000_000, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER] + const bytes = concat(...values.map((value) => encode(Schema.Number, value))) + const parser = SchemaBinary.parser(Schema.Number) + const out: Array = [] + for (const byte of bytes) out.push(...parser.feedSync(Uint8Array.of(byte))) + parser.endSync() + assert.strictEqual(out.length, values.length) + values.forEach((value, index) => sameNumber(out[index], value)) + }) + + it("emits a bare varint when the schema proves the value is an integer", () => { + assert.deepStrictEqual(Array.from(encode(Schema.Int, 1)), [2, 0x20, 2]) + assert.deepStrictEqual(Array.from(encode(Schema.Natural, 1)), [2, 0x20, 2]) + assert.deepStrictEqual(Array.from(encode(Schema.Number.check(Schema.isInt32()), 1)), [2, 0x20, 2]) + + // no mode byte for an array, no length prefix for a tuple slot + assert.strictEqual(encode(Schema.Array(Schema.Int), [1, 2, 3]).length, 6) + assert.strictEqual(encode(Schema.Array(Schema.Number), [1, 2, 3]).length, 7) + assert.strictEqual(encode(Schema.Tuple([Schema.Int, Schema.Int]), [1, 2]).length, 4) + assert.strictEqual(encode(Schema.Tuple([Schema.Number, Schema.Number]), [1, 2]).length, 6) + + for (const value of [0, -0, 1, -1, 2 ** 48, -(2 ** 48), Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]) { + sameNumber(roundtrip(Schema.Int, value), value) + sameNumber(roundtrip(Schema.Array(Schema.Int), [value])[0], value) + sameNumber(roundtrip(Schema.Struct({ n: Schema.Int }), { n: value }).n, value) + } + assert.deepStrictEqual( + roundtrip(Schema.Array(Schema.Int), [1, -2, 3]), + [1, -2, 3] + ) + }) + + it("rejects a non-integer that reaches the integer layout", () => { + const codec = SchemaBinary.toCodec(Schema.Int) + assert.match( + schemaError(() => Schema.encodeUnknownSync(codec, { disableChecks: true })(1.5)).message, + /an integer/ + ) + }) + + it("rejects malformed number payloads", () => { + const codec = SchemaBinary.toCodec(Schema.Number) + // nine payload bytes are neither the varint nor the f64 form + const wide = new Uint8Array([10, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(wide)).message, /Expected f64/) + // a varint that never terminates inside its extent + const truncated = new Uint8Array([3, 0x20, 0x80, 0x80]) + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(truncated)).message, /complete value/) + // a decimal tail whose scale byte is zero or out of range + const zeroScale = new Uint8Array([3, 0x20, 30, 0]) + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(zeroScale)).message, /Expected decimal/) + const wideScale = new Uint8Array([3, 0x20, 30, 9]) + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(wideScale)).message, /Expected decimal/) + const longTail = new Uint8Array([4, 0x20, 30, 1, 1]) + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(longTail)).message, /Expected decimal/) + + const arrayCodec = SchemaBinary.toCodec(Schema.Array(Schema.Number)) + const run = Schema.encodeUnknownSync(arrayCodec)([1, 2, 3]) + const unknownMode = run.slice() + unknownMode[3] = 3 + assert.match(schemaError(() => Schema.decodeUnknownSync(arrayCodec)(unknownMode)).message, /Expected f64/) + const shortRun = run.slice(0, run.length - 1) + shortRun[0] -= 1 + assert.match(schemaError(() => Schema.decodeUnknownSync(arrayCodec)(shortRun)).message, /complete value/) + }) + }) + + describe("schema evolution", () => { + it("skips unknown fields, accepts field reorder, and leaves missing optionals absent", () => { + const Writer = Schema.Struct({ a: Schema.Number, extra: Schema.String, b: Schema.String }) + const Reader = Schema.Struct({ b: Schema.String, a: Schema.Number, optional: Schema.optionalKey(Schema.Boolean) }) + const bytes = encode(Writer, { a: 1, extra: "drop", b: "keep" }) + assert.deepStrictEqual(Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader))(bytes), { a: 1, b: "keep" }) + + const OrderedA = Schema.Struct({ a: Schema.Number, b: Schema.String }) + const OrderedB = Schema.Struct({ b: Schema.String, a: Schema.Number }) + assert.deepStrictEqual([...encode(OrderedA, { a: 1, b: "x" })], [...encode(OrderedB, { a: 1, b: "x" })]) + }) + + it("orders extra keys by raw UTF-8 whatever the insertion order", () => { + const record = Schema.Record(Schema.String, Schema.Number) + // Code unit order puts the surrogate pair first, raw UTF-8 does not. + const wide = "\u{10000}" + const replacement = "\uFFFD" + assert.deepStrictEqual( + [...encode(record, { [wide]: 1, [replacement]: 2 })], + [...encode(record, { [replacement]: 2, [wide]: 1 })] + ) + assert.deepStrictEqual(roundtrip(record, { [wide]: 1, [replacement]: 2 }), { [wide]: 1, [replacement]: 2 }) + assert.deepStrictEqual( + [...encode(record, { b: 1, a: 2 })], + [...encode(record, { a: 2, b: 1 })] + ) + }) + + it("keeps canonical key order when record shapes change within a frame", () => { + const rows = Schema.Array(Schema.Record(Schema.String, Schema.Number)) + const mixed = [{ b: 2, a: 1 }, { a: 1, b: 2 }, { a: 1 }, { c: 3, a: 1 }, { a: 1, b: 2 }] + assert.deepStrictEqual(roundtrip(rows, mixed), mixed) + assert.deepStrictEqual( + [...encode(rows, mixed)], + [...encode(rows, [{ a: 1, b: 2 }, { b: 2, a: 1 }, { a: 1 }, { a: 1, c: 3 }, { b: 2, a: 1 }])] + ) + }) + + it("uses fieldId as the encoded-side field identity", () => { + const Before = Schema.Struct({ oldName: Schema.String.pipe(SchemaBinary.fieldId(1)) }) + const After = Schema.Struct({ newName: Schema.String.pipe(SchemaBinary.fieldId(1)) }) + const bytes = encode(Before, { oldName: "value" }) + assert.deepStrictEqual(Schema.decodeUnknownSync(SchemaBinary.toCodec(After))(bytes), { newName: "value" }) + assert.throws(() => SchemaBinary.fieldId(0)) + assert.throws(() => SchemaBinary.fieldId(1.5)) + }) + + it("packs explicit field ids with scalar wire kinds", () => { + const schema = Schema.Struct({ + n: Schema.Number.pipe(SchemaBinary.fieldId(1)), + flag: Schema.Boolean.pipe(SchemaBinary.fieldId(2)), + text: Schema.String.pipe(SchemaBinary.fieldId(3)) + }) + const value = { n: 7, flag: true, text: "x" } + + assert.deepStrictEqual([...encode(schema, value)], [7, 0x20, 9, 14, 20, 24, 1, 120]) + assert.deepStrictEqual(roundtrip(schema, value), value) + }) + + it("validates the full fieldId range and ignores tuple annotations", () => { + for (const id of [-1, Number.NaN, Number.POSITIVE_INFINITY, 0x1_0000_0000]) { + assert.throws(() => SchemaBinary.fieldId(id), /integer in \[1, 4294967295\]/) + } + assert.doesNotThrow(() => SchemaBinary.fieldId(0xFFFFFFFF)) + + const Max = Schema.Struct({ flag: Schema.Boolean.pipe(SchemaBinary.fieldId(0xFFFFFFFF)) }) + const maxBytes = encode(Max, { flag: true }) + assert.strictEqual(maxBytes.length, 7) + assert.deepStrictEqual([...maxBytes.subarray(2)], [0xFC, 0xFF, 0xFF, 0xFF, 0x7F]) + assert.deepStrictEqual(roundtrip(Max, { flag: true }), { flag: true }) + + const plain = Schema.Tuple([Schema.String]) + const annotated = Schema.Tuple([Schema.String.pipe(SchemaBinary.fieldId(123))]) + assert.deepStrictEqual([...encode(annotated, ["value"])], [...encode(plain, ["value"])]) + }) + + it("keeps canonically distinct Unicode field names distinct", () => { + const composed = "\u00E9" + const decomposed = "e\u0301" + const schema = Schema.Struct({ [composed]: Schema.String, [decomposed]: Schema.String }) + const value = { [composed]: "composed", [decomposed]: "decomposed" } + + assert.deepStrictEqual(roundtrip(schema, value), value) + }) + + it("skips unknown fields as opaque bytes and still rejects repeated unknown ids", () => { + const Writer = Schema.Struct({ future: Schema.Uint8Array.pipe(SchemaBinary.fieldId(7)) }) + const Reader = Schema.Struct({}) + const bytes = encode(Writer, { future: Uint8Array.of(0x80, 0xFF, 0x10) }) + assert.deepStrictEqual(Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader))(bytes), {}) + + const field = bytes.slice(2) + const duplicate = concat(Uint8Array.of(1 + field.length * 2, 0x20), field, field) + assert.match( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader))(duplicate)).message, + /unique field ids/ + ) + }) + + it("skips every scalar wire shape for unknown fields", () => { + const Reader = Schema.Struct({}) + const bytes = Uint8Array.of( + 10, + 0x20, + 7 * 8 + 5, + 0, + 0, + 8 * 8 + 6, + 0, + 0, + 0, + 0, + 9 * 8 + 7 + ) + + assert.deepStrictEqual(Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader))(bytes), {}) + }) + + it("skips an unknown union member on an optional struct field", () => { + const A = Schema.Struct({ _tag: Schema.Literal("A"), n: Schema.Number }) + const B = Schema.Struct({ _tag: Schema.Literal("B"), text: Schema.String }) + const Writer = Schema.Struct({ event: Schema.optionalKey(Schema.Union([A, B])) }) + const Reader = Schema.Struct({ event: Schema.optionalKey(Schema.Union([A])) }) + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader))(encode(Writer, { event: { _tag: "B", text: "new" } })), + {} + ) + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader))(encode(Writer, { event: { _tag: "A", n: 1 } })), + { event: { _tag: "A", n: 1 } } + ) + }) + + it("keeps tuple sentinels in variant payloads", () => { + const A = Schema.Tuple([Schema.Literal("A"), Schema.Number]) + const B = Schema.Tuple([Schema.Literal("B"), Schema.String]) + const schema = Schema.Union([A, B]) + assert.deepStrictEqual(roundtrip(schema, ["A", 1]), ["A", 1]) + assert.deepStrictEqual(roundtrip(schema, ["B", "value"]), ["B", "value"]) + }) + + it("rejects unknown union members in positional slots", () => { + const A = Schema.Struct({ _tag: Schema.Literal("A") }) + const B = Schema.Struct({ _tag: Schema.Literal("B") }) + const WriterArray = Schema.Array(Schema.Union([A, B])) + const ReaderArray = Schema.Array(Schema.Union([A])) + assert.match( + schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(ReaderArray))(encode(WriterArray, [{ _tag: "B" }])) + ).message, + /Missing key/ + ) + + const WriterTuple = Schema.Tuple([Schema.optionalKey(Schema.Union([A, B]))]) + const ReaderTuple = Schema.Tuple([Schema.optionalKey(Schema.Union([A]))]) + assert.match( + schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(ReaderTuple))(encode(WriterTuple, [{ _tag: "B" }])) + ).message, + /known union member/ + ) + }) + + it("reports the array or tuple index for an unknown positional union member", () => { + const A = Schema.Struct({ _tag: Schema.Literal("A") }) + const B = Schema.Struct({ _tag: Schema.Literal("B") }) + const WriterArray = Schema.Struct({ xs: Schema.Array(Schema.Union([A, B])) }) + const ReaderArray = Schema.Struct({ xs: Schema.Array(Schema.Union([A])) }) + assert.match( + schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(ReaderArray))( + encode(WriterArray, { xs: [{ _tag: "B" }] }) + ) + ).message, + /Missing key\n at \["xs"\]\[0\]/ + ) + + const WriterTuple = Schema.Struct({ xs: Schema.Tuple([Schema.String, Schema.Union([A, B])]) }) + const ReaderTuple = Schema.Struct({ xs: Schema.Tuple([Schema.String, Schema.Union([A])]) }) + assert.match( + schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(ReaderTuple))( + encode(WriterTuple, { xs: ["head", { _tag: "B" }] }) + ) + ).message, + /Missing key\n at \["xs"\]\[1\]/ + ) + }) + + it("uses kind tags for mixed enums", () => { + const Mixed = { Text: "text", Code: 1 } as const + const schema = Schema.Enum(Mixed) + assert.strictEqual(roundtrip(schema, Mixed.Text), "text") + assert.strictEqual(roundtrip(schema, Mixed.Code), 1) + }) + + it("encodes record entries in the reserved field zero map", () => { + const schema = Schema.Record(Schema.String, Schema.Number) + const value = { z: 1, a: 2 } + assert.deepStrictEqual(roundtrip(schema, value), value) + assert.deepStrictEqual([...encode(schema, value)], [...encode(schema, { a: 2, z: 1 })]) + }) + + it("round-trips __proto__ named fields without changing the prototype", () => { + const schema = Schema.Struct({ ["__proto__"]: Schema.String, ok: Schema.String }) + const value = { ["__proto__"]: "named", ok: "yes" } + + for (const result of [roundtrip(schema, value), roundtripFingerprint(schema, value)]) { + assert.strictEqual(Object.getPrototypeOf(result), Object.prototype) + assert.isTrue(Object.prototype.propertyIsEnumerable.call(result, "__proto__")) + assert.strictEqual(result.__proto__, "named") + assert.strictEqual(({} as Record).polluted, undefined) + } + }) + + it("round-trips __proto__ record entries with object values without changing the prototype", () => { + const schema = Schema.Record(Schema.String, Schema.Struct({ polluted: Schema.Boolean })) + const value = { ["__proto__"]: { polluted: true } } + + for (const result of [roundtrip(schema, value), roundtripFingerprint(schema, value)]) { + assert.strictEqual(Object.getPrototypeOf(result), Object.prototype) + assert.isTrue(Object.prototype.propertyIsEnumerable.call(result, "__proto__")) + assert.deepStrictEqual(result.__proto__, { polluted: true }) + assert.strictEqual(({} as Record).polluted, undefined) + } + }) + + it("round-trips __proto__ tagged-union sentinels without changing the prototype", () => { + const A = Schema.Struct({ ["__proto__"]: Schema.Literal("A"), value: Schema.String }) + const B = Schema.Struct({ ["__proto__"]: Schema.Literal("B"), value: Schema.String }) + const schema = Schema.Union([A, B]) + const value = { ["__proto__"]: "A" as const, value: "a" } + + for (const result of [roundtrip(schema, value), roundtripFingerprint(schema, value)]) { + assert.strictEqual(Object.getPrototypeOf(result), Object.prototype) + assert.isTrue(Object.prototype.propertyIsEnumerable.call(result, "__proto__")) + assert.strictEqual(result.__proto__, "A") + assert.strictEqual(({} as Record).polluted, undefined) + } + }) + }) + + describe("struct row runs", () => { + const Row = Schema.Struct({ + id: Schema.String, + label: Schema.String, + tags: Schema.Array(Schema.String), + attributes: Schema.Record(Schema.String, Schema.String) + }) + const rows = Array.from({ length: 6 }, (_, index) => ({ + id: `row-${index}`, + label: index % 2 === 0 ? "even" : "odd", + tags: index % 3 === 0 ? ["red", "blue"] : ["blue"], + attributes: { region: "eu", worker: `w${index}` } + })) + const nonEmptyRows = rows as [typeof rows[number], ...Array] + + it("shares field ids and repeated strings across rows", () => { + assert.deepStrictEqual(roundtrip(Schema.Array(Row), rows), rows) + assert.deepStrictEqual(roundtrip(Schema.NonEmptyArray(Row), nonEmptyRows), nonEmptyRows) + const one = encode(Schema.Array(Row), rows.slice(0, 1)).length + const six = encode(Schema.Array(Row), rows).length + assert.isBelow(six, one * 6) + }) + + it("keeps evolution rules inside a run", () => { + const Reader = Schema.Struct({ + tags: Schema.Array(Schema.String), + id: Schema.String, + later: Schema.optionalKey(Schema.Boolean) + }) + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.Array(Reader)))(encode(Schema.Array(Row), rows)), + rows.map(({ id, tags }) => ({ id, tags })) + ) + }) + + it("declares a new shape whenever the present fields change", () => { + assert.deepStrictEqual(roundtrip(Schema.Array(Varying), varying), varying) + }) + + it("interns index signature keys on the row struct itself", () => { + const WithRest = Schema.StructWithRest(Schema.Struct({ id: Schema.String }), [ + Schema.Record(Schema.String, Schema.String) + ]) + const values = [{ id: "a", note: "1" }, { id: "b", note: "2" }, { id: "c" }] + assert.deepStrictEqual(roundtrip(Schema.Array(WithRest), values), values) + }) + + it("runs nested inside a run", () => { + const Inner = Schema.Struct({ k: Schema.String, v: Schema.String }) + const Outer = Schema.Struct({ name: Schema.String, items: Schema.Array(Inner) }) + const outers = Array.from({ length: 4 }, (_, index) => ({ + name: `n${index}`, + items: [{ k: "a", v: "1" }, { k: "b", v: "1" }] + })) + assert.deepStrictEqual(roundtrip(Schema.Array(Outer), outers), outers) + }) + + it("keeps rows independent past the 30 field shape limit", () => { + const fields: Record = {} + const value: Record = {} + for (let i = 0; i < 35; i++) { + fields[`f${i}`] = Schema.String + value[`f${i}`] = `v${i}` + } + const Wide = Schema.Struct(fields) + assert.deepStrictEqual(roundtrip(Schema.Array(Wide), [value, value, value]), [value, value, value]) + }) + + it("rejects an unknown row shape and an out of range back-reference", () => { + const Simple = Schema.Array(Schema.Struct({ a: Schema.String })) + const codec = SchemaBinary.toCodec(Simple) + // The second row reuses shape 0 and back-references the first `a`, so its + // last two bytes are the shape code and the region code. + const bytes = encode(Simple, [{ a: "x" }, { a: "x" }]) + assert.deepStrictEqual([...bytes.subarray(bytes.length - 2)], [1, 1]) + + const unknownShape = bytes.slice() + unknownShape[unknownShape.length - 2] = 9 + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(unknownShape)).message, /row shape/) + + const unknownRef = bytes.slice() + unknownRef[unknownRef.length - 1] = 5 + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(unknownRef)).message, /back-reference/) + }) + + it("keeps a skipped field's own run out of the reader's tables", () => { + const Writer = Schema.Struct({ + keep: Schema.String, + gone: Schema.Array(Schema.Struct({ p: Schema.String, q: Schema.String })) + }) + const Reader = Schema.Struct({ keep: Schema.String }) + const values = Array.from({ length: 4 }, (_, index) => ({ + keep: `k${index}`, + gone: [{ p: "z", q: "z" }, { p: "z", q: "z" }] + })) + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.Array(Reader)))(encode(Schema.Array(Writer), values)), + values.map(({ keep }) => ({ keep })) + ) + }) + + it("interns every index signature key, including ones it has no signature for", () => { + const Writer = Schema.Struct({ attrs: Schema.Record(Schema.String, Schema.String) }) + const Reader = Schema.Struct({ + attrs: Schema.Record( + Schema.String.check(Schema.makeFilter((key: string) => key.startsWith("keep"))), + Schema.String + ) + }) + // The reader drops `drop` but must still number it, or row 2's reference + // to `keep1` resolves to `keep2`. + const values = [ + { attrs: { drop: "d", keep1: "a" } }, + { attrs: { keep2: "b" } }, + { attrs: { keep1: "c" } } + ] + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.Array(Reader)))(encode(Schema.Array(Writer), values)), + [{ attrs: { keep1: "a" } }, { attrs: { keep2: "b" } }, { attrs: { keep1: "c" } }] + ) + }) + + it("keeps two fields sharing one layout on separate tables", () => { + const Writer = Schema.Struct({ keep: Schema.String, gone: Schema.String }) + const Reader = Schema.Struct({ keep: Schema.String }) + const values = [ + { keep: "first", gone: "other" }, + { keep: "other", gone: "first" }, + { keep: "first", gone: "first" } + ] + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.Array(Reader)))(encode(Schema.Array(Writer), values)), + values.map(({ keep }) => ({ keep })) + ) + }) + + it("holds references past the point a slot switches to a map", () => { + const Many = Schema.Struct({ v: Schema.String }) + const many = Array.from({ length: 60 }, (_, index) => ({ v: `v${index % 21}` })) + assert.deepStrictEqual(roundtrip(Schema.Array(Many), many), many) + }) + + it("round-trips a slot that stops interning values that never repeat", () => { + const Many = Schema.Struct({ v: Schema.String }) + // All-distinct values disable the writer's table at the decide point; + // repeats past it are written literally and must still round-trip. + const many = Array.from({ length: 160 }, (_, index) => ({ v: `value-${index % 80}` })) + assert.deepStrictEqual(roundtrip(Schema.Array(Many), many), many) + const keyed = Schema.Struct({ attributes: Schema.Record(Schema.String, Schema.String) }) + const rows = Array.from({ length: 160 }, (_, index) => ({ + attributes: { [`key-${index % 80}`]: `w${index}` } + })) + assert.deepStrictEqual(roundtrip(Schema.Array(keyed), rows), rows) + }) + + it("gives each recursive occurrence its own run", () => { + interface Node { + readonly name: string + readonly children: ReadonlyArray + } + const Node = Schema.Struct({ + name: Schema.String, + children: Schema.Array(Schema.suspend((): Schema.Codec => Node)) + }) + const tree = [ + { name: "a", children: [{ name: "x", children: [] }, { name: "x", children: [] }] }, + { name: "a", children: [{ name: "y", children: [{ name: "x", children: [] }] }] } + ] + assert.deepStrictEqual(roundtrip(Schema.Array(Node), tree), tree) + }) + + it("reports the row index and field name for a missing key", () => { + const Required = Schema.Struct({ a: Schema.String, b: Schema.String }) + assert.match( + schemaError(() => + Schema.encodeUnknownSync(SchemaBinary.toCodec(Schema.Array(Required)))( + [{ a: "1", b: "2" }, { a: "1" } as never] + ) + ).message, + /Missing key\n at \[1\]\["b"\]/ + ) + }) + + it("runs rows in fingerprint mode with mask-declared shapes", () => { + assert.deepStrictEqual(roundtripFingerprint(Schema.Array(Row), rows), rows) + // repeated strings are interned across rows + const one = encodeFingerprint(Schema.Array(Row), rows.slice(0, 1)).length + const six = encodeFingerprint(Schema.Array(Row), rows).length + assert.isBelow(six, one * 6) + // the mask shape has no id list, so fingerprint rows undercut default rows + assert.isBelow(six, encode(Schema.Array(Row), rows).length) + }) + + it("declares fingerprint shapes whenever the present fields change", () => { + assert.deepStrictEqual(roundtripFingerprint(Schema.Array(Varying), varying), varying) + }) + + it("rejects a fingerprint shape mask with unknown bits", () => { + const codec = SchemaBinary.toCodec(Schema.Array(Row), { fingerprint: true }) + const bytes = Schema.encodeUnknownSync(codec)(rows.slice(0, 1)).slice() + // length | envelope | 8-byte fingerprint | count | row length | code 0 | mask + assert.strictEqual(bytes[12], 0) + bytes[13] = 0x7F + assert.match( + schemaError(() => Schema.decodeUnknownSync(codec)(bytes)).message, + /known row shape/ + ) + }) + }) + + describe("parser", () => { + it("accepts empty input and spends the parser after a successful end", () => { + const parser = SchemaBinary.parser(Schema.String) + assert.deepStrictEqual(parser.feedSync(new Uint8Array()), []) + parser.endSync() + assert.match(schemaError(() => parser.endSync()).message, /parser is spent/) + }) + + it("parses concatenated frames split across chunks", () => { + const schema = Schema.Struct({ a: Schema.Number }) + const first = encode(schema, { a: 1 }) + const second = encode(schema, { a: 2 }) + const bytes = concat(first, second) + const parser = SchemaBinary.parser(schema) + assert.deepStrictEqual(parser.feedSync(bytes.slice(0, first.length + 3)), [{ a: 1 }]) + assert.deepStrictEqual(parser.feedSync(bytes.slice(first.length + 3)), [{ a: 2 }]) + parser.endSync() + assert.match(schemaError(() => parser.feedSync(new Uint8Array())).message, /parser is spent/) + }) + + it("parses exact nested structs, arrays, and string records", () => { + const schema = Schema.Struct({ + id: Schema.Number, + active: Schema.Boolean, + tags: Schema.Array(Schema.String), + metrics: Schema.Record(Schema.String, Schema.Number) + }) + const value = { id: 1, active: true, tags: ["a", "b"], metrics: { x: 1, y: 2 } } + const parser = SchemaBinary.parser(schema) + + assert.deepStrictEqual(parser.feedSync(encode(schema, value)), [value]) + parser.endSync() + }) + + it("keeps __proto__ safe on the exact parser path", () => { + const schema = Schema.Struct({ ["__proto__"]: Schema.String, value: Schema.Number }) + const value = { ["__proto__"]: "own", value: 1 } + const parser = SchemaBinary.parser(schema) + const [result] = parser.feedSync(encode(schema, value)) + + assert.strictEqual(Object.getPrototypeOf(result), Object.prototype) + assert.isTrue(Object.prototype.propertyIsEnumerable.call(result, "__proto__")) + assert.strictEqual(result.__proto__, "own") + assert.strictEqual(({} as Record).polluted, undefined) + parser.endSync() + }) + + it("retains Schema predicates erased by the binary layout", () => { + const rejects = ( + reader: Schema.Constraint, + writer: Schema.Codec, + value: unknown + ) => { + const parser = SchemaBinary.parser(reader) + assert.isTrue(Schema.isSchemaError(schemaError(() => parser.feedSync(encode(writer, value))))) + } + + rejects(Schema.TemplateLiteral(["a"]), Schema.String, "zzz") + rejects(Schema.Literal("a"), Schema.String, "zzz") + rejects(Schema.Enum({ A: "a", B: "b" }), Schema.String, "zzz") + rejects(Schema.UniqueSymbol(Symbol.for("expected")), Schema.Symbol, Symbol.for("other")) + rejects(Schema.ObjectKeyword, Schema.Unknown, 1) + }) + + it("retains Schema parsing for numeric record keys", () => { + const Writer = Schema.Record(Schema.String, Schema.Number) + const Reader = Schema.Record(Schema.Number, Schema.Number) + const parser = SchemaBinary.parser(Reader) + + assert.deepStrictEqual(parser.feedSync(encode(Writer, { "01": 1, "1e2": 2, "-0": 3 })), [{ + "0": 3, + "1": 1, + "100": 2 + }]) + parser.endSync() + }) + + it("retains transformations, checks, unions, declarations, and recursive validation", () => { + const transformed = SchemaBinary.parser(Schema.NumberFromString) + assert.deepStrictEqual(transformed.feedSync(encode(Schema.NumberFromString, 123)), [123]) + transformed.endSync() + + const NonNegative = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)) + assert.match( + schemaError(() => SchemaBinary.parser(NonNegative).feedSync(encode(Schema.Number, -1))).message, + /greater than or equal to 0/ + ) + + const CheckedUnion = Schema.Union([NonNegative, Schema.String]) + const BroadUnion = Schema.Union([Schema.Number, Schema.String]) + assert.match( + schemaError(() => SchemaBinary.parser(CheckedUnion).feedSync(encode(BroadUnion, -1))).message, + /greater than or equal to 0/ + ) + + const CheckedOption = Schema.Option(NonNegative) + const BroadOption = Schema.Option(Schema.Number) + assert.match( + schemaError(() => SchemaBinary.parser(CheckedOption).feedSync(encode(BroadOption, Option.some(-1)))).message, + /greater than or equal to 0/ + ) + + interface Node { + readonly value: number + readonly children: ReadonlyArray + } + let Writer: Schema.Codec + Writer = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend(() => Writer)) + }) + let Reader: Schema.Codec + Reader = Schema.Struct({ + value: NonNegative, + children: Schema.Array(Schema.suspend(() => Reader)) + }) + const invalid = { value: 1, children: [{ value: -1, children: [] }] } + assert.match( + schemaError(() => SchemaBinary.parser(Reader).feedSync(encode(Writer, invalid))).message, + /greater than or equal to 0/ + ) + }) + + it("retains partial frames across one-byte feeds", () => { + const bytes = concat(...Array.from({ length: 100 }, (_, i) => encode(Schema.Number, i))) + const parser = SchemaBinary.parser(Schema.Number) + const values: Array = [] + for (const byte of bytes) values.push(...parser.feedSync(Uint8Array.of(byte))) + parser.endSync() + assert.deepStrictEqual(values, Array.from({ length: 100 }, (_, i) => i)) + }) + + it("waits for a fragmented multi-byte frame header", () => { + const value = "x".repeat(300) + const bytes = encode(Schema.String, value) + const parser = SchemaBinary.parser(Schema.String) + + assert.deepStrictEqual(parser.feedSync(bytes.slice(0, 1)), []) + assert.deepStrictEqual(parser.feedSync(bytes.slice(1)), [value]) + parser.endSync() + }) + + it("uses the bigint header path for longer safe lengths", () => { + // Canonical uvarint(Number.MAX_SAFE_INTEGER): seven continuation groups + // followed by the final four bits. maxFrameSize rejects it before the + // parser waits for an impractically large body. + const bytes = Uint8Array.of(0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F) + const parser = SchemaBinary.parser(Schema.String, { maxFrameSize: 1 }) + + assert.deepStrictEqual(parser.feedSync(bytes.slice(0, 7)), []) + assert.match(schemaError(() => parser.feedSync(bytes.slice(7))).message, /frame within maxFrameSize/) + }) + + it("keeps parser index-signature caching bounded with FIFO eviction", () => { + let checks = 0 + const Key = Schema.String.check(Schema.makeFilter((_: string) => { + checks++ + return true + })) + const Writer = Schema.Record(Schema.String, Schema.Number) + const parser = SchemaBinary.parser(Schema.Record(Key, Schema.Number)) + const feed = (key: string) => { + const value = { [key]: 1 } + assert.deepStrictEqual(parser.feedSync(encode(Writer, value)), [value]) + } + + // The parser cache holds 256 attacker-controlled (layout, key) pairs. + // The 257th evicts key-0, while key-1 remains cached. + for (let i = 0; i < 257; i++) feed(`key-${i}`) + const beforeHit = checks + feed("key-1") + const hitChecks = checks - beforeHit + const beforeMiss = checks + feed("key-0") + const missChecks = checks - beforeMiss + + assert.strictEqual(missChecks, hitChecks + 1) + parser.endSync() + }) + + it("does not thrash the index-signature cache above its bound", () => { + let checks = 0 + const Key = Schema.String.check(Schema.makeFilter((_: string) => { + checks++ + return true + })) + const Writer = Schema.Record(Schema.String, Schema.Number) + const Reader = Schema.Record(Key, Schema.Number) + const allHitParser = SchemaBinary.parser(Reader) + const allHitValue = Object.fromEntries(Array.from({ length: 256 }, (_, index) => [`hit-${index}`, index])) + const allHitBytes = encode(Writer, allHitValue) + + assert.deepStrictEqual(allHitParser.feedSync(allHitBytes), [allHitValue]) + const beforeAllHit = checks + assert.deepStrictEqual(allHitParser.feedSync(allHitBytes), [allHitValue]) + const allHitChecksPerKey = (checks - beforeAllHit) / 256 + allHitParser.endSync() + + const parser = SchemaBinary.parser(Reader) + const value = Object.fromEntries(Array.from({ length: 300 }, (_, index) => [`key-${index}`, index])) + const bytes = encode(Writer, value) + + assert.deepStrictEqual(parser.feedSync(bytes), [value]) + const firstChecks = checks + assert.deepStrictEqual(parser.feedSync(bytes), [value]) + const secondChecks = checks - firstChecks + const misses = secondChecks - allHitChecksPerKey * 300 + + // Derive the decoder's per-key work from a fully warm parser. Each cache + // miss adds one classification check beyond that all-hit baseline. + assert.strictEqual(misses, 45) + parser.endSync() + }) + + it("isolates index-signature caches by parser options", () => { + const Key = Schema.String.check(Schema.makeFilter((key: string) => key.startsWith("allowed-"))) + const Writer = Schema.Record(Schema.String, Schema.Number) + const Reader = Schema.Record(Key, Schema.Number) + const bytes = encode(Writer, { denied: 1 }) + const strict = SchemaBinary.parser(Reader) + const unchecked = SchemaBinary.parser(Reader, { disableChecks: true }) + + assert.deepStrictEqual(strict.feedSync(bytes), [{}]) + assert.deepStrictEqual(unchecked.feedSync(bytes), [{ denied: 1 }]) + strict.endSync() + unchecked.endSync() + }) + + it("keeps nested SchemaBinary decodes independent from the outer reader", () => { + const Inner = Schema.Struct({ id: Schema.Number, label: Schema.String }) + const Outer = Schema.Struct({ id: Schema.String, inner: SchemaBinary.toCodec(Inner) }) + const first = { id: "first", inner: { id: 1, label: "one" } } + const second = { id: "second", inner: { id: 2, label: "two" } } + const parser = SchemaBinary.parser(Outer) + + assert.deepStrictEqual(parser.feedSync(concat(encode(Outer, first), encode(Outer, second))), [first, second]) + parser.endSync() + }) + + it("returns a checked-out one-shot reader after an exceptional decode", () => { + const codec = SchemaBinary.toCodec(Schema.String) + const decode = Schema.decodeUnknownSync(codec) + + assert.match(schemaError(() => decode(Uint8Array.of(2, 0x20, 0xFF))).message, /utf-8/) + assert.strictEqual(decode(encode(Schema.String, "after failure")), "after failure") + }) + + it("delivers completed values before reporting a later failure", () => { + const good = encode(Schema.Number, 1) + const bad = encode(Schema.Number, 2).slice(0, 2) + const parser = SchemaBinary.parser(Schema.Number) + assert.deepStrictEqual(parser.feedSync(concat(good, bad)), [1]) + assert.match(schemaError(() => parser.endSync()).message, /complete value/) + assert.match(schemaError(() => parser.feedSync(new Uint8Array())).message, /parser is spent/) + }) + + it("stashes a malformed complete frame after returning earlier values", () => { + const good = encode(Schema.Number, 1) + const bad = encode(Schema.Number, 2).slice() + bad[1] = 0x10 + const parser = SchemaBinary.parser(Schema.Number) + + assert.deepStrictEqual(parser.feedSync(concat(good, bad)), [1]) + assert.match(schemaError(() => parser.feedSync(new Uint8Array())).message, /version 2 envelope, flags 0/) + assert.match(schemaError(() => parser.endSync()).message, /parser is spent/) + }) + + it("enforces maxFrameSize", () => { + const bytes = encode(Schema.String, "too large") + const parser = SchemaBinary.parser(Schema.String, { maxFrameSize: 2 }) + assert.match(schemaError(() => parser.feedSync(bytes)).message, /frame within maxFrameSize/) + + const exact = SchemaBinary.parser(Schema.String, { maxFrameSize: bytes[0] }) + assert.deepStrictEqual(exact.feedSync(bytes), ["too large"]) + exact.endSync() + }) + + it("fails a ten-byte unterminated length immediately", () => { + const bytes = new Uint8Array(10).fill(0x80) + const parser = SchemaBinary.parser(Schema.String, { reportInput: true }) + const error = schemaError(() => parser.feedSync(bytes)) + assert.match(error.message, /uvarint/) + assert.isTrue(SchemaIssue.hasInput(error.issue)) + }) + + it("rejects a terminated header above the safe-integer bound", () => { + const bytes = Uint8Array.of(0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10) + const parser = SchemaBinary.parser(Schema.String) + + assert.match(schemaError(() => parser.feedSync(bytes)).message, /safe integer length/) + assert.match(schemaError(() => parser.feedSync(new Uint8Array())).message, /parser is spent/) + }) + + it.effect("wraps feed and end in SchemaError effects", () => + Effect.gen(function*() { + const parser = SchemaBinary.parser(Schema.Number) + const values = yield* parser.feed(encode(Schema.Number, 1)) + assert.deepStrictEqual(values, [1]) + yield* parser.end + })) + + it.effect("fails malformed feed and truncated end through the Effect surface", () => + Effect.gen(function*() { + const malformed = SchemaBinary.parser(Schema.Boolean) + const feedError = yield* malformed.feed(Uint8Array.of(2, 0x20, 2)).pipe(Effect.flip) + assert.isTrue(Schema.isSchemaError(feedError)) + + const truncated = SchemaBinary.parser(Schema.String) + yield* truncated.feed(Uint8Array.of(2, 0x20)) + const endError = yield* truncated.end.pipe(Effect.flip) + assert.isTrue(Schema.isSchemaError(endError)) + })) + }) + + describe("native declarations", () => { + it("round-trips bigint, Date, bytes, Option, and Result", () => { + assert.strictEqual(roundtrip(Schema.BigInt, -12345678901234567890n), -12345678901234567890n) + assert.strictEqual(roundtrip(Schema.Date, new Date(-123456789)).getTime(), -123456789) + assert.deepStrictEqual([...roundtrip(Schema.Uint8Array, Uint8Array.of(0, 1, 255))], [0, 1, 255]) + + const option = roundtrip(Schema.Option(Schema.String), Option.some("value")) + assert.isTrue(Option.isSome(option)) + if (Option.isSome(option)) assert.strictEqual(option.value, "value") + + const result = roundtrip(Schema.Result(Schema.Number, Schema.String), Result.fail("error")) + assert.isTrue(Result.isFailure(result)) + if (Result.isFailure(result)) assert.strictEqual(result.failure, "error") + }) + + it("round-trips Duration and normalized BigDecimal", () => { + const duration = roundtrip(Schema.Duration, Duration.millis(1.5)) + assert.strictEqual(Duration.toNanosUnsafe(duration), 1_500_000n) + assert.strictEqual(roundtrip(Schema.Duration, Duration.infinity), Duration.infinity) + assert.strictEqual(roundtrip(Schema.Duration, Duration.negativeInfinity), Duration.negativeInfinity) + assert.strictEqual(Duration.toNanosUnsafe(roundtrip(Schema.Duration, Duration.nanos(-7n))), -7n) + + const decimal = roundtrip(Schema.BigDecimal, BigDecimal.make(100n, 2)) + assert.strictEqual(decimal.value, 1n) + assert.strictEqual(decimal.scale, 0) + assert.deepStrictEqual(roundtrip(Schema.BigDecimal, BigDecimal.make(-123n, -4)), BigDecimal.make(-123n, -4)) + }) + + it("round-trips the empty and success branches of native sums", () => { + assert.isTrue(Option.isNone(roundtrip(Schema.Option(Schema.String), Option.none()))) + + const result = roundtrip(Schema.Result(Schema.Number, Schema.String), Result.succeed(42)) + assert.isTrue(Result.isSuccess(result)) + if (Result.isSuccess(result)) assert.strictEqual(result.success, 42) + + const exit = roundtrip(Schema.Exit(Schema.Number, Schema.String, Schema.Unknown), Exit.succeed(42)) + assert.isTrue(Exit.isSuccess(exit)) + if (Exit.isSuccess(exit)) assert.strictEqual(exit.value, 42) + }) + + it("skips future CauseReason tags inside Cause", () => { + const schema = Schema.Cause(Schema.String, Schema.Unknown) + const bytes = Uint8Array.of(7, 0x20, 2, 1, 99, 2, 0, 0x78) + const decoded = Schema.decodeUnknownSync(SchemaBinary.toCodec(schema))(bytes) + + assert.strictEqual(decoded.reasons.length, 1) + assert.strictEqual(decoded.reasons[0]._tag, "Fail") + assert.strictEqual((decoded.reasons[0] as Cause.Fail).error, "x") + assert.match( + schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.CauseReason(Schema.String, Schema.Unknown)))( + Uint8Array.of(2, 0x20, 99) + ) + ).message, + /Missing key/ + ) + }) + + it("round-trips UTC and zoned DateTime values", () => { + const utc = DateTime.makeUnsafe(-123456789) + assert.strictEqual(DateTime.toEpochMillis(roundtrip(Schema.DateTimeUtc, utc)), -123456789) + + for ( + const zoned of [ + DateTime.makeZonedUnsafe(123456789, { timeZone: 3_600_000 }), + DateTime.makeZonedUnsafe(123456789, { timeZone: "Europe/London" }) + ] + ) { + const decoded = roundtrip(Schema.DateTimeZoned, zoned) + assert.strictEqual(DateTime.toEpochMillis(decoded), DateTime.toEpochMillis(zoned)) + assert.strictEqual(DateTime.zoneToString(decoded.zone), DateTime.zoneToString(zoned.zone)) + } + }) + + it("round-trips Exit, Cause, and CauseReason", () => { + const causeSchema = Schema.Cause(Schema.String, Schema.Unknown) + const cause = Cause.fromReasons([ + Cause.makeFailReason("boom"), + Cause.makeDieReason({ defect: true }), + Cause.makeInterruptReason(0), + Cause.makeInterruptReason() + ]) + const decoded = roundtrip(causeSchema, cause) + assert.deepStrictEqual(decoded.reasons.map((reason) => reason._tag), ["Fail", "Die", "Interrupt", "Interrupt"]) + assert.strictEqual((decoded.reasons[2] as Cause.Interrupt).fiberId, 0) + assert.strictEqual((decoded.reasons[3] as Cause.Interrupt).fiberId, undefined) + + const reason = roundtrip( + Schema.CauseReason(Schema.String, Schema.Unknown), + Cause.makeFailReason("failure") + ) + assert.strictEqual(reason._tag, "Fail") + + const exit = roundtrip(Schema.Exit(Schema.Number, Schema.String, Schema.Unknown), Exit.failCause(cause)) + assert.isTrue(Exit.isFailure(exit)) + }) + + // An `Exit` layout carries the `Cause` node its failure branch writes, + // rather than rebuilding one per value. Both branches must still round-trip + // in fingerprint mode, where the failure branch has no length prefix to + // resynchronise on, and the shared node must not make the two schemas hash + // alike. + it("round-trips both Exit branches in fingerprint mode", () => { + const ExitSchema = Schema.Exit(Schema.Number, Schema.String, Schema.Unknown) + const cause = Cause.fromReasons([Cause.makeFailReason("boom"), Cause.makeDieReason({ defect: true })]) + + assert.isTrue(Exit.isSuccess(roundtripFingerprint(ExitSchema, Exit.succeed(7)))) + + const failure = roundtripFingerprint(ExitSchema, Exit.failCause(cause)) + assert.deepStrictEqual( + Exit.isFailure(failure) ? failure.cause.reasons.map((reason) => reason._tag) : [], + ["Fail", "Die"] + ) + + assert.notStrictEqual( + fingerprintFrame(ExitSchema, Exit.failCause(cause)).fingerprint, + fingerprintFrame(Schema.Cause(Schema.String, Schema.Unknown), cause).fingerprint + ) + }) + }) + + describe("generic declarations and recursion", () => { + it("uses declaration codec links for collections, Redacted, Class, and TaggedClass", () => { + const chunk = roundtrip(Schema.Chunk(Schema.Number), Chunk.make(1, 2, 3)) + assert.deepStrictEqual(Chunk.toReadonlyArray(chunk), [1, 2, 3]) + + const map = roundtrip(Schema.HashMap(Schema.String, Schema.Number), HashMap.make(["a", 1], ["b", 2])) + assert.strictEqual(HashMap.get(map, "a").pipe(Option.getOrUndefined), 1) + assert.strictEqual(HashMap.get(map, "b").pipe(Option.getOrUndefined), 2) + + const set = roundtrip(Schema.HashSet(Schema.String), HashSet.make("a", "b")) + assert.isTrue(HashSet.has(set, "a")) + assert.isTrue(HashSet.has(set, "b")) + + const redacted = roundtrip(Schema.Redacted(Schema.String), Redacted.make("secret")) + assert.strictEqual(Redacted.value(redacted), "secret") + + class Person extends Schema.Class("Person")({ name: Schema.String }) {} + const person = roundtrip(Person, new Person({ name: "Ada" })) + assert.instanceOf(person, Person) + assert.strictEqual(person.name, "Ada") + + class Event extends Schema.TaggedClass()("Event", { value: Schema.Number }) {} + const event = roundtrip(Event, new Event({ value: 1 })) + assert.instanceOf(event, Event) + assert.strictEqual(event._tag, "Event") + assert.strictEqual(event.value, 1) + }) + + it("compiles and round-trips recursive suspended schemas", () => { + interface Node { + readonly value: number + readonly children: ReadonlyArray + } + let Node: Schema.Codec + Node = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend(() => Node)) + }) + const value: Node = { value: 1, children: [{ value: 2, children: [] }] } + assert.deepStrictEqual(roundtrip(Node, value), value) + }) + + it("fails cyclic JSON values with the acyclic value issue", () => { + const value: Record = {} + value.self = value + assert.match(schemaError(() => encode(Schema.Unknown, value)).message, /acyclic value/) + }) + + it("fails cyclic recursive struct values with the acyclic value issue", () => { + interface Node { + readonly value: number + readonly next?: Node + } + let Node: Schema.Codec + Node = Schema.Struct({ + value: Schema.Number, + next: Schema.optionalKey(Schema.suspend(() => Node)) + }) + const value: { value: number; next?: Node } = { value: 1 } + value.next = value + assert.match(schemaError(() => encode(Node, value)).message, /acyclic value/) + }) + + it("fails array-mediated cycles with the acyclic value issue", () => { + interface Node { + readonly name: string + readonly children: ReadonlyArray + } + let Node: Schema.Codec + Node = Schema.Struct({ + name: Schema.String, + children: Schema.Array(Schema.suspend(() => Node)) + }) + const node: { name: string; children: Array } = { name: "root", children: [] } + node.children.push(node) + assert.match(schemaError(() => encode(Node, node)).message, /acyclic value/) + + const unknown: Array = [] + unknown.push(unknown) + assert.match(schemaError(() => encode(Schema.Unknown, unknown)).message, /acyclic value/) + }) + + it("accepts shared recursive values that are DAGs rather than cycles", () => { + interface Node { + readonly value: number + readonly children: ReadonlyArray + } + let Node: Schema.Codec + Node = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend(() => Node)) + }) + const leaf: Node = { value: 2, children: [] } + const value: Node = { value: 1, children: [leaf, leaf] } + + assert.deepStrictEqual(roundtrip(Node, value), { + value: 1, + children: [{ value: 2, children: [] }, { value: 2, children: [] }] + }) + }) + + it("rejects non-serializable JSON values without calling them cycles", () => { + for (const value of [undefined, 1n, Symbol.for("json"), () => 1]) { + const error = schemaError(() => encode(Schema.Unknown, value)) + assert.match(error.message, /JSON-serializable value/) + assert.isFalse(error.message.includes("acyclic value")) + } + }) + + it("runs user encoding links before the binary layer", () => { + const bytes = encode(Schema.NumberFromString, 123) + assert.strictEqual(bytes.length, 5) + assert.strictEqual(roundtrip(Schema.NumberFromString, 123), 123) + + const Before = Schema.Struct({ value: Schema.NumberFromString.pipe(SchemaBinary.fieldId(7)) }) + const After = Schema.Struct({ renamed: Schema.NumberFromString.pipe(SchemaBinary.fieldId(7)) }) + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(After))(encode(Before, { value: 123 })), + { renamed: 123 } + ) + }) + + it.effect("round-trips a yielding transformOrFail through SchemaParser Effect APIs", () => + Effect.gen(function*() { + const schema = Schema.String.pipe( + Schema.decodeTo( + Schema.Number, + SchemaTransformation.transformOrFail({ + decode: (s) => Effect.yieldNow.pipe(Effect.as(Number(s))), + encode: (n) => Effect.yieldNow.pipe(Effect.as(String(n))) + }) + ) + ) + const codec = SchemaBinary.toCodec(schema) + const bytes = yield* SchemaParser.encodeUnknownEffect(codec)(123) + assert.strictEqual(yield* SchemaParser.decodeUnknownEffect(codec)(bytes), 123) + })) + + it("keeps a sound runtime type guard on the derived codec", () => { + const codec = SchemaBinary.toCodec(Schema.Struct({ name: Schema.String, age: Schema.Number })) + assert.isTrue(Schema.is(codec)({ name: "Ada", age: 42 })) + assert.isFalse(Schema.is(codec)({ nope: 1 })) + }) + + it("keeps a sound runtime type guard on recursive derived codecs", () => { + interface Node { + readonly value: number + readonly children: ReadonlyArray + } + let Node: Schema.Codec + Node = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend(() => Node)) + }) + const codec = SchemaBinary.toCodec(Node) + + assert.isTrue(Schema.is(codec)({ value: 1, children: [] })) + assert.isFalse(Schema.is(codec)({ nope: 1 })) + assert.isFalse(Schema.is(Schema.Struct({ inner: codec }))({ inner: { nope: 1 } })) + }) + }) + + describe("parse options", () => { + it("honors checks and disableChecks", () => { + const NonNegative = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)) + const bytes = encode(Schema.Number, -1.5) + assert.match( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(NonNegative))(bytes)).message, + /greater than or equal to 0/ + ) + + const parser = SchemaBinary.parser(NonNegative, { disableChecks: true }) + assert.deepStrictEqual(parser.feedSync(bytes), [-1.5]) + parser.endSync() + }) + + it("honors disableChecks for recursive schemas", () => { + interface Node { + readonly value: number + readonly children: ReadonlyArray + } + let Writer: Schema.Codec + Writer = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend(() => Writer)) + }) + let Reader: Schema.Codec + Reader = Schema.Struct({ + value: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)), + children: Schema.Array(Schema.suspend(() => Reader)) + }) + const value: Node = { value: -1.5, children: [] } + const parser = SchemaBinary.parser(Reader, { disableChecks: true }) + + assert.deepStrictEqual(parser.feedSync(encode(Writer, value)), [value]) + parser.endSync() + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader), { disableChecks: true })(encode(Writer, value)), + value + ) + }) + + it("keeps one-shot index-signature caching within each options call", () => { + const Key = Schema.String.check(Schema.makeFilter((key: string) => key.startsWith("allowed-"))) + const Writer = Schema.Record(Schema.String, Schema.Number) + const codec = SchemaBinary.toCodec(Schema.Record(Key, Schema.Number)) + const bytes = encode(Writer, { denied: 1 }) + + assert.deepStrictEqual(Schema.decodeUnknownSync(codec)(bytes), {}) + assert.deepStrictEqual(Schema.decodeUnknownSync(codec, { disableChecks: true })(bytes), { denied: 1 }) + }) + + it("honors errors all for missing fields", () => { + const bytes = encode(Schema.Struct({}), {}) + const Reader = Schema.Struct({ a: Schema.String, b: Schema.Number }) + const error = schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader), { errors: "all" })(bytes)) + assert.strictEqual(error.message.match(/Missing key/g)?.length, 2) + }) + + it("ignores excess-property and property-order options at the binary boundary", () => { + const Writer = Schema.Struct({ extra: Schema.String, known: Schema.Number }) + const Reader = Schema.Struct({ known: Schema.Number }) + const bytes = encode(Writer, { extra: "drop", known: 1 }) + + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader), { onExcessProperty: "error" })(bytes), + { known: 1 } + ) + assert.deepStrictEqual( + SchemaBinary.parser(Reader, { onExcessProperty: "preserve", propertyOrder: "original" }).feedSync(bytes), + [{ known: 1 }] + ) + }) + }) + + describe("layout and data errors", () => { + it("throws Error while compiling invalid layouts", () => { + const declaration = Schema.declare((_): _ is { readonly value: string } => true) + assert.throws( + () => SchemaBinary.toCodec(declaration), + /Binary layout: declaration has no toCodecJson or toCodec/ + ) + assert.throws( + () => + SchemaBinary.toCodec(Schema.Union([ + Schema.Struct({ a: Schema.String }), + Schema.Struct({ b: Schema.Number }) + ])), + /union members are not uniquely identifiable/ + ) + assert.throws( + () => + SchemaBinary.toCodec(Schema.Struct({ + a: Schema.String.pipe(SchemaBinary.fieldId(1)), + b: Schema.String.pipe(SchemaBinary.fieldId(1)) + })), + /Binary layout field id collision: 1/ + ) + assert.throws( + () => + SchemaBinary.toCodec(Schema.Struct({ + hashed: Schema.String, + explicit: Schema.String.pipe(SchemaBinary.fieldId(1836165160)) + })), + /Binary layout field id collision: 1836165160 \(hashed, explicit\)/ + ) + assert.throws( + () => + SchemaBinary.toCodec(Schema.Union([ + Schema.Literal("a"), + Schema.UniqueSymbol(Symbol.for("SchemaBinary/symbol")) + ])), + /union members are not uniquely identifiable/ + ) + assert.throws( + () => SchemaBinary.toCodec(Schema.Struct({ [Symbol.for("SchemaBinary/key")]: Schema.String })), + /symbol property names are illegal/ + ) + assert.throws( + () => + SchemaBinary.toCodec(Schema.Union([ + Schema.Struct({ _tag: Schema.Literal("1a0a49t3mq") }), + Schema.Struct({ _tag: Schema.Literal("m7r4q02dm7") }) + ])), + /Binary layout sentinel collision: 3370793117/ + ) + assert.throws( + () => + SchemaBinary.toCodec(Schema.Union([ + Schema.Struct({ _tag: Schema.UniqueSymbol(Symbol("local")) }) + ])), + /unregistered unique symbol/ + ) + assert.throws( + () => SchemaBinary.toCodec(Schema.Union([Schema.Date, Schema.DateTimeUtc])), + /union members are not uniquely identifiable/ + ) + }) + + it("uses SchemaIssue for binary failures", () => { + const codec = SchemaBinary.toCodec(Schema.Boolean) + const error = schemaError(() => Schema.decodeUnknownSync(codec)(Uint8Array.of(2, 0x20, 2))) + assert.isTrue(SchemaIssue.isIssue(error.issue)) + assert.match(error.message, /bool/) + }) + + it.effect("preserves the SchemaParser Issue and Schema SchemaError surfaces", () => + Effect.gen(function*() { + const codec = SchemaBinary.toCodec(Schema.Boolean) + const bytes = Uint8Array.of(2, 0x20, 2) + const issue = yield* SchemaParser.decodeUnknownEffect(codec)(bytes).pipe(Effect.flip) + assert.isTrue(SchemaIssue.isIssue(issue)) + const error = yield* Schema.decodeUnknownEffect(codec)(bytes).pipe(Effect.flip) + assert.isTrue(Schema.isSchemaError(error)) + })) + + it("reports a missing fixed tuple slot as MissingKey", () => { + const emptyTuple = encode(Schema.Tuple([]), []) + const error = schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.Tuple([Schema.String])))(emptyTuple) + ) + assert.match(error.message, /Missing key/) + }) + + it("rejects malformed lengths and text", () => { + const string = SchemaBinary.toCodec(Schema.String) + assert.match( + schemaError(() => Schema.decodeUnknownSync(string)(Uint8Array.of(0))).message, + /nonzero frame length/ + ) + assert.match( + schemaError(() => + Schema.decodeUnknownSync(string)(Uint8Array.of(0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10)) + ).message, + /safe integer length/ + ) + assert.match( + schemaError(() => Schema.decodeUnknownSync(string)(Uint8Array.of(2, 0x20, 0xFF))).message, + /utf-8/ + ) + assert.match( + schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.Date))(Uint8Array.of(8, 0x20, 0, 0, 0, 0, 0, 0, 0)) + ).message, + /int64/ + ) + assert.match( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.Null))(Uint8Array.of(2, 0x20, 0))) + .message, + /empty/ + ) + assert.match( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.Unknown))(Uint8Array.of(2, 0x20, 0x7B))) + .message, + /json/ + ) + }) + + it("rejects invalid native tags and payload shapes", () => { + const rejects = (schema: Schema.Codec, bytes: Uint8Array, expected: RegExp) => { + assert.match(schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(schema))(bytes)).message, expected) + } + rejects(Schema.Duration, Uint8Array.of(2, 0x20, 3), /duration/) + rejects(Schema.Option(Schema.String), Uint8Array.of(2, 0x20, 2), /bool/) + rejects(Schema.Option(Schema.String), Uint8Array.of(3, 0x20, 0, 0), /empty/) + rejects(Schema.Result(Schema.String, Schema.String), Uint8Array.of(2, 0x20, 2), /bool/) + rejects(Schema.Exit(Schema.String, Schema.String, Schema.Unknown), Uint8Array.of(2, 0x20, 2), /bool/) + rejects(Schema.DateTimeZoned, Uint8Array.of(10, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 2), /time zone/) + rejects(Schema.DateTimeZoned, Uint8Array.of(10, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0), /time zone/) + }) + + it("rejects attacker-sized zero-width array counts", () => { + const codec = SchemaBinary.toCodec(Schema.Array(Schema.Null)) + const bytes = Uint8Array.of(6, 0x20, 0x80, 0x80, 0x80, 0x80, 0x10) + assert.match( + schemaError(() => Schema.decodeUnknownSync(codec)(bytes)).message, + /array count within allocation limit/ + ) + }) + + it("rejects duplicate struct field ids and extra keys", () => { + const struct = Schema.Struct({ value: Schema.String }) + const encodedStruct = encode(struct, { value: "x" }) + const field = encodedStruct.slice(2) + const duplicateField = concat(Uint8Array.of(1 + field.length * 2, 0x20), field, field) + assert.match( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(struct))(duplicateField)).message, + /unique field ids/ + ) + + const record = Schema.Record(Schema.String, Schema.Number) + const encodedRecord = encode(record, { a: 1 }) + const pair = encodedRecord.slice(4) + const duplicateKey = concat(Uint8Array.of(3 + pair.length * 2, 0x20, 0, pair.length * 2), pair, pair) + assert.match( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(record))(duplicateKey)).message, + /unique extra keys/ + ) + }) + + it("rejects a duplicate field id after an unknown union decodes as absent", () => { + const A = Schema.Struct({ _tag: Schema.Literal("A") }) + const B = Schema.Struct({ _tag: Schema.Literal("B"), value: Schema.String }) + const Writer = Schema.Struct({ event: Schema.optionalKey(Schema.Union([A, B])) }) + const Reader = Schema.Struct({ event: Schema.optionalKey(Schema.Union([A])) }) + const encoded = encode(Writer, { event: { _tag: "B", value: "new" } }) + const field = encoded.slice(2) + const duplicateField = concat(Uint8Array.of(1 + field.length * 2, 0x20), field, field) + + assert.match( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Reader))(duplicateField)).message, + /unique field ids/ + ) + }) + + it("does not satisfy wide-field presence from the extra-key map", () => { + const fields: Record = {} + const value: Record = {} + for (let i = 0; i < 34; i++) { + const key = `field${i}` + fields[key] = Schema.String + value[key] = key + } + const record = Schema.Record(Schema.String, Schema.String) + const struct = Schema.StructWithRest(Schema.Struct(fields), [record]) + const error = schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(struct), { errors: "all" })(encode(record, value)) + ) + + assert.strictEqual(error.message.match(/Missing key/g)?.length, 34) + }) + + it("validates literals instead of leaning on the schema pass", () => { + const Tagged = Schema.Struct({ kind: Schema.Literal("ok") }) + const Loose = Schema.Struct({ kind: Schema.String }) + assert.match( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Tagged))(encode(Loose, { kind: "nope" }))) + .message, + /"ok"/ + ) + assert.match(schemaError(() => encode(Tagged, { kind: "nope" } as never)).message, /"ok"/) + + const Level = Schema.Struct({ kind: Schema.Literals(["info", "warning"]) }) + assert.deepStrictEqual( + Schema.decodeUnknownSync(SchemaBinary.toCodec(Level))(encode(Loose, { kind: "warning" })), + { kind: "warning" } + ) + assert.match( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Level))(encode(Loose, { kind: "debug" }))) + .message, + /"info" \| "warning"/ + ) + }) + + it("rejects a Date outside the representable range", () => { + const codec = SchemaBinary.toCodec(Schema.Date) + const bytes = Schema.encodeUnknownSync(codec)(new Date(0)) + bytes.set(Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0x7F), bytes.length - 8) + assert.match(schemaError(() => Schema.decodeUnknownSync(codec)(bytes)).message, /a valid Date/) + }) + + it("fails Never values and unregistered symbols through SchemaError", () => { + assert.isTrue(Schema.isSchemaError(schemaError(() => encode(Schema.Never, undefined)))) + assert.isTrue( + Schema.isSchemaError( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Schema.Never))(Uint8Array.of(1, 0x20))) + ) + ) + assert.match(schemaError(() => encode(Schema.Symbol, Symbol("local"))).message, /registered symbol/) + }) + }) + + describe("fingerprint mode", () => { + const Person = Schema.Struct({ + name: Schema.String, + age: Schema.Number.check(Schema.isInt()), + active: Schema.Boolean, + nickname: Schema.optional(Schema.String) + }) + const person = { name: "Ada", age: 36, active: true } + + it("writes a fingerprint envelope, a presence bitmap, and no field ids", () => { + // length | envelope 0x21 | fingerprint | bitmap | age varint | name | active + assert.deepStrictEqual([...encodeFingerprint(Person, person)], [ + 16, + 0x21, + 184, + 213, + 85, + 38, + 67, + 231, + 116, + 53, + 0, + 72, + 3, + 65, + 100, + 97, + 1 + ]) + // the same frame with the optional field present: its bit is set and it + // moves ahead of `age`, which is where its wire id sorts it + assert.deepStrictEqual([...encodeFingerprint(Person, { ...person, nickname: "A" })], [ + 19, + 0x21, + 184, + 213, + 85, + 38, + 67, + 231, + 116, + 53, + 1, + 2, + 1, + 65, + 72, + 3, + 65, + 100, + 97, + 1 + ]) + }) + + it("leaves the default mode untouched", () => { + const expected = [...encode(Person, person)] + assert.deepStrictEqual(expected[1], 0x20) + assert.deepStrictEqual([...Schema.encodeUnknownSync(SchemaBinary.toCodec(Person, {}))(person)], expected) + assert.deepStrictEqual( + [...Schema.encodeUnknownSync(SchemaBinary.toCodec(Person, { fingerprint: false }))(person)], + expected + ) + }) + + it("addresses union members by canonical position", () => { + const Click = Schema.Struct({ _tag: Schema.Literal("click"), x: Schema.Number.check(Schema.isInt()) }) + const Key = Schema.Struct({ _tag: Schema.Literal("key"), code: Schema.String }) + const Event = Schema.Union([Click, Key]) + assert.deepStrictEqual([...encodeFingerprint(Event, { _tag: "click", x: 3 })], [ + 11, + 0x21, + 237, + 163, + 78, + 151, + 96, + 43, + 76, + 0, + 0, + 6 + ]) + const key = { _tag: "key", code: "Esc" } as const + const keyFrame = [...encodeFingerprint(Event, key)] + assert.deepStrictEqual(keyFrame, [14, 0x21, 237, 163, 78, 151, 96, 43, 76, 0, 1, 3, 69, 115, 99]) + // declaration order never reaches the wire + assert.deepStrictEqual([...encodeFingerprint(Schema.Union([Key, Click]), key)], keyFrame) + assert.deepStrictEqual(roundtripFingerprint(Event, key), key) + }) + + it("counts the extra-key map instead of reserving field zero", () => { + const schema = Schema.Record(Schema.String, Schema.Number) + assert.deepStrictEqual([...encodeFingerprint(schema, { b: 2, a: 1 })], [ + 16, + 0x21, + 189, + 249, + 115, + 23, + 65, + 225, + 229, + 6, + 2, + 9, + 97, + 2, + 9, + 98, + 4 + ]) + assert.deepStrictEqual(roundtripFingerprint(schema, { z: 1, a: 2 }), { z: 1, a: 2 }) + assert.deepStrictEqual(roundtripFingerprint(schema, {}), {}) + }) + + it("keeps the default tuple and array layout", () => { + assert.deepStrictEqual([...encodeFingerprint(Schema.Tuple([Schema.Boolean, Schema.String]), [true, "x"])], [ + 12, + 0x21, + 3, + 44, + 71, + 25, + 31, + 66, + 141, + 82, + 1, + 1, + 120 + ]) + assert.deepStrictEqual([...encodeFingerprint(Schema.Array(Schema.Number.check(Schema.isInt())), [1, -2, 3])], [ + 13, + 0x21, + 206, + 94, + 13, + 113, + 158, + 175, + 76, + 32, + 3, + 2, + 5, + 6 + ]) + }) + + it("inlines fixed-size and zero-width leaves", () => { + const schema = Schema.Struct({ + nothing: Schema.Null, + flag: Schema.Boolean, + missing: Schema.Undefined, + when: Schema.Date, + count: Schema.Number.check(Schema.isInt()), + label: Schema.String + }) + const value = { nothing: null, flag: true, missing: undefined, when: new Date(1000), count: -7, label: "hi" } + // 1 envelope + 8 fingerprint + 1 varint + 8 int64 + 3 string + 1 bool + assert.strictEqual(encodeFingerprint(schema, value).length, 23) + assert.deepStrictEqual(roundtripFingerprint(schema, value), value) + }) + + it("spans a presence bitmap across several bytes", () => { + const schema = Schema.Struct({ + a: Schema.optional(Schema.Number), + b: Schema.optional(Schema.Number), + c: Schema.optional(Schema.Number), + d: Schema.optional(Schema.Number), + e: Schema.optional(Schema.Number), + f: Schema.optional(Schema.Number), + g: Schema.optional(Schema.Number), + h: Schema.optional(Schema.Number), + i: Schema.optional(Schema.Number), + j: Schema.optional(Schema.Number) + }) + assert.deepStrictEqual(roundtripFingerprint(schema, {}), {}) + assert.deepStrictEqual(roundtripFingerprint(schema, { a: 1, j: 2 }), { a: 1, j: 2 }) + const full = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 9, j: 10 } + assert.deepStrictEqual(roundtripFingerprint(schema, full), full) + }) + + it("round-trips native declarations, recursion, and mixed unions", () => { + const natives = Schema.Struct({ + option: Schema.Option(Schema.String), + result: Schema.Result(Schema.Number, Schema.String), + big: Schema.BigInt, + bytes: Schema.Uint8Array, + duration: Schema.Duration, + decimal: Schema.BigDecimal + }) + const nativeValue = { + option: Option.some("x"), + result: Result.fail("boom"), + big: 2n ** 70n, + bytes: new Uint8Array([1, 2, 3]), + duration: Duration.nanos(1_500_000_000n), + decimal: BigDecimal.make(123n, 2) + } + const natived = roundtripFingerprint(natives, nativeValue) + assert.deepStrictEqual(natived.option, nativeValue.option) + assert.deepStrictEqual(natived.result, nativeValue.result) + assert.strictEqual(natived.big, nativeValue.big) + assert.deepStrictEqual([...natived.bytes], [1, 2, 3]) + assert.strictEqual(Duration.toNanosUnsafe(natived.duration), 1_500_000_000n) + assert.strictEqual(natived.decimal.value, 123n) + assert.strictEqual(natived.decimal.scale, 2) + + interface Tree { + readonly value: number + readonly children: ReadonlyArray + } + const Tree: Schema.Codec = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend((): Schema.Codec => Tree)) + }) + const tree = { value: 1, children: [{ value: 2, children: [] }, { value: 3, children: [] }] } + assert.deepStrictEqual(roundtripFingerprint(Tree, tree), tree) + + const mixed = Schema.Union([Schema.String, Schema.Number, Schema.Struct({ n: Schema.Boolean })]) + assert.deepStrictEqual(roundtripFingerprint(mixed, "x"), "x") + assert.deepStrictEqual(roundtripFingerprint(mixed, 1.5), 1.5) + assert.deepStrictEqual(roundtripFingerprint(mixed, { n: true }), { n: true }) + }) + }) + + describe("layout fingerprint", () => { + interface Tree { + readonly value: number + readonly children: ReadonlyArray + } + const makeTree = (): Schema.Codec => { + const self: Schema.Codec = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend((): Schema.Codec => self)) + }) + return self + } + + const base = Schema.Struct({ name: Schema.String, age: Schema.Number }) + const value = { name: "a", age: 1 } + const baseline = fingerprintOf(base, value) + + it("ignores schema changes that do not reach the wire", () => { + assert.strictEqual( + fingerprintOf( + Schema.Struct({ + name: Schema.String.check(Schema.isMinLength(1)).annotate({ description: "the name" }), + age: Schema.Number + }), + value + ), + baseline + ) + // property declaration order: encode sorts by wire id + assert.strictEqual(fingerprintOf(Schema.Struct({ age: Schema.Number, name: Schema.String }), value), baseline) + // a decoded-side transformation leaves the encoded layout alone + assert.strictEqual( + fingerprintOf( + Schema.Struct({ name: Schema.String, age: Schema.Number.pipe(Schema.decodeTo(Schema.Number)) }), + value + ), + baseline + ) + }) + + it("does not depend on the order a literal union declares its members", () => { + assert.strictEqual( + fingerprintOf(Schema.Literals(["a", "b", "c"]), "a"), + fingerprintOf(Schema.Literals(["c", "a", "b"]), "a") + ) + assert.notStrictEqual( + fingerprintOf(Schema.Literals(["a", "b"]), "a"), + fingerprintOf(Schema.Literals(["a", "c"]), "a") + ) + assert.notStrictEqual(fingerprintOf(Schema.Literal("a"), "a"), fingerprintOf(Schema.String, "a")) + }) + + it("does not depend on whether an acyclic sub-schema is shared or repeated", () => { + const Point = Schema.Struct({ x: Schema.Number, y: Schema.Number }) + const pair = { a: { x: 1, y: 2 }, b: { x: 3, y: 4 } } + const shared = fingerprintOf(Schema.Struct({ a: Point, b: Point }), pair) + const repeated = fingerprintOf( + Schema.Struct({ + a: Schema.Struct({ x: Schema.Number, y: Schema.Number }), + b: Schema.Struct({ x: Schema.Number, y: Schema.Number }) + }), + pair + ) + assert.strictEqual(shared, repeated) + + const leaf = { value: 1, children: [] } + assert.strictEqual(fingerprintOf(makeTree(), leaf), fingerprintOf(makeTree(), leaf)) + // one recursive node reached from two fields, versus two of them. Both + // sides have the same cycle structure, which is the only recursive case + // the hash canonicalises; see the factoring test below for the limit. + const both = { left: leaf, right: leaf } + const one = makeTree() + assert.strictEqual( + fingerprintOf(Schema.Struct({ left: one, right: one }), both), + fingerprintOf(Schema.Struct({ left: makeTree(), right: makeTree() }), both) + ) + }) + + it("hashes the layout graph, so re-factoring a recursive schema moves it", () => { + // Three finite graphs denoting the same infinite wire shape: the cycle + // itself, the cycle behind one non-recursive alias, and a two-node + // mutual recursion of the same shape. + const direct = makeTree() + const aliased = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(makeTree()) + }) as unknown as Schema.Codec + const mutualA: Schema.Codec = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend((): Schema.Codec => mutualB)) + }) + const mutualB: Schema.Codec = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend((): Schema.Codec => mutualA)) + }) + + const tree = { value: 1, children: [{ value: 2, children: [] }] } + const frames = [direct, aliased, mutualA].map((schema) => fingerprintFrame(schema, tree)) + + // identical bytes on the wire + assert.strictEqual(new Set(frames.map((frame) => frame.payload)).size, 1) + // and identical in the tolerant default mode, in both directions + const directDefault = SchemaBinary.toCodec(direct) + const aliasedDefault = SchemaBinary.toCodec(aliased) + assert.deepStrictEqual( + Schema.decodeUnknownSync(aliasedDefault)(Schema.encodeUnknownSync(directDefault)(tree)), + tree + ) + assert.deepStrictEqual( + Schema.decodeUnknownSync(directDefault)(Schema.encodeUnknownSync(aliasedDefault)(tree)), + tree + ) + + // but each factoring hashes differently, and they reject each other + assert.strictEqual(new Set(frames.map((frame) => frame.fingerprint)).size, 3) + assert.include( + schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(aliased, { fingerprint: true }))( + encodeFingerprint(direct, tree) + ) + ).message, + "Expected matching layout fingerprint" + ) + }) + + it("changes whenever the wire layout changes", () => { + const changed = [ + fingerprintOf(Schema.Struct({ label: Schema.String, age: Schema.Number }), { label: "a", age: 1 }), + fingerprintOf( + Schema.Struct({ name: Schema.String, age: Schema.Number, extra: Schema.Boolean }), + { name: "a", age: 1, extra: true } + ), + fingerprintOf(Schema.Struct({ name: Schema.String, age: Schema.optional(Schema.Number) }), value), + fingerprintOf(Schema.Struct({ name: Schema.String, age: Schema.Number.check(Schema.isInt()) }), value), + fingerprintOf( + Schema.Struct({ name: Schema.String.pipe(SchemaBinary.fieldId(1)), age: Schema.Number }), + value + ), + fingerprintOf(Schema.Struct({ name: Schema.String, age: Schema.String }), { name: "a", age: "1" }) + ] + assert.strictEqual(new Set([baseline, ...changed]).size, changed.length + 1) + + assert.notStrictEqual( + fingerprintOf(Schema.Tuple([Schema.Number, Schema.Number]), [1, 2]), + fingerprintOf(Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]), [1, 2, 3]) + ) + assert.notStrictEqual( + fingerprintOf(Schema.Array(Schema.Number), [1]), + fingerprintOf(Schema.Tuple([Schema.Number]), [1]) + ) + assert.notStrictEqual( + fingerprintOf(Schema.Date, new Date(0)), + fingerprintOf(Schema.DateTimeUtc, DateTime.makeUnsafe(0)) + ) + + const A = Schema.Struct({ _tag: Schema.Literal("a"), n: Schema.Number }) + const B = Schema.Struct({ _tag: Schema.Literal("b"), s: Schema.String }) + const C = Schema.Struct({ _tag: Schema.Literal("c"), s: Schema.String }) + const a = { _tag: "a", n: 1 } as const + assert.strictEqual(fingerprintOf(Schema.Union([A, B]), a), fingerprintOf(Schema.Union([B, A]), a)) + assert.notStrictEqual(fingerprintOf(Schema.Union([A, B]), a), fingerprintOf(Schema.Union([A, B, C]), a)) + }) + }) + + describe("fingerprint mode failures", () => { + const Person = Schema.Struct({ name: Schema.String, age: Schema.Number }) + const person = { name: "Ada", age: 36 } + + it("fails closed when the layout fingerprint differs", () => { + const frame = encodeFingerprint(Person, person) + const other = SchemaBinary.toCodec(Schema.Struct({ label: Schema.String, age: Schema.Number }), { + fingerprint: true + }) + assert.include( + schemaError(() => Schema.decodeUnknownSync(other)(frame)).message, + "Expected matching layout fingerprint" + ) + }) + + it("rejects the other mode's frames in both directions", () => { + const fingerprintFrame = encodeFingerprint(Person, person) + const defaultFrame = encode(Person, person) + assert.include( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Person))(fingerprintFrame)).message, + "Expected version 2 envelope, flags 0" + ) + assert.include( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(Person, { fingerprint: true }))(defaultFrame)) + .message, + "Expected version 2 envelope, flags 1" + ) + }) + + it("rejects truncated and oversized frames", () => { + const codec = SchemaBinary.toCodec(Person, { fingerprint: true }) + const frame = encodeFingerprint(Person, person) + // the fingerprint itself does not fit + const shortFrame = concat(new Uint8Array([5, 0x21]), frame.subarray(2, 6)) + assert.include( + schemaError(() => Schema.decodeUnknownSync(codec)(shortFrame)).message, + "Expected complete value" + ) + assert.include( + schemaError(() => Schema.decodeUnknownSync(codec)(frame.subarray(0, frame.length - 1))).message, + "Expected complete value" + ) + assert.include( + schemaError(() => Schema.decodeUnknownSync(codec)(concat(frame, new Uint8Array([0])))).message, + "Expected no leftover bytes" + ) + const withLeftover = new Uint8Array(frame) + withLeftover[0] = frame[0] + 1 + assert.include( + schemaError(() => Schema.decodeUnknownSync(codec)(concat(withLeftover, new Uint8Array([0])))).message, + "Expected no leftover bytes" + ) + }) + + it("rejects a union position outside the member table", () => { + const Event = Schema.Union([ + Schema.Struct({ _tag: Schema.Literal("a"), n: Schema.Number }), + Schema.Struct({ _tag: Schema.Literal("b"), s: Schema.String }) + ]) + const codec = SchemaBinary.toCodec(Event, { fingerprint: true }) + const frame = new Uint8Array(encodeFingerprint(Event, { _tag: "b", s: "x" })) + // the byte after the envelope and fingerprint is the member position + frame[10] = 7 + assert.include( + schemaError(() => Schema.decodeUnknownSync(codec)(frame)).message, + "Expected known union member" + ) + }) + + it("reports a required field a newer writer left unreadable as missing", () => { + const schema = Schema.Struct({ reason: Schema.CauseReason(Schema.String, Schema.String) }) + const frame = new Uint8Array(encodeFingerprint(schema, { reason: Cause.makeFailReason("boom") })) + // the reason payload is length-prefixed; its first byte is the tag + frame[11] = 9 + const error = schemaError(() => + Schema.decodeUnknownSync(SchemaBinary.toCodec(schema, { fingerprint: true }))(frame) + ) + assert.include(error.message, "reason") + assert.include(error.message, "Missing key") + }) + + it("rejects duplicate extra keys", () => { + const schema = Schema.Record(Schema.String, Schema.Number) + const frame = encodeFingerprint(schema, { a: 1, b: 2 }) + const duplicated = new Uint8Array(frame) + // rewrite the second key so both pairs claim "a" + duplicated[duplicated.length - 2] = 97 + assert.include( + schemaError(() => Schema.decodeUnknownSync(SchemaBinary.toCodec(schema, { fingerprint: true }))(duplicated)) + .message, + "Expected unique extra keys" + ) + }) + }) + + describe("direct codec", () => { + const Person = Schema.Struct({ name: Schema.String, age: Schema.Number }) + + it("matches toCodec on the wire and round-trips", () => { + const direct = SchemaBinary.toCodecDirect(Person) + const value = { name: "Ada", age: 36 } + const bytes = Schema.encodeUnknownSync(direct)(value) + assert.deepStrictEqual(Array.from(bytes), Array.from(encode(Person, value))) + assert.deepStrictEqual(Schema.decodeUnknownSync(direct)(bytes), value) + }) + + it("still rejects invalid input, through the binary layer", () => { + const direct = SchemaBinary.toCodecDirect(Person) + assert.include(schemaError(() => Schema.encodeUnknownSync(direct)({ name: "Ada", age: "old" })).message, "age") + assert.include(schemaError(() => Schema.encodeUnknownSync(direct)({ name: "Ada" })).message, "age") + assert.include(schemaError(() => Schema.encodeUnknownSync(direct)("Ada")).message, "Expected an object") + }) + + it("rejects a wrong runtime type for every leaf an exact schema can reach", () => { + const cases: ReadonlyArray = [ + [Schema.String, 1, "a string"], + [Schema.Number, "1", "a number"], + [Schema.Boolean, 1, "a boolean"], + [Schema.BigInt, 1, "a bigint"], + [Schema.Symbol, "s", "a symbol"], + [Schema.Null, 1, "null"], + [Schema.Undefined, 1, "undefined"], + [Schema.Uint8Array, [1, 2], "a Uint8Array"], + [Schema.Date, 0, "a Date"], + [Schema.Array(Schema.String), "ab", "an array"], + [Schema.Struct({ a: Schema.String }), "a", "an object"] + ] + for (const [schema, value, expected] of cases) { + const direct = SchemaBinary.toCodecDirect(schema as Schema.Codec) + assert.include( + schemaError(() => Schema.encodeUnknownSync(direct)(value)).message, + `Expected ${expected}`, + `${expected} for ${globalThis.String(value)}` + ) + } + }) + + it("keeps failure exits on the schema pass when only the success side is exact", () => { + const ExitSchema = Schema.Exit( + Schema.Struct({ id: Schema.String }), + Schema.String, + Schema.Defect() + ) + const direct = SchemaBinary.toCodecDirect(ExitSchema as Schema.Codec) + const sound = SchemaBinary.toCodec(ExitSchema as Schema.Codec) + const exits = [ + Exit.succeed({ id: "user-1" }), + Exit.fail("nope"), + Exit.die(new Error("boom")), + Exit.failCause(Cause.interrupt(7)) + ] + for (const exit of exits) { + const bytes = Schema.encodeUnknownSync(direct)(exit) + assert.deepStrictEqual(Array.from(bytes), Array.from(Schema.encodeUnknownSync(sound)(exit))) + assert.deepStrictEqual( + Schema.decodeUnknownSync(direct)(bytes.slice()), + Schema.decodeUnknownSync(sound)(bytes.slice()) + ) + } + const decodedDie = Schema.decodeUnknownSync(direct)( + Schema.encodeUnknownSync(direct)(Exit.die(new Error("boom"))).slice() + ) as Exit.Exit + assert.isTrue(Exit.isFailure(decodedDie)) + const reason = (decodedDie as Exit.Failure).cause.reasons[0] as Cause.Die + assert.instanceOf(reason.defect, Error) + assert.strictEqual((reason.defect as Error).message, "boom") + + assert.isDefined(schemaError(() => Schema.encodeUnknownSync(direct)(Exit.succeed({ id: 42 })))) + assert.isDefined(schemaError(() => Schema.encodeUnknownSync(direct)("not an exit"))) + + const excess = Exit.succeed({ id: "ok", extra: true }) + assert.deepStrictEqual( + Array.from(Schema.encodeUnknownSync(direct)(excess)), + Array.from(Schema.encodeUnknownSync(sound)(excess)) + ) + assert.include( + schemaError(() => Schema.encodeUnknownSync(direct, { onExcessProperty: "error" })(excess)).message, + "extra" + ) + assert.include( + schemaError(() => + SchemaBinary.encodeUnknownSync( + ExitSchema as Schema.Codec, + { onExcessProperty: "error" } + )(excess) + ).message, + "extra" + ) + }) + + it("rejects an inherited discriminator, like the schema pass", () => { + const schema = Schema.Union([ + Schema.Struct({ _tag: Schema.Literal("A"), value: Schema.Number }), + Schema.Struct({ _tag: Schema.Literal("B"), value: Schema.String }) + ]) + const inherited = Object.assign(Object.create({ _tag: "A" }), { value: 1 }) + for (const options of [undefined, { fingerprint: true }]) { + const encodeDirect = Schema.encodeUnknownSync(SchemaBinary.toCodecDirect(schema, options)) + assert.isDefined(schemaError(() => encodeDirect(inherited))) + } + }) + + it("reports excess properties when the call site asks for it", () => { + const direct = SchemaBinary.toCodecDirect(Person) + const excess = { name: "Ada", age: 36, extra: true } + assert.deepStrictEqual( + Array.from(Schema.encodeUnknownSync(direct)(excess)), + Array.from(encode(Person, { name: "Ada", age: 36 })) + ) + assert.include( + schemaError(() => Schema.encodeUnknownSync(direct, { onExcessProperty: "error" })(excess)).message, + "extra" + ) + }) + + it("falls back to toCodec when the binary layer cannot prove the schema", () => { + const NonNegative = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)) + const direct = SchemaBinary.toCodecDirect(NonNegative) + assert.isTrue(Schema.is(direct)(1)) + assert.isFalse(Schema.is(direct)(-1)) + assert.include(schemaError(() => Schema.encodeUnknownSync(direct)(-1)).message, "greater than or equal to 0") + }) + }) + + describe("encodeManyUnknownSync", () => { + const Person = Schema.Struct({ name: Schema.String, age: Schema.Number }) + const people = [{ name: "Ada", age: 36 }, { name: "Grace", age: 45 }] + + it("writes the same bytes as concatenated single frames", () => { + const many = SchemaBinary.encodeManyUnknownSync(Person, { fingerprint: true }) + assert.deepStrictEqual( + Array.from(many(people)), + Array.from(concat(...people.map((person) => encodeFingerprint(Person, person)))) + ) + assert.deepStrictEqual(SchemaBinary.parser(Person, { fingerprint: true }).feedSync(many(people)), people) + assert.deepStrictEqual(Array.from(many([])), []) + }) + + it("reports the failing frame and takes the fallback path for checked schemas", () => { + const many = SchemaBinary.encodeManyUnknownSync(Person) + assert.include(schemaError(() => many([people[0], { name: "Grace" }])).message, "age") + + const Checked = Schema.Struct({ age: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)) }) + const checked = SchemaBinary.encodeManyUnknownSync(Checked) + assert.deepStrictEqual( + Array.from(checked([{ age: 1 }, { age: 2 }])), + Array.from(concat(encode(Checked, { age: 1 }), encode(Checked, { age: 2 }))) + ) + assert.include(schemaError(() => checked([{ age: -1 }])).message, "greater than or equal to 0") + }) + }) + + describe("schemas the binary layer validates on its own", () => { + // `dictionary: true` is refused for anything the binary layer does not + // fully validate, so it doubles as an assertion that the schema pass + // around the codec is redundant and gets skipped. + const validatesItself = (schema: Schema.Codec): boolean => { + try { + SchemaBinary.encoder(schema, { dictionary: true }) + return true + } catch (error) { + if (error instanceof Error && error.message.includes("dictionary")) return false + throw error + } + } + + it("covers never, which an inferred schema reaches through an empty array", () => { + assert.isTrue(validatesItself(Schema.Never)) + const Row = Schema.Struct({ id: Schema.String, tags: Schema.Array(Schema.Never) }) + assert.isTrue(validatesItself(Row)) + const value = { id: "a", tags: [] as ReadonlyArray } + assert.deepStrictEqual(roundtrip(Row, value), value) + assert.include(schemaError(() => encode(Row, { id: "a", tags: ["x"] } as any)).message, "never") + assert.isFalse(Schema.is(SchemaBinary.toCodec(Row))({ id: 1, tags: [] } as any)) + }) + + it("covers unknown and any, which the JSON layer already rejects or accepts", () => { + assert.isTrue(validatesItself(Schema.Unknown)) + assert.isTrue(validatesItself(Schema.Any)) + // A JSON payload can be a string, so an object keyword still needs the pass. + assert.isFalse(validatesItself(Schema.ObjectKeyword)) + const Row = Schema.Struct({ id: Schema.String, payload: Schema.Unknown }) + assert.isTrue(validatesItself(Row)) + const value = { id: "a", payload: { nested: [1, "two", true, null] } } + assert.deepStrictEqual(roundtrip(Row, value), value) + assert.isFalse(Schema.is(SchemaBinary.toCodec(Row))({ id: 1, payload: 1 } as any)) + const cyclic: any = { id: "a", payload: {} } + cyclic.payload.self = cyclic.payload + assert.include(schemaError(() => encode(Row, cyclic)).message, "acyclic value") + }) + + it("covers recursion on decode while encode keeps the cycle walk", () => { + interface TreeType { + readonly value: string + readonly children: ReadonlyArray + } + const Tree = Schema.Struct({ + value: Schema.String, + children: Schema.Array(Schema.suspend((): Schema.Codec => Tree as any)) + }) + assert.isTrue(validatesItself(Tree)) + const tree = { value: "root", children: [{ value: "leaf", children: [] }] } + assert.deepStrictEqual(roundtrip(Tree, tree), tree) + assert.isFalse(Schema.is(SchemaBinary.toCodec(Tree))({ value: 1, children: [] } as any)) + assert.include(schemaError(() => encode(Tree, { value: "root", children: [1] } as any)).message, "object") + const cyclic: any = { value: "root", children: [] } + cyclic.children.push(cyclic) + assert.include(schemaError(() => encode(Tree, cyclic)).message, "acyclic value") + }) + }) + + describe("connection dictionary", () => { + const Message = Schema.Struct({ + tag: Schema.String, + traceId: Schema.String, + body: Schema.String, + n: Schema.Number + }) + const message = (index: number) => ({ + tag: ["Search", "Get", "Delete"][index % 3], + traceId: "0123456789abcdef0123456789abcdef", + body: `body ${index}`, + n: index + }) + + it("round-trips a stream and shrinks repeated strings", () => { + for (const fingerprint of [false, true]) { + const options = { dictionary: true, fingerprint } as const + const encoder = SchemaBinary.encoder(Message, options) + const parser = SchemaBinary.parser(Message, options) + const plain = SchemaBinary.encoder(Message, { fingerprint }) + const values = Array.from({ length: 30 }, (_, index) => message(index)) + const decoded: Array = [] + const sizes: Array = [] + for (const value of values) { + const frame = encoder.encode(value) + sizes.push(frame.length) + decoded.push(...parser.feedSync(frame)) + } + assert.deepStrictEqual(decoded, values) + // The first frame carries every string, so it costs what no dictionary + // would; later frames reference the ones that came back. + assert.strictEqual(sizes[0], plain.encode(values[0]).length) + assert.isBelow(sizes[sizes.length - 1], plain.encode(values[values.length - 1]).length * 0.6) + } + }) + + it("round-trips batched frames and fragmented feeds", () => { + const encoder = SchemaBinary.encoder(Message, { dictionary: true }) + const parser = SchemaBinary.parser(Message, { dictionary: true }) + const values = Array.from({ length: 12 }, (_, index) => message(index)) + assert.deepStrictEqual(parser.feedSync(encoder.encodeMany(values)), values) + + const fragmented = SchemaBinary.encoder(Message, { dictionary: true }) + const fragmentedParser = SchemaBinary.parser(Message, { dictionary: true }) + const decoded: Array = [] + for (const value of values) { + const frame = fragmented.encode(value).slice() + decoded.push(...fragmentedParser.feedSync(frame.subarray(0, 1))) + decoded.push(...fragmentedParser.feedSync(frame.subarray(1))) + } + assert.deepStrictEqual(decoded, values) + }) + + it("keeps the reader in step when a frame fails to encode", () => { + const encoder = SchemaBinary.encoder(Message, { dictionary: true }) + const parser = SchemaBinary.parser(Message, { dictionary: true }) + const first = message(0) + const decoded: Array = [...parser.feedSync(encoder.encode(first))] + // Never reaches the reader, so its strings must not reach the writer's + // tables either. + assert.include( + schemaError(() => encoder.encode({ ...message(1), tag: "NeverSent", n: "not a number" })).message, + "number" + ) + assert.include( + schemaError(() => encoder.encodeMany([message(2), { ...message(3), n: "not a number" }])).message, + "number" + ) + const next = message(4) + decoded.push(...parser.feedSync(encoder.encode(next))) + assert.deepStrictEqual(decoded, [first, next]) + }) + + it("costs nothing on a field whose strings never repeat", () => { + const Unique = Schema.Struct({ s: Schema.String }) + const encoder = SchemaBinary.encoder(Unique, { dictionary: true }) + const parser = SchemaBinary.parser(Unique, { dictionary: true }) + const plain = SchemaBinary.encoder(Unique, {}) + const values = Array.from({ length: 200 }, (_, index) => ({ s: `value-${index}` })) + const decoded: Array = [] + let dictionaryBytes = 0 + let plainBytes = 0 + for (const value of values) { + const frame = encoder.encode(value) + dictionaryBytes += frame.length + plainBytes += plain.encode(value).length + decoded.push(...parser.feedSync(frame)) + } + assert.deepStrictEqual(decoded, values) + assert.strictEqual(dictionaryBytes, plainBytes) + }) + + it("rejects a schema the binary layer does not validate on its own", () => { + const Checked = Schema.Struct({ s: Schema.String.check(Schema.isMinLength(2)) }) + assert.throws(() => SchemaBinary.encoder(Checked, { dictionary: true }), /dictionary/) + assert.throws(() => SchemaBinary.parser(Checked, { dictionary: true }), /dictionary/) + }) + + it("rejects a reference the stream never sent", () => { + const encoder = SchemaBinary.encoder(Message, { dictionary: true }) + const parser = SchemaBinary.parser(Message, { dictionary: true }) + for (let index = 0; index < 4; index++) parser.feedSync(encoder.encode(message(index))) + const frame = encoder.encode(message(4)).slice() + // Point the tag at a table entry past the end of what was sent. + const tagged = frame.map((byte) => byte === 1 ? 0xFF : byte) + assert.throws(() => parser.feedSync(tagged)) + }) + + it("stays off by default", () => { + const encoder = SchemaBinary.encoder(Message, {}) + assert.deepStrictEqual(Array.from(encoder.encode(message(0))), Array.from(encode(Message, message(0)))) + }) + }) + + describe("fingerprint mode parser", () => { + const Person = Schema.Struct({ name: Schema.String, age: Schema.Number }) + const first = { name: "Ada", age: 36 } + const second = { name: "Grace", age: 45 } + + it("parses concatenated frames, one byte at a time, and mid-frame splits", () => { + const frames = concat(encodeFingerprint(Person, first), encodeFingerprint(Person, second)) + assert.deepStrictEqual( + SchemaBinary.parser(Person, { fingerprint: true }).feedSync(frames), + [first, second] + ) + + const byteParser = SchemaBinary.parser(Person, { fingerprint: true }) + const out: Array = [] + for (const byte of frames) out.push(...byteParser.feedSync(new Uint8Array([byte]))) + byteParser.endSync() + assert.deepStrictEqual(out, [first, second]) + + // a split inside the fingerprint must wait rather than fail + const split = SchemaBinary.parser(Person, { fingerprint: true }) + assert.deepStrictEqual(split.feedSync(frames.subarray(0, 6)), []) + assert.deepStrictEqual(split.feedSync(frames.subarray(6)), [first, second]) + }) + + it("fails closed on a default-mode frame in a fingerprint stream", () => { + const parser = SchemaBinary.parser(Person, { fingerprint: true }) + const mixed = concat(encodeFingerprint(Person, first), encode(Person, second)) + assert.deepStrictEqual(parser.feedSync(mixed), [first]) + assert.include( + schemaError(() => parser.feedSync(new Uint8Array(0))).message, + "Expected version 2 envelope, flags 1" + ) + assert.include(schemaError(() => parser.endSync()).message, "Expected parser is spent") + }) + + it("honours maxFrameSize and reports truncation at end", () => { + const bounded = SchemaBinary.parser(Person, { fingerprint: true, maxFrameSize: 4 }) + assert.include( + schemaError(() => bounded.feedSync(encodeFingerprint(Person, first))).message, + "Expected frame within maxFrameSize" + ) + const truncated = SchemaBinary.parser(Person, { fingerprint: true }) + const frame = encodeFingerprint(Person, first) + assert.deepStrictEqual(truncated.feedSync(frame.subarray(0, frame.length - 2)), []) + assert.include(schemaError(() => truncated.endSync()).message, "Expected complete value") + }) + }) + + describe("channels", () => { + const Person = Schema.Struct({ name: Schema.String, age: Schema.Number }) + const ada = { name: "Ada", age: 36 } + const grace = { name: "Grace", age: 45 } + + it.effect("encode then decode round-trips a stream", () => + Effect.gen(function*() { + const values = yield* Stream.make(ada, grace).pipe( + Stream.pipeThroughChannel(SchemaBinary.encode(Person)()), + Stream.pipeThroughChannel(SchemaBinary.decode(Person)()), + Stream.runCollect + ) + + assert.deepStrictEqual([...values], [ada, grace]) + })) + + it.effect("encode batches a chunk into one concatenated byte element", () => + Effect.gen(function*() { + const frames = yield* Stream.make(ada, grace).pipe( + Stream.pipeThroughChannel(SchemaBinary.encode(Person)()), + Stream.runCollect + ) + + assert.deepStrictEqual([...frames], [concat(encode(Person, ada), encode(Person, grace))]) + })) + + it.effect("decodes a value split across byte chunks", () => + Effect.gen(function*() { + const frame = encode(Person, ada) + const values = yield* Stream.make(frame.subarray(0, 3), frame.subarray(3)).pipe( + Stream.pipeThroughChannel(SchemaBinary.decode(Person)()), + Stream.runCollect + ) + + assert.deepStrictEqual([...values], [ada]) + })) + + it.effect("decodes concatenated frames in one chunk", () => + Effect.gen(function*() { + const bytes = concat(encode(Person, ada), encode(Person, grace)) as Uint8Array + const values = yield* Stream.make(bytes).pipe( + Stream.pipeThroughChannel(SchemaBinary.decode(Person)()), + Stream.runCollect + ) + + assert.deepStrictEqual([...values], [ada, grace]) + })) + + it.effect("fails when the stream ends with an incomplete frame", () => + Effect.gen(function*() { + const frame = encode(Person, ada) + const error = yield* Stream.make(frame.subarray(0, frame.length - 1)).pipe( + Stream.pipeThroughChannel(SchemaBinary.decode(Person)()), + Stream.runCollect, + Effect.flip + ) + + assert.instanceOf(error, Schema.SchemaError) + assert.include(error.message, "Expected complete value") + })) + + it.effect("emits completed values before reporting a later failure", () => + Effect.gen(function*() { + const bad = encode(Person, grace).slice() + bad[1] = 0x10 + const seen: Array = [] + const error = yield* Stream.make( + concat(encode(Person, ada), bad) as Uint8Array, + encode(Person, grace) + ).pipe( + Stream.pipeThroughChannel(SchemaBinary.decode(Person)()), + Stream.tap((value) => + Effect.sync(() => { + seen.push(value) + }) + ), + Stream.runDrain, + Effect.flip + ) + + assert.deepStrictEqual(seen, [ada]) + assert.include(error.message, "version 2 envelope, flags 0") + })) + + it.effect("enforces maxFrameSize on decode and ignores it on encode", () => + Effect.gen(function*() { + const values = yield* Stream.make(ada).pipe( + Stream.pipeThroughChannel(SchemaBinary.encode(Person, { maxFrameSize: 1 })()), + Stream.pipeThroughChannel(SchemaBinary.decode(Person)()), + Stream.runCollect + ) + assert.deepStrictEqual([...values], [ada]) + + const error = yield* Stream.make(encode(Person, ada)).pipe( + Stream.pipeThroughChannel(SchemaBinary.decode(Person, { maxFrameSize: 2 })()), + Stream.runCollect, + Effect.flip + ) + assert.include(error.message, "Expected frame within maxFrameSize") + })) + + it.effect("duplex encodes requests and decodes responses with different schemas", () => + Effect.gen(function*() { + const Request = Schema.Struct({ id: Schema.Number }) + const Response = Schema.Struct({ id: Schema.Number, name: Schema.String }) + const socket = SchemaBinary.decode(Request)().pipe( + Channel.map((chunk) => Arr.map(chunk, (request) => ({ id: request.id, name: `user-${request.id}` }))), + Channel.pipeTo(SchemaBinary.encode(Response)()) + ) + + const values = yield* Stream.make({ id: 1 }, { id: 2 }).pipe( + Stream.pipeThroughChannel( + SchemaBinary.duplex(socket, { inputSchema: Request, outputSchema: Response }) + ), + Stream.runCollect + ) + + assert.deepStrictEqual([...values], [{ id: 1, name: "user-1" }, { id: 2, name: "user-2" }]) + })) + + it.effect("duplex supports the data-last call shape", () => + Effect.gen(function*() { + const Request = Schema.Struct({ id: Schema.Number }) + const Response = Schema.Struct({ ok: Schema.Boolean }) + const socket = SchemaBinary.decode(Request)().pipe( + Channel.map((chunk) => Arr.map(chunk, () => ({ ok: true }))), + Channel.pipeTo(SchemaBinary.encode(Response)()) + ) + + const values = yield* Stream.make({ id: 1 }).pipe( + Stream.pipeThroughChannel( + socket.pipe(SchemaBinary.duplex({ inputSchema: Request, outputSchema: Response })) + ), + Stream.runCollect + ) + + assert.deepStrictEqual([...values], [{ ok: true }]) + })) + + it.effect("round-trips fingerprint mode through the channel path", () => + Effect.gen(function*() { + const frames = yield* Stream.make(ada, grace).pipe( + Stream.pipeThroughChannel(SchemaBinary.encode(Person, { fingerprint: true })()), + Stream.runCollect + ) + assert.deepStrictEqual( + [...frames], + [concat(encodeFingerprint(Person, ada), encodeFingerprint(Person, grace))] + ) + + const values = yield* Stream.fromArray([...frames]).pipe( + Stream.pipeThroughChannel(SchemaBinary.decode(Person, { fingerprint: true })()), + Stream.runCollect + ) + assert.deepStrictEqual([...values], [ada, grace]) + })) + + const AsyncName = Schema.String.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.promise(() => Promise.resolve(value.toUpperCase())), + encode: (value) => Effect.promise(() => Promise.resolve(value.toLowerCase())) + }) + ) + ) + const AsyncPerson = Schema.Struct({ name: AsyncName, age: Schema.Number }) + + it.effect("supports async transformations on encode and decode", () => + Effect.gen(function*() { + const frames = yield* Stream.make(ada, grace).pipe( + Stream.pipeThroughChannel(SchemaBinary.encode(AsyncPerson)()), + Stream.runCollect + ) + // the async encode transformation ran before the bytes hit the wire + assert.deepStrictEqual( + [...frames], + [concat(encode(Person, { name: "ada", age: 36 }), encode(Person, { name: "grace", age: 45 }))] + ) + + const values = yield* Stream.fromArray([...frames]).pipe( + Stream.pipeThroughChannel(SchemaBinary.decode(AsyncPerson)()), + Stream.runCollect + ) + assert.deepStrictEqual([...values], [{ name: "ADA", age: 36 }, { name: "GRACE", age: 45 }]) + })) + + it.effect("supports async transformations across a duplex channel", () => + Effect.gen(function*() { + const echo = Channel.identity< + Arr.NonEmptyReadonlyArray>, + Schema.SchemaError, + unknown + >() + + const values = yield* Stream.make(ada).pipe( + Stream.pipeThroughChannel( + SchemaBinary.duplex(echo, { inputSchema: AsyncPerson, outputSchema: AsyncPerson }) + ), + Stream.runCollect + ) + + assert.deepStrictEqual([...values], [{ name: "ADA", age: 36 }]) + })) + + it.effect("emits values decoded before an async transformation failure", () => + Effect.gen(function*() { + const Wire = Schema.Struct({ name: Schema.String }) + const Reader = Schema.Struct({ + name: Schema.String.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value, options) => + value === "bob" + ? Effect.fail(new SchemaIssue.InvalidValue({ expected: "not bob" }, value, options)) + : Effect.promise(() => Promise.resolve(value)), + encode: (value) => Effect.succeed(value) + }) + ) + ) + }) + const bytes = concat(encode(Wire, { name: "ada" }), encode(Wire, { name: "bob" })) as Uint8Array + const seen: Array<{ name: string }> = [] + const error = yield* Stream.make(bytes).pipe( + Stream.pipeThroughChannel(SchemaBinary.decode(Reader)()), + Stream.tap((value) => + Effect.sync(() => { + seen.push(value) + }) + ), + Stream.runDrain, + Effect.flip + ) + + assert.deepStrictEqual(seen, [{ name: "ada" }]) + assert.include(error.message, "not bob") + })) + }) +}) diff --git a/repos/effect/packages/effect/test/unstable/eventlog/EventJournal.test.ts b/repos/effect/packages/effect/test/unstable/eventlog/EventJournal.test.ts index 29729d794f..5bb681fbf8 100644 --- a/repos/effect/packages/effect/test/unstable/eventlog/EventJournal.test.ts +++ b/repos/effect/packages/effect/test/unstable/eventlog/EventJournal.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest" -import { Effect } from "effect" +import { Effect, Option } from "effect" import * as EventJournal from "effect/unstable/eventlog/EventJournal" const entry = (msecs: number) => @@ -28,10 +28,24 @@ describe("EventJournal", () => { entries: [new EventJournal.RemoteEntry({ remoteSequence: 0, entry })], effect: () => Effect.void }) - const missing = yield* journal.withRemoteUncommited(target, Effect.succeed) - assert.deepStrictEqual(missing.map((item) => item.idString), [entry.idString]) - const sourceMissing = yield* journal.withRemoteUncommited(source, Effect.succeed) - assert.deepStrictEqual(sourceMissing, []) + let targetCalls = 0 + const missing = yield* journal.withRemoteUncommited(target, (entries) => + Effect.sync(() => { + targetCalls++ + return entries + })) + if (Option.isNone(missing)) assert.fail("Expected the callback result") + assert.deepStrictEqual(missing.value.map((item) => item.idString), [entry.idString]) + assert.strictEqual(targetCalls, 1) + + let sourceCalls = 0 + const sourceMissing = yield* journal.withRemoteUncommited(source, () => + Effect.sync(() => { + sourceCalls++ + return "called" + })) + assert.isTrue(Option.isNone(sourceMissing)) + assert.strictEqual(sourceCalls, 0) })) it.effect("returns the next unused remote sequence", () => diff --git a/repos/effect/packages/effect/test/unstable/eventlog/EventLog.test.ts b/repos/effect/packages/effect/test/unstable/eventlog/EventLog.test.ts index c8fd59fb98..4fe739403f 100644 --- a/repos/effect/packages/effect/test/unstable/eventlog/EventLog.test.ts +++ b/repos/effect/packages/effect/test/unstable/eventlog/EventLog.test.ts @@ -1,9 +1,11 @@ import { assert, describe, it } from "@effect/vitest" -import { Effect, Layer, Ref, Schema } from "effect" +import { Effect, Layer, PubSub, Queue, Ref, Schema } from "effect" +import { TestClock } from "effect/testing" import * as EventGroup from "effect/unstable/eventlog/EventGroup" import * as EventJournal from "effect/unstable/eventlog/EventJournal" import * as EventLog from "effect/unstable/eventlog/EventLog" import * as EventLogEncryption from "effect/unstable/eventlog/EventLogEncryption" +import * as EventLogRemote from "effect/unstable/eventlog/EventLogRemote" const UserPayload = Schema.Struct({ id: Schema.String @@ -26,9 +28,12 @@ const handlerLayer = (handled: Ref.Ref>) => Layer.provide(EventLog.layerRegistry) ) -const logLayer = (handled: Ref.Ref>) => +const logLayer = ( + handled: Ref.Ref>, + journalLayer: Layer.Layer = EventJournal.layerMemory +) => EventLog.layer(schema, handlerLayer(handled)).pipe( - Layer.provide(EventJournal.layerMemory), + Layer.provide(journalLayer), Layer.provide( Layer.effect(EventLog.Identity, EventLog.makeIdentity).pipe( Layer.provide(EventLogEncryption.layerSubtle) @@ -55,6 +60,78 @@ describe("EventLog", () => { }).pipe(Effect.provide(logLayer(handled))) })) + it.effect("retries a failed remote write and flushes changes made during the retry", () => + Effect.gen(function*() { + const handled = yield* Ref.make>([]) + const subscriptions = yield* Queue.unbounded>() + const journalLayer = Layer.effect( + EventJournal.EventJournal, + EventJournal.makeMemory.pipe( + Effect.map((journal) => + EventJournal.EventJournal.of({ + ...journal, + changes: journal.changes.pipe(Effect.tap((subscription) => Queue.offer(subscriptions, subscription))) + }) + ) + ) + ) + return yield* Effect.gen(function*() { + const log = yield* EventLog.EventLog + const registry = yield* EventLog.Registry + const attempts = yield* Queue.unbounded>() + const release = yield* Queue.unbounded() + let attempt = 0 + let failedChangeWrite = false + const remote = EventLogRemote.EventLogRemote.of({ + id: EventJournal.makeRemoteIdUnsafe(), + changes: () => Queue.unbounded(), + write: ({ entries }) => + Queue.offer(attempts, entries).pipe( + Effect.flatMap(() => { + attempt++ + if (attempt === 1) { + return Effect.fail(new EventLogRemote.EventLogRemoteError({ method: "write", cause: "offline" })) + } + if (attempt === 2) return Queue.take(release) + if (!failedChangeWrite && entries.some((entry) => entry.primaryKey === "user-4")) { + failedChangeWrite = true + return Effect.fail(new EventLogRemote.EventLogRemoteError({ method: "write", cause: "offline" })) + } + return Effect.void + }) + ), + whenAuthenticated: (effect) => effect + }) + + yield* log.write({ schema, event: "UserCreated", payload: { id: "user-1" } }) + yield* registry.registerRemote(remote) + const subscription = yield* Queue.take(subscriptions) + const initial = yield* Queue.take(attempts) + yield* TestClock.adjust("200 millis") + const retry = yield* Queue.take(attempts) + yield* log.write({ schema, event: "UserCreated", payload: { id: "user-2" } }) + yield* log.write({ schema, event: "UserCreated", payload: { id: "user-3" } }) + yield* Effect.yieldNow + assert.strictEqual(yield* PubSub.remaining(subscription), 0) + yield* Queue.offer(release, undefined) + const afterChange = yield* Queue.take(attempts) + yield* Effect.yieldNow + assert.strictEqual(yield* Queue.size(attempts), 0) + yield* log.write({ schema, event: "UserCreated", payload: { id: "user-4" } }) + const changeAttempt = yield* Queue.take(attempts) + yield* TestClock.adjust("200 millis") + const changeRetry = yield* Queue.take(attempts) + + assert.deepStrictEqual(retry.map((entry) => entry.idString), initial.map((entry) => entry.idString)) + assert.deepStrictEqual(afterChange.map((entry) => entry.primaryKey), ["user-2", "user-3"]) + assert.deepStrictEqual(changeAttempt.map((entry) => entry.primaryKey), ["user-4"]) + assert.deepStrictEqual( + changeRetry.map((entry) => entry.idString), + changeAttempt.map((entry) => entry.idString) + ) + }).pipe(Effect.provide(logLayer(handled, journalLayer))) + })) + it.effect("encrypts and decrypts entries with a distinct IV per entry", () => Effect.gen(function*() { const encryption = yield* EventLogEncryption.EventLogEncryption diff --git a/repos/effect/packages/effect/test/unstable/http/HttpClient.test.ts b/repos/effect/packages/effect/test/unstable/http/HttpClient.test.ts index 1f06181300..4f44a3de5c 100644 --- a/repos/effect/packages/effect/test/unstable/http/HttpClient.test.ts +++ b/repos/effect/packages/effect/test/unstable/http/HttpClient.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest" import { strictEqual } from "@effect/vitest/utils" -import { Clock, Duration, Effect, Fiber, Layer, Ref, Stream } from "effect" +import { Clock, Duration, Effect, Fiber, Layer, Ref, Schema, Stream } from "effect" import { TestClock } from "effect/testing" import * as Tracer from "effect/Tracer" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -38,6 +38,47 @@ const makeRedirectClient = Effect.fnUntraced(function*(status: number, location: const RateLimiterTestLayer = RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory)) describe("HttpClient", () => { + it.effect("applies JSON revivers to response schema decoders", () => + Effect.gen(function*() { + const makeResponse = () => + HttpClientResponse.fromWeb( + HttpClientRequest.get("https://example.com"), + new Response("{\"value\":\"original\"}") + ) + const options = { + reviver: (key: string, value: unknown) => key === "value" ? "revived" : value + } + + assert.deepStrictEqual( + yield* HttpClientResponse.schemaBodyJson(Schema.Struct({ value: Schema.String }), options)(makeResponse()), + { value: "revived" } + ) + assert.deepStrictEqual( + yield* HttpClientResponse.schemaJson( + Schema.Struct({ body: Schema.Struct({ value: Schema.String }) }), + options + )(makeResponse()), + { body: { value: "revived" } } + ) + })) + + it.effect("preserves a raw large integer through schemaBodyJson reviver context", () => + Effect.gen(function*() { + const response = HttpClientResponse.fromWeb( + HttpClientRequest.get("https://example.com"), + new Response("{\"value\":9223372036854775807}") + ) + const reviver = ((key: string, value: unknown, context: { readonly source: string }) => + key === "value" ? context.source : value) as Parameters[1] + + const decoded = yield* HttpClientResponse.schemaBodyJson( + Schema.Struct({ value: Schema.String }), + { reviver } + )(response) + + assert.strictEqual(decoded.value, "9223372036854775807") + })) + it.effect("preserves source bytes after reading response text", () => Effect.gen(function*() { const response = HttpClientResponse.fromWeb( diff --git a/repos/effect/packages/effect/test/unstable/http/HttpServerRequest.test.ts b/repos/effect/packages/effect/test/unstable/http/HttpServerRequest.test.ts index 63aeeacf60..a7713ba1ad 100644 --- a/repos/effect/packages/effect/test/unstable/http/HttpServerRequest.test.ts +++ b/repos/effect/packages/effect/test/unstable/http/HttpServerRequest.test.ts @@ -178,12 +178,15 @@ describe("HttpServerRequest", () => { name: Schema.String }) - const decoded = yield* HttpServerRequest.schemaBodyJson(schema, { onExcessProperty: "preserve" }).pipe( + const decoded = yield* HttpServerRequest.schemaBodyJson(schema, { + onExcessProperty: "preserve", + reviver: (key, value) => key === "status" ? "revived" : value + }).pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, request) ) const decodedRecord = decoded as Record - assert.strictEqual(decoded.status, "ok") + assert.strictEqual(decoded.status, "revived") assert.strictEqual(decoded.name, "svc") assert.strictEqual(decodedRecord.sha, "abc") assert.strictEqual(decodedRecord.version, "1.0.0") diff --git a/repos/effect/packages/effect/test/unstable/http/HttpStatus.test.ts b/repos/effect/packages/effect/test/unstable/http/HttpStatus.test.ts new file mode 100644 index 0000000000..cebe4d7b16 --- /dev/null +++ b/repos/effect/packages/effect/test/unstable/http/HttpStatus.test.ts @@ -0,0 +1,16 @@ +import { describe, it } from "@effect/vitest" +import { strictEqual } from "@effect/vitest/utils" +import { HttpStatus } from "effect/unstable/http" + +describe("HttpStatus", () => { + it("fromLiteral returns the numeric status code", () => { + strictEqual(HttpStatus.fromLiteral("Continue"), 100) + strictEqual(HttpStatus.fromLiteral("OK"), 200) + strictEqual(HttpStatus.fromLiteral("Ok"), 200) + strictEqual(HttpStatus.fromLiteral("Created"), 201) + strictEqual(HttpStatus.fromLiteral("MovedPermanently"), 301) + strictEqual(HttpStatus.fromLiteral("Conflict"), 409) + strictEqual(HttpStatus.fromLiteral("InternalServerError"), 500) + strictEqual(HttpStatus.fromLiteral("NetworkAuthenticationRequired"), 511) + }) +}) diff --git a/repos/effect/packages/effect/test/unstable/http/Multipart.test.ts b/repos/effect/packages/effect/test/unstable/http/Multipart.test.ts index 948bacdf14..f568924e7f 100644 --- a/repos/effect/packages/effect/test/unstable/http/Multipart.test.ts +++ b/repos/effect/packages/effect/test/unstable/http/Multipart.test.ts @@ -11,6 +11,15 @@ import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondab import { deepStrictEqual, notStrictEqual, strictEqual } from "node:assert" describe("Multipart", () => { + it.effect("schemaJson applies a JSON reviver", () => + Effect.gen(function*() { + const decoded = yield* Multipart.schemaJson(Schema.Struct({ value: Schema.String }), { + reviver: (key, value) => key === "value" ? "revived" : value + })({ json: "{\"value\":\"original\"}" }, "json") + + deepStrictEqual(decoded, { value: "revived" }) + })) + it.effect("parses fields and streams file content", () => Effect.gen(function*() { const data = new globalThis.FormData() diff --git a/repos/effect/packages/effect/test/unstable/http/UrlParams.test.ts b/repos/effect/packages/effect/test/unstable/http/UrlParams.test.ts index 6b03a285af..ef321780ae 100644 --- a/repos/effect/packages/effect/test/unstable/http/UrlParams.test.ts +++ b/repos/effect/packages/effect/test/unstable/http/UrlParams.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "@effect/vitest" -import { assertNone, assertSome } from "@effect/vitest/utils" +import { assertNone, assertSome, deepStrictEqual } from "@effect/vitest/utils" import { Schema } from "effect" import { UrlParams } from "effect/unstable/http" import { assertSuccess } from "../../utils/assert.ts" @@ -27,4 +27,15 @@ describe("UrlParams", () => { assertNone(UrlParams.getFirst(params, "bar")) assertNone(UrlParams.getLast(params, "bar")) }) + + it("schemaJsonField applies a JSON reviver", () => { + const schema = UrlParams.schemaJsonField("json", { + reviver: (key, value) => key === "value" ? "revived" : value + }).pipe(Schema.decodeTo(Schema.Struct({ value: Schema.String }))) + + deepStrictEqual( + Schema.decodeSync(schema)(UrlParams.fromInput({ json: "{\"value\":\"original\"}" })), + { value: "revived" } + ) + }) }) diff --git a/repos/effect/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts b/repos/effect/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts index 60fc7e2d99..e07d644450 100644 --- a/repos/effect/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts +++ b/repos/effect/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts @@ -33,6 +33,32 @@ const TestServices = Layer.mergeAll( HttpPlatform.layer ).pipe(Layer.provideMerge(FileSystem.layerNoop({}))) +it.layer(TestServices)("HttpApiBuilder query parameters", (it) => { + it.effect("round trips array query parameters with one or more values", () => + Effect.gen(function*() { + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("test").add( + HttpApiEndpoint.get("list", "/list", { + query: { ids: Schema.Array(Schema.String) }, + success: Schema.Struct({ ids: Schema.Array(Schema.String) }) + }) + ) + ) + const GroupLayer = HttpApiBuilder.group( + Api, + "test", + (handlers) => handlers.handle("list", ({ query }) => Effect.succeed(query)) + ) + + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) + const multiple = yield* client.test.list({ query: { ids: ["a", "b"] } }) + const single = yield* client.test.list({ query: { ids: ["a"] } }) + + assert.deepStrictEqual(multiple, { ids: ["a", "b"] }) + assert.deepStrictEqual(single, { ids: ["a"] }) + })) +}) + it.effect("reuses response schema transformations by source AST", () => { const SharedSuccess = Schema.String.pipe(HttpApiSchema.asText()) const DistinctSuccess = Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/custom" })) @@ -42,7 +68,7 @@ it.effect("reuses response schema transformations by source AST", () => { .add(HttpApiEndpoint.get("second", "/second", { success: SharedSuccess })) .add(HttpApiEndpoint.get("distinct", "/distinct", { success: DistinctSuccess })) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -56,7 +82,7 @@ it.effect("reuses response schema transformations by source AST", () => { Effect.sync(() => vi.spyOn(Schema, "decodeTo")), (decodeTo) => Effect.gen(function*() { - yield* Effect.scoped(Layer.build(GroupLive)) + yield* Effect.scoped(Layer.build(GroupLayer)) const responseSchemaCalls = decodeTo.mock.calls.filter( ([schema]) => schema === SharedSuccess || schema === DistinctSuccess ) @@ -85,13 +111,13 @@ it.layer(TestServices)("HttpApiBuilder payload content types", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("create", ({ payload }) => Effect.succeed(payload)) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const declared = yield* client.test.create({ headers: {}, payload: { name: "Ada" } @@ -120,13 +146,13 @@ it.layer(TestServices)("HttpApiBuilder payload content types", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("create", ({ payload }) => Effect.succeed(payload)) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const result = yield* client.test.create({ payload: { name: "Ada" } }) assert.deepStrictEqual(result, { name: "Ada" }) @@ -159,7 +185,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { HttpApiEndpoint.get("result", "/test", { success: [Ok, Created] }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -170,7 +196,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.result({ responseMode: "response-only" }) assert.strictEqual(response.status, 201) @@ -192,7 +218,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -203,7 +229,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.result({ responseMode: "response-only" }) assert.strictEqual(response.status, 200) @@ -225,7 +251,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -236,7 +262,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) as any)) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const exit = yield* Effect.exit(client.test.result({ responseMode: "response-only" })) assert.strictEqual(exit._tag, "Failure") @@ -260,7 +286,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -271,7 +297,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const result = yield* client.test.result({}) assert.deepStrictEqual( @@ -303,7 +329,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -320,7 +346,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const [success, successResponse] = yield* client.test.success({ responseMode: "decoded-and-response" }) const error = yield* Effect.flip(client.test.error({})) const errorResponse = yield* client.test.error({ responseMode: "response-only" }) @@ -346,7 +372,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -357,7 +383,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const [value, response] = yield* client.test.created({ responseMode: "decoded-and-response" }) assert.strictEqual(response.headers["x-count"], "2") @@ -379,7 +405,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -390,7 +416,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const value = yield* client.test.created({}) assert.strictEqual(value.body.toISOString(), "2024-01-02T03:04:05.000Z") @@ -409,7 +435,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { .add(HttpApiEndpoint.get("forwarded", "/forwarded", { success: Success })) ) let forwarded: typeof Success.Type | undefined - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -422,7 +448,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { .handle("forwarded", () => Effect.succeed(forwarded!)) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const [value, response] = yield* client.test.created({ responseMode: "decoded-and-response" }) forwarded = value const result = yield* client.test.forwarded({}) @@ -446,7 +472,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -457,7 +483,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.created({ responseMode: "response-only" }) assert.strictEqual(response.status, 201) @@ -482,13 +508,13 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("mixed", () => Effect.succeed({ body: "plain", headers: { "x-source": "body" } })) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.mixed({ responseMode: "response-only" }) assert.strictEqual(response.headers["content-type"], "application/vnd.plain+json") @@ -510,13 +536,13 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { HttpApiEndpoint.get("mixed", "/test", { success: [Wrapped, Plain] }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("mixed", () => Effect.succeed({ _tag: "Plain" as const, value: "plain" })) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const value = yield* client.test.mixed({}) assert.deepStrictEqual(value, { _tag: "Plain", value: "plain" }) @@ -534,7 +560,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -545,7 +571,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.override({ responseMode: "response-only" }) assert.strictEqual(response.headers["content-type"], "application/custom") @@ -572,13 +598,13 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { HttpApiEndpoint.get("limited", "/test", { error: RateLimitedResponse }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("limited", () => Effect.fail(new RateLimited({ retryAfter: 30 }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.limited({ responseMode: "response-only" }) assert.strictEqual(response.status, 429) @@ -607,13 +633,13 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { ) ) const expiresAt = DateTime.makeUnsafe("2026-08-05T02:00:00.000Z").pipe(DateTime.toDate) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("limited", () => Effect.fail(new RateLimited({ expiresAt }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.limited({ responseMode: "response-only" }) const error = yield* Effect.flip(client.test.limited({})) @@ -636,13 +662,13 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { body: { _tag: "RateLimited" as const, message: "slow down" }, headers: { "retry-after": 30 } }) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("limited", () => Effect.fail(expected)) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.limited({ responseMode: "response-only" }) assert.strictEqual(response.status, 429) @@ -673,13 +699,13 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("limited", () => Effect.fail(new RateLimited({ retryAfter: "30" }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.limited({ responseMode: "response-only" }) assert.strictEqual(response.status, 429) @@ -718,14 +744,14 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { HttpApiEndpoint.get("limited", "/test", { error: RateLimitedResponse }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("limited", () => Effect.fail(new RateLimited({ message: "slow down", retryAfter: 30 }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const error = yield* Effect.flip(client.test.limited({})) assert.deepStrictEqual(error, new RateLimited({ message: "slow down", retryAfter: 30 })) @@ -740,7 +766,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -751,7 +777,7 @@ it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const exit = yield* Effect.exit(client.test.invalid({ responseMode: "response-only" })) assert.strictEqual(exit._tag, "Failure") @@ -776,7 +802,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -786,7 +812,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { )) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.download({ responseMode: "response-only" }) const chunks = yield* response.stream.pipe(Stream.runCollect) @@ -809,7 +835,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -820,7 +846,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.download({ responseMode: "response-only" }) const chunks = yield* response.stream.pipe(Stream.runCollect) @@ -850,7 +876,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -861,7 +887,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { ))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.events({ responseMode: "response-only" }) const chunks = yield* response.stream.pipe(Stream.runCollect) @@ -895,7 +921,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -906,7 +932,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.events({ responseMode: "response-only" }) const chunks = yield* response.stream.pipe(Stream.runCollect) @@ -930,7 +956,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -941,7 +967,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const exit = yield* Effect.exit(client.test.download({ responseMode: "response-only" })) assert.strictEqual(exit._tag, "Failure") @@ -967,7 +993,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -977,7 +1003,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { )) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.events({ responseMode: "response-only" }) const chunks = yield* response.stream.pipe(Stream.runCollect) const rendered = Array.from(chunks, (chunk) => textDecoder.decode(chunk)) @@ -1014,7 +1040,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -1026,7 +1052,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { )) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const bufferedResponse = yield* client.test.chat({ query: { stream: "false" }, responseMode: "response-only" }) assert.strictEqual(bufferedResponse.status, 200) assert.strictEqual(bufferedResponse.headers["content-type"], "application/json") @@ -1057,13 +1083,13 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("result", () => Effect.succeed({ message: "done" })) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.result({ responseMode: "response-only" }) assert.strictEqual(response.headers["content-type"], "application/json") @@ -1083,7 +1109,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => @@ -1094,7 +1120,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }))) ) - const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer)) const response = yield* client.test.result({ responseMode: "response-only" }) assert.strictEqual(response.headers["content-type"], "application/json") @@ -1127,7 +1153,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "users", (handlers) => @@ -1148,7 +1174,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }) ) - const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLayer)) const getUser = yield* client.users.getUser({ params: { id: "user-1" } }) const createUser = yield* client.users.createUser({ payload: { name: "Grace" } }) @@ -1165,12 +1191,12 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { ) for (const identifier of ["missing", "toString"] as const) { - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "users", (handlers) => handlers.handle(identifier as "getUser", () => Effect.succeed("missing")) ) - const exit = yield* Effect.exit(Effect.scoped(Layer.build(GroupLive))) + const exit = yield* Effect.exit(Effect.scoped(Layer.build(GroupLayer))) assert.strictEqual(exit._tag, "Failure") if (exit._tag === "Failure") { @@ -1191,7 +1217,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { HttpApiEndpoint.get("getUser", "/users", { success: Schema.String }) ) ) - const HandleLive = HttpApiBuilder.group( + const HandleLayer = HttpApiBuilder.group( Api, "users", (handlers) => { @@ -1199,7 +1225,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { return handlers.handle("getUser", () => Effect.succeed("second")) } ) - const HandleAllLive = HttpApiBuilder.group( + const HandleAllLayer = HttpApiBuilder.group( Api, "users", (handlers) => { @@ -1208,8 +1234,8 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { } ) - for (const GroupLive of [HandleLive, HandleAllLive]) { - const exit = yield* Effect.exit(Effect.scoped(Layer.build(GroupLive))) + for (const GroupLayer of [HandleLayer, HandleAllLayer]) { + const exit = yield* Effect.exit(Effect.scoped(Layer.build(GroupLayer))) assert.strictEqual(exit._tag, "Failure") if (exit._tag === "Failure") { const error = Cause.squash(exit.cause) @@ -1241,13 +1267,13 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { getUser: () => Effect.succeed("own") } ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "users", (handlers) => handlers.handleAll(implementations) ) - const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLayer)) assert.strictEqual(yield* client.users.getUser(), "own") })) @@ -1270,7 +1296,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "users", (handlers) => @@ -1283,7 +1309,7 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { ) ) - const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLive)) + const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLayer)) const getUser = yield* client.users.getUser({ params: { id: "user-1" } }) assert.deepStrictEqual(getUser, { id: "user-1", name: "Ada" }) @@ -1324,12 +1350,12 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }).middleware(M) ) ) - const GroupLive = HttpApiBuilder.group( + const GroupLayer = HttpApiBuilder.group( Api, "test", (handlers) => handlers.handle("protected", () => Effect.fail(new HandlerFailure({ message: "handler failed" }))) ) - const MLive = Layer.succeed(M)({ + const MLayer = Layer.succeed(M)({ first: (effect, { credential }) => Redacted.value(credential) === "ok" ? effect : Effect.fail("first unauthorized"), second: (effect, { credential }) => @@ -1337,8 +1363,8 @@ it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => { }) const client = yield* HttpApiTest.groups(Api, ["test"]).pipe( - Effect.provide(GroupLive), - Effect.provide(MLive) + Effect.provide(GroupLayer), + Effect.provide(MLayer) ) const error = yield* Effect.flip(client.test.protected({ headers: { "x-first": "ok" } })) diff --git a/repos/effect/packages/effect/test/unstable/httpapi/HttpApiDocumentation.test.ts b/repos/effect/packages/effect/test/unstable/httpapi/HttpApiDocumentation.test.ts index 29c230f030..04a4a9d2dd 100644 --- a/repos/effect/packages/effect/test/unstable/httpapi/HttpApiDocumentation.test.ts +++ b/repos/effect/packages/effect/test/unstable/httpapi/HttpApiDocumentation.test.ts @@ -1,9 +1,185 @@ -import { assert, describe, it } from "@effect/vitest" -import { Effect, type Layer } from "effect" -import { HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi" +import { assert, describe, it, vi } from "@effect/vitest" +import { Effect, FileSystem, Layer, Path } from "effect" +import { Etag, HttpPlatform, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi" + +const TestServices = Layer.mergeAll( + Path.layer, + Etag.layerWeak, + HttpPlatform.layer +).pipe(Layer.provideMerge(FileSystem.layerNoop({}))) + +describe("HttpApiBuilder", () => { + it.effect("defers and memoizes successful openapiPath responses", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("OpenApiPath").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + return spec + } + ) + const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) + + yield* Effect.acquireUseRelease( + Effect.sync(() => vi.spyOn(HttpServerResponse, "jsonUnsafe")), + (jsonUnsafe) => + withHandler( + Layer.merge(HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }), Health).pipe( + Layer.provide(TestServices) + ), + (handler) => + Effect.gen(function*() { + const health = yield* Effect.promise(() => handler(new Request("http://test/health"))) + assert.strictEqual(health.status, 200) + assert.strictEqual(transforms, 0) + assert.strictEqual(jsonUnsafe.mock.calls.length, 0) + + const firstRequests = yield* Effect.promise(() => + Promise.all( + Array.from({ length: 20 }, () => handler(new Request("http://test/openapi.json"))) + ) + ) + assert.ok(firstRequests.every((response) => response.status === 200)) + assert.strictEqual(transforms, 1) + assert.strictEqual(jsonUnsafe.mock.calls.length, 1) + + const cached = yield* Effect.promise(() => handler(new Request("http://test/openapi.json"))) + assert.strictEqual(cached.status, 200) + assert.strictEqual(transforms, 1) + assert.strictEqual(jsonUnsafe.mock.calls.length, 1) + }) + ), + (jsonUnsafe) => Effect.sync(() => jsonUnsafe.mockRestore()) + ) + })) + + it.effect("retries openapiPath generation after a defect", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("OpenApiPathRecovery").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + if (transforms === 1) throw new Error("OpenAPI generation defect") + return spec + } + ) + + yield* withHandler( + HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(Layer.provide(TestServices)), + (handler) => + Effect.gen(function*() { + const first = yield* Effect.promise(() => handler(new Request("http://test/openapi.json"))) + assert.strictEqual(first.status, 500) + assert.strictEqual(transforms, 1) + + const second = yield* Effect.promise(() => handler(new Request("http://test/openapi.json"))) + assert.strictEqual(second.status, 200) + assert.strictEqual(transforms, 2) + + const cached = yield* Effect.promise(() => handler(new Request("http://test/openapi.json"))) + assert.strictEqual(cached.status, 200) + assert.strictEqual(transforms, 2) + }) + ) + })) +}) describe("HttpApiScalar", () => { + it.effect("defers and memoizes successful OpenAPI responses", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("Docs").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + return spec + } + ) + const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) + + yield* Effect.acquireUseRelease( + Effect.sync(() => vi.spyOn(HttpServerResponse, "html")), + (html) => + withHandler(Layer.merge(HttpApiScalar.layerCdn(Api), Health), (handler) => + Effect.gen(function*() { + const health = yield* Effect.promise(() => handler(new Request("http://test/health"))) + assert.strictEqual(health.status, 200) + assert.strictEqual(transforms, 0) + assert.strictEqual(html.mock.calls.length, 0) + + const firstRequests = yield* Effect.promise(() => + Promise.all( + Array.from({ length: 20 }, () => handler(new Request("http://test/docs"))) + ) + ) + assert.ok(firstRequests.every((response) => response.status === 200)) + assert.strictEqual(transforms, 1) + assert.strictEqual(html.mock.calls.length, 1) + + const cached = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(cached.status, 200) + assert.strictEqual(transforms, 1) + assert.strictEqual(html.mock.calls.length, 1) + })), + (html) => Effect.sync(() => html.mockRestore()) + ) + })) + + it.effect("retries OpenAPI generation after a defect", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("ScalarRecovery").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + if (transforms === 1) throw new Error("OpenAPI generation defect") + return spec + } + ) + + yield* withHandler(HttpApiScalar.layerCdn(Api), (handler) => + Effect.gen(function*() { + const first = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(first.status, 500) + assert.strictEqual(transforms, 1) + + const second = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(second.status, 200) + assert.strictEqual(transforms, 2) + + const cached = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(cached.status, 200) + assert.strictEqual(transforms, 2) + })) + })) + + it.effect("defers inline Scalar generation until the route is requested", () => + Effect.gen(function*() { + let transforms = 0 + const Api = HttpApi.make("ScalarInline").annotate( + OpenApi.Transform, + (spec) => { + transforms++ + return spec + } + ) + const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) + + yield* withHandler(Layer.merge(HttpApiScalar.layer(Api), Health), (handler) => + Effect.gen(function*() { + const health = yield* Effect.promise(() => handler(new Request("http://test/health"))) + assert.strictEqual(health.status, 200) + assert.strictEqual(transforms, 0) + + const docs = yield* Effect.promise(() => handler(new Request("http://test/docs"))) + assert.strictEqual(docs.status, 200) + assert.strictEqual(transforms, 1) + })) + })) + it.effect("escapes OpenAPI metadata in its HTML contexts", () => Effect.gen(function*() { const title = `Docs "title"