effect-orpc 0.0.1

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,313 @@
1
+ import { fallbackORPCErrorMessage, ORPCError } from "@orpc/client";
2
+ import { Effect, Layer, ManagedRuntime } from "effect";
3
+ import { describe, expect, expectTypeOf, it } from "vitest";
4
+ import { z } from "zod";
5
+
6
+ import type {
7
+ EffectErrorMap,
8
+ EffectErrorMapToUnion,
9
+ ORPCTaggedErrorInstance,
10
+ } from "../tagged-error";
11
+
12
+ import { makeEffectORPC } from "../effect-builder";
13
+ import {
14
+ createEffectErrorConstructorMap,
15
+ effectErrorMapToErrorMap,
16
+ isORPCTaggedError,
17
+ isORPCTaggedErrorClass,
18
+ ORPCTaggedError,
19
+ } from "../tagged-error";
20
+
21
+ // Define test tagged errors
22
+ class UserNotFoundError extends ORPCTaggedError<UserNotFoundError>()(
23
+ "UserNotFoundError",
24
+ ) {}
25
+ class ValidationError extends ORPCTaggedError<
26
+ ValidationError,
27
+ { fields: string[] }
28
+ >()("ValidationError", "BAD_REQUEST", { message: "Validation failed" }) {}
29
+ class PermissionDenied extends ORPCTaggedError<PermissionDenied>()(
30
+ "PermissionDenied",
31
+ "FORBIDDEN",
32
+ ) {}
33
+
34
+ describe("effectErrorMap types", () => {
35
+ it("should accept both traditional and tagged error formats", () => {
36
+ const errorMap = {
37
+ // Traditional format
38
+ BAD_REQUEST: { status: 400, message: "Bad request" },
39
+ // Tagged error class references
40
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
41
+ FORBIDDEN: PermissionDenied,
42
+ } satisfies EffectErrorMap;
43
+
44
+ expect(errorMap.BAD_REQUEST).toEqual({
45
+ status: 400,
46
+ message: "Bad request",
47
+ });
48
+ expect(errorMap.USER_NOT_FOUND_ERROR).toBe(UserNotFoundError);
49
+ expect(errorMap.FORBIDDEN).toBe(PermissionDenied);
50
+ });
51
+
52
+ it("should infer correct union type from EffectErrorMap", () => {
53
+ type TestErrorMap = {
54
+ BAD_REQUEST: { status?: number; message?: string };
55
+ USER_NOT_FOUND_ERROR: typeof UserNotFoundError;
56
+ FORBIDDEN: typeof PermissionDenied;
57
+ };
58
+
59
+ type ErrorUnion = EffectErrorMapToUnion<TestErrorMap>;
60
+
61
+ // The union should include ORPCError for traditional and tagged error instances for classes
62
+ expectTypeOf<ErrorUnion>().toMatchTypeOf<
63
+ | ORPCError<"BAD_REQUEST", unknown>
64
+ | ORPCTaggedErrorInstance<
65
+ "UserNotFoundError",
66
+ "USER_NOT_FOUND_ERROR",
67
+ undefined
68
+ >
69
+ | ORPCTaggedErrorInstance<"PermissionDenied", "FORBIDDEN", undefined>
70
+ >();
71
+ });
72
+ });
73
+
74
+ describe("isORPCTaggedErrorClass", () => {
75
+ it("should return true for tagged error classes", () => {
76
+ expect(isORPCTaggedErrorClass(UserNotFoundError)).toBe(true);
77
+ expect(isORPCTaggedErrorClass(ValidationError)).toBe(true);
78
+ expect(isORPCTaggedErrorClass(PermissionDenied)).toBe(true);
79
+ });
80
+
81
+ it("should return false for non-tagged error classes", () => {
82
+ expect(isORPCTaggedErrorClass(Error)).toBe(false);
83
+ expect(isORPCTaggedErrorClass({})).toBe(false);
84
+ expect(isORPCTaggedErrorClass(null)).toBe(false);
85
+ expect(isORPCTaggedErrorClass(undefined)).toBe(false);
86
+ expect(isORPCTaggedErrorClass(() => {})).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe("createEffectErrorConstructorMap", () => {
91
+ it("should pass through tagged error classes", () => {
92
+ const errorMap = {
93
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
94
+ FORBIDDEN: PermissionDenied,
95
+ } satisfies EffectErrorMap;
96
+
97
+ const constructorMap = createEffectErrorConstructorMap(errorMap);
98
+
99
+ expect(constructorMap.USER_NOT_FOUND_ERROR).toBe(UserNotFoundError);
100
+ expect(constructorMap.FORBIDDEN).toBe(PermissionDenied);
101
+ });
102
+
103
+ it("should create ORPCError factory for traditional items", () => {
104
+ const errorMap = {
105
+ BAD_REQUEST: { status: 400, message: "Bad request" },
106
+ NOT_FOUND: { message: "Not found" },
107
+ } satisfies EffectErrorMap;
108
+
109
+ const constructorMap = createEffectErrorConstructorMap(errorMap);
110
+
111
+ const badRequestError = constructorMap.BAD_REQUEST();
112
+ expect(badRequestError).toBeInstanceOf(ORPCError);
113
+ expect(badRequestError.code).toBe("BAD_REQUEST");
114
+ expect(badRequestError.status).toBe(400);
115
+ expect(badRequestError.message).toBe("Bad request");
116
+
117
+ const notFoundError = constructorMap.NOT_FOUND();
118
+ expect(notFoundError).toBeInstanceOf(ORPCError);
119
+ expect(notFoundError.code).toBe("NOT_FOUND");
120
+ });
121
+
122
+ it("should work with mixed error map", () => {
123
+ const errorMap = {
124
+ BAD_REQUEST: { status: 400 },
125
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
126
+ } satisfies EffectErrorMap;
127
+
128
+ const constructorMap = createEffectErrorConstructorMap(errorMap);
129
+
130
+ // Traditional error returns ORPCError
131
+ const badRequestError = constructorMap.BAD_REQUEST({
132
+ message: "Invalid input",
133
+ });
134
+ expect(badRequestError).toBeInstanceOf(ORPCError);
135
+ expect(badRequestError.message).toBe("Invalid input");
136
+
137
+ // Tagged error class is passed through
138
+ const userNotFoundError = new constructorMap.USER_NOT_FOUND_ERROR();
139
+ expect(isORPCTaggedError(userNotFoundError)).toBe(true);
140
+ expect(userNotFoundError._tag).toBe("UserNotFoundError");
141
+ });
142
+ });
143
+
144
+ describe("effectErrorMapToErrorMap", () => {
145
+ it("should convert EffectErrorMap to standard ErrorMap", () => {
146
+ const effectErrorMap = {
147
+ BAD_REQUEST: { status: 400, message: "Bad request" },
148
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
149
+ FORBIDDEN: PermissionDenied,
150
+ } satisfies EffectErrorMap;
151
+
152
+ const errorMap = effectErrorMapToErrorMap(effectErrorMap);
153
+
154
+ expect(errorMap.BAD_REQUEST).toEqual({
155
+ status: 400,
156
+ message: "Bad request",
157
+ });
158
+ expect(errorMap.USER_NOT_FOUND_ERROR).toEqual({
159
+ data: undefined,
160
+ message: "USER_NOT_FOUND_ERROR",
161
+ status: 500,
162
+ });
163
+ expect(errorMap.FORBIDDEN).toEqual({
164
+ data: undefined,
165
+ message: fallbackORPCErrorMessage("FORBIDDEN", undefined),
166
+ status: 403,
167
+ });
168
+ });
169
+ });
170
+
171
+ describe("effectBuilder with EffectErrorMap", () => {
172
+ const runtime = ManagedRuntime.make(Layer.empty);
173
+ const effectOs = makeEffectORPC(runtime);
174
+
175
+ it("should support errors() with traditional format", () => {
176
+ const builder = effectOs.errors({
177
+ BAD_REQUEST: { status: 400, message: "Bad request" },
178
+ });
179
+
180
+ expect(builder["~orpc"].effectErrorMap).toEqual({
181
+ BAD_REQUEST: { status: 400, message: "Bad request" },
182
+ });
183
+ });
184
+
185
+ it("should support errors() with tagged error classes", () => {
186
+ const builder = effectOs.errors({
187
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
188
+ FORBIDDEN: PermissionDenied,
189
+ });
190
+
191
+ expect(builder["~orpc"].effectErrorMap.USER_NOT_FOUND_ERROR).toBe(
192
+ UserNotFoundError,
193
+ );
194
+ expect(builder["~orpc"].effectErrorMap.FORBIDDEN).toBe(PermissionDenied);
195
+ });
196
+
197
+ it("should support mixed error format", () => {
198
+ const builder = effectOs.errors({
199
+ BAD_REQUEST: { status: 400 },
200
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
201
+ });
202
+
203
+ expect(builder["~orpc"].effectErrorMap.BAD_REQUEST).toEqual({
204
+ status: 400,
205
+ });
206
+ expect(builder["~orpc"].effectErrorMap.USER_NOT_FOUND_ERROR).toBe(
207
+ UserNotFoundError,
208
+ );
209
+ });
210
+
211
+ it("should merge errors correctly", () => {
212
+ const builder = effectOs
213
+ .errors({ BAD_REQUEST: { status: 400 } })
214
+ .errors({ USER_NOT_FOUND_ERROR: UserNotFoundError })
215
+ .errors({ FORBIDDEN: PermissionDenied });
216
+
217
+ expect(builder["~orpc"].effectErrorMap).toEqual({
218
+ BAD_REQUEST: { status: 400 },
219
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
220
+ FORBIDDEN: PermissionDenied,
221
+ });
222
+ });
223
+
224
+ it("should create procedure with effect handler", async () => {
225
+ const procedure = effectOs
226
+ .errors({
227
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
228
+ BAD_REQUEST: { status: 400 },
229
+ })
230
+ .input(z.object({ id: z.string() }))
231
+ .effect(({ input, errors }) => {
232
+ // errors.USER_NOT_FOUND_ERROR is the class
233
+ expect(errors.USER_NOT_FOUND_ERROR).toBe(UserNotFoundError);
234
+
235
+ // errors.BAD_REQUEST is a factory function
236
+ expect(typeof errors.BAD_REQUEST).toBe("function");
237
+
238
+ return Effect.succeed({ id: input.id, name: "Test User" });
239
+ });
240
+
241
+ expect(procedure["~orpc"].effectErrorMap.USER_NOT_FOUND_ERROR).toBe(
242
+ UserNotFoundError,
243
+ );
244
+ });
245
+
246
+ it("should allow throwing tagged errors in effect handler", async () => {
247
+ const procedure = effectOs
248
+ .errors({
249
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
250
+ })
251
+ .input(z.object({ id: z.string() }))
252
+ .effect(({ input, errors }) => {
253
+ if (input.id === "not-found") {
254
+ return Effect.fail(new errors.USER_NOT_FOUND_ERROR());
255
+ }
256
+ return Effect.succeed({ id: input.id, name: "Test User" });
257
+ });
258
+
259
+ // Test successful case
260
+ const successResult = await procedure["~orpc"].handler({
261
+ context: {},
262
+ input: { id: "123" },
263
+ path: ["test"],
264
+ procedure: {} as any,
265
+ signal: undefined,
266
+ lastEventId: undefined,
267
+ errors: {} as any,
268
+ });
269
+ expect(successResult).toEqual({ id: "123", name: "Test User" });
270
+
271
+ // Test error case
272
+ await expect(
273
+ procedure["~orpc"].handler({
274
+ context: {},
275
+ input: { id: "not-found" },
276
+ path: ["test"],
277
+ procedure: {} as any,
278
+ signal: undefined,
279
+ lastEventId: undefined,
280
+ errors: {} as any,
281
+ }),
282
+ ).rejects.toThrow();
283
+ });
284
+ });
285
+
286
+ describe("effectDecoratedProcedure.errors()", () => {
287
+ const runtime = ManagedRuntime.make(Layer.empty);
288
+ const effectOs = makeEffectORPC(runtime);
289
+
290
+ it("should support adding errors to procedure", () => {
291
+ const procedure = effectOs
292
+ .input(z.object({ id: z.string() }))
293
+ .effect(({ input }) => Effect.succeed({ id: input.id }))
294
+ .errors({ USER_NOT_FOUND_ERROR: UserNotFoundError });
295
+
296
+ expect(procedure["~orpc"].effectErrorMap.USER_NOT_FOUND_ERROR).toBe(
297
+ UserNotFoundError,
298
+ );
299
+ });
300
+
301
+ it("should merge errors on procedure", () => {
302
+ const procedure = effectOs
303
+ .errors({ BAD_REQUEST: { status: 400 } })
304
+ .input(z.object({ id: z.string() }))
305
+ .effect(({ input }) => Effect.succeed({ id: input.id }))
306
+ .errors({ USER_NOT_FOUND_ERROR: UserNotFoundError });
307
+
308
+ expect(procedure["~orpc"].effectErrorMap).toEqual({
309
+ BAD_REQUEST: { status: 400 },
310
+ USER_NOT_FOUND_ERROR: UserNotFoundError,
311
+ });
312
+ });
313
+ });
@@ -0,0 +1,213 @@
1
+ import { isProcedure } from "@orpc/server";
2
+ import { Layer, ManagedRuntime } from "effect";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import z from "zod";
5
+
6
+ import { EffectDecoratedProcedure } from "../effect-procedure";
7
+ import {
8
+ baseErrorMap,
9
+ baseMeta,
10
+ baseRoute,
11
+ inputSchema,
12
+ outputSchema,
13
+ } from "./shared";
14
+
15
+ vi.mock("@orpc/server", async (importOriginal) => {
16
+ const original = await importOriginal<typeof import("@orpc/server")>();
17
+ return {
18
+ ...original,
19
+ decorateMiddleware: vi.fn((mid) => ({
20
+ mapInput: vi.fn((map) => [mid, map]),
21
+ })),
22
+ createProcedureClient: vi.fn(() => vi.fn()),
23
+ createActionableClient: vi.fn(() => vi.fn()),
24
+ };
25
+ });
26
+
27
+ const runtime = ManagedRuntime.make(Layer.empty);
28
+
29
+ const handler = vi.fn();
30
+ const middleware = vi.fn();
31
+
32
+ const def = {
33
+ middlewares: [middleware],
34
+ errorMap: baseErrorMap,
35
+ effectErrorMap: {},
36
+ inputSchema,
37
+ outputSchema,
38
+ inputValidationIndex: 1,
39
+ outputValidationIndex: 1,
40
+ meta: baseMeta,
41
+ route: baseRoute,
42
+ handler,
43
+ runtime,
44
+ };
45
+
46
+ const decorated = new EffectDecoratedProcedure(def);
47
+
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ });
51
+
52
+ describe("effectDecoratedProcedure", () => {
53
+ it("is a procedure", () => {
54
+ expect(decorated).toSatisfy(isProcedure);
55
+ });
56
+
57
+ it(".errors", () => {
58
+ const errors = {
59
+ BAD_GATEWAY: {
60
+ data: z.object({
61
+ why: z.string(),
62
+ }),
63
+ },
64
+ };
65
+
66
+ const applied = decorated.errors(errors);
67
+ expect(applied).not.toBe(decorated);
68
+ expect(applied).toBeInstanceOf(EffectDecoratedProcedure);
69
+
70
+ expect(applied["~orpc"]).toEqual({
71
+ ...def,
72
+ effectErrorMap: errors,
73
+ errorMap: {
74
+ BAD_GATEWAY: {
75
+ data: errors.BAD_GATEWAY.data,
76
+ message: undefined,
77
+ status: undefined,
78
+ },
79
+ },
80
+ });
81
+
82
+ // Preserves runtime
83
+ expect(applied["~orpc"].runtime).toBe(runtime);
84
+ });
85
+
86
+ it(".meta", () => {
87
+ const meta = { mode: "test" } as const;
88
+
89
+ const applied = decorated.meta(meta);
90
+ expect(applied).not.toBe(decorated);
91
+ expect(applied).toBeInstanceOf(EffectDecoratedProcedure);
92
+
93
+ expect(applied["~orpc"]).toEqual({
94
+ ...def,
95
+ meta: { ...def.meta, ...meta },
96
+ });
97
+
98
+ // Preserves runtime
99
+ expect(applied["~orpc"].runtime).toBe(runtime);
100
+ });
101
+
102
+ it(".route", () => {
103
+ const route = { path: "/test", method: "GET", tags: ["hiu"] } as const;
104
+
105
+ const applied = decorated.route(route);
106
+ expect(applied).not.toBe(decorated);
107
+ expect(applied).toBeInstanceOf(EffectDecoratedProcedure);
108
+
109
+ expect(applied["~orpc"]).toEqual({
110
+ ...def,
111
+ route: { ...def.route, ...route },
112
+ });
113
+
114
+ // Preserves runtime
115
+ expect(applied["~orpc"].runtime).toBe(runtime);
116
+ });
117
+
118
+ describe(".use", () => {
119
+ it("without map input", () => {
120
+ const mid = vi.fn();
121
+
122
+ const applied = decorated.use(mid);
123
+ expect(applied).not.toBe(decorated);
124
+ expect(applied).toBeInstanceOf(EffectDecoratedProcedure);
125
+
126
+ expect(applied["~orpc"]).toEqual({
127
+ ...def,
128
+ middlewares: [...def.middlewares, mid],
129
+ });
130
+
131
+ // Preserves runtime
132
+ expect(applied["~orpc"].runtime).toBe(runtime);
133
+ });
134
+
135
+ it("with map input", () => {
136
+ const mid = vi.fn();
137
+ const map = vi.fn();
138
+
139
+ const applied = decorated.use(mid, map);
140
+ expect(applied).not.toBe(decorated);
141
+ expect(applied).toBeInstanceOf(EffectDecoratedProcedure);
142
+
143
+ expect(applied["~orpc"]).toEqual({
144
+ ...def,
145
+ middlewares: [...def.middlewares, [mid, map]],
146
+ });
147
+
148
+ // Preserves runtime
149
+ expect(applied["~orpc"].runtime).toBe(runtime);
150
+ });
151
+ });
152
+
153
+ it(".callable", async () => {
154
+ const { createProcedureClient } = await import("@orpc/server");
155
+ const options = { context: { db: "postgres" } };
156
+
157
+ const applied = decorated.callable(options);
158
+ expect(applied).toBeInstanceOf(Function);
159
+ expect(applied).toSatisfy(isProcedure);
160
+
161
+ expect(createProcedureClient).toBeCalledTimes(1);
162
+ expect(createProcedureClient).toBeCalledWith(decorated, options);
163
+
164
+ // Can access procedure properties
165
+ expect("use" in applied).toBe(true);
166
+ expect("route" in applied).toBe(true);
167
+ expect("meta" in applied).toBe(true);
168
+
169
+ // Returns EffectDecoratedProcedure when chaining
170
+ const chained = applied.route({});
171
+ expect(chained).toBeInstanceOf(EffectDecoratedProcedure);
172
+ });
173
+
174
+ it(".actionable", async () => {
175
+ const { createProcedureClient, createActionableClient } =
176
+ await import("@orpc/server");
177
+ const options = { context: { db: "postgres" } };
178
+
179
+ const applied = decorated.actionable(options);
180
+ expect(applied).toBeInstanceOf(Function);
181
+ expect(applied).toSatisfy(isProcedure);
182
+
183
+ expect(createProcedureClient).toBeCalledTimes(1);
184
+ expect(createProcedureClient).toBeCalledWith(decorated, options);
185
+
186
+ expect(createActionableClient).toBeCalledTimes(1);
187
+ expect(createActionableClient).toBeCalledWith(
188
+ vi.mocked(createProcedureClient).mock.results[0]!.value,
189
+ );
190
+
191
+ // Can access procedure properties
192
+ expect("use" in applied).toBe(true);
193
+ expect("route" in applied).toBe(true);
194
+ expect("meta" in applied).toBe(true);
195
+
196
+ // Returns EffectDecoratedProcedure when chaining
197
+ const chained = applied.route({});
198
+ expect(chained).toBeInstanceOf(EffectDecoratedProcedure);
199
+ });
200
+ });
201
+
202
+ describe("effectDecoratedProcedure chaining", () => {
203
+ it("preserves Effect types through method chains", () => {
204
+ const applied = decorated
205
+ .errors({ CUSTOM: { message: "custom error" } })
206
+ .meta({ custom: true } as any)
207
+ .route({ path: "/custom" });
208
+
209
+ expect(applied).toBeInstanceOf(EffectDecoratedProcedure);
210
+ expect(applied["~orpc"].runtime).toBe(runtime);
211
+ expect(applied["~orpc"].errorMap).toHaveProperty("CUSTOM");
212
+ });
213
+ });
@@ -0,0 +1,79 @@
1
+ import type { Meta, Schema } from "@orpc/contract";
2
+
3
+ import { ContractProcedure, eventIterator } from "@orpc/contract";
4
+ import * as z from "zod";
5
+
6
+ export const inputSchema = z.object({
7
+ input: z.number().transform((n) => `${n}`),
8
+ });
9
+
10
+ export const outputSchema = z.object({
11
+ output: z.number().transform((n) => `${n}`),
12
+ });
13
+
14
+ export const generalSchema = z.object({
15
+ general: z.number().transform((n) => `${n}`),
16
+ });
17
+
18
+ export const baseErrorMap = {
19
+ BASE: {
20
+ data: outputSchema,
21
+ },
22
+ OVERRIDE: {},
23
+ };
24
+
25
+ export const baseRoute = { path: "/base" } as const;
26
+
27
+ export type BaseMeta = { mode?: string; log?: boolean };
28
+
29
+ export const baseMeta: BaseMeta = {
30
+ mode: "dev",
31
+ };
32
+
33
+ export const ping = new ContractProcedure<
34
+ typeof inputSchema,
35
+ typeof outputSchema,
36
+ typeof baseErrorMap,
37
+ BaseMeta
38
+ >({
39
+ inputSchema,
40
+ outputSchema,
41
+ errorMap: baseErrorMap,
42
+ meta: baseMeta,
43
+ route: baseRoute,
44
+ });
45
+
46
+ export const pong = new ContractProcedure<
47
+ Schema<unknown, unknown>,
48
+ Schema<unknown, unknown>,
49
+ Record<never, never>,
50
+ Meta
51
+ >({
52
+ errorMap: {},
53
+ meta: {},
54
+ route: {},
55
+ });
56
+
57
+ export const router = {
58
+ ping,
59
+ pong,
60
+ nested: {
61
+ ping,
62
+ pong,
63
+ },
64
+ };
65
+
66
+ export const streamedOutputSchema = eventIterator(outputSchema);
67
+
68
+ export const streamed = new ContractProcedure<
69
+ typeof inputSchema,
70
+ typeof streamedOutputSchema,
71
+ typeof baseErrorMap,
72
+ Meta
73
+ >({
74
+ errorMap: baseErrorMap,
75
+ meta: {},
76
+ route: {},
77
+ inputSchema,
78
+ outputSchema: streamedOutputSchema,
79
+ });