Skip to content
Open
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
96 changes: 58 additions & 38 deletions .patterns/fate-effect-bridge.md
Original file line number Diff line number Diff line change
@@ -1,86 +1,105 @@
# The fate ↔ Effect bridge

How fate's resolvers and source handlers run phoenix domain logic. The short answer: a small family of helpers — `fateQuery`, `fateList`, `fateMutation`, `fateSource` — wraps an Effect generator into the plain-async function fate expects, running it against the captured worker `Context<FateEnv>` (ADR 0029). Feature code never calls `Effect.runPromise*` directly.
How fate's resolvers and source handlers run phoenix domain logic. The short answer: a small family of helpers — `fateQuery`, `fateList`, `fateMutation`, `fateSource` — wraps an Effect generator into the plain-async function fate expects, running it on the **one isolate-level `ManagedRuntime`** carried by the `FateContext` (ADR 0029). Feature code never calls `Effect.runPromise*` directly.

This is the **load-bearing seam** of the backend. The Effect domain services (Sozluk, Pano, Vote, Pasaport, Stats) are protocol-neutral — see [feature-services.md](./feature-services.md). The bridge is the single place they meet the wire.

## The fate context carries the captured service map
## The fate context carries the runtime + the two per-request service values

`createFateServer({context})` produces a per-request context object that every resolver and source handler receives as `ctx`. In phoenix that object carries the live `Context.Context<FateEnv>` the `/fate` route captured with `Effect.context<FateEnv>()` (ADR 0029) — **not** a `ManagedRuntime`:
`createFateServer({context})` produces a per-request context object that every resolver and source handler receives as `ctx`. In phoenix (the F4 model, ADR 0029) that object carries the **one worker-level `ManagedRuntime`** — built once per isolate in worker init, never disposed per request — plus the two genuinely per-request services as VALUES (`auth`, `liveBus`) and the raw `request`:

```ts
// worker/features/fate/context.ts
import type * as Context from "effect/Context";
import type {FateEnv} from "./layers";
import type * as ManagedRuntime from "effect/ManagedRuntime";
import type {LiveBus} from "../fate-live/event-bus";
import type {Auth} from "../pasaport/Auth";
import type {WorkerFateServices} from "./layers";

export interface FateContext {
readonly context: Context.Context<FateEnv>;
export interface FateContext<R = WorkerFateServices> {
readonly runtime: ManagedRuntime.ManagedRuntime<R, never>;
readonly request: Request;
readonly auth: typeof Auth.Service;
readonly liveBus: typeof LiveBus.Service;
}
```

Session is **not** a field on `FateContext`. It's provided into the captured context's `Auth` service when the `/fate` route runs ([alchemy-runtime.md](./alchemy-runtime.md)), so resolvers read it with `yield* Auth.required`.
Carrying `auth`/`liveBus` as VALUES (rather than a captured `Context` or a `provideRequest` closure) makes the per-request contract explicit and invalid states unrepresentable: a `FateContext` cannot exist without both, and the bridge cannot forget to provide one. Session is **not** a field — it lives inside `auth`, read with `yield* Auth.required`. The interface is generic in the runtime env `R` (default `WorkerFateServices`) so a test can drive a resolver on a tiny marker runtime cast-free.

## The low-level runner

One function does the `provide → Exit → wire-error` dance. Everything else funnels through it:
One function does the `provide(Auth/LiveBus) → run-on-runtime → Exit → wire-error` dance. Everything else funnels through it:

```ts
// worker/features/fate/effect.ts
import {Cause, Effect, Exit} from "effect";
import {Cause, Effect, Exit, Option} from "effect";
import {FateRequestError} from "@nkzw/fate/server";
import {LiveBus} from "../fate-live/event-bus";
import {Auth} from "../pasaport/Auth";
import {encodeFateError} from "./errors";
import type {FateContext} from "./context";
import type {FateEnv} from "./layers";

const runEffect = <A>(
ctx: FateContext,
effect: Effect.Effect<A, unknown, FateEnv>,
const runEffect = <A, R>(
ctx: FateContext<R>,
effect: Effect.Effect<A, unknown, R | Auth | LiveBus>,
): Promise<A> =>
Effect.runPromiseExit(Effect.provide(effect, ctx.context)).then((exit) => {
if (Exit.isSuccess(exit)) return exit.value;
const found = Cause.findError(exit.cause);
if (found._tag === "Success") {
const e = found.success;
// Already wire-shaped (resolver-side validation, Auth) → pass through.
if (e instanceof FateRequestError) throw e;
throw encodeFateError(e);
}
// Defects (uncaught throw that never became an Effect failure).
throw encodeFateError(Cause.squash(exit.cause));
});
ctx.runtime
.runPromiseExit(
effect.pipe(
Effect.provideService(Auth, ctx.auth),
Effect.provideService(LiveBus, ctx.liveBus),
),
// The request's AbortSignal interrupts the resolver fiber if the
// fate client disconnects (matches HttpEffect's run-with-signal contract).
{signal: ctx.request.signal},
)
.then((exit) => {
if (Exit.isSuccess(exit)) return exit.value;
// Cause.findErrorOption keeps the Result tag out of boundary code.
return Option.match(Cause.findErrorOption(exit.cause), {
// Already wire-shaped (resolver-side validation, Auth) → pass through.
onSome: (e) => {
throw e instanceof FateRequestError ? e : encodeFateError(e);
},
// Defects (uncaught throw that never became an Effect failure).
onNone: () => {
throw encodeFateError(Cause.squash(exit.cause));
},
});
});
```

