effect-orpc 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ import { oc, type InferSchemaOutput } from "@orpc/contract";
2
+ import { call, ORPCError, type Router } from "@orpc/server";
3
+ import { Effect, FiberRef, Layer, ManagedRuntime } from "effect";
4
+ import { describe, expect, expectTypeOf, it } from "vitest";
5
+ import z from "zod";
6
+
7
+ import { eoc, implementEffect, ORPCTaggedError } from "../index";
8
+ import { withFiberContext } from "../node";
9
+
10
+ class Counter extends Effect.Tag("Counter")<
11
+ Counter,
12
+ {
13
+ readonly increment: (n: number) => Effect.Effect<number>;
14
+ }
15
+ >() {}
16
+
17
+ const requestIdRef = FiberRef.unsafeMake("missing");
18
+
19
+ const runtime = ManagedRuntime.make(
20
+ Layer.succeed(Counter, {
21
+ increment: (n: number) => Effect.succeed(n + 1),
22
+ }),
23
+ );
24
+
25
+ const contract = {
26
+ users: {
27
+ list: oc
28
+ .input(z.object({ amount: z.number() }))
29
+ .output(z.object({ next: z.number(), requestId: z.string() })),
30
+ },
31
+ };
32
+
33
+ describe("implementEffect", () => {
34
+ it("mirrors the contract tree and adds effect support on leaves", async () => {
35
+ const oe = implementEffect(contract, runtime);
36
+
37
+ expect(oe.users).toBeDefined();
38
+ expect(oe.users.list).toBeDefined();
39
+ expect(oe.users.list.handler).toBeTypeOf("function");
40
+ expect(oe.users.list.effect).toBeTypeOf("function");
41
+
42
+ const procedure = oe.users.list.effect(function* ({ input }) {
43
+ const counter = yield* Counter;
44
+ const requestId = yield* FiberRef.get(requestIdRef);
45
+
46
+ return {
47
+ next: yield* counter.increment(input.amount),
48
+ requestId,
49
+ };
50
+ });
51
+
52
+ const result = await Effect.runPromise(
53
+ Effect.gen(function* () {
54
+ yield* FiberRef.set(requestIdRef, "req-123");
55
+ return yield* withFiberContext(() => call(procedure, { amount: 2 }));
56
+ }),
57
+ );
58
+
59
+ expect(result).toEqual({
60
+ next: 3,
61
+ requestId: "req-123",
62
+ });
63
+ });
64
+
65
+ it("preserves contract enforcement at the root router", async () => {
66
+ const oe = implementEffect(contract, runtime);
67
+
68
+ const router = oe.router({
69
+ users: {
70
+ list: oe.users.list.effect(function* ({ input }) {
71
+ const counter = yield* Counter;
72
+
73
+ return {
74
+ next: yield* counter.increment(input.amount),
75
+ requestId: yield* FiberRef.get(requestIdRef),
76
+ };
77
+ }),
78
+ },
79
+ });
80
+
81
+ const result = await Effect.runPromise(
82
+ Effect.gen(function* () {
83
+ yield* FiberRef.set(requestIdRef, "req-456");
84
+ return yield* withFiberContext(() =>
85
+ call(router.users.list, { amount: 4 }),
86
+ );
87
+ }),
88
+ );
89
+
90
+ expect(result).toEqual({
91
+ next: 5,
92
+ requestId: "req-456",
93
+ });
94
+
95
+ expectTypeOf(router.users.list["~effect"]).toBeObject();
96
+ expectTypeOf(router).toExtend<
97
+ Router<typeof contract, Record<string, never>>
98
+ >();
99
+ });
100
+
101
+ it("maps tagged errors and raw ORPCError values the same way as EffectBuilder", async () => {
102
+ class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
103
+ code: "NOT_FOUND",
104
+ schema: z.object({ userId: z.string() }),
105
+ }) {}
106
+
107
+ const taggedContract = {
108
+ users: {
109
+ find: eoc
110
+ .errors({
111
+ NOT_FOUND: UserNotFoundError,
112
+ })
113
+ .input(z.object({ userId: z.string() }))
114
+ .output(z.object({ userId: z.string() })),
115
+ },
116
+ };
117
+
118
+ const oe = implementEffect(taggedContract, runtime);
119
+
120
+ const taggedProcedure = oe.users.find.effect(function* ({ input }) {
121
+ return yield* Effect.fail(
122
+ new UserNotFoundError({ data: { userId: input.userId } }),
123
+ );
124
+ });
125
+
126
+ await expect(
127
+ call(taggedProcedure, { userId: "u-1" }),
128
+ ).rejects.toMatchObject({
129
+ code: "NOT_FOUND",
130
+ data: { userId: "u-1" },
131
+ });
132
+
133
+ const rawProcedure = oe.users.find.effect(function* () {
134
+ return yield* Effect.fail(
135
+ new ORPCError("FORBIDDEN", {
136
+ data: { userId: "nope" },
137
+ }),
138
+ );
139
+ });
140
+
141
+ await expect(call(rawProcedure, { userId: "u-2" })).rejects.toMatchObject({
142
+ code: "FORBIDDEN",
143
+ data: { userId: "nope" },
144
+ });
145
+ });
146
+
147
+ it("preserves tagged error constructors from eoc inside effect handlers", async () => {
148
+ class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
149
+ schema: z.object({ userId: z.string() }),
150
+ }) {}
151
+
152
+ const taggedContract = {
153
+ users: {
154
+ find: eoc
155
+ .errors({
156
+ NOT_FOUND: UserNotFoundError,
157
+ FORBIDDEN: {},
158
+ })
159
+ .input(z.object({ userId: z.string() }))
160
+ .output(z.object({ userId: z.string() })),
161
+ },
162
+ };
163
+
164
+ const oe = implementEffect(taggedContract, runtime);
165
+ let taggedError:
166
+ | ORPCError<"USER_NOT_FOUND_ERROR", { userId: string }>
167
+ | undefined;
168
+ let forbiddenError: ORPCError<"FORBIDDEN", unknown> | undefined;
169
+
170
+ const procedure = oe.users.find.effect(function* ({ input, errors }) {
171
+ taggedError = errors.NOT_FOUND({
172
+ data: { userId: input.userId },
173
+ });
174
+ forbiddenError = errors.FORBIDDEN();
175
+
176
+ return yield* Effect.fail(taggedError);
177
+ });
178
+
179
+ const result = call(procedure, { userId: "u-3" });
180
+ await expect(result).rejects.toMatchObject({
181
+ code: "USER_NOT_FOUND_ERROR",
182
+ data: { userId: "u-3" },
183
+ });
184
+ await expect(result).rejects.toBeInstanceOf(ORPCError);
185
+
186
+ expect(taggedError).toBeInstanceOf(UserNotFoundError);
187
+ expect(forbiddenError).toBeInstanceOf(ORPCError);
188
+ });
189
+
190
+ it("preserves tagged error constructors with custom code from eoc inside effect handlers", async () => {
191
+ class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
192
+ schema: z.object({ userId: z.string() }),
193
+ code: "NOT_FOUND",
194
+ }) {}
195
+
196
+ const taggedContract = {
197
+ users: {
198
+ find: eoc
199
+ .errors({
200
+ UserNotFoundError,
201
+ FORBIDDEN: {},
202
+ })
203
+ .input(z.object({ userId: z.string() }))
204
+ .output(z.object({ userId: z.string() })),
205
+ },
206
+ };
207
+
208
+ const oe = implementEffect(taggedContract, runtime);
209
+ let taggedError: ORPCError<"NOT_FOUND", { userId: string }> | undefined;
210
+ let forbiddenError: ORPCError<"FORBIDDEN", unknown> | undefined;
211
+
212
+ const procedure = oe.users.find.effect(function* ({ input, errors }) {
213
+ taggedError = errors.UserNotFoundError({
214
+ data: { userId: input.userId },
215
+ });
216
+ forbiddenError = errors.FORBIDDEN();
217
+
218
+ return yield* Effect.fail(taggedError);
219
+ });
220
+
221
+ const result = call(procedure, { userId: "u-3" });
222
+ await expect(result).rejects.toMatchObject({
223
+ code: "NOT_FOUND",
224
+ data: { userId: "u-3" },
225
+ });
226
+ await expect(result).rejects.toBeInstanceOf(ORPCError);
227
+
228
+ expect(taggedError).toBeInstanceOf(UserNotFoundError);
229
+ expect(forbiddenError).toBeInstanceOf(ORPCError);
230
+ });
231
+
232
+ it("preserves tagged error constructors through eoc.router contracts", async () => {
233
+ class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
234
+ schema: z.object({ userId: z.string() }),
235
+ }) {}
236
+
237
+ const routedContract = eoc
238
+ .errors({
239
+ NOT_FOUND: UserNotFoundError,
240
+ })
241
+ .router({
242
+ users: {
243
+ find: oc
244
+ .input(z.object({ userId: z.string() }))
245
+ .output(z.object({ userId: z.string() })),
246
+ },
247
+ });
248
+
249
+ const oe = implementEffect(routedContract, runtime);
250
+ let taggedError:
251
+ | ORPCError<"USER_NOT_FOUND_ERROR", { userId: string }>
252
+ | undefined;
253
+
254
+ const procedure = oe.users.find.effect(function* ({ input, errors }) {
255
+ taggedError = errors.NOT_FOUND({ data: { userId: input.userId } });
256
+ return yield* Effect.fail(taggedError);
257
+ });
258
+
259
+ const result = call(procedure, { userId: "u-4" });
260
+ await expect(result).rejects.toMatchObject({
261
+ code: "USER_NOT_FOUND_ERROR",
262
+ data: { userId: "u-4" },
263
+ });
264
+ await expect(result).rejects.toBeInstanceOf(ORPCError);
265
+
266
+ expect(taggedError).toBeInstanceOf(UserNotFoundError);
267
+ });
268
+
269
+ it("preserves tagged error constructors with custom code through eoc.router contracts", async () => {
270
+ class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
271
+ schema: z.object({ userId: z.string() }),
272
+ code: "NOT_FOUND",
273
+ }) {}
274
+
275
+ const routedContract = eoc
276
+ .errors({
277
+ UserNotFoundError,
278
+ })
279
+ .router({
280
+ users: {
281
+ find: oc
282
+ .input(z.object({ userId: z.string() }))
283
+ .output(z.object({ userId: z.string() })),
284
+ },
285
+ });
286
+
287
+ const oe = implementEffect(routedContract, runtime);
288
+ let taggedError: ORPCError<"NOT_FOUND", { userId: string }> | undefined;
289
+
290
+ const procedure = oe.users.find.effect(function* ({ input, errors }) {
291
+ taggedError = errors.UserNotFoundError({
292
+ data: { userId: input.userId },
293
+ });
294
+ return yield* Effect.fail(taggedError);
295
+ });
296
+
297
+ const result = call(procedure, { userId: "u-4" });
298
+ await expect(result).rejects.toMatchObject({
299
+ code: "NOT_FOUND",
300
+ data: { userId: "u-4" },
301
+ });
302
+ await expect(result).rejects.toBeInstanceOf(ORPCError);
303
+
304
+ expect(taggedError).toBeInstanceOf(UserNotFoundError);
305
+ });
306
+
307
+ it("retains contract-first restrictions at the type level", () => {
308
+ const oe = implementEffect(contract, runtime);
309
+
310
+ expectTypeOf(oe.users.list.handler).toBeFunction();
311
+ expectTypeOf(oe.users.list.effect).toBeFunction();
312
+ // @ts-expect-error input is not a property of the implementer
313
+ expect(oe.users.list.input).toBeUndefined();
314
+ });
315
+
316
+ it("enforces contract output typing for handlers", () => {
317
+ const oe = implementEffect(contract, runtime);
318
+
319
+ oe.users.list.effect(
320
+ // @ts-expect-error effect() must return the contract output shape
321
+ function* () {
322
+ return { next: "wrong", requestId: 123 };
323
+ },
324
+ );
325
+
326
+ oe.users.list.handler(
327
+ // @ts-expect-error handler() must return the contract output shape
328
+ () => ({ next: "wrong", requestId: 123 }),
329
+ );
330
+
331
+ const procedure = oe.users.list.effect(function* ({ input }) {
332
+ return {
333
+ next: input.amount + 1,
334
+ requestId: "req-ok",
335
+ };
336
+ });
337
+
338
+ type ProcedureOutput = InferSchemaOutput<
339
+ NonNullable<(typeof procedure)["~orpc"]["outputSchema"]>
340
+ >;
341
+ expectTypeOf<ProcedureOutput>().toEqualTypeOf<{
342
+ next: number;
343
+ requestId: string;
344
+ }>();
345
+ });
346
+ });
@@ -50,8 +50,12 @@ describe("effectErrorMap types", () => {
50
50
  });
