Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .changeset/schema-error-rpc.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/effect-app/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions packages/effect-app/src/Schema/SchemaErrorCodec.ts
Original file line number Diff line number Diff line change
@@ -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 }))
})
)
9 changes: 5 additions & 4 deletions packages/effect-app/src/client/makeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<E> = [GeneralErrors] extends [never] ? SchemaOrFields<E> : S.Union<[SchemaOrFields<E>, GeneralErrors]>
type MergeError<E> = [GeneralErrors] extends [never] ? S.Union<[SchemaOrFields<E>, typeof S.SchemaErrorCodec]>
: S.Union<[SchemaOrFields<E>, typeof S.SchemaErrorCodec, GeneralErrors]>
type ErrorResult<C> = C extends { error: infer E } ? MergeError<E>
: [GeneralErrors] extends [never] ? typeof S.Never
: GeneralErrors
: [GeneralErrors] extends [never] ? typeof S.SchemaErrorCodec
: S.Union<[typeof S.SchemaErrorCodec, GeneralErrors]>

function makeRequestClass<Tag extends string, Fields extends S.Struct.Fields, C extends Partial<ServiceMap>>(
tag: Tag,
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions packages/effect-app/test/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof _statsError>().toEqualTypeOf<never>()
expectTypeOf<typeof _statsError>().toEqualTypeOf<S.SchemaError>()
expectTypeOf<typeof _statsRequestType>().toEqualTypeOf<"query">()
expectTypeOf(statsFromMake).toEqualTypeOf<Stats>()
expectTypeOf(statsFromMakeOption).toEqualTypeOf<Option.Option<Stats>>()
Expand Down
29 changes: 29 additions & 0 deletions packages/effect-app/test/schema-error-codec.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
2 changes: 0 additions & 2 deletions packages/infra/src/routing/middleware/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/infra/test/rpc-e2e-invalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
71 changes: 71 additions & 0 deletions packages/infra/test/rpc-stream-fullstack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -97,6 +98,27 @@ class StreamNoSuccess extends Req.Command<StreamNoSuccess>()("StreamNoSuccess",
allowAnonymous: true
}) {}

class QuerySchemaFailure extends Req.Query<QuerySchemaFailure>()("QuerySchemaFailure", {}, {
allowAnonymous: true,
success: S.Number
}) {}

class CommandSchemaFailure extends Req.Command<CommandSchemaFailure>()("CommandSchemaFailure", {}, {
allowAnonymous: true,
success: S.Number
}) {}

class StreamSchemaFailure extends Req.Command<StreamSchemaFailure>()("StreamSchemaFailure", {}, {
stream: true,
allowAnonymous: true,
success: S.Number
}) {}

class QuerySchemaDefect extends Req.Query<QuerySchemaDefect>()("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>()("StreamRequiresAuth", {}, {
Expand All @@ -113,6 +135,10 @@ class QueryRequiresAuth extends Req.Query<QueryRequiresAuth>()("QueryRequiresAut
}) {}

const StreamyRsc = {
QuerySchemaFailure,
CommandSchemaFailure,
StreamSchemaFailure,
QuerySchemaDefect,
StreamTicks,
StreamCountTo,
StreamRealtime,
Expand All @@ -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
Expand Down Expand Up @@ -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<unknown, S.SchemaError> = 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
// ---------------------------------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions packages/vue/test/makeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Exit.Exit<number, never>>()
expectTypeOf(result).toEqualTypeOf<Exit.Exit<number, S.SchemaError>>()
return []
}) {}
void TypeInferenceWithSuccess
Expand All @@ -102,7 +102,7 @@ it("TaggedRequestFor .moduleName and request .id / .moduleName", () => {
{},
(_queryKey, _resources, input, result) => {
expectTypeOf(input).toEqualTypeOf<{ readonly id: string }>()
expectTypeOf(result).toEqualTypeOf<Exit.Exit<void, never>>()
expectTypeOf(result).toEqualTypeOf<Exit.Exit<void, S.SchemaError>>()
return []
}
) {}
Expand Down Expand Up @@ -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<Atom.Writable<Atom.PullResult<number, never>, void>>()
expectTypeOf(streamAtom).toEqualTypeOf<Atom.Writable<Atom.PullResult<number, S.SchemaError>, void>>()
const streamView = client.StreamQuery.queryNew({ id: "abc" })
expectTypeOf(streamView.items.value).toEqualTypeOf<ReadonlyArray<number>>()
expectTypeOf(streamView.latest.value).toEqualTypeOf<number | undefined>()
Expand Down
Loading