This is the single place `Effect.runPromiseExit` appears in the codebase. Worker-level Layers are built once in init (`makeFateLayer` in `features/fate/layers.ts`); the route provides `Auth` per request and captures the live map; the bridge provides that map onto each resolver Effect and runs it on the default runtime. There is nothing to dispose.
This is the single place `runPromiseExit` appears in the codebase. Worker-level Layers are built once in init (`makeFateLayer` in `features/fate/layers.ts`) into the one `ManagedRuntime`; the bridge provides only the two per-request service VALUES (`Auth`, `LiveBus`) onto each resolver Effect and runs it THROUGH that runtime — so resolver spans nest under the runtime's request span and nothing is built or disposed per request. Providing `Auth`/`LiveBus` discharges those two, leaving exactly the runtime's own env `R`.

`FateRequestError` instances pass through verbatim — that's the escape hatch for code that already knows its wire shape. Every other failure goes through `encodeFateError`, which maps domain `_tag`s onto stable codes (below).

## The helper family

fate has four callback shapes; each gets one wrapper. All take an Effect generator and return the plain-async function fate invokes.

Each body is a `Generator<any, A, any>` (resolver bodies `yield*` heterogeneous services and fail with arbitrary tagged errors, so the yield is `any`). The runtime env `R` is the **inner** returned function's generic — inferred from the `ctx` fate passes at invocation, so production gets `WorkerFateServices` and the isolation tests get their marker `R`, neither naming it. `genEffect` asserts the env to `R` (the bridge's single contained boundary cast — see below).

```ts
type Selection = ReadonlyArray<string>;

// Root query: ({ctx, input:{args}, select}) => Promise<Output>
export const fateQuery =
<Args, A>(body: (o: {args: Args | undefined; select: Selection}) => Generator<any, A, any>) =>
({ctx, input, select}: {ctx: FateContext; input: {args?: Args}; select: Array<string>}) =>
runEffect(ctx, Effect.gen(() => body({args: input.args, select})));
<R>({ctx, input, select}: QueryArgs<Args, R>) =>
runEffect(ctx, genEffect(() => body({args: input.args, select})));

// Root list: same, but returns a ConnectionResult (see fate-connections.md)
export const fateList =
<Args, A>(
body: (o: {args: Args | undefined; select: Selection}) => Generator<any, ConnectionResult<A>, any>,
) =>
({ctx, input, select}: {ctx: FateContext; input: {args?: Args}; select: Array<string>}) =>
runEffect(ctx, Effect.gen(() => body({args: input.args, select})));
<R>({ctx, input, select}: QueryArgs<Args, R>) =>
runEffect(ctx, genEffect(() => body({args: input.args, select})));

// Mutation: ({ctx, input, select}) => Promise<Output>
export const fateMutation =
<Input, A>(body: (o: {input: Input; select: Selection}) => Generator<any, A, any>) =>
({ctx, input, select}: {ctx: FateContext; input: Input; select: Array<string>}) =>
runEffect(ctx, Effect.gen(() => body({input, select})));
<R>({ctx, input, select}: MutationArgs<Input, R>) =>
runEffect(ctx, genEffect(() => body({input, select})));
```

