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,488 @@
1
+ import { isContractProcedure } from "@orpc/contract";
2
+ import { os } from "@orpc/server";
3
+ import { Effect, Layer, ManagedRuntime } from "effect";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import z from "zod";
6
+
7
+ import { EffectBuilder, makeEffectORPC } from "../effect-builder";
8
+ import { EffectDecoratedProcedure } from "../effect-procedure";
9
+ import {
10
+ baseErrorMap,
11
+ baseMeta,
12
+ baseRoute,
13
+ generalSchema,
14
+ inputSchema,
15
+ outputSchema,
16
+ } from "./shared";
17
+
18
+ const mid = vi.fn();
19
+ const runtime = ManagedRuntime.make(Layer.empty);
20
+
21
+ const def = {
22
+ config: {
23
+ initialInputValidationIndex: 11,
24
+ initialOutputValidationIndex: 22,
25
+ },
26
+ middlewares: [mid],
27
+ errorMap: baseErrorMap,
28
+ effectErrorMap: {},
29
+ inputSchema,
30
+ outputSchema,
31
+ inputValidationIndex: 99,
32
+ meta: baseMeta,
33
+ outputValidationIndex: 88,
34
+ route: baseRoute,
35
+ dedupeLeadingMiddlewares: true,
36
+ runtime,
37
+ };
38
+
39
+ const builder = new EffectBuilder(def);
40
+
41
+ beforeEach(() => vi.clearAllMocks());
42
+
43
+ describe("effectBuilder", () => {
44
+ it("is a contract procedure", () => {
45
+ expect(builder).toSatisfy(isContractProcedure);
46
+ });
47
+
48
+ it(".errors", () => {
49
+ const errors = { BAD_GATEWAY: { message: "BAD" } };
50
+
51
+ const applied = builder.errors(errors);
52
+ expect(applied).instanceOf(EffectBuilder);
53
+ expect(applied).not.toBe(builder);
54
+
55
+ expect(applied["~orpc"]).toEqual({
56
+ ...def,
57
+ effectErrorMap: errors,
58
+ errorMap: { ...def.errorMap, ...errors },
59
+ });
60
+ });
61
+
62
+ describe(".use", () => {
63
+ it("without map input", () => {
64
+ const mid2 = vi.fn();
65
+ const applied = builder.use(mid2);
66
+
67
+ expect(applied).instanceOf(EffectBuilder);
68
+ expect(applied).not.toBe(builder);
69
+ expect(applied["~orpc"]).toEqual({
70
+ ...def,
71
+ middlewares: [mid, mid2],
72
+ });
73
+ });
74
+ });
75
+
76
+ it(".meta", () => {
77
+ const meta = { log: true } as any;
78
+ const applied = builder.meta(meta);
79
+
80
+ expect(applied).instanceOf(EffectBuilder);
81
+ expect(applied).not.toBe(builder);
82
+ expect(applied["~orpc"]).toEqual({
83
+ ...def,
84
+ meta: { ...def.meta, ...meta },
85
+ });
86
+ });
87
+
88
+ it(".route", () => {
89
+ const route = { description: "test" } as any;
90
+ const applied = builder.route(route);
91
+
92
+ expect(applied).instanceOf(EffectBuilder);
93
+ expect(applied).not.toBe(builder);
94
+ expect(applied["~orpc"]).toEqual({
95
+ ...def,
96
+ route: { ...def.route, ...route },
97
+ });
98
+ });
99
+
100
+ it(".input", () => {
101
+ const applied = builder.input(generalSchema);
102
+
103
+ expect(applied).instanceOf(EffectBuilder);
104
+ expect(applied).not.toBe(builder);
105
+ expect(applied["~orpc"]).toEqual({
106
+ ...def,
107
+ inputSchema: generalSchema,
108
+ inputValidationIndex: 12,
109
+ });
110
+ });
111
+
112
+ it(".output", () => {
113
+ const applied = builder.output(generalSchema);
114
+
115
+ expect(applied).instanceOf(EffectBuilder);
116
+ expect(applied).not.toBe(builder);
117
+ expect(applied["~orpc"]).toEqual({
118
+ ...def,
119
+ outputSchema: generalSchema,
120
+ outputValidationIndex: 23,
121
+ });
122
+ });
123
+
124
+ it(".effect", () => {
125
+ const effectFn = vi.fn(() => Effect.succeed({ result: "test" }));
126
+ const applied = builder.effect(effectFn);
127
+
128
+ expect(applied).instanceOf(EffectDecoratedProcedure);
129
+ expect(applied["~orpc"].runtime).toBe(runtime);
130
+ expect(applied["~orpc"].handler).toBeInstanceOf(Function);
131
+ });
132
+
133
+ it(".effect runs effect with runtime", async () => {
134
+ const effectFn = vi.fn(({ input }: { input: any }) =>
135
+ Effect.succeed({ output: `processed-${input}` }),
136
+ );
137
+
138
+ const applied = builder.effect(effectFn);
139
+
140
+ const result = await applied["~orpc"].handler({
141
+ context: {},
142
+ input: "test-input",
143
+ path: ["test"],
144
+ procedure: applied as any,
145
+ signal: undefined,
146
+ lastEventId: undefined,
147
+ errors: {},
148
+ });
149
+
150
+ expect(result).toEqual({ output: "processed-test-input" });
151
+ expect(effectFn).toHaveBeenCalledTimes(1);
152
+ });
153
+ });
154
+
155
+ describe("makeEffectORPC factory", () => {
156
+ it("uses default os when no builder provided", () => {
157
+ const effectBuilder = makeEffectORPC(runtime);
158
+
159
+ expect(effectBuilder).instanceOf(EffectBuilder);
160
+ expect(effectBuilder["~orpc"].runtime).toBe(runtime);
161
+ // Should inherit os's default definition
162
+ expect(effectBuilder["~orpc"].middlewares).toEqual(os["~orpc"].middlewares);
163
+ expect(effectBuilder["~orpc"].effectErrorMap).toEqual(os["~orpc"].errorMap);
164
+ });
165
+
166
+ it("wraps a custom builder when provided", () => {
167
+ const effectBuilder = makeEffectORPC(runtime, os);
168
+
169
+ expect(effectBuilder).instanceOf(EffectBuilder);
170
+ expect(effectBuilder["~orpc"].runtime).toBe(runtime);
171
+ expect(effectBuilder["~orpc"].middlewares).toEqual(os["~orpc"].middlewares);
172
+ expect(effectBuilder["~orpc"].effectErrorMap).toEqual(os["~orpc"].errorMap);
173
+ });
174
+
175
+ it("creates working procedure with default os", async () => {
176
+ const effectBuilder = makeEffectORPC(runtime);
177
+
178
+ const procedure = effectBuilder.effect(() => Effect.succeed("hello"));
179
+
180
+ const result = await procedure["~orpc"].handler({
181
+ context: {},
182
+ input: undefined,
183
+ path: ["test"],
184
+ procedure: procedure as any,
185
+ signal: undefined,
186
+ lastEventId: undefined,
187
+ errors: {},
188
+ });
189
+
190
+ expect(result).toBe("hello");
191
+ });
192
+
193
+ it("supports Effect.fn generator syntax", async () => {
194
+ const effectBuilder = makeEffectORPC(runtime);
195
+
196
+ const procedure = effectBuilder.effect(
197
+ Effect.fn(function* () {
198
+ const a = yield* Effect.succeed(1);
199
+ const b = yield* Effect.succeed(2);
200
+ return a + b;
201
+ }),
202
+ );
203
+
204
+ const result = await procedure["~orpc"].handler({
205
+ context: {},
206
+ input: undefined,
207
+ path: ["test"],
208
+ procedure: procedure as any,
209
+ signal: undefined,
210
+ lastEventId: undefined,
211
+ errors: {},
212
+ });
213
+
214
+ expect(result).toBe(3);
215
+ });
216
+
217
+ it("chains builder methods correctly", () => {
218
+ const effectBuilder = makeEffectORPC(runtime);
219
+
220
+ const procedure = effectBuilder
221
+ .errors({ NOT_FOUND: { message: "not found" } })
222
+ .meta({ auth: true } as any)
223
+ .route({ path: "/test" })
224
+ .input(z.object({ id: z.string() }))
225
+ .output(z.object({ name: z.string() }))
226
+ .effect(() => Effect.succeed({ name: "test" }));
227
+
228
+ expect(procedure).instanceOf(EffectDecoratedProcedure);
229
+ expect(procedure["~orpc"].errorMap).toHaveProperty("NOT_FOUND");
230
+ expect(procedure["~orpc"].meta).toEqual({ auth: true });
231
+ expect(procedure["~orpc"].route).toEqual({ path: "/test" });
232
+ });
233
+
234
+ it("wraps a customized builder", () => {
235
+ const customBuilder = os
236
+ .errors({ CUSTOM_ERROR: { message: "custom" } })
237
+ .use(vi.fn());
238
+
239
+ const effectBuilder = makeEffectORPC(runtime, customBuilder);
240
+
241
+ expect(effectBuilder["~orpc"].effectErrorMap).toHaveProperty(
242
+ "CUSTOM_ERROR",
243
+ );
244
+ expect(effectBuilder["~orpc"].middlewares.length).toBe(1);
245
+ });
246
+ });
247
+
248
+ describe("effect with services", () => {
249
+ it("can use services from runtime layer", async () => {
250
+ // Define a simple service
251
+ class Counter extends Effect.Tag("Counter")<
252
+ Counter,
253
+ { increment: (n: number) => Effect.Effect<number> }
254
+ >() {}
255
+
256
+ // Create a layer with the service
257
+ const CounterLive = Layer.succeed(Counter, {
258
+ increment: (n: number) => Effect.succeed(n + 1),
259
+ });
260
+
261
+ // Create runtime with the service
262
+ const serviceRuntime = ManagedRuntime.make(CounterLive);
263
+ const effectBuilder = makeEffectORPC(serviceRuntime);
264
+
265
+ const procedure = effectBuilder.input(z.number()).effect(
266
+ Effect.fn(function* ({ input }) {
267
+ const counter = yield* Counter;
268
+ return yield* counter.increment(input as number);
269
+ }),
270
+ );
271
+
272
+ const result = await procedure["~orpc"].handler({
273
+ context: {},
274
+ input: 5,
275
+ path: ["test"],
276
+ procedure: procedure as any,
277
+ signal: undefined,
278
+ lastEventId: undefined,
279
+ errors: {},
280
+ });
281
+
282
+ expect(result).toBe(6);
283
+
284
+ // Cleanup
285
+ await serviceRuntime.dispose();
286
+ });
287
+ });
288
+
289
+ describe(".traced", () => {
290
+ it("creates an EffectBuilder with span config", () => {
291
+ const effectBuilder = makeEffectORPC(runtime);
292
+
293
+ const traced = effectBuilder.traced("users.getUser");
294
+
295
+ expect(traced).instanceOf(EffectBuilder);
296
+ expect(traced).not.toBe(effectBuilder);
297
+ expect(traced["~orpc"].spanConfig).toBeDefined();
298
+ expect(traced["~orpc"].spanConfig?.name).toBe("users.getUser");
299
+ expect(traced["~orpc"].spanConfig?.captureStackTrace).toBeInstanceOf(
300
+ Function,
301
+ );
302
+ });
303
+
304
+ it("preserves span config through chained methods", () => {
305
+ const effectBuilder = makeEffectORPC(runtime);
306
+
307
+ const procedure = effectBuilder
308
+ .input(z.object({ id: z.string() }))
309
+ .traced("users.getUser")
310
+ .effect(() => Effect.succeed({ name: "test" }));
311
+
312
+ expect(procedure).instanceOf(EffectDecoratedProcedure);
313
+ // The span wrapping happens in the handler, so we just verify the procedure was created
314
+ });
315
+
316
+ it("traced procedure handler runs successfully", async () => {
317
+ const effectBuilder = makeEffectORPC(runtime);
318
+
319
+ const procedure = effectBuilder
320
+ .input(z.object({ id: z.string() }))
321
+ .traced("users.getUser")
322
+ .effect(({ input }) => Effect.succeed({ id: input.id, name: "Alice" }));
323
+
324
+ const result = await procedure["~orpc"].handler({
325
+ context: {},
326
+ input: { id: "123" },
327
+ path: ["users", "getUser"],
328
+ procedure: procedure as any,
329
+ signal: undefined,
330
+ lastEventId: undefined,
331
+ errors: {},
332
+ });
333
+
334
+ expect(result).toEqual({ id: "123", name: "Alice" });
335
+ });
336
+
337
+ it("traced procedure with Effect.fn generator syntax", async () => {
338
+ const effectBuilder = makeEffectORPC(runtime);
339
+
340
+ const procedure = effectBuilder.traced("math.add").effect(
341
+ Effect.fn(function* () {
342
+ const a = yield* Effect.succeed(10);
343
+ const b = yield* Effect.succeed(20);
344
+ return a + b;
345
+ }),
346
+ );
347
+
348
+ const result = await procedure["~orpc"].handler({
349
+ context: {},
350
+ input: undefined,
351
+ path: ["math", "add"],
352
+ procedure: procedure as any,
353
+ signal: undefined,
354
+ lastEventId: undefined,
355
+ errors: {},
356
+ });
357
+
358
+ expect(result).toBe(30);
359
+ });
360
+
361
+ it("captures stack trace at definition time", () => {
362
+ const effectBuilder = makeEffectORPC(runtime);
363
+
364
+ // The stack trace is captured when .traced() is called
365
+ const traced = effectBuilder.traced("test.procedure");
366
+
367
+ const stackTrace = traced["~orpc"].spanConfig?.captureStackTrace();
368
+ // The stack trace should be a string containing the file location
369
+ // It may be undefined in some test environments
370
+ if (stackTrace !== undefined) {
371
+ expect(typeof stackTrace).toBe("string");
372
+ }
373
+ });
374
+ });
375
+
376
+ describe("default tracing (without .traced())", () => {
377
+ it("procedure without .traced() still runs successfully", async () => {
378
+ const effectBuilder = makeEffectORPC(runtime);
379
+
380
+ // No .traced() call - should still work and use path as span name
381
+ const procedure = effectBuilder
382
+ .input(z.object({ id: z.string() }))
383
+ .effect(({ input }) => Effect.succeed({ id: input.id, name: "Bob" }));
384
+
385
+ const result = await procedure["~orpc"].handler({
386
+ context: {},
387
+ input: { id: "456" },
388
+ path: ["users", "findById"],
389
+ procedure: procedure as any,
390
+ signal: undefined,
391
+ lastEventId: undefined,
392
+ errors: {},
393
+ });
394
+
395
+ expect(result).toEqual({ id: "456", name: "Bob" });
396
+ });
397
+
398
+ it("uses procedure path as default span name", async () => {
399
+ const effectBuilder = makeEffectORPC(runtime);
400
+
401
+ // Without .traced(), the span name should be derived from path
402
+ const procedure = effectBuilder.effect(() => Effect.succeed("hello"));
403
+
404
+ // The procedure should work with any path
405
+ const result = await procedure["~orpc"].handler({
406
+ context: {},
407
+ input: undefined,
408
+ path: ["api", "v1", "greet"],
409
+ procedure: procedure as any,
410
+ signal: undefined,
411
+ lastEventId: undefined,
412
+ errors: {},
413
+ });
414
+
415
+ expect(result).toBe("hello");
416
+ });
417
+
418
+ it("default tracing works with Effect.fn generator", async () => {
419
+ const effectBuilder = makeEffectORPC(runtime);
420
+
421
+ const procedure = effectBuilder.effect(
422
+ Effect.fn(function* () {
423
+ const x = yield* Effect.succeed(5);
424
+ const y = yield* Effect.succeed(10);
425
+ return x * y;
426
+ }),
427
+ );
428
+
429
+ const result = await procedure["~orpc"].handler({
430
+ context: {},
431
+ input: undefined,
432
+ path: ["math", "multiply"],
433
+ procedure: procedure as any,
434
+ signal: undefined,
435
+ lastEventId: undefined,
436
+ errors: {},
437
+ });
438
+
439
+ expect(result).toBe(50);
440
+ });
441
+
442
+ it("default tracing works with services from runtime", async () => {
443
+ class Greeter extends Effect.Tag("Greeter")<
444
+ Greeter,
445
+ { greet: (name: string) => Effect.Effect<string> }
446
+ >() {}
447
+
448
+ const GreeterLive = Layer.succeed(Greeter, {
449
+ greet: (name: string) => Effect.succeed(`Hello, ${name}!`),
450
+ });
451
+
452
+ const serviceRuntime = ManagedRuntime.make(GreeterLive);
453
+ const effectBuilder = makeEffectORPC(serviceRuntime);
454
+
455
+ const procedure = effectBuilder
456
+ .input(z.object({ name: z.string() }))
457
+ .effect(
458
+ Effect.fn(function* ({ input }) {
459
+ const greeter = yield* Greeter;
460
+ return yield* greeter.greet(input.name);
461
+ }),
462
+ );
463
+
464
+ const result = await procedure["~orpc"].handler({
465
+ context: {},
466
+ input: { name: "World" },
467
+ path: ["greeting", "say"],
468
+ procedure: procedure as any,
469
+ signal: undefined,
470
+ lastEventId: undefined,
471
+ errors: {},
472
+ });
473
+
474
+ expect(result).toBe("Hello, World!");
475
+
476
+ await serviceRuntime.dispose();
477
+ });
478
+
479
+ it("no spanConfig is set when .traced() is not called", () => {
480
+ const effectBuilder = makeEffectORPC(runtime);
481
+
482
+ // Without .traced(), spanConfig should be undefined
483
+ expect(effectBuilder["~orpc"].spanConfig).toBeUndefined();
484
+
485
+ const withInput = effectBuilder.input(z.string());
486
+ expect(withInput["~orpc"].spanConfig).toBeUndefined();
487
+ });
488
+ });