diff --git a/bun.lock b/bun.lock index eb77f14b9..8070346f8 100644 --- a/bun.lock +++ b/bun.lock @@ -585,6 +585,7 @@ }, "devDependencies": { "@effect/atom-react": "catalog:", + "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@executor-js/react": "workspace:*", "@types/node": "catalog:", diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 28f2ea5af..7a6621e45 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -1427,22 +1427,24 @@ describe("tenant isolation (SDK)", () => { }), ); - it.effect("secrets.list surfaces provider-enumerated entries; status still gates on routed rows", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins: [providerOnlySecretPlugin()] as const }), - ); + it.effect( + "secrets.list surfaces provider-enumerated entries; status still gates on routed rows", + () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [providerOnlySecretPlugin()] as const }), + ); - const refs = yield* executor.secrets.list(); - const status = yield* executor.secrets.status("provider-token"); - const value = yield* executor.secrets.get("provider-token"); + const refs = yield* executor.secrets.list(); + const status = yield* executor.secrets.status("provider-token"); + const value = yield* executor.secrets.get("provider-token"); - const entry = refs.find((ref) => ref.id === "provider-token"); - expect(entry?.provider).toBe("provider-only"); - expect(entry?.name).toBe("Provider token"); - expect(status).toBe("missing"); - expect(value).toBe("provider-value"); - }), + const entry = refs.find((ref) => ref.id === "provider-token"); + expect(entry?.provider).toBe("provider-only"); + expect(entry?.name).toBe("Provider token"); + expect(status).toBe("missing"); + expect(value).toBe("provider-value"); + }), ); it.effect("secrets.get short-circuits provider fallback in registration order", () => diff --git a/packages/plugins/google-discovery/package.json b/packages/plugins/google-discovery/package.json index ba3a2fb9f..2a244a826 100644 --- a/packages/plugins/google-discovery/package.json +++ b/packages/plugins/google-discovery/package.json @@ -60,6 +60,7 @@ }, "devDependencies": { "@effect/atom-react": "catalog:", + "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@executor-js/react": "workspace:*", "@types/node": "catalog:", diff --git a/packages/plugins/google-discovery/src/sdk/option-json-repro.test.ts b/packages/plugins/google-discovery/src/sdk/option-json-repro.test.ts new file mode 100644 index 000000000..668322549 --- /dev/null +++ b/packages/plugins/google-discovery/src/sdk/option-json-repro.test.ts @@ -0,0 +1,109 @@ +// Reproduces the PR 706 bug class using Effect-native primitives only — +// no JSON.parse, no JSON.stringify, no node:fs on our side. We split the +// JSON boundary into two Effect schema steps: +// +// 1. Schema.encodeEffect(Inner)(value) → encoded JS shape +// 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string +// 3. fs.writeFileString → fs.readFileString +// 4. Schema.decodeUnknownEffect(UnknownFromJsonString) → unknown +// 5. Schema.decodeUnknownEffect(Inner) → final value +// +// Even though every step runs through Effect, Schema.Option(X) still +// breaks because its Encoded type IS Option — not a JSON value. So +// step 2's JSON-stringify (driven by Effect, not us) flattens the Option +// to {_id,_tag,value}, and step 5 rejects the shape. +// +// Run: vitest run packages/plugins/google-discovery/src/sdk/option-json-repro.test.ts + +import { describe, expect, it } from "@effect/vitest"; +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import { Effect, Exit, FileSystem, Option, Schema } from "effect"; + +const Broken = Schema.Struct({ description: Schema.Option(Schema.String) }); +const Fixed = Schema.Struct({ description: Schema.OptionFromOptional(Schema.String) }); + +const broken = { description: Option.some("hello") }; +const fixed = { description: Option.some("hello") }; + +const withTmpFile = (fn: (path: string) => Effect.Effect) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-repro-" }); + return yield* fn(`${dir}/binding.json`); + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)); + +describe("Schema.Option round-trips through Effect-native JSON I/O", () => { + it.effect("BREAKS: every step driven by Effect, still loses the Option shape", () => + withTmpFile((path) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + // Step 1: schema encode → encoded JS shape. For Schema.Option, + // the encoded `description` is still an Option instance. + const encodedShape = yield* Schema.encodeEffect(Broken)(broken); + expect(Option.isOption(encodedShape.description)).toBe(true); + + // Step 2: turn the encoded shape into a JSON string via Effect. + const jsonString = yield* Schema.encodeEffect(Schema.UnknownFromJsonString)(encodedShape); + // This is what Effect produced — Option's toJSON shape: + expect(jsonString).toContain('"_id":"Option"'); + expect(jsonString).toContain('"_tag":"Some"'); + + // Step 3 + 4: round-trip via the platform FileSystem. + yield* fs.writeFileString(path, jsonString); + const onDisk = yield* fs.readFileString(path); + + // Step 5: parse string → unknown via Effect. + const parsed = yield* Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(onDisk); + + // Step 6: decode the unknown back through the schema → fails, + // because the wire shape isn't an Option instance. + const result = yield* Effect.exit(Schema.decodeUnknownEffect(Broken)(parsed)); + expect(Exit.isFailure(result)).toBe(true); + }), + ), + ); + + it.effect("WORKS: same pipeline with Schema.OptionFromOptional", () => + withTmpFile((path) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + const encodedShape = yield* Schema.encodeEffect(Fixed)(fixed); + // Encoded form is JSON-safe: { description: "hello" } + expect(encodedShape.description).toBe("hello"); + + const jsonString = yield* Schema.encodeEffect(Schema.UnknownFromJsonString)(encodedShape); + expect(jsonString).toBe('{"description":"hello"}'); + + yield* fs.writeFileString(path, jsonString); + const onDisk = yield* fs.readFileString(path); + + const parsed = yield* Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(onDisk); + const decoded = yield* Schema.decodeUnknownEffect(Fixed)(parsed); + + expect(Option.getOrNull(decoded.description)).toBe("hello"); + }), + ), + ); + + it.effect("WORKS: None round-trips as a missing key with OptionFromOptional", () => + withTmpFile((path) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const noneVal = { description: Option.none() }; + + const encodedShape = yield* Schema.encodeEffect(Fixed)(noneVal); + const jsonString = yield* Schema.encodeEffect(Schema.UnknownFromJsonString)(encodedShape); + expect(jsonString).toBe("{}"); + + yield* fs.writeFileString(path, jsonString); + const onDisk = yield* fs.readFileString(path); + const parsed = yield* Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(onDisk); + const decoded = yield* Schema.decodeUnknownEffect(Fixed)(parsed); + + expect(Option.isNone(decoded.description)).toBe(true); + }), + ), + ); +}); diff --git a/packages/plugins/google-discovery/src/sdk/types.ts b/packages/plugins/google-discovery/src/sdk/types.ts index b2f3a9830..468eef607 100644 --- a/packages/plugins/google-discovery/src/sdk/types.ts +++ b/packages/plugins/google-discovery/src/sdk/types.ts @@ -22,8 +22,8 @@ export class GoogleDiscoveryParameter extends Schema.Class( @@ -39,22 +39,22 @@ export class GoogleDiscoveryManifestMethod extends Schema.Class( "GoogleDiscoveryManifest", )({ - title: Schema.Option(Schema.String), + title: Schema.OptionFromOptional(Schema.String), service: Schema.String, version: Schema.String, rootUrl: Schema.String, servicePath: Schema.String, - oauthScopes: Schema.Option(Schema.Record(Schema.String, Schema.String)), + oauthScopes: Schema.OptionFromOptional(Schema.Record(Schema.String, Schema.String)), schemaDefinitions: Schema.Record(Schema.String, Schema.Unknown), methods: Schema.Array(GoogleDiscoveryManifestMethod), }) {}