diff --git a/.changeset/schema-error-rpc.md b/.changeset/schema-error-rpc.md new file mode 100644 index 000000000..1dcad1e45 --- /dev/null +++ b/.changeset/schema-error-rpc.md @@ -0,0 +1,9 @@ +--- +"effect-app": patch +"@effect-app/infra": patch +--- + +Pass typed SchemaError failures through query, command, and streaming RPCs. +Every request's error contract includes a JSON codec that preserves native +SchemaError identity and its formatted message without serializing schema +ASTs or input. Schema errors raised as defects remain defects. diff --git a/packages/effect-app/src/Schema.ts b/packages/effect-app/src/Schema.ts index 71afd0056..e071a3911 100644 --- a/packages/effect-app/src/Schema.ts +++ b/packages/effect-app/src/Schema.ts @@ -124,6 +124,7 @@ export * from "./Schema/moreStrings.ts" export * from "./Schema/numbers.ts" export * from "./Schema/phoneNumber.ts" export * from "./Schema/schema.ts" +export { SchemaErrorCodec } from "./Schema/SchemaErrorCodec.ts" export * from "./Schema/SpecialJsonSchema.ts" export * from "./Schema/SpecialOpenApi.ts" export * from "./Schema/strings.ts" diff --git a/packages/effect-app/src/Schema/SchemaErrorCodec.ts b/packages/effect-app/src/Schema/SchemaErrorCodec.ts new file mode 100644 index 000000000..acb2faf27 --- /dev/null +++ b/packages/effect-app/src/Schema/SchemaErrorCodec.ts @@ -0,0 +1,21 @@ +import * as Schema from "effect/Schema" +import * as SchemaGetter from "effect/SchemaGetter" +import * as SchemaIssue from "effect/SchemaIssue" + +/** + * Transport native SchemaError identity and its formatted message. The decoded + * issue is an InvalidValue carrying that message, not the original AST/input. + */ +export const SchemaErrorCodec = Schema + .Struct({ + _tag: Schema.Literal("SchemaError"), + message: Schema.String + }) + .pipe( + Schema.decodeTo(Schema.declare(Schema.isSchemaError), { + decode: SchemaGetter.transform(({ message }) => + new Schema.SchemaError(new SchemaIssue.InvalidValue({ message })) + ), + encode: SchemaGetter.transform((error) => ({ _tag: "SchemaError" as const, message: error.message })) + }) + ) diff --git a/packages/effect-app/src/client/makeClient.ts b/packages/effect-app/src/client/makeClient.ts index c1785f084..2c0f18a17 100644 --- a/packages/effect-app/src/client/makeClient.ts +++ b/packages/effect-app/src/client/makeClient.ts @@ -151,10 +151,11 @@ export const makeRpcClient = < // errors is now the middleware tag attached to the rpc on both server and // client (see `ApiClientFactory.makeFor({ middleware })`). `Rpc.exitSchema` // unions in `rpc.middlewares[*].error` automatically. - type MergeError = [GeneralErrors] extends [never] ? SchemaOrFields : S.Union<[SchemaOrFields, GeneralErrors]> + type MergeError = [GeneralErrors] extends [never] ? S.Union<[SchemaOrFields, typeof S.SchemaErrorCodec]> + : S.Union<[SchemaOrFields, typeof S.SchemaErrorCodec, GeneralErrors]> type ErrorResult = C extends { error: infer E } ? MergeError - : [GeneralErrors] extends [never] ? typeof S.Never - : GeneralErrors + : [GeneralErrors] extends [never] ? typeof S.SchemaErrorCodec + : S.Union<[typeof S.SchemaErrorCodec, GeneralErrors]> function makeRequestClass>( tag: Tag, @@ -163,7 +164,7 @@ export const makeRpcClient = < ) { const failureSchema = merge( config?.error ? S.isSchema(config.error) ? config.error : S.Struct(config.error) : undefined, - [generalErrors].filter(Boolean) + [S.SchemaErrorCodec, generalErrors].filter(Boolean) ) const successSchema = config?.success ? S.isSchema(config.success) diff --git a/packages/effect-app/test/rpc.test.ts b/packages/effect-app/test/rpc.test.ts index d76ed2e00..2df4d6ad2 100644 --- a/packages/effect-app/test/rpc.test.ts +++ b/packages/effect-app/test/rpc.test.ts @@ -51,10 +51,10 @@ test("Void decodes and encodes as void", () => { readonly newUsersLast24Hours: number readonly newUsersLastWeek: number }>() - // Resource error carries only `config.error` (and optional `generalErrors`); rcm-derived + // Resource error carries SchemaError, `config.error` and optional `generalErrors`; rcm-derived // middleware errors no longer leak into `resource.error` — they reach the wire via the // middleware tag attached to the rpc group (`rpc.middlewares[*].error` failure-union). - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf<"query">() expectTypeOf(statsFromMake).toEqualTypeOf() expectTypeOf(statsFromMakeOption).toEqualTypeOf>() diff --git a/packages/effect-app/test/schema-error-codec.test.ts b/packages/effect-app/test/schema-error-codec.test.ts new file mode 100644 index 000000000..edb73feb2 --- /dev/null +++ b/packages/effect-app/test/schema-error-codec.test.ts @@ -0,0 +1,29 @@ +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import * as SchemaIssue from "effect/SchemaIssue" +import { describe, expect, it } from "vitest" +import { SchemaErrorCodec } from "../src/Schema.js" + +describe("SchemaErrorCodec", () => { + it("round-trips a native schema failure through JSON with its message and identity", () => { + const error = new Schema.SchemaError( + new SchemaIssue.Pointer(["orders", 0, "quantity"], new SchemaIssue.InvalidType(Schema.Number.ast, "bad")) + ) + const codec = Schema.fromJsonString(SchemaErrorCodec) + const encoded = Schema.encodeSync(codec)(error) + const decoded = Schema.decodeUnknownSync(codec)(encoded) + expect(Schema.isSchemaError(decoded)).toBe(true) + expect(decoded).toBeInstanceOf(Schema.SchemaError) + expect(decoded.message).toBe(error.message) + expect(decoded.toString()).toBe(error.toString()) + expect(JSON.parse(encoded)).toEqual({ _tag: "SchemaError", message: error.message }) + }) + + it("rejects malformed wire errors and does not accept another error type", () => { + expect(Option.isNone(Schema.decodeUnknownOption(SchemaErrorCodec)({ _tag: "SchemaError" }))).toBe(true) + expect(Option.isNone(Schema.decodeUnknownOption(SchemaErrorCodec)({ _tag: "OtherError", message: "bad" }))).toBe( + true + ) + expect(() => Schema.encodeUnknownSync(SchemaErrorCodec)(new Error("bad"))).toThrow() + }) +}) diff --git a/packages/infra/src/routing/middleware/middleware.ts b/packages/infra/src/routing/middleware/middleware.ts index c61b6a584..363114f79 100644 --- a/packages/infra/src/routing/middleware/middleware.ts +++ b/packages/infra/src/routing/middleware/middleware.ts @@ -108,8 +108,6 @@ export const LoggerMiddlewareLive = Layer }) .pipe( Effect.andThen(effect), - // TODO: support SchemaError if the error channel of the request allows it.. but who would want that? - Effect.catch((_) => Schema.isSchemaError(_) ? Effect.die(_) : Effect.fail(_)), Effect.tapCause((cause) => Cause.hasFails(cause) ? logRequestError(cause) : Effect.void), Effect.tapCauseIf(Cause.hasDies, (cause) => Effect diff --git a/packages/infra/test/rpc-e2e-invalidation.test.ts b/packages/infra/test/rpc-e2e-invalidation.test.ts index f6ed3addd..61dd5a9dd 100644 --- a/packages/infra/test/rpc-e2e-invalidation.test.ts +++ b/packages/infra/test/rpc-e2e-invalidation.test.ts @@ -324,7 +324,7 @@ it.live( expect(Option.isSome(error)).toBe(true) if (Option.isSome(error)) { expect(error.value._tag).toBe("CmdBoom") - expect(error.value.reason).toBe("intentional failure") + if (error.value._tag === "CmdBoom") expect(error.value.reason).toBe("intentional failure") } } expect(keys).toStrictEqual([DynamicKey]) diff --git a/packages/infra/test/rpc-stream-fullstack.test.ts b/packages/infra/test/rpc-stream-fullstack.test.ts index 981dfa2da..533da84ac 100644 --- a/packages/infra/test/rpc-stream-fullstack.test.ts +++ b/packages/infra/test/rpc-stream-fullstack.test.ts @@ -17,6 +17,7 @@ import { DefaultGenericMiddlewares } from "effect-app/middleware" import { MiddlewareMaker } from "effect-app/rpc" import * as S from "effect-app/Schema" import { TaggedErrorClass } from "effect-app/Schema" +import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" import { FetchHttpClient } from "effect/http" @@ -97,6 +98,27 @@ class StreamNoSuccess extends Req.Command()("StreamNoSuccess", allowAnonymous: true }) {} +class QuerySchemaFailure extends Req.Query()("QuerySchemaFailure", {}, { + allowAnonymous: true, + success: S.Number +}) {} + +class CommandSchemaFailure extends Req.Command()("CommandSchemaFailure", {}, { + allowAnonymous: true, + success: S.Number +}) {} + +class StreamSchemaFailure extends Req.Command()("StreamSchemaFailure", {}, { + stream: true, + allowAnonymous: true, + success: S.Number +}) {} + +class QuerySchemaDefect extends Req.Query()("QuerySchemaDefect", {}, { + allowAnonymous: true, + success: S.Number +}) {} + // Defaults to allowAnonymous: false → AllowAnonymous middleware fails with NotLoggedInError // when the request lacks the `x-user` header. class StreamRequiresAuth extends Req.Command()("StreamRequiresAuth", {}, { @@ -113,6 +135,10 @@ class QueryRequiresAuth extends Req.Query()("QueryRequiresAut }) {} const StreamyRsc = { + QuerySchemaFailure, + CommandSchemaFailure, + StreamSchemaFailure, + QuerySchemaDefect, StreamTicks, StreamCountTo, StreamRealtime, @@ -130,6 +156,11 @@ const StreamyRsc = { const router = Router(StreamyRsc)({ *effect(match) { return match({ + QuerySchemaFailure: () => S.decodeUnknownEffect(S.Number)("bad"), + QuerySchemaDefect: () => S.decodeUnknownEffect(S.Number)("bad").pipe(Effect.orDie), + CommandSchemaFailure: () => S.decodeUnknownEffect(S.Number)("bad"), + StreamSchemaFailure: () => + Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(S.decodeUnknownEffect(S.Number)("bad")))), StreamTicks: () => Stream.fromIterable([10, 20, 30]), StreamCountTo: ({ to }: { readonly to: number }) => Effect @@ -191,6 +222,46 @@ const ClientLayer = Layer const TestLayer = Layer.mergeAll(ServerLayer, ClientLayer) +for (const kind of ["query", "command", "stream"] as const) { + it.live( + `${kind} RPC preserves typed SchemaError failures through HTTP`, + Effect.fnUntraced(function*() { + const client = yield* ApiClientFactory.makeFor(Layer.empty)(StreamyRsc) + const call: Effect.Effect = kind === "stream" + ? Stream.runCollect(client.StreamSchemaFailure.handler()) + : kind === "command" + ? client.CommandSchemaFailure.handler() + : client.QuerySchemaFailure.handler() + const exit = yield* Effect.exit(call) + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) return + expect(Cause.hasDies(exit.cause)).toBe(false) + const failure = Cause.findErrorOption(exit.cause) + expect(Option.isSome(failure)).toBe(true) + if (!Option.isSome(failure)) return + expect(S.isSchemaError(failure.value)).toBe(true) + if (S.isSchemaError(failure.value)) { + expect(failure.value.message).toBe("Expected number") + expect(failure.value).toBeInstanceOf(S.SchemaError) + } + }, Effect.provide(TestLayer)), + { timeout: 10_000 } + ) +} + +it.live( + "an explicitly raised SchemaError defect remains a defect through HTTP", + Effect.fnUntraced(function*() { + const client = yield* ApiClientFactory.makeFor(Layer.empty)(StreamyRsc) + const exit = yield* Effect.exit(client.QuerySchemaDefect.handler()) + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) return + expect(Cause.hasDies(exit.cause)).toBe(true) + expect(Cause.hasFails(exit.cause)).toBe(false) + }, Effect.provide(TestLayer)), + { timeout: 10_000 } +) + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/packages/vue/test/makeClient.test.ts b/packages/vue/test/makeClient.test.ts index c909c1906..22d5a49ba 100644 --- a/packages/vue/test/makeClient.test.ts +++ b/packages/vue/test/makeClient.test.ts @@ -89,7 +89,7 @@ it("TaggedRequestFor .moduleName and request .id / .moduleName", () => { success: S.FiniteFromString }, (_queryKey, _resources, input, result) => { expectTypeOf(input).toEqualTypeOf<{ readonly id: string }>() - expectTypeOf(result).toEqualTypeOf>() + expectTypeOf(result).toEqualTypeOf>() return [] }) {} void TypeInferenceWithSuccess @@ -102,7 +102,7 @@ it("TaggedRequestFor .moduleName and request .id / .moduleName", () => { {}, (_queryKey, _resources, input, result) => { expectTypeOf(input).toEqualTypeOf<{ readonly id: string }>() - expectTypeOf(result).toEqualTypeOf>() + expectTypeOf(result).toEqualTypeOf>() return [] } ) {} @@ -244,7 +244,7 @@ it("client[Key].Input — extracted input type per props variant", () => { const assertStreamQueryTypes = () => { const streamAtom = client.StreamQuery.atom({ id: "abc" }) - expectTypeOf(streamAtom).toEqualTypeOf, void>>() + expectTypeOf(streamAtom).toEqualTypeOf, void>>() const streamView = client.StreamQuery.queryNew({ id: "abc" }) expectTypeOf(streamView.items.value).toEqualTypeOf>() expectTypeOf(streamView.latest.value).toEqualTypeOf()