diff --git a/apps/content/docs/integrations/effect.mdx b/apps/content/docs/integrations/effect.mdx index c8d4ca0f3a..49fd4bf1be 100644 --- a/apps/content/docs/integrations/effect.mdx +++ b/apps/content/docs/integrations/effect.mdx @@ -134,21 +134,40 @@ This integration preserves the original error whenever possible. If you call `Ef To customize this behavior, wrap the effect before execution using `effect/wrap` in the context: -```ts -import { Context, Effect } from 'effect' +```ts twoslash +import type { WithEffectContext } from '@orpc/experimental-effect' +import { RPCHandler } from '@orpc/server/fetch' interface ServerContext extends WithEffectContext {} +declare const handler: RPCHandler +// ---cut--- +import { ORPCError } from '@orpc/server' +import { Cause, Context, Data, Effect } from 'effect' + +class NotFoundError extends Data.TaggedError('NotFoundError')<{ + readonly resource: string +}> {} + export async function fetch(request: Request) { const { matched, response } = await handler.handle(request, { context: { 'effect/context': Context.empty(), - 'effect/wrap': (effect, opts) => effect.pipe( + 'effect/wrap': effect => effect.pipe( Effect.catchCause((cause) => { + const error = Cause.squash(cause) + + if (error instanceof NotFoundError) { + return Effect.fail(new ORPCError('NOT_FOUND', { + message: `${error.resource} not found`, + cause: error, + })) + } - }) + return Effect.failCause(cause) + }), ), - } + }, }) if (matched) { @@ -159,6 +178,8 @@ export async function fetch(request: Request) { } ``` +The wrap runs with `effect/context`, once per `handlerGen` and `middlewareGen` (see `opts.kind`). Keep it idempotent, since failures from inner layers pass through it again. + :::info For app level error handling, we recommend [middleware](/docs/middleware) or interceptors. ::: diff --git a/packages/effect/src/context.test-d.ts b/packages/effect/src/context.test-d.ts new file mode 100644 index 0000000000..5cc928ffb0 --- /dev/null +++ b/packages/effect/src/context.test-d.ts @@ -0,0 +1,106 @@ +import type { AnyProcedure, Context as ORPCContext, ORPCErrorConstructorMap } from '@orpc/server' +import type { EffectWrapHandlerOptions, EffectWrapMiddlewareOptions, EffectWrapOptions, InferEffectServices, WithEffectContext } from './context' +import { ORPCError } from '@orpc/server' +import { Cause, Context, Effect } from 'effect' + +class Service1 extends Context.Service< + Service1, + { + readonly id: 'Service1' + } +>()('Service1') {} + +class Service2 extends Context.Service< + Service2, + { + readonly id: 'Service2' + } +>()('Service2') {} + +class DomainError extends Error {} + +type Wrap = NonNullable['effect/wrap']> + +describe('WithEffectContext', () => { + it('effect/context', () => { + expectTypeOf['effect/context']>().toEqualTypeOf>() + }) + + describe('effect/wrap', () => { + it('keeps the success type and allows changing the error type', () => { + const mapError: Wrap = effect => effect.pipe( + Effect.mapError(error => new ORPCError('BAD_REQUEST', { cause: error })), + ) + + const mapSome: Wrap = effect => effect.pipe( + Effect.catchCause((cause) => { + const error = Cause.squash(cause) + + if (error instanceof DomainError) { + return Effect.fail(new ORPCError('BAD_REQUEST', { cause: error })) + } + + return Effect.failCause(cause) + }), + ) + + void mapError + void mapSome + + // @ts-expect-error - must keep the success type + const invalidSuccess: Wrap = effect => effect.pipe(Effect.as('invalid')) + void invalidSuccess + }) + + it('does not allow requiring services', () => { + // services from effect/context are available at runtime, but must be read without requirements + const serviceOption: Wrap = effect => Effect.serviceOption(Service1).pipe(Effect.andThen(effect)) + void serviceOption + + // @ts-expect-error - wrap cannot require services + const requireService: Wrap = effect => Effect.gen(function* () { + yield* Service2 + return yield* effect + }) + void requireService + }) + + it('receives options discriminated by kind', () => { + const wrap: Wrap = (effect, opts) => { + expectTypeOf(opts).toEqualTypeOf() + expectTypeOf(opts.kind).toEqualTypeOf<'middleware' | 'handler'>() + expectTypeOf(opts.context).toEqualTypeOf() + expectTypeOf(opts.input).toEqualTypeOf() + expectTypeOf(opts.path).toEqualTypeOf() + expectTypeOf(opts.procedure).toExtend() + expectTypeOf(opts.signal).toEqualTypeOf() + expectTypeOf(opts.lastEventId).toEqualTypeOf() + expectTypeOf(opts.errors).toEqualTypeOf>() + + if (opts.kind === 'middleware') { + expectTypeOf(opts).toEqualTypeOf() + expectTypeOf(opts).not.toHaveProperty('next') + } + else { + expectTypeOf(opts).toEqualTypeOf() + } + + return effect + } + + void wrap + }) + }) +}) + +describe('InferEffectServices', () => { + it('infers services from effect/context', () => { + expectTypeOf>>().toEqualTypeOf() + expectTypeOf>>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + }) + + it('accepts a context with more services than required', () => { + expectTypeOf>().toExtend>() + }) +}) diff --git a/packages/effect/src/context.ts b/packages/effect/src/context.ts index c78a6a6711..c047675ce8 100644 --- a/packages/effect/src/context.ts +++ b/packages/effect/src/context.ts @@ -1,6 +1,35 @@ -import type { AnyProcedure, Context } from '@orpc/server' +import type { Context, MiddlewareOptions, ORPCErrorConstructorMap, ProcedureHandlerOptions } from '@orpc/server' import type { Effect, Context as EffectContext } from 'effect' +/** + * The options `effect/wrap` receives when wrapping the effect of a `middlewareGen`. + * + * @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling} + */ +export interface EffectWrapMiddlewareOptions extends Omit>, 'next'> { + kind: 'middleware' + /** + * The middleware's input, validated by the schemas defined before it. + */ + input: unknown +} + +/** + * The options `effect/wrap` receives when wrapping the effect of a `handlerGen`. + * + * @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling} + */ +export interface EffectWrapHandlerOptions extends ProcedureHandlerOptions> { + kind: 'handler' +} + +/** + * The options `effect/wrap` receives, discriminated by `kind`. + * + * @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling} + */ +export type EffectWrapOptions = EffectWrapMiddlewareOptions | EffectWrapHandlerOptions + /** * A context shape that provides Effect services to effectful handlers * through the oRPC context in a typesafe way. @@ -12,6 +41,8 @@ export interface WithEffectContext { * A pre-built Effect context providing the services available within this oRPC context. * Automatically provided to any effect that runs under this context. * + * References such as `Tracer`, `Logger` or `Scheduler` can be supplied here too. + * * @example * ```ts * import type { WithEffectContext } from '@orpc/experimental-effect' @@ -33,32 +64,39 @@ export interface WithEffectContext { ['effect/context']: EffectContext.Context /** - * An optional hook to wrap any effect before it is executed within this oRPC context. - * Useful for adding observability, tracing, or error handling. + * An optional hook to wrap an effect before it is executed within this oRPC context. + * Useful for customizing error handling, such as mapping errors to `ORPCError`. * - * Runs for every effectful middleware and handler, so provide services through `effect/context` instead. + * Runs with `effect/context`, once per `middlewareGen` and `handlerGen` (see `opts.kind`), so provide + * services through `effect/context` rather than here. Keep it idempotent, since failures from inner + * layers pass through it again. * * @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling} * * @example * ```ts * import type { WithEffectContext } from '@orpc/experimental-effect' - * import { Context, Effect } from 'effect' + * import { ORPCError } from '@orpc/server' + * import { Context, Data, Effect } from 'effect' + * + * class NotFoundError extends Data.TaggedError('NotFoundError') {} * * interface ServerContext extends WithEffectContext {} * * const context: ServerContext = { * 'effect/context': Context.empty(), - * 'effect/wrap': (effect, opts) => effect.pipe( - * Effect.withSpan(opts.path.join('.')), + * 'effect/wrap': effect => effect.pipe( + * Effect.mapError(error => error instanceof NotFoundError + * ? new ORPCError('NOT_FOUND', { cause: error }) + * : error), * ), * } * ``` */ - ['effect/wrap']?: ( - effect: Effect.Effect, - opts: { path: string[], procedure: AnyProcedure, signal?: undefined | AbortSignal }, - ) => Effect.Effect + ['effect/wrap']?: ( + effect: Effect.Effect, + opts: EffectWrapOptions, + ) => Effect.Effect } /** diff --git a/packages/effect/src/handler.test.ts b/packages/effect/src/handler.test.ts index 30fd6a843e..c3d4648ee7 100644 --- a/packages/effect/src/handler.test.ts +++ b/packages/effect/src/handler.test.ts @@ -1,6 +1,7 @@ -import type { WithEffectContext } from './context' +import type { EffectWrapOptions, WithEffectContext } from './context' import { call, ORPCError, os, type } from '@orpc/server' -import { Context, Effect } from 'effect' +import { Cause, Context, Effect, Option, Tracer } from 'effect' +import { z } from 'zod' import { handlerGen } from './handler' import * as EffectModule from './runtime' @@ -42,17 +43,6 @@ describe('handlerGen', () => { expect(error.defined).toBe(false) }) - it('throw original errors without fiber failure error wrapper', async () => { - const error = new Error('__TEST__') - await expect( - call(os.handler(handlerGen(function* () { - yield* Effect.fail(error) - }))), - ).rejects.toBe(error) - - expect(runPromiseSpy).toHaveBeenCalledTimes(1) - }) - it('forwards the provided signal to runPromise', async () => { const controller = new AbortController() @@ -67,7 +57,7 @@ describe('handlerGen', () => { ).resolves.toEqual('output') expect(runPromiseSpy).toHaveBeenCalledTimes(1) - expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { signal: controller.signal }) + expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { context: undefined, signal: controller.signal }) }) it('can access context, input, errors, signal, ...', async () => { @@ -115,63 +105,129 @@ describe('handlerGen', () => { )).rejects.toThrow('Service2') }) - it('can wrap effect execution and receives procedure options', async () => { - let wrappedCalls = 0 - let wrappedPath: string[] | undefined - let wrappedProcedure: unknown - let wrappedSignal: unknown + it('can wrap effect execution and receives handler options', async () => { + const wrap = vi.fn((effect: any, _opts: EffectWrapOptions) => effect) const procedure = os .$context>() + .input(z.object({ id: z.string().transform(Number) })) + .errors({ BASE: {} }) .handler(handlerGen(function* () { return 'output' })) const signal = AbortSignal.timeout(100) + const context = { 'effect/context': Context.empty(), 'effect/wrap': wrap } await expect(call( procedure, - undefined, - { - path: ['wrapped', 'procedure'], - context: { - 'effect/context': Context.empty(), - 'effect/wrap': (effect, opts) => effect.pipe(Effect.tap(() => Effect.sync(() => { - wrappedCalls += 1 - wrappedPath = opts.path - wrappedProcedure = opts.procedure - wrappedSignal = opts.signal - }))), - }, - signal, - }, + { id: '1' }, + { path: ['wrapped', 'procedure'], context, signal, lastEventId: 'id' }, )).resolves.toEqual('output') - expect(wrappedCalls).toBe(1) - expect(wrappedPath).toEqual(['wrapped', 'procedure']) - expect(wrappedProcedure).toBe(procedure) - expect(wrappedSignal).toBe(signal) + expect(wrap).toHaveBeenCalledExactlyOnceWith(expect.anything(), { + kind: 'handler', + context, + input: { id: 1 }, + path: ['wrapped', 'procedure'], + procedure, + signal, + lastEventId: 'id', + errors: { BASE: expect.any(Function) }, + }) }) - it('throws ORPCError failures from wrap as-is', async () => { - const error = new ORPCError('__TEST__') + it('can map failures to a different error type via wrap, and throws it as-is', async () => { + const domainError = new Error('__DOMAIN__') + const error = new ORPCError('BAD_REQUEST', { cause: domainError }) const procedure = os .$context>() .handler(handlerGen(function* () { - return 'output' + return yield* Effect.fail(domainError) })) - await expect(call( - procedure, - undefined, - { - context: { - 'effect/context': Context.empty(), - 'effect/wrap': () => Effect.fail(error) as any, - }, + await expect(call(procedure, undefined, { + context: { + 'effect/context': Context.empty(), + 'effect/wrap': effect => effect.pipe( + Effect.mapError(cause => cause === domainError ? error : cause), + ), }, - )).rejects.toBe(error) + })).rejects.toBe(error) expect(error.defined).toBe(false) }) + + it('can convert defects to ORPCError via wrap with catchCause', async () => { + const defect = new Error('__DEFECT__') + + const procedure = os + .$context>() + .handler(handlerGen(function* () { + throw defect + })) + + await expect(call(procedure, undefined, { + context: { + 'effect/context': Context.empty(), + 'effect/wrap': effect => effect.pipe( + Effect.catchCause(cause => Effect.fail(new ORPCError('INTERNAL_SERVER_ERROR', { cause: Cause.squash(cause) }))), + ), + }, + })).rejects.toSatisfy( + error => error instanceof ORPCError && error.code === 'INTERNAL_SERVER_ERROR' && error.cause === defect, + ) + }) + + it('runs wrap with effect/context so wrap and handler share services and tracer', async () => { + const spans: Tracer.Span[] = [] + const tracer = Tracer.make({ + span(options) { + const span = Tracer.nativeTracer.span(options) + spans.push(span) + return span + }, + }) + + let serviceInWrap: Option.Option | undefined + + const procedure = os + .$context>() + .handler(handlerGen(function* () { + return yield* Effect.succeed('output').pipe(Effect.withSpan('handler')) + })) + + await expect(call(procedure, undefined, { + context: { + 'effect/context': Context.empty().pipe( + Context.add(Service1, { id: 'Service1' }), + Context.add(Tracer.Tracer, tracer), + ), + 'effect/wrap': effect => Effect.gen(function* () { + serviceInWrap = yield* Effect.serviceOption(Service1) + return yield* effect + }).pipe(Effect.withSpan('wrap')), + }, + })).resolves.toEqual('output') + + expect(serviceInWrap).toEqual(Option.some({ id: 'Service1' })) + expect(spans.map(span => span.name)).toEqual(['wrap', 'handler']) + expect(spans[1]!.parent).toEqual(Option.some(spans[0])) + }) + + it('lets services provided by wrap take precedence over effect/context', async () => { + const procedure = os + .$context>() + .handler(handlerGen(function* () { + const service1 = yield* Service1 + return `output:${service1.id}` + })) + + await expect(call(procedure, undefined, { + context: { + 'effect/context': Context.empty().pipe(Context.add(Service1, { id: 'Service1' })), + 'effect/wrap': effect => effect.pipe(Effect.provideService(Service1, { id: 'fromWrap' as any })), + }, + })).resolves.toEqual('output:fromWrap') + }) }) diff --git a/packages/effect/src/handler.ts b/packages/effect/src/handler.ts index 4f3ae82832..9f7d10dc8c 100644 --- a/packages/effect/src/handler.ts +++ b/packages/effect/src/handler.ts @@ -39,17 +39,13 @@ export function handlerGen< handler: HandlerGen, ): ProcedureHandler { return (opts, input) => { - let ef = Effect.gen(() => handler(opts, input)) as Effect.Effect> + const effectContext = opts.context['effect/context'] + const wrap = opts.context['effect/wrap'] as WithEffectContext['effect/wrap'] + const ef = Effect.gen(() => handler(opts, input)) as Effect.Effect> - if (EffectContext.isContext(opts.context['effect/context'])) { - ef = ef.pipe(Effect.provide(opts.context['effect/context'])) - } - - if (typeof opts.context['effect/wrap'] === 'function') { - const intercept = opts.context['effect/wrap'] as Exclude['effect/wrap'], undefined> - ef = intercept(ef, opts) - } - - return runPromise(ef, { signal: opts.signal }) + return runPromise( + typeof wrap === 'function' ? wrap(ef, { ...opts, kind: 'handler' }) : ef, + { context: EffectContext.isContext(effectContext) ? effectContext : undefined, signal: opts.signal }, + ) } } diff --git a/packages/effect/src/middleware.test.ts b/packages/effect/src/middleware.test.ts index 2423f81e4d..786c5932d0 100644 --- a/packages/effect/src/middleware.test.ts +++ b/packages/effect/src/middleware.test.ts @@ -1,6 +1,8 @@ -import type { WithEffectContext } from './context' +import type { EffectWrapOptions, WithEffectContext } from './context' import { call, ORPCError, os } from '@orpc/server' import { Context, Effect } from 'effect' +import { z } from 'zod' +import { handlerGen } from './handler' import { middlewareGen } from './middleware' import * as EffectModule from './runtime' @@ -121,45 +123,105 @@ describe('middlewareGen', () => { }) it('can wrap effect execution via effect/wrap', async () => { - let wrappedCalls = 0 + const wrap = vi.fn((effect: any, _opts: EffectWrapOptions) => effect) const procedure = os .$context>() + .input(z.object({ id: z.string().transform(Number) })) + .errors({ BASE: {} }) .use(middlewareGen(function* ({ next }) { return yield* next() })) .handler(() => 'output') + const signal = AbortSignal.timeout(100) + const context = { 'effect/context': Context.empty(), 'effect/wrap': wrap } + await expect(call( procedure, - undefined, - { - context: { - 'effect/context': Context.empty(), - 'effect/wrap': effect => effect.pipe(Effect.tap(() => Effect.sync(() => { - wrappedCalls += 1 - }))), - }, - }, + { id: '1' }, + { path: ['wrapped', 'procedure'], context, signal, lastEventId: 'id' }, )).resolves.toEqual('output') - expect(wrappedCalls).toBe(1) + expect(wrap).toHaveBeenCalledExactlyOnceWith(expect.anything(), { + kind: 'middleware', + context, + // validated input of the middleware rather than the raw procedure input + input: { id: 1 }, + path: ['wrapped', 'procedure'], + procedure, + signal, + lastEventId: 'id', + errors: { BASE: expect.any(Function) }, + }) + }) + + it('invokes effect/wrap once per middlewareGen and handlerGen', async () => { + const wrap = vi.fn((effect: any, _opts: EffectWrapOptions) => effect) + const context = { 'effect/context': Context.empty(), 'effect/wrap': wrap } + + const procedure = os + .$context>() + .use(middlewareGen(function* ({ next }) { + return yield* next({ context: { first: true } }) + })) + .use(({ next }) => next({ context: { plain: true } })) + .use(middlewareGen(function* ({ next }) { + return yield* next({ context: { second: true } }) + })) + .handler(handlerGen(function* () { + return 'output' + })) + + await expect(call(procedure, undefined, { context })).resolves.toEqual('output') + + expect(wrap.mock.calls.map(([, opts]) => [opts.kind, opts.context])).toEqual([ + ['middleware', context], + ['middleware', { ...context, first: true, plain: true }], + ['handler', { ...context, first: true, plain: true, second: true }], + ]) }) - it('forwards the provided signal to runPromise', async () => { + it('passes downstream failures through effect/wrap', async () => { + const downstreamError = new Error('__DOWNSTREAM__') + + const procedure = os + .$context>() + .use(middlewareGen(function* ({ next }) { + return yield* next() + })) + .handler(() => { + throw downstreamError + }) + + await expect(call(procedure, undefined, { + context: { + 'effect/context': Context.empty(), + 'effect/wrap': (effect, opts) => effect.pipe( + Effect.mapError(cause => new ORPCError('BAD_REQUEST', { message: opts.kind, cause })), + ), + }, + })).rejects.toSatisfy( + error => error instanceof ORPCError && error.message === 'middleware' && error.cause === downstreamError, + ) + }) + + it('forwards effect/context and the provided signal to runPromise', async () => { const controller = new AbortController() + const effectContext = Context.empty().pipe(Context.add(Service1, { id: 'Service1' })) const procedure = os + .$context>() .use(middlewareGen(function* ({ next }) { return yield* next() })) .handler(() => 'output') await expect( - call(procedure, undefined, { signal: controller.signal }), + call(procedure, undefined, { context: { 'effect/context': effectContext }, signal: controller.signal }), ).resolves.toEqual('output') expect(runPromiseSpy).toHaveBeenCalledTimes(1) - expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { signal: controller.signal }) + expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { context: effectContext, signal: controller.signal }) }) }) diff --git a/packages/effect/src/middleware.ts b/packages/effect/src/middleware.ts index 9d106666d0..6fd0a8b3b0 100644 --- a/packages/effect/src/middleware.ts +++ b/packages/effect/src/middleware.ts @@ -64,18 +64,20 @@ export function middlewareGen< catch: error => error, }) - let ef = Effect.gen(() => middleware({ ...opts, next }, input, done)) as Effect.Effect, unknown> + const effectContext = opts.context['effect/context'] + const wrap = opts.context['effect/wrap'] as WithEffectContext['effect/wrap'] + const ef = Effect.gen(() => middleware({ ...opts, next }, input, done)) as Effect.Effect, unknown> - if (EffectContext.isContext(opts.context['effect/context'])) { - ef = ef.pipe(Effect.provide(opts.context['effect/context'])) + let wrapped = ef + if (typeof wrap === 'function') { + const { next: _next, ...rest } = opts + wrapped = wrap(ef, { ...rest, kind: 'middleware', input }) } - if (typeof opts.context['effect/wrap'] === 'function') { - const intercept = opts.context['effect/wrap'] as Exclude['effect/wrap'], undefined> - ef = intercept(ef, opts) - } - - return runPromise(ef, { signal: opts.signal }) + return runPromise(wrapped, { + context: EffectContext.isContext(effectContext) ? effectContext : undefined, + signal: opts.signal, + }) } Object.defineProperty(mid, 'name', { value: middleware.name }) diff --git a/packages/effect/src/runtime.test.ts b/packages/effect/src/runtime.test.ts index 4b74e99b83..384b8adbd3 100644 --- a/packages/effect/src/runtime.test.ts +++ b/packages/effect/src/runtime.test.ts @@ -1,6 +1,6 @@ import { AbortError } from '@orpc/shared' -import { Cause, Effect } from 'effect' -import { describe, expect, it } from 'vitest' +import { Cause, Context, Effect, Option, Scheduler } from 'effect' +import { describe, expect, it, vi } from 'vitest' import { runPromise } from './runtime' describe('runPromise & extractErrorFromCause', () => { @@ -17,6 +17,25 @@ describe('runPromise & extractErrorFromCause', () => { }) }) + describe('context', () => { + class Service1 extends Context.Service()('Service1') {} + + it('runs the effect with the provided services', async () => { + const context = Context.empty().pipe(Context.add(Service1, { id: 'Service1' })) + + await expect(runPromise(Effect.serviceOption(Service1), { context })).resolves.toEqual(Option.some({ id: 'Service1' })) + }) + + it('runs the effect with the provided scheduler', async () => { + const scheduler = new Scheduler.MixedScheduler('sync') + const makeDispatcher = vi.spyOn(scheduler, 'makeDispatcher') + const context = Context.empty().pipe(Context.add(Scheduler.Scheduler, scheduler)) + + await expect(runPromise(Effect.yieldNow.pipe(Effect.as(42)), { context })).resolves.toBe(42) + expect(makeDispatcher).toHaveBeenCalled() + }) + }) + describe('failure - throws original error without FiberFailure wrapper', () => { it('throws signal reason when interrupted with signal', async () => { const signal = AbortSignal.timeout(0) @@ -33,11 +52,6 @@ describe('runPromise & extractErrorFromCause', () => { await expect(runPromise(effect)).rejects.toThrow(new AbortError('All fibers interrupted without error')) }) - it('throws the original Error instance from Effect.fail', async () => { - const original = new TypeError('typed domain error') - await expect(runPromise(Effect.fail(original))).rejects.toThrow(original) - }) - it('throws the original defect from Effect.die', async () => { const defect = new RangeError('unexpected defect') await expect(runPromise(Effect.die(defect))).rejects.toThrow(defect) @@ -76,8 +90,8 @@ describe('runPromise & extractErrorFromCause', () => { await expect(runPromise(effect)).rejects.toThrow(leftError) }) - it('does NOT wrap errors in FiberFailure', async () => { - const original = new TypeError('original') + it('throws the original Error instance from Effect.fail, not a FiberFailure', async () => { + const original = new TypeError('typed domain error') await expect(runPromise(Effect.fail(original))).rejects.toBe(original) }) diff --git a/packages/effect/src/runtime.ts b/packages/effect/src/runtime.ts index dd2d636659..ccc2265a8b 100644 --- a/packages/effect/src/runtime.ts +++ b/packages/effect/src/runtime.ts @@ -1,15 +1,18 @@ import { AbortError } from '@orpc/shared' -import { Cause, Effect, Exit } from 'effect' +import { Cause, Context, Effect, Exit } from 'effect' -export interface RunPromiseOptions { - signal?: undefined | AbortSignal +export interface RunPromiseOptions extends Effect.RunOptions { + /** + * The Effect context the effect runs with, including references such as `Tracer` or `Scheduler`. + */ + context?: undefined | Context.Context } /** * Runs an Effect as a Promise and throws the most meaningful errors. */ export async function runPromise(effect: Effect.Effect, options: RunPromiseOptions = {}): Promise { - const exit = await Effect.runPromiseExit(effect, options) + const exit = await Effect.runPromiseExitWith(options.context ?? Context.empty())(effect, options) if (Exit.isSuccess(exit)) { return exit.value