diff --git a/.changeset/bright-tags-recognize.md b/.changeset/bright-tags-recognize.md new file mode 100644 index 00000000000..edead0f682b --- /dev/null +++ b/.changeset/bright-tags-recognize.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Recognize tagged Config and RPC errors across duplicated `effect` package copies. diff --git a/packages/effect/src/Config.ts b/packages/effect/src/Config.ts index 5caddba3a43..68f21037e9f 100644 --- a/packages/effect/src/Config.ts +++ b/packages/effect/src/Config.ts @@ -194,13 +194,15 @@ const evaluationFailure = (error: ConfigError, hasInput: boolean): EvaluationFai hasInput }) +const isSourceError = (u: unknown): u is ConfigProvider.SourceError => Predicate.isTagged(u, "SourceError") + const catchSourceError = ( self: Effect.Effect, hasInput: boolean ): Effect.Effect => self.pipe( Effect.catchDefect((defect) => - defect instanceof ConfigProvider.SourceError + isSourceError(defect) ? Effect.fail(evaluationFailure(new ConfigError(defect), hasInput)) : Effect.die(defect) ) diff --git a/packages/effect/src/unstable/rpc/RpcClient.ts b/packages/effect/src/unstable/rpc/RpcClient.ts index 6a9483fc48a..8f028a5716d 100644 --- a/packages/effect/src/unstable/rpc/RpcClient.ts +++ b/packages/effect/src/unstable/rpc/RpcClient.ts @@ -23,6 +23,7 @@ import * as Latch from "../../Latch.ts" import * as Layer from "../../Layer.ts" import * as Option from "../../Option.ts" import * as Pool from "../../Pool.ts" +import * as Predicate from "../../Predicate.ts" import * as Queue from "../../Queue.ts" import * as Result from "../../Result.ts" import * as Schedule from "../../Schedule.ts" @@ -51,6 +52,8 @@ import * as RpcSerialization from "./RpcSerialization.ts" import * as RpcWorker from "./RpcWorker.ts" import { withRunClient } from "./Utils.ts" +const isRpcClientError = (u: unknown): u is RpcClientError => Predicate.isTagged(u, "RpcClientError") + /** * The object-shaped client generated from a union of RPC definitions, with one * method per RPC tag. @@ -960,7 +963,7 @@ export const makeProtocolHttp = (client: HttpClient.HttpClient): Effect.Effect< }) }) )).pipe( - Effect.mapError((cause) => cause instanceof RpcClientError ? cause : httpClientError(cause)) + Effect.mapError((cause) => isRpcClientError(cause) ? cause : httpClientError(cause)) ) if (!hasResponse) { return yield* emptyResponseError(request) diff --git a/packages/effect/test/Config.test.ts b/packages/effect/test/Config.test.ts index ddccd151da0..e2f7c6a96da 100644 --- a/packages/effect/test/Config.test.ts +++ b/packages/effect/test/Config.test.ts @@ -12,6 +12,8 @@ import { SchemaIssue, SchemaTransformation } from "effect" +import { vi } from "vitest" +import type * as ConfigProviderModule from "../src/ConfigProvider.ts" async function assertSuccess(config: Config.Config, provider: ConfigProvider.ConfigProvider, expected: T) { const r = await config.parse(provider).pipe( @@ -31,6 +33,23 @@ async function assertFailure(config: Config.Config, provider: ConfigProvid } describe("Config", () => { + it("recognizes SourceError defects from a reloaded module copy", async () => { + vi.resetModules() + const ForeignConfigProvider = await vi.importActual( + "../src/ConfigProvider.ts" + ) + const sourceError = new ForeignConfigProvider.SourceError({ message: "source unavailable" }) + assert.isFalse(sourceError instanceof ConfigProvider.SourceError) + + const provider = ConfigProvider.make(() => Effect.die(sourceError)) + const error = await Config.string("value").parse(provider).pipe( + Effect.flip, + Effect.runPromise + ) + + assert.strictEqual(error.cause, sourceError) + }) + it.effect("uses the current ConfigProvider when yielded as an Effect", () => Effect.gen(function*() { const provider = ConfigProvider.fromEnv({ env: { STRING: "value" } }) diff --git a/packages/effect/test/rpc/RpcClient.test.ts b/packages/effect/test/rpc/RpcClient.test.ts index 52474a5f622..995825d6b71 100644 --- a/packages/effect/test/rpc/RpcClient.test.ts +++ b/packages/effect/test/rpc/RpcClient.test.ts @@ -6,6 +6,8 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" import { Rpc, RpcClient, RpcGroup, RpcMessage, RpcSchema, RpcSerialization } from "effect/unstable/rpc" import { RpcClientError } from "effect/unstable/rpc/RpcClientError" import * as Socket from "effect/unstable/socket/Socket" +import { vi } from "vitest" +import type * as RpcClientErrorModule from "../../src/unstable/rpc/RpcClientError.ts" const TestGroup = RpcGroup.make( Rpc.make("Ping", { success: Schema.String }), @@ -22,15 +24,20 @@ const makeHttpClient = (body: string): HttpClient.HttpClient => ) ) -const makeProtocolLayer = ( +const makeProtocolLayerWithClient = ( serializationLayer: Layer.Layer, - body: string + client: HttpClient.HttpClient ) => RpcClient.layerProtocolHttp({ url: "http://localhost/rpc" }).pipe( Layer.provideMerge(serializationLayer), - Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, makeHttpClient(body))) + Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)) ) +const makeProtocolLayer = ( + serializationLayer: Layer.Layer, + body: string +) => makeProtocolLayerWithClient(serializationLayer, makeHttpClient(body)) + const assertEmptyResponseFailsRequest = ( serializationLayer: Layer.Layer, body: string @@ -53,6 +60,31 @@ const assertEmptyResponseFailsRequest = ( }) describe("RpcClient", () => { + it("preserves RpcClientError failures from a reloaded module copy", async () => { + vi.resetModules() + const ForeignRpcClientError = await vi.importActual( + "../../src/unstable/rpc/RpcClientError.ts" + ) + const rpcClientError = new ForeignRpcClientError.RpcClientError({ + reason: new ForeignRpcClientError.RpcClientDefect({ message: "boom", cause: undefined }) + }) + assert.isFalse(rpcClientError instanceof RpcClientError) + + const httpClient = HttpClient.make((request) => { + const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 })) + Object.defineProperty(response, "stream", { value: Stream.fail(rpcClientError) }) + return Effect.succeed(response) + }) + const error = await Effect.gen(function*() { + const client = yield* RpcClient.make(TestGroup).pipe( + Effect.provide(makeProtocolLayerWithClient(RpcSerialization.layerNdjson, httpClient)) + ) + return yield* client.Ping().pipe(Effect.flip) + }).pipe(Effect.scoped, Effect.runPromise) + + assert.strictEqual(error, rpcClientError) + }) + it.effect("fails request on empty HTTP response for unframed serialization", () => assertEmptyResponseFailsRequest(RpcSerialization.layerJson, "[]")) diff --git a/packages/tools/api-diff/src/Annotations.ts b/packages/tools/api-diff/src/Annotations.ts index a6e9a8bb201..86bcbc096e1 100644 --- a/packages/tools/api-diff/src/Annotations.ts +++ b/packages/tools/api-diff/src/Annotations.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect" import * as FileSystem from "effect/FileSystem" import * as Path from "effect/Path" import * as Yaml from "yaml" -import { ApiDiffError } from "./Error.ts" +import { ApiDiffError, isApiDiffError } from "./Error.ts" export interface MigrationAnnotation { readonly replacement: string @@ -65,7 +65,7 @@ const loadAnnotationsInternal = Effect.fnUntraced(function*(directory: string) { export const loadAnnotations = (directory: string) => loadAnnotationsInternal(directory).pipe( Effect.mapError((cause) => - cause instanceof ApiDiffError + isApiDiffError(cause) ? cause : new ApiDiffError({ message: `Could not load annotations from ${directory}`, cause }) ) diff --git a/packages/tools/api-diff/src/ApiDiff.ts b/packages/tools/api-diff/src/ApiDiff.ts index f7a4953f2f3..3a05ebb30ef 100644 --- a/packages/tools/api-diff/src/ApiDiff.ts +++ b/packages/tools/api-diff/src/ApiDiff.ts @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer" import * as Path from "effect/Path" import { loadAnnotations } from "./Annotations.ts" import { diffSnapshots } from "./Diff.ts" -import { ApiDiffError } from "./Error.ts" +import { ApiDiffError, isApiDiffError } from "./Error.ts" import { prettyJson } from "./Json.ts" import { extractImportMapSections, @@ -151,7 +151,7 @@ export class ApiDiff extends Context.Service => runInternal(options).pipe( Effect.mapError((cause) => - cause instanceof ApiDiffError + isApiDiffError(cause) ? cause : new ApiDiffError({ message: "API diff failed", diff --git a/packages/tools/api-diff/src/Error.ts b/packages/tools/api-diff/src/Error.ts index 5762592c118..d2d4444c408 100644 --- a/packages/tools/api-diff/src/Error.ts +++ b/packages/tools/api-diff/src/Error.ts @@ -1,6 +1,9 @@ +import * as Predicate from "effect/Predicate" import * as Schema from "effect/Schema" export class ApiDiffError extends Schema.TaggedError()("ApiDiffError", { message: Schema.String, cause: Schema.optional(Schema.Defect()) }) {} + +export const isApiDiffError = (u: unknown): u is ApiDiffError => Predicate.isTagged(u, "ApiDiffError") diff --git a/packages/tools/api-diff/src/Snapshot.ts b/packages/tools/api-diff/src/Snapshot.ts index 9770d45ef47..445d5809703 100644 --- a/packages/tools/api-diff/src/Snapshot.ts +++ b/packages/tools/api-diff/src/Snapshot.ts @@ -2,6 +2,7 @@ import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import * as Path from "effect/Path" +import * as Predicate from "effect/Predicate" import * as Schema from "effect/Schema" import ts from "typescript-compiler" import { Discovery, type DiscoveryResult } from "./Discovery.ts" @@ -709,6 +710,9 @@ export class SnapshotExtractionError extends Schema.TaggedError + Predicate.isTagged(u, "SnapshotExtractionError") + const snapshotExtractionError = (diagnostics: ReadonlyArray): SnapshotExtractionError => new SnapshotExtractionError({ message: diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n"), @@ -861,7 +865,7 @@ export class Snapshotter extends Context.Service extractSnapshot(options, discovered, path), catch: (cause) => - cause instanceof SnapshotExtractionError + isSnapshotExtractionError(cause) ? cause : new ApiDiffError({ message: `Could not extract the API snapshot for ${options.ref}`, diff --git a/packages/tools/api-diff/src/Worktrees.ts b/packages/tools/api-diff/src/Worktrees.ts index 149a9046972..837cc985391 100644 --- a/packages/tools/api-diff/src/Worktrees.ts +++ b/packages/tools/api-diff/src/Worktrees.ts @@ -7,7 +7,7 @@ import * as Path from "effect/Path" import * as Stream from "effect/Stream" import * as ChildProcess from "effect/unstable/process/ChildProcess" import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner" -import { ApiDiffError } from "./Error.ts" +import { ApiDiffError, isApiDiffError } from "./Error.ts" import { decodeJson } from "./Json.ts" import type { ApiSnapshot } from "./Model.ts" import { snapshotCacheKey, Snapshotter } from "./Snapshot.ts" @@ -186,7 +186,7 @@ export class Worktrees extends Context.Service - cause instanceof ApiDiffError + isApiDiffError(cause) ? cause : new ApiDiffError({ message: `Could not extract the ${options.name} API snapshot`, @@ -221,7 +221,7 @@ export class Worktrees extends Context.Service => prepareSnapshotInternal(options).pipe( Effect.mapError((cause) => - cause instanceof ApiDiffError + isApiDiffError(cause) ? cause : new ApiDiffError({ message: `Could not prepare the ${options.name} API snapshot`, diff --git a/packages/tools/api-diff/test/Error.test.ts b/packages/tools/api-diff/test/Error.test.ts new file mode 100644 index 00000000000..d9b5e6433a2 --- /dev/null +++ b/packages/tools/api-diff/test/Error.test.ts @@ -0,0 +1,25 @@ +import { ApiDiffError, isApiDiffError } from "@effect/api-diff/Error" +import { isSnapshotExtractionError, SnapshotExtractionError } from "@effect/api-diff/Snapshot" +import { assert, describe, it } from "@effect/vitest" +import { vi } from "vitest" +import type * as ApiDiffErrorModule from "../src/Error.ts" +import type * as SnapshotModule from "../src/Snapshot.ts" + +describe("tagged errors", () => { + it("recognizes errors from a reloaded module copy", async () => { + vi.resetModules() + const ForeignApiDiffError = await vi.importActual("../src/Error.ts") + const ForeignSnapshot = await vi.importActual("../src/Snapshot.ts") + + const apiDiffError = new ForeignApiDiffError.ApiDiffError({ message: "boom" }) + assert.isFalse(apiDiffError instanceof ApiDiffError) + assert.isTrue(isApiDiffError(apiDiffError)) + + const snapshotError = new ForeignSnapshot.SnapshotExtractionError({ + message: "boom", + diagnostics: [] + }) + assert.isFalse(snapshotError instanceof SnapshotExtractionError) + assert.isTrue(isSnapshotExtractionError(snapshotError)) + }) +})