A resolver reads as a thin orchestration over a service:
Expand All @@ -99,14 +118,15 @@ queries: {

Source executors (`byId` / `byIds` / `connection`) feed fate's read path. They return **raw domain rows**, not shaped output: fate masks each row to the requested view+selection afterward via the source plan (`plan.resolveMany`), so handlers never receive `select` — they just fetch. fate **does** pass each handler a `plan` argument; our wrapper ignores it (the masking happens after the handler returns).

> **`SourceExecutor` is not exported.** `@nkzw/fate/server` re-exports `SourceRegistry` and `SourceDefinition` but **not** the `SourceExecutor` type. Recover the executor type from the registry's value half — `type SourceExecutor = SourceRegistry<FateContext> extends Map<unknown, infer V> ? V : never` — rather than importing the unexported name. Under `exactOptionalPropertyTypes`, build the executor as **one object literal with conditional spreads** (not conditional property assignment), or the optional fields widen to `… | undefined` and fail to match fate's shape.
> **`SourceExecutor` is not exported.** `@nkzw/fate/server` re-exports `SourceRegistry` and `SourceDefinition` but **not** the `SourceExecutor` type. Recover the executor type from the registry's value half — `type SourceExecutor<R = WorkerFateServices> = SourceRegistry<FateContext<R>> extends Map<unknown, infer V> ? V : never` — rather than importing the unexported name. Under `exactOptionalPropertyTypes`, build the executor as **one object literal with conditional spreads** (not conditional property assignment), or the optional fields widen to `… | undefined` and fail to match fate's shape.

```ts
import type {SourceRegistry} from "@nkzw/fate/server";

type SourceExecutor = SourceRegistry<FateContext> extends Map<unknown, infer V> ? V : never;
type SourceExecutor<R = WorkerFateServices> =
SourceRegistry<FateContext<R>> extends Map<unknown, infer V> ? V : never;

export const fateSource = <Item extends Record<string, unknown>>(handlers: {
export const fateSource = <Item extends Record<string, unknown>, R = WorkerFateServices>(handlers: {
byId?: (id: string) => Generator<any, Item | null, any>;
byIds?: (ids: ReadonlyArray<string>) => Generator<any, ReadonlyArray<Item>, any>;
connection?: (page: {
Expand All @@ -115,7 +135,7 @@ export const fateSource = <Item extends Record<string, unknown>>(handlers: {
take: number;
skip?: number;
}) => Generator<any, ReadonlyArray<Item>, any>;
}): SourceExecutor => {
}): SourceExecutor<R> => {
const {byId, byIds, connection} = handlers;
return {
...(byId ? {byId: ({ctx, id}) => runEffect(ctx, genEffect(() => byId(id)))} : {}),
Expand All @@ -128,7 +148,7 @@ export const fateSource = <Item extends Record<string, unknown>>(handlers: {
};
```

`genEffect` is the single `Effect.gen(body) as Effect.Effect<A, unknown, FateEnv>` assertion the bridge makes: the generators yield heterogeneous services (`any` element type), so `Effect.gen` infers env `unknown`; we assert it back to `FateEnv`, which the captured `Context` provides at run time.
`genEffect` is `Effect.gen(body) as Effect.Effect<A, unknown, R>` the bridge's single contained boundary cast. The body is a `Generator<any, A, any>` whose `any` yield erases the env to `unknown`, so it is asserted to `R`. fate never sees a generator (its contract is `(args) => Promise<Output>`), so the cast is *reducible* in principle (pin `R` in the yield via `Effect.gen.Return`) — but `R` in the yield position is contravariant (a narrow-`R` body fails against the wider `FateEnv`) and the friction cascades into fate's `QueryDefinition<FateContext<WorkerFateServices>>` server constraint, so it is kept as one cast.

See [fate-sources.md](./fate-sources.md) for how these executors wire into the `SourceResolver` and which service backs each type.

Expand Down
9 changes: 6 additions & 3 deletions apps/web/worker/features/fate-live/event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,14 @@ export function liveBusFor(publisher: LivePublisher): typeof LiveBus.Service {
* A capturing {@link LiveBus} for bridge tests. Its publisher runs the real
* {@link topicsForPublish} (so it captures the *resolved* topic keys, catching a
* wrong-but-valid mis-route — e.g. an args publish collapsing to the global
* wildcard) and records each key into the returned `published` array. The test
* provides `layer` with `Effect.provide` and asserts on `published`.
* wildcard) and records each key into the returned `published` array. Tests that
* provide the bus through a `Context`/runtime use `layer`; tests that carry the
* per-request bus VALUE on a `FateContext` (the F4 bridge) use `service`. Both
* close over the SAME `published` array.
*/
export function makeLiveBusTest(): {
readonly layer: Layer.Layer<LiveBus>;
readonly service: typeof LiveBus.Service;
readonly published: ReadonlyArray<string>;
} {
const published: Array<string> = [];
Expand All @@ -335,5 +338,5 @@ export function makeLiveBusTest(): {
const service = liveBusFor((topicKey) => {
published.push(topicKey);
});
return {layer: Layer.succeed(LiveBus)(service), published};
return {layer: Layer.succeed(LiveBus)(service), service, published};
}
Loading
Loading