effect-orpc 0.4.0 → 0.5.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,410 @@
1
+ import { oc } from "@orpc/contract";
2
+ import { call, createRouterClient } from "@orpc/server";
3
+ import type { Context as ORPCContext } from "@orpc/server";
4
+ import { Context, Effect, Layer, ManagedRuntime, Option, Tracer } from "effect";
5
+ import { describe, expect, it } from "vitest";
6
+ import z from "zod";
7
+
8
+ import { implementEffect } from "../contract";
9
+ import { eos, makeEffectORPC } from "../effect-builder";
10
+ import type { EffectOrORPCMiddleware } from "../types";
11
+
12
+ type TestMiddleware<
13
+ TOutContext extends ORPCContext = { readonly value: string },
14
+ > = EffectOrORPCMiddleware<
15
+ ORPCContext | Record<never, never>,
16
+ TOutContext,
17
+ unknown,
18
+ unknown,
19
+ Record<never, never>,
20
+ never,
21
+ Record<never, never>
22
+ >;
23
+
24
+ type TestMiddlewareOptions = Parameters<TestMiddleware>[0];
25
+ type SpanMiddlewareOptions = Parameters<
26
+ TestMiddleware<Record<never, never>>
27
+ >[0];
28
+
29
+ type MiddlewareShape = {
30
+ readonly name: string;
31
+ readonly middleware: TestMiddleware;
32
+ readonly expected: string;
33
+ };
34
+
35
+ function effectHandlerShapes() {
36
+ return [
37
+ {
38
+ name: "function*",
39
+ handler: function* ({ input }: { input: number }) {
40
+ const increment = yield* Effect.succeed(1);
41
+ return input + increment;
42
+ },
43
+ },
44
+ {
45
+ name: "named Effect.fn",
46
+ handler: Effect.fn("test.effect.named")(function* ({
47
+ input,
48
+ }: {
49
+ input: number;
50
+ }) {
51
+ const increment = yield* Effect.succeed(1);
52
+ return input + increment;
53
+ }),
54
+ },
55
+ {
56
+ name: "anonymous Effect.fn",
57
+ handler: Effect.fn(function* ({ input }: { input: number }) {
58
+ const increment = yield* Effect.succeed(1);
59
+ return input + increment;
60
+ }),
61
+ },
62
+ {
63
+ name: "Effect.gen-returning function",
64
+ handler: ({ input }: { input: number }) =>
65
+ Effect.gen(function* () {
66
+ const increment = yield* Effect.succeed(1);
67
+ return input + increment;
68
+ }),
69
+ },
70
+ ] as const;
71
+ }
72
+
73
+ function providerShapes(suffix: string) {
74
+ return [
75
+ {
76
+ name: "function*",
77
+ provider: function* ({ context }: { context: { value: string } }) {
78
+ yield* Effect.void;
79
+ return { value: `${context.value}:${suffix}:generator` };
80
+ },
81
+ },
82
+ {
83
+ name: "named Effect.fn",
84
+ provider: Effect.fn("test.provider.named")(function* ({
85
+ context,
86
+ }: {
87
+ context: { value: string };
88
+ }) {
89
+ yield* Effect.void;
90
+ return { value: `${context.value}:${suffix}:named` };
91
+ }),
92
+ },
93
+ {
94
+ name: "anonymous Effect.fn",
95
+ provider: Effect.fn(function* ({
96
+ context,
97
+ }: {
98
+ context: { value: string };
99
+ }) {
100
+ yield* Effect.void;
101
+ return { value: `${context.value}:${suffix}:anonymous` };
102
+ }),
103
+ },
104
+ {
105
+ name: "Effect.gen-returning function",
106
+ provider: ({ context }: { context: { value: string } }) =>
107
+ Effect.gen(function* () {
108
+ yield* Effect.void;
109
+ return { value: `${context.value}:${suffix}:effect-gen` };
110
+ }),
111
+ },
112
+ ] as const;
113
+ }
114
+
115
+ function optionalProviderShapes(suffix: string) {
116
+ return [
117
+ {
118
+ name: "function*",
119
+ provider: function* ({ context }: { context: { value?: string } }) {
120
+ yield* Effect.void;
121
+ return Option.map(Option.fromNullable(context.value), (value) => ({
122
+ value: `${value}:${suffix}:generator`,
123
+ }));
124
+ },
125
+ },
126
+ {
127
+ name: "named Effect.fn",
128
+ provider: Effect.fn("test.optional-provider.named")(function* ({
129
+ context,
130
+ }: {
131
+ context: { value?: string };
132
+ }) {
133
+ yield* Effect.void;
134
+ return Option.map(Option.fromNullable(context.value), (value) => ({
135
+ value: `${value}:${suffix}:named`,
136
+ }));
137
+ }),
138
+ },
139
+ {
140
+ name: "anonymous Effect.fn",
141
+ provider: Effect.fn(function* ({
142
+ context,
143
+ }: {
144
+ context: { value?: string };
145
+ }) {
146
+ yield* Effect.void;
147
+ return Option.map(Option.fromNullable(context.value), (value) => ({
148
+ value: `${value}:${suffix}:anonymous`,
149
+ }));
150
+ }),
151
+ },
152
+ {
153
+ name: "Effect.gen-returning function",
154
+ provider: ({ context }: { context: { value?: string } }) =>
155
+ Effect.gen(function* () {
156
+ yield* Effect.void;
157
+ return Option.map(Option.fromNullable(context.value), (value) => ({
158
+ value: `${value}:${suffix}:effect-gen`,
159
+ }));
160
+ }),
161
+ },
162
+ ] as const;
163
+ }
164
+
165
+ function middlewareShapes(): ReadonlyArray<MiddlewareShape> {
166
+ return [
167
+ {
168
+ name: "native guard-only function",
169
+ middleware: () => {},
170
+ expected: "handler",
171
+ },
172
+ {
173
+ name: "native next-returning function",
174
+ middleware: ({ next }) => next({ context: { value: "native-next" } }),
175
+ expected: "native-next",
176
+ },
177
+ {
178
+ name: "function*",
179
+ middleware: function* ({ next }: TestMiddlewareOptions) {
180
+ yield* Effect.void;
181
+ return yield* next({ context: { value: "generator" } });
182
+ },
183
+ expected: "generator",
184
+ },
185
+ {
186
+ name: "named Effect.fn",
187
+ middleware: Effect.fn("test.middleware.named")(function* ({
188
+ next,
189
+ }: TestMiddlewareOptions) {
190
+ yield* Effect.void;
191
+ return yield* next({ context: { value: "named" } });
192
+ }),
193
+ expected: "named",
194
+ },
195
+ {
196
+ name: "anonymous Effect.fn",
197
+ middleware: Effect.fn(function* ({ next }: TestMiddlewareOptions) {
198
+ yield* Effect.void;
199
+ return yield* next({ context: { value: "anonymous" } });
200
+ }),
201
+ expected: "anonymous",
202
+ },
203
+ {
204
+ name: "Effect.gen-returning function",
205
+ middleware: ({ next }) =>
206
+ Effect.gen(function* () {
207
+ yield* Effect.void;
208
+ return yield* next({ context: { value: "effect-gen" } });
209
+ }),
210
+ expected: "effect-gen",
211
+ },
212
+ {
213
+ name: "Effect.gen-returning guard-only function",
214
+ middleware: () =>
215
+ Effect.gen(function* () {
216
+ yield* Effect.void;
217
+ }),
218
+ expected: "handler",
219
+ },
220
+ ];
221
+ }
222
+
223
+ describe("Effect callback shapes", () => {
224
+ for (const { name, handler } of effectHandlerShapes()) {
225
+ it(`.effect supports ${name}`, async () => {
226
+ const procedure = eos.input(z.number()).effect(handler);
227
+
228
+ await expect(call(procedure, 41)).resolves.toBe(42);
229
+ });
230
+ }
231
+
232
+ it("contract implementer .effect supports Effect-returning handlers", async () => {
233
+ const contract = {
234
+ increment: oc.input(z.number()).output(z.number()),
235
+ };
236
+ const implementer = implementEffect(contract, Layer.empty);
237
+
238
+ const named = implementer.increment.effect(
239
+ Effect.fn("test.contract.effect")(function* ({ input }) {
240
+ const increment = yield* Effect.succeed(1);
241
+ return input + increment;
242
+ }),
243
+ );
244
+
245
+ await expect(call(named, 41)).resolves.toBe(42);
246
+ });
247
+
248
+ for (const { name, provider } of providerShapes("provide")) {
249
+ it(`.provide supports ${name}`, async () => {
250
+ class RequestValue extends Context.Tag(`RequestValue:${name}`)<
251
+ RequestValue,
252
+ { readonly value: string }
253
+ >() {}
254
+
255
+ const procedure = eos
256
+ .$context<{ value: string }>()
257
+ .provide(RequestValue, provider)
258
+ .effect(function* () {
259
+ const service = yield* RequestValue;
260
+ return service.value;
261
+ });
262
+
263
+ await expect(
264
+ call(procedure, undefined, { context: { value: "request" } }),
265
+ ).resolves.toContain("request:provide");
266
+ });
267
+ }
268
+
269
+ for (const { name, provider } of optionalProviderShapes("optional")) {
270
+ it(`.provideOptional supports ${name}`, async () => {
271
+ class RequestValue extends Context.Tag(`OptionalRequestValue:${name}`)<
272
+ RequestValue,
273
+ { readonly value: string }
274
+ >() {}
275
+
276
+ const procedure = eos
277
+ .$context<{ value?: string }>()
278
+ .provideOptional(RequestValue, provider)
279
+ .effect(function* () {
280
+ return yield* Effect.serviceOption(RequestValue);
281
+ });
282
+
283
+ await expect(
284
+ call(procedure, undefined, { context: { value: "request" } }),
285
+ ).resolves.toSatisfy(
286
+ (option: Option.Option<{ readonly value: string }>) =>
287
+ Option.isSome(option) &&
288
+ option.value.value.includes("request:optional"),
289
+ );
290
+ await expect(
291
+ call(procedure, undefined, { context: {} }),
292
+ ).resolves.toEqual(Option.none());
293
+ });
294
+ }
295
+
296
+ for (const { name, middleware, expected } of middlewareShapes()) {
297
+ it(`.use supports ${name}`, async () => {
298
+ const procedure = eos.use(middleware).effect(function* ({ context }) {
299
+ return "value" in context ? context.value : "handler";
300
+ });
301
+
302
+ await expect(call(procedure, undefined)).resolves.toBe(expected);
303
+ });
304
+ }
305
+
306
+ for (const { name, middleware, expected } of middlewareShapes().filter(
307
+ ({ name }) => !name.startsWith("native"),
308
+ )) {
309
+ it(`.middleware supports ${name}`, async () => {
310
+ const reusable = eos.middleware(middleware);
311
+ const procedure = eos.use(reusable).effect(function* ({ context }) {
312
+ return "value" in context ? context.value : "handler";
313
+ });
314
+
315
+ await expect(call(procedure, undefined)).resolves.toBe(expected);
316
+ });
317
+ }
318
+
319
+ it("Effect handlers keep their own spans inside routed procedure spans", async () => {
320
+ const spans: Array<{
321
+ readonly name: string;
322
+ readonly parentName: string | undefined;
323
+ }> = [];
324
+ const spanNamesById = new Map<string, string>();
325
+ const tracer = Tracer.make({
326
+ context: (f) => f(),
327
+ span(name, parent, context, links, startTime, kind) {
328
+ const spanId = `span-${spans.length + 1}`;
329
+ spans.push({
330
+ name,
331
+ parentName: Option.match(parent, {
332
+ onNone: () => undefined,
333
+ onSome: (span) => spanNamesById.get(span.spanId),
334
+ }),
335
+ });
336
+ spanNamesById.set(spanId, name);
337
+ const attributes = new Map<string, unknown>();
338
+
339
+ return {
340
+ _tag: "Span" as const,
341
+ name,
342
+ spanId,
343
+ traceId: "trace",
344
+ parent,
345
+ context,
346
+ status: { _tag: "Started" as const, startTime },
347
+ attributes,
348
+ links,
349
+ sampled: true,
350
+ kind,
351
+ end() {},
352
+ attribute(key: string, value: unknown) {
353
+ attributes.set(key, value);
354
+ },
355
+ event() {},
356
+ addLinks() {},
357
+ };
358
+ },
359
+ });
360
+ const tracedRuntime = ManagedRuntime.make(Layer.setTracer(tracer));
361
+ const effectFnProcedure = makeEffectORPC(tracedRuntime)
362
+ .use(
363
+ Effect.fn("custom.middleware.span")(function* ({
364
+ next,
365
+ }: SpanMiddlewareOptions) {
366
+ return yield* next();
367
+ }),
368
+ )
369
+ .effect(
370
+ Effect.fn("custom.handler.span")(function* () {
371
+ return "ok";
372
+ }),
373
+ );
374
+ const withSpanProcedure = makeEffectORPC(tracedRuntime).effect(() =>
375
+ Effect.succeed("ok").pipe(Effect.withSpan("custom.with-span.handler")),
376
+ );
377
+ const client = createRouterClient({
378
+ users: {
379
+ effectFn: effectFnProcedure,
380
+ withSpan: withSpanProcedure,
381
+ },
382
+ });
383
+
384
+ try {
385
+ await expect(client.users.effectFn(undefined)).resolves.toBe("ok");
386
+ await expect(client.users.withSpan(undefined)).resolves.toBe("ok");
387
+ expect(spans).toContainEqual({
388
+ name: "users.effectFn",
389
+ parentName: undefined,
390
+ });
391
+ expect(spans).toContainEqual({
392
+ name: "custom.handler.span",
393
+ parentName: "users.effectFn",
394
+ });
395
+ expect(spans).toContainEqual({
396
+ name: "users.withSpan",
397
+ parentName: undefined,
398
+ });
399
+ expect(spans).toContainEqual({
400
+ name: "custom.with-span.handler",
401
+ parentName: "users.withSpan",
402
+ });
403
+ expect(
404
+ spans.filter(({ name }) => name === "custom.middleware.span"),
405
+ ).toHaveLength(1);
406
+ } finally {
407
+ await tracedRuntime.dispose();
408
+ }
409
+ });
410
+ });
@@ -1,9 +1,9 @@
1
1
  import { fallbackORPCErrorMessage, ORPCError } from "@orpc/client";
