Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 16 additions & 14 deletions packages/core/sdk/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () =>
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/google-discovery/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
},
"devDependencies": {
"@effect/atom-react": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/vitest": "catalog:",
"@executor-js/react": "workspace:*",
"@types/node": "catalog:",
Expand Down
109 changes: 109 additions & 0 deletions packages/plugins/google-discovery/src/sdk/option-json-repro.test.ts
Original file line number Diff line number Diff line change
@@ -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<X> — 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 = <A, E>(fn: (path: string) => Effect.Effect<A, E, FileSystem.FileSystem>) =>
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<string>() };

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);
}),
),
);
});
14 changes: 7 additions & 7 deletions packages/plugins/google-discovery/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export class GoogleDiscoveryParameter extends Schema.Class<GoogleDiscoveryParame
location: GoogleDiscoveryParameterLocation,
required: Schema.Boolean,
repeated: Schema.Boolean,
description: Schema.Option(Schema.String),
schema: Schema.Option(Schema.Unknown),
description: Schema.OptionFromOptional(Schema.String),
schema: Schema.OptionFromOptional(Schema.Unknown),
}) {}

export class GoogleDiscoveryMethodBinding extends Schema.Class<GoogleDiscoveryMethodBinding>(
Expand All @@ -39,22 +39,22 @@ export class GoogleDiscoveryManifestMethod extends Schema.Class<GoogleDiscoveryM
"GoogleDiscoveryManifestMethod",
)({
toolPath: Schema.String,
description: Schema.Option(Schema.String),
description: Schema.OptionFromOptional(Schema.String),
binding: GoogleDiscoveryMethodBinding,
inputSchema: Schema.Option(Schema.Unknown),
outputSchema: Schema.Option(Schema.Unknown),
inputSchema: Schema.OptionFromOptional(Schema.Unknown),
outputSchema: Schema.OptionFromOptional(Schema.Unknown),
scopes: Schema.Array(Schema.String),
}) {}

export class GoogleDiscoveryManifest extends Schema.Class<GoogleDiscoveryManifest>(
"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),
}) {}
Expand Down
Loading