51
51
 
52
52
  it("should infer correct union type from EffectErrorMap", () => {
53
+ const zBadRequestData = z.object({
54
+ status: z.number(),
55
+ message: z.string(),
56
+ });
53
57
  type TestErrorMap = {
54
- BAD_REQUEST: { status?: number; message?: string };
58
+ BAD_REQUEST: { data: typeof zBadRequestData };
55
59
  USER_NOT_FOUND_ERROR: typeof UserNotFoundError;
56
60
  FORBIDDEN: typeof PermissionDenied;
57
61
  };
@@ -59,8 +63,8 @@ describe("effectErrorMap types", () => {
59
63
  type ErrorUnion = EffectErrorMapToUnion<TestErrorMap>;
60
64
 
61
65
  // The union should include ORPCError for traditional and tagged error instances for classes
62
- expectTypeOf<ErrorUnion>().toMatchTypeOf<
63
- | ORPCError<"BAD_REQUEST", unknown>
66
+ expectTypeOf<ErrorUnion>().toExtend<
67
+ | ORPCError<"BAD_REQUEST", typeof zBadRequestData>
64
68
  | ORPCTaggedErrorInstance<"UserNotFoundError", "USER_NOT_FOUND_ERROR">
65
69
  | ORPCTaggedErrorInstance<"PermissionDenied", "FORBIDDEN">
66
70
  >();
@@ -84,6 +88,21 @@ describe("isORPCTaggedErrorClass", () => {
84
88
  });
85
89
 
86
90
  describe("createEffectErrorConstructorMap", () => {
91
+ it("preserves declaration keys even when a tagged error class uses a different code", () => {
92
+ const errorMap = {
93
+ NOT_FOUND: UserNotFoundError,
94
+ } satisfies EffectErrorMap;
95
+
96
+ const constructorMap = createEffectErrorConstructorMap(errorMap);
97
+ const userNotFoundError = constructorMap.NOT_FOUND({
98
+ data: { userId: "123" },
99
+ });
100
+
101
+ expect(constructorMap).not.toHaveProperty("USER_NOT_FOUND_ERROR");
102
+ expect(userNotFoundError).toBeInstanceOf(UserNotFoundError);
103
+ expect(userNotFoundError.code).toBe("USER_NOT_FOUND_ERROR");
104
+ });
105
+
87
106
  it("should pass through tagged error classes", () => {
88
107
  const errorMap = {
89
108
  USER_NOT_FOUND_ERROR: UserNotFoundError,
@@ -0,0 +1,32 @@
1
+ import { Layer, ManagedRuntime } from "effect";
2
+
3
+ import { eoc } from "../index";
4
+ import {
5
+ baseErrorMap,
6
+ baseMeta,
7
+ inputSchema,
8
+ outputSchema,
9
+ pong,
10
+ } from "./shared";
11
+
12
+ export type InitialContext = { db: string };
13
+ export type CurrentContext = InitialContext & { auth: boolean };
14
+
15
+ export const runtime = ManagedRuntime.make(Layer.empty);
16
+
17
+ export const typedContract = {
18
+ ping: eoc
19
+ .errors(baseErrorMap)
20
+ .meta(baseMeta)
21
+ .input(inputSchema)
22
+ .output(outputSchema),
23
+ pong,
24
+ nested: {
25
+ ping: eoc
26
+ .errors(baseErrorMap)
27
+ .meta(baseMeta)
28
+ .input(inputSchema)
29
+ .output(outputSchema),
30
+ pong,
31
+ },
32
+ };
@@ -0,0 +1,192 @@
1
+ import type { OmitChainMethodDeep } from "@orpc/shared";
2
+ import { describe, expectTypeOf, it } from "vitest";
3
+
4
+ import type {
5
+ EffectContractProcedureBuilderWithInputOutput,
6
+ EffectContractRouterBuilder,
7
+ } from "../index";
8
+ import { eoc } from "../index";
9
+ import {
10
+ type BaseMeta,
11
+ baseErrorMap,
12
+ generalSchema,
13
+ inputSchema,
14
+ outputSchema,
15
+ ping,
16
+ pong,
17
+ } from "./shared";
18
+
19
+ const generalBuilder = eoc
20
+ .$meta({ mode: "dev" } as BaseMeta)
21
+ .$input(inputSchema)
22
+ .errors(baseErrorMap);
23
+
24
+ describe("parity: @orpc/contract builder-variants.test-d.ts", () => {
25
+ describe("EffectContractProcedureBuilder", () => {
26
+ const builder = eoc.errors(baseErrorMap).meta({ mode: "dev" } as BaseMeta);
27
+
28
+ it("backward compatibility", () => {
29
+ const expected = {} as OmitChainMethodDeep<
30
+ typeof generalBuilder,
31
+ "$meta" | "$route" | "$input" | "prefix" | "tag" | "router"
32
+ >;
33
+
34
+ expectTypeOf<keyof typeof builder>().toEqualTypeOf<
35
+ keyof typeof expected
36
+ >();
37
+ });
38
+
39
+ it(".errors / .meta / .route / .input / .output", () => {
40
+ expectTypeOf(
41
+ builder.errors({ INVALID: { message: "invalid" } }),
42
+ ).toBeObject();
43
+ expectTypeOf(builder.meta({ log: true })).toBeObject();
44
+ expectTypeOf(builder.route({ method: "GET" })).toBeObject();
45
+ expectTypeOf(builder.input(generalSchema)).toBeObject();
46
+ expectTypeOf(builder.output(generalSchema)).toBeObject();
47
+
48
+ // @ts-expect-error - schema is invalid
49
+ builder.errors({ TOO_MANY_REQUESTS: { data: {} } });
50
+ // @ts-expect-error - invalid method
51
+ builder.route({ method: "INVALID" });
52
+ // @ts-expect-error - schema is invalid
53
+ builder.input({});
54
+ // @ts-expect-error - schema is invalid
55
+ builder.output({});
56
+ });
57
+ });
58
+
59
+ describe("EffectContractProcedureBuilderWithInput", () => {
60
+ const builder = eoc
61
+ .errors(baseErrorMap)
62
+ .meta({ mode: "dev" })
63
+ .input(inputSchema);
64
+
65
+ it("backward compatibility", () => {
66
+ const expected = {} as OmitChainMethodDeep<
67
+ typeof generalBuilder,
68
+ "$meta" | "$route" | "$input" | "prefix" | "tag" | "router" | "input"
69
+ >;
70
+
71
+ expectTypeOf<keyof typeof builder>().toEqualTypeOf<
72
+ keyof typeof expected
73
+ >();
74
+ });
75
+
76
+ it(".errors / .meta / .route / .output", () => {
77
+ expectTypeOf(
78
+ builder.errors({ INVALID: { message: "invalid" } }),
79
+ ).toBeObject();
80
+ expectTypeOf(builder.meta({ log: true })).toBeObject();
81
+ expectTypeOf(builder.route({ method: "GET" })).toBeObject();
82
+ expectTypeOf(builder.output(generalSchema)).toExtend<
83
+ EffectContractProcedureBuilderWithInputOutput<
84
+ typeof inputSchema,
85
+ typeof generalSchema,
86
+ typeof baseErrorMap,
87
+ BaseMeta
88
+ >
89
+ >();
90
+ });
91
+ });
92
+
93
+ describe("EffectContractProcedureBuilderWithOutput", () => {
94
+ const builder = eoc
95
+ .errors(baseErrorMap)
96
+ .meta({ mode: "dev" })
97
+ .output(outputSchema);
98
+
99
+ it("backward compatibility", () => {
100
+ const expected = {} as OmitChainMethodDeep<
101
+ typeof generalBuilder,
102
+ "$meta" | "$route" | "$input" | "prefix" | "tag" | "router" | "output"
103
+ >;
104
+
105
+ expectTypeOf<keyof typeof builder>().toEqualTypeOf<
106
+ keyof typeof expected
107
+ >();
108
+ });
109
+
110
+ it(".errors / .meta / .route / .input", () => {
111
+ expectTypeOf(
112
+ builder.errors({ INVALID: { message: "invalid" } }),
113
+ ).toBeObject();
114
+ expectTypeOf(builder.meta({ log: true })).toBeObject();
115
+ expectTypeOf(builder.route({ method: "GET" })).toBeObject();
116
+ expectTypeOf(builder.input(generalSchema)).toExtend<
117
+ EffectContractProcedureBuilderWithInputOutput<
118
+ typeof generalSchema,
119
+ typeof outputSchema,
120
+ typeof baseErrorMap,
121
+ BaseMeta
122
+ >
123
+ >();
124
+ });
125
+ });
126
+
127
+ describe("EffectContractProcedureBuilderWithInputOutput", () => {
128
+ const builder = eoc
129
+ .errors(baseErrorMap)
130
+ .meta({ mode: "dev" })
131
+ .input(inputSchema)
132
+ .output(outputSchema);
133
+
134
+ it("backward compatibility", () => {
135
+ const expected = {} as OmitChainMethodDeep<
136
+ typeof generalBuilder,
137
+ | "$meta"
138
+ | "$route"
139
+ | "$input"
140
+ | "prefix"
141
+ | "tag"
142
+ | "router"
143
+ | "input"
144
+ | "output"
145
+ >;
146
+
147
+ expectTypeOf<keyof typeof builder>().toEqualTypeOf<
148
+ keyof typeof expected
149
+ >();
150
+ });
151
+
152
+ it(".errors / .meta / .route", () => {
153
+ expectTypeOf(
154
+ builder.errors({ INVALID: { message: "invalid" } }),
155
+ ).toBeObject();
156
+ expectTypeOf(builder.meta({ log: true })).toBeObject();
157
+ expectTypeOf(builder.route({ method: "GET" })).toBeObject();
158
+ });
159
+ });
160
+
161
+ describe("EffectContractRouterBuilder", () => {
162
+ const builder = eoc.errors(baseErrorMap).prefix("/api");
163
+
164
+ it("backward compatibility", () => {
165
+ expectTypeOf(builder.errors).toBeFunction();
166
+ expectTypeOf(builder.prefix).toBeFunction();
167
+ expectTypeOf(builder.tag).toBeFunction();
168
+ expectTypeOf(builder.router).toBeFunction();
169
+ });
170
+
171
+ it(".errors / .prefix / .tag / .router", () => {
172
+ expectTypeOf(
173
+ builder.errors({ INVALID: { message: "invalid" } }),
174
+ ).toBeObject();
175
+ expectTypeOf(builder.prefix("/api")).toExtend<
176
+ EffectContractRouterBuilder<typeof baseErrorMap, BaseMeta>
177
+ >();
178
+ expectTypeOf(builder.tag("tag1", "tag2")).toExtend<
179
+ EffectContractRouterBuilder<typeof baseErrorMap, BaseMeta>
180
+ >();
181
+ expectTypeOf(builder.router({ ping, pong })).toExtend<{
182
+ ping: typeof ping;
183
+ pong: typeof pong;
184
+ }>();
185
+
186
+ // @ts-expect-error - invalid prefix
187
+ builder.prefix(1);
188
+ // @ts-expect-error - invalid tag
189
+ builder.tag(1);
190
+ });
191
+ });
192
+ });