Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions packages/agent-bundle/src/effect/lift.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
import { Effect } from 'effect';

/**
* Lifts for the dev seam's existing Promise/sync helpers. Both keep the
* thrown/rejected value untouched in the error channel — the dev seam's
* Lifts for the dev seam's existing Promise/sync helpers (Stage 3). Both keep
* the thrown/rejected value untouched in the error channel — the dev seam's
* typed contracts are plain `Error` subclasses that must cross
* `src/effect/boundary.ts` identity-preserved, and several call sites
* re-raise non-Error values (for example an `AbortSignal.reason`) verbatim.
*
* A bare `Effect.tryPromise(fn)` would wrap every rejection in
* `Cause.UnknownError`; these helpers are the only sanctioned way to lift a
* leaf helper (`docs/effect-conventions.md` § Stage 3 "Hurt / gotchas").
*/

export const liftPromise = <A>(evaluate: () => PromiseLike<A>): Effect.Effect<A, unknown> =>
Effect.tryPromise({ catch: (error) => error, try: evaluate });
/**
* The raw value a lifted helper threw or rejected with. It is `unknown` by
* contract — the lift is an identity, not a normalizer — so callers narrow it
* where they know the helper's failure contract (`instanceof`, `isErrno`,
* `Effect.mapError` into a typed dev error) instead of assuming a shape. The
* boundary maps whatever reaches it: typed dev errors and `Error`s rethrow
* as-is, other values are wrapped, interruption becomes `AbortError`.
*/
export type LiftedRejection = unknown;

/**
* Lift a Promise-returning leaf helper. `evaluate` receives Effect's
* interruption `AbortSignal` (aborted when the fiber is interrupted), so a
* cancellable API can be passed the signal directly; thunks that ignore it
* keep working unchanged.
*/
export const liftPromise = <A>(
evaluate: (signal: AbortSignal) => PromiseLike<A>,
): Effect.Effect<A, LiftedRejection> =>
Effect.tryPromise({ catch: (error): LiftedRejection => error, try: evaluate });

export const liftTry = <A>(evaluate: () => A): Effect.Effect<A, unknown> =>
Effect.try({ catch: (error) => error, try: evaluate });
/** Lift a synchronous leaf helper; a throw becomes a typed failure carrying the thrown value. */
export const liftTry = <A>(evaluate: () => A): Effect.Effect<A, LiftedRejection> =>
Effect.try({ catch: (error): LiftedRejection => error, try: evaluate });
39 changes: 39 additions & 0 deletions packages/agent-bundle/tests/effect-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
runSync,
toDevError,
} from '../src/effect/boundary.ts';
import { liftPromise, liftTry } from '../src/effect/lift.ts';
import * as rootApi from '../src/index.ts';

describe('effect boundary (agent-bundle dev seam)', () => {
Expand Down Expand Up @@ -90,3 +91,41 @@ describe('effect boundary (agent-bundle dev seam)', () => {
await expect(runPromise(abortToInterrupt(controller.signal))).rejects.toSatisfy(isAbortError);
});
});

describe('effect lifts (src/effect/lift.ts)', () => {
it('keeps the rejected or thrown value identity-preserved on the fail channel', async () => {
const typed = new EpochStoreError('EPOCH_NOT_FOUND', 'Epoch "e1" does not exist.');
const reason = { code: 'ECUSTOM', message: 'not an Error' };
const rejected = await runPromiseExit(liftPromise(() => Promise.reject(typed)));
expect(Exit.isFailure(rejected) && Cause.squash(rejected.cause)).toBe(typed);
const rawReason = await runPromiseExit(liftPromise(() => Promise.reject(reason)));
expect(Exit.isFailure(rawReason) && Cause.squash(rawReason.cause)).toBe(reason);
const thrown = await runPromiseExit(liftTry((): never => { throw typed; }));
expect(Exit.isFailure(thrown) && Cause.squash(thrown.cause)).toBe(typed);
expect(runSync(liftTry(() => 7))).toBe(7);
// The Promise edge rethrows typed errors as-is and wraps non-Error values,
// exactly per the boundary's mapping table — the lift itself never
// normalizes, so a caller that must re-raise a raw reason reads the Exit.
await expect(runPromise(liftPromise(() => Promise.reject(typed)))).rejects.toBe(typed);
await expect(runPromise(liftPromise(() => Promise.reject(reason)))).rejects.toEqual(new Error(String(reason)));
});

it('hands the lifted helper an AbortSignal that aborts when the fiber is interrupted', async () => {
const host = new AbortController();
let observed: AbortSignal | undefined;
const pending = runPromise(
liftPromise((signal) => {
observed = signal;
return new Promise<never>((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
}),
{ signal: host.signal },
);
await Promise.resolve();
expect(observed?.aborted).toBe(false);
host.abort();
await expect(pending).rejects.toSatisfy(isAbortError);
expect(observed?.aborted).toBe(true);
});
});
21 changes: 13 additions & 8 deletions packages/rsc-runtime/src/reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,18 +285,23 @@ type SettledBoundary = {
readonly ok: boolean;
};

/**
* Waits for the first pending boundary to settle either way. The rejection
* reason is data, not a failure to normalize: React rejects a boundary with a
* `{ message, digest }` object that `renderErrorFrom` reads, so mapping it
* through `toRuntimeError` would erase the digest. Both settlements are folded
* inside the promise, which therefore cannot reject and never puts an
* `unknown` on the Effect error channel.
*/
const waitSettledBoundary = (
pending: readonly PendingBoundary[],
): Effect.Effect<SettledBoundary, Error> =>
): Effect.Effect<SettledBoundary> =>
Effect.raceAll(
pending.map((boundary) =>
Effect.tryPromise({
catch: (error) => error,
try: () => Promise.resolve(boundary.thenable),
}).pipe(
Effect.map(() => ({ boundary, ok: true as const })),
Effect.catch((error) => Effect.succeed({ boundary, error, ok: false as const })),
),
Effect.promise((): Promise<SettledBoundary> => Promise.resolve(boundary.thenable).then(
() => ({ boundary, ok: true as const }),
(error: unknown) => ({ boundary, error, ok: false as const }),
)),
),
);

Expand Down
Loading