2
- import { Effect, Layer, ManagedRuntime } from "effect";
2
+ import { Effect } from "effect";
3
3
  import { describe, expect, expectTypeOf, it } from "vitest";
4
4
  import { z } from "zod";
5
5
 
6
- import { makeEffectORPC } from "../effect-builder";
6
+ import { eos } from "../effect-builder";
7
7
  import type {
8
8
  EffectErrorMap,
9
9
  EffectErrorMapToUnion,
@@ -192,8 +192,7 @@ describe("effectErrorMapToErrorMap", () => {
192
192
  });
193
193
 
194
194
  describe("effectBuilder with EffectErrorMap", () => {
195
- const runtime = ManagedRuntime.make(Layer.empty);
196
- const effectProcedure = makeEffectORPC(runtime);
195
+ const effectProcedure = eos;
197
196
 
198
197
  it("should support errors() with traditional format", () => {
199
198
  const builder = effectProcedure.errors({
@@ -309,8 +308,7 @@ describe("effectBuilder with EffectErrorMap", () => {
309
308
  });
310
309
 
311
310
  describe("effectDecoratedProcedure.errors()", () => {
312
- const runtime = ManagedRuntime.make(Layer.empty);
313
- const effectProcedure = makeEffectORPC(runtime);
311
+ const effectProcedure = eos;
314
312
 
315
313
  it("should support adding errors to procedure", () => {
316
314
  const procedure = effectProcedure
@@ -119,16 +119,55 @@ describe("effectDecoratedProcedure", () => {
119
119
  });
120
120
 
121
121
  describe(".use", () => {
122
- it("without map input", () => {
123
- const mid = vi.fn();
122
+ it("without map input", async () => {
123
+ const mid = vi.fn(({ next }) =>
124
+ next({ context: { fromMiddleware: true } }),
125
+ );
124
126
 
125
127
  const applied = decorated.use(mid);
126
128
  expect(applied).not.toBe(decorated);
127
129
  expect(applied).toBeInstanceOf(EffectDecoratedProcedure);
128
130
 
129
- expect(applied["~effect"]).toEqual({
130
- ...def,
131
- middlewares: [...def.middlewares, mid],
131
+ expect(applied["~effect"].middlewares).toHaveLength(
132
+ def.middlewares.length + 1,
133
+ );
134
+ expect(applied["~effect"].middlewares[0]).toBe(def.middlewares[0]);
135
+
136
+ const wrapped = applied["~effect"].middlewares[1]!;
137
+ let nextCalls = 0;
138
+ let nextOptions: unknown;
139
+ const next = <TContext extends Record<PropertyKey, unknown>>(options?: {
140
+ context?: TContext;
141
+ }) => {
142
+ nextCalls++;
143
+ nextOptions = options;
144
+ return Promise.resolve({
145
+ output: "ok",
146
+ context: options?.context ?? ({} as TContext),
147
+ });
148
+ };
149
+ await expect(
150
+ wrapped(
151
+ {
152
+ context: {},
153
+ errors: {},
154
+ path: [],
155
+ procedure: applied,
156
+ signal: undefined,
157
+ lastEventId: undefined,
158
+ next,
159
+ },
160
+ "input",
161
+ vi.fn(),
162
+ ),
163
+ ).resolves.toEqual({
164
+ output: "ok",
165
+ context: { fromMiddleware: true },
166
+ });
167
+ expect(mid).toHaveBeenCalledOnce();
168
+ expect(nextCalls).toBe(1);
169
+ expect(nextOptions).toEqual({
170
+ context: { fromMiddleware: true },
132
171
  });
133
172
 
134
173
  // Preserves runtime
@@ -1,4 +1,4 @@
1
- import { Layer, ManagedRuntime } from "effect";
1
+ import { Layer } from "effect";
2
2
 
3
3
  import { eoc } from "../index";
4
4
  import {
@@ -12,7 +12,7 @@ import {
12
12
  export type InitialContext = { db: string };
13
13
  export type CurrentContext = InitialContext & { auth: boolean };
14
14
 
15
- export const runtime = ManagedRuntime.make(Layer.empty);
15
+ export const runtime = Layer.empty;
16
16
 
17
17
  export const typedContract = {
18
18
  ping: eoc
@@ -135,18 +135,17 @@ export type EffectProcedureHandler<
135
135
  EffectErrorConstructorMap<TEffectErrorMap>,
136
136
  TMeta
137
137
  >,
138
- ) => Generator<
139
- YieldWrap<
140
- Effect.Effect<
141
- any,
142
- | EffectErrorMapToUnion<TEffectErrorMap>
143
- | ORPCError<ORPCErrorCode, unknown>,
138
+ ) =>
139
+ | Effect.Effect<
140
+ THandlerOutput,
141
+ EffectOperationError<TEffectErrorMap>,
144
142
  TRequirementsProvided
145
143
  >
146
- >,
147
- THandlerOutput,
148
- never
149
- >;
144
+ | EffectCallbackGenerator<
145
+ THandlerOutput,
146
+ TEffectErrorMap,
147
+ TRequirementsProvided
148
+ >;
150
149
 
151
150
  export interface EffectProcedureHandlerConfig {
152
151
  readonly effectFn: EffectProcedureHandler<any, any, any, any, any, any>;
@@ -157,6 +156,26 @@ export interface EffectProcedureHandlerConfig {
157
156
  type EffectTagService<T extends EffectContext.Tag<any, any>> =
158
157
  T extends EffectContext.Tag<any, infer S> ? S : never;
159
158
 
159
+ type EffectOperationError<TEffectErrorMap extends EffectErrorMap> =
160
+ | EffectErrorMapToUnion<TEffectErrorMap>
161
+ | ORPCError<ORPCErrorCode, unknown>;
162
+
163
+ type EffectCallbackGenerator<
164
+ TReturn,
165
+ TEffectErrorMap extends EffectErrorMap,
166
+ TRequirementsProvided,
167
+ > = Generator<
168
+ YieldWrap<
169
+ Effect.Effect<
170
+ unknown,
171
+ EffectOperationError<TEffectErrorMap>,
172
+ TRequirementsProvided
173
+ >
174
+ >,
175
+ TReturn,
176
+ never
177
+ >;
178
+
160
179
  export type EffectProvider<
161
180
  TCurrentContext extends Context,
162
181
  TInput,
@@ -171,11 +190,17 @@ export type EffectProvider<
171
190
  EffectErrorConstructorMap<TEffectErrorMap>,
172
191
  TMeta
173
192
  >,
174
- ) => Effect.Effect<
175
- EffectTagService<TTag>,
176
- EffectErrorMapToUnion<TEffectErrorMap> | ORPCError<ORPCErrorCode, unknown>,
177
- TRequirementsProvided
178
- >;
193
+ ) =>
194
+ | Effect.Effect<
195
+ EffectTagService<TTag>,
196
+ EffectOperationError<TEffectErrorMap>,
197
+ TRequirementsProvided
198
+ >
199
+ | EffectCallbackGenerator<
200
+ EffectTagService<TTag>,
201
+ TEffectErrorMap,
202
+ TRequirementsProvided
203
+ >;
179
204
 
180
205
  export type EffectOptionalProvider<
181
206
  TCurrentContext extends Context,
@@ -191,11 +216,17 @@ export type EffectOptionalProvider<
191
216
  EffectErrorConstructorMap<TEffectErrorMap>,
192
217
  TMeta
193
218
  >,
194
- ) => Effect.Effect<
195
- Option.Option<EffectTagService<TTag>>,
196
- EffectErrorMapToUnion<TEffectErrorMap> | ORPCError<ORPCErrorCode, unknown>,
197
- TRequirementsProvided
198
- >;
219
+ ) =>
220
+ | Effect.Effect<
221
+ Option.Option<EffectTagService<TTag>>,
222
+ EffectOperationError<TEffectErrorMap>,
223
+ TRequirementsProvided
224
+ >
225
+ | EffectCallbackGenerator<
226
+ Option.Option<EffectTagService<TTag>>,
227
+ TEffectErrorMap,
228
+ TRequirementsProvided
229
+ >;
199
230
 
200
231
  interface EffectMiddlewareNext<
201
232
  TOutput,
@@ -327,17 +358,16 @@ export type EffectOrORPCMiddleware<
327
358
  ) =>
328
359
  | MiddlewareResult<TOutContext, TOutput>
329
360
  | PromiseLike<MiddlewareResult<TOutContext, TOutput>>
330
- | Generator<
331
- YieldWrap<
332
- Effect.Effect<
333
- unknown,
334
- | EffectErrorMapToUnion<TEffectErrorMap>
335
- | ORPCError<ORPCErrorCode, unknown>,
336
- TRequirementsProvided
337
- >
338
- >,
361
+ | void
362
+ | Effect.Effect<
363
+ EffectMiddlewareResult<TOutContext, TOutput> | void,
364
+ EffectOperationError<TEffectErrorMap>,
365
+ TRequirementsProvided
366
+ >
367
+ | EffectCallbackGenerator<
339
368
  EffectMiddlewareResult<TOutContext, TOutput> | void,
340
- never
369
+ TEffectErrorMap,
370
+ TRequirementsProvided
341
371
  >;
342
372
 
343
373
  export type EffectMiddlewareOptions<
@@ -384,18 +414,18 @@ export type EffectMiddleware<
384
414
  TEffectErrorMap,
385
415
  TRequirementsProvided
386
416
  >,
387
- ) => Generator<
388
- YieldWrap<
389
- Effect.Effect<
390
- unknown,
391
- | EffectErrorMapToUnion<TEffectErrorMap>
392
- | ORPCError<ORPCErrorCode, unknown>,
417
+ ) =>
418
+ | Effect.Effect<
419
+ EffectMiddlewareResult<TOutContext, TOutput> | void,
420
+ EffectOperationError<TEffectErrorMap>,
393
421
  TRequirementsProvided
394
422
  >
395
- >,
396
- EffectMiddlewareResult<TOutContext, TOutput> | void,
397
- never
398
- >;
423
+ | EffectCallbackGenerator<
424
+ EffectMiddlewareResult<TOutContext, TOutput> | void,
425
+ TEffectErrorMap,
426
+ TRequirementsProvided
427
+ >
428
+ | void;
399
429
 
400
430
  type EffectProvideStep = {
401
431
  readonly _tag: "provide";