diff --git a/.patterns/fate-effect-bridge.md b/.patterns/fate-effect-bridge.md index 905d5a5..d3835e0 100644 --- a/.patterns/fate-effect-bridge.md +++ b/.patterns/fate-effect-bridge.md @@ -1,57 +1,74 @@ # 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` (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` the `/fate` route captured with `Effect.context()` (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; +export interface FateContext { + readonly runtime: ManagedRuntime.ManagedRuntime; 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 = ( - ctx: FateContext, - effect: Effect.Effect, +const runEffect = ( + ctx: FateContext, + effect: Effect.Effect, ): Promise => - 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). @@ -59,28 +76,30 @@ This is the single place `Effect.runPromiseExit` appears in the codebase. Worker 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` (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; // Root query: ({ctx, input:{args}, select}) => Promise export const fateQuery = (body: (o: {args: Args | undefined; select: Selection}) => Generator) => - ({ctx, input, select}: {ctx: FateContext; input: {args?: Args}; select: Array}) => - runEffect(ctx, Effect.gen(() => body({args: input.args, select}))); + ({ctx, input, select}: QueryArgs) => + runEffect(ctx, genEffect(() => body({args: input.args, select}))); // Root list: same, but returns a ConnectionResult (see fate-connections.md) export const fateList = ( body: (o: {args: Args | undefined; select: Selection}) => Generator, any>, ) => - ({ctx, input, select}: {ctx: FateContext; input: {args?: Args}; select: Array}) => - runEffect(ctx, Effect.gen(() => body({args: input.args, select}))); + ({ctx, input, select}: QueryArgs) => + runEffect(ctx, genEffect(() => body({args: input.args, select}))); // Mutation: ({ctx, input, select}) => Promise export const fateMutation = (body: (o: {input: Input; select: Selection}) => Generator) => - ({ctx, input, select}: {ctx: FateContext; input: Input; select: Array}) => - runEffect(ctx, Effect.gen(() => body({input, select}))); + ({ctx, input, select}: MutationArgs) => + runEffect(ctx, genEffect(() => body({input, select}))); ``` A resolver reads as a thin orchestration over a service: @@ -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 extends Map ? 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 = SourceRegistry> extends Map ? 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 extends Map ? V : never; +type SourceExecutor = + SourceRegistry> extends Map ? V : never; -export const fateSource = >(handlers: { +export const fateSource = , R = WorkerFateServices>(handlers: { byId?: (id: string) => Generator; byIds?: (ids: ReadonlyArray) => Generator, any>; connection?: (page: { @@ -115,7 +135,7 @@ export const fateSource = >(handlers: { take: number; skip?: number; }) => Generator, any>; -}): SourceExecutor => { +}): SourceExecutor => { const {byId, byIds, connection} = handlers; return { ...(byId ? {byId: ({ctx, id}) => runEffect(ctx, genEffect(() => byId(id)))} : {}), @@ -128,7 +148,7 @@ export const fateSource = >(handlers: { }; ``` -`genEffect` is the single `Effect.gen(body) as Effect.Effect` 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` — the bridge's single contained boundary cast. The body is a `Generator` whose `any` yield erases the env to `unknown`, so it is asserted to `R`. fate never sees a generator (its contract is `(args) => Promise`), 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>` 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. diff --git a/apps/web/worker/features/fate-live/event-bus.ts b/apps/web/worker/features/fate-live/event-bus.ts index b41cc85..d996e3c 100644 --- a/apps/web/worker/features/fate-live/event-bus.ts +++ b/apps/web/worker/features/fate-live/event-bus.ts @@ -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; + readonly service: typeof LiveBus.Service; readonly published: ReadonlyArray; } { const published: Array = []; @@ -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}; } diff --git a/apps/web/worker/features/fate/bridge-products.test.ts b/apps/web/worker/features/fate/bridge-products.test.ts index 698b155..03eadba 100644 --- a/apps/web/worker/features/fate/bridge-products.test.ts +++ b/apps/web/worker/features/fate/bridge-products.test.ts @@ -7,12 +7,14 @@ * — every query, list, mutation, and source — driven through the SAME bridge: * * 1. `Drizzle` + the feature services are built ONCE from a bound D1 (here a - * `node:sqlite` stand-in) via `makeFateLayer` — the worker init layer. - * 2. Per "request" the harness provides only `Auth` + `HttpServerRequest`, - * captures the live service map with `Effect.context()`, and hands - * it to `fateServer.handleRequest` through `{context, request}`. - * 3. The bridge runs each resolver with `Effect.provide(effect, ctx.context)` - * — nothing built or disposed per request. + * `node:sqlite` stand-in) via `makeFateLayer`, wrapped in ONE worker-level + * `ManagedRuntime` — the F4 isolate runtime the worker init builds. + * 2. Per "request" the harness builds the two per-request service VALUES — + * `Auth` (the session) and a capturing `LiveBus` — and hands fate a + * `FateContext` of `{runtime, request, auth, liveBus}`. + * 3. The bridge runs each resolver THROUGH `ctx.runtime`, providing + * `Auth`/`LiveBus` onto each resolver effect — nothing built or disposed per + * request. * * Asserts wire parity with the pre-migration `/fate` surface for these products * (the shapes the pool-workers `fate-pano-*` / `fate-pasaport-*` tests assert): @@ -28,22 +30,24 @@ * Runs in the node pool (no workerd) — same constraint as `bridge-sozluk.test.ts`. */ import {liveConnectionTopic, liveEntityTopic} from "@nkzw/fate/server"; -import {Effect, Layer} from "effect"; -import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import {Effect, ManagedRuntime} from "effect"; import {afterAll, beforeAll, describe, expect, it} from "vitest"; import {createDrizzle} from "../../db/Drizzle"; import baselineMigration from "../../db/drizzle/migrations/0000_d1_baseline.sql?raw"; import {makeSqliteD1, type SqliteD1} from "../../db/sqlite-d1.fake"; import {makeLiveBusTest} from "../fate-live/event-bus"; import {Pano} from "../pano/Pano"; -import {Auth} from "../pasaport/Auth"; import {Pasaport} from "../pasaport/Pasaport"; -import {type FateEnv, makeFateLayer, type WorkerFateServices} from "./layers"; +import {makeFateLayer, type WorkerRuntime} from "./layers"; import {fateServer} from "./server"; let sqlite: SqliteD1; -/** The worker-level layer (Drizzle + features), built once over the bound D1. */ -let WorkerLive: Layer.Layer; +/** + * The worker-level `ManagedRuntime` (Drizzle + features), built ONCE over the + * bound D1 — the F4 isolate runtime. `fateOp` hands it to fate on a + * `FateContext`; the seed helpers run feature services on it directly. + */ +let fateRuntime: WorkerRuntime; const AUTHOR = {id: "u-author", name: "umut", email: "umut@example.com"}; const VOTER = {id: "u-voter", name: "elif", email: "elif@example.com"}; @@ -55,9 +59,9 @@ type FateResult = /** * Drive one fate operation through the bridge the way the `/fate` route does: - * provide per-request `Auth` + `HttpServerRequest`, capture the `Context`, and - * run `fateServer.handleRequest`. `auth` chooses the session (anonymous by - * default). + * build the per-request `Auth` + `LiveBus` VALUES and hand fate a `FateContext` + * of `{runtime, request, auth, liveBus}`, then run `fateServer.handleRequest`. + * `auth` chooses the session (anonymous by default). */ async function fateOp( operation: Record, @@ -70,22 +74,16 @@ async function fateOp( }); // Capturing `LiveBus` (ADR 0039): records the RESOLVED topic keys each - // mutation's `live.*` fans out to, provided structurally like the `/fate` - // route provides the real one. - const {layer: LiveBusTest, published} = makeLiveBusTest(); - - const captureAndServe = Effect.gen(function* () { - const context = yield* Effect.context(); - return yield* Effect.promise(() => fateServer.handleRequest(request, {request, context})); - }).pipe( - Effect.provideService(Auth, {user: opts.auth as never, session: undefined}), - Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)), - // One merged provide (the capturing `LiveBus` + the worker-level services) — - // chaining two `Effect.provide` calls trips the multipleEffectProvide lint. - Effect.provide(Layer.mergeAll(LiveBusTest, WorkerLive)), - ); - - const res = await Effect.runPromise(captureAndServe); + // mutation's `live.*` fans out to, carried as the per-request VALUE like the + // `/fate` route carries the real one. + const {service: liveBus, published} = makeLiveBusTest(); + + const res = await fateServer.handleRequest(request, { + runtime: fateRuntime, + request, + auth: {user: opts.auth as never, session: undefined}, + liveBus, + }); const body = (await res.json()) as {version: number; results: FateResult[]}; return {status: res.status, result: body.results[0]!, published}; } @@ -104,7 +102,7 @@ beforeAll(async () => { const fakeAuth = {api: {getSession: async () => null}} as unknown as Parameters< typeof makeFateLayer >[1]; - WorkerLive = makeFateLayer(db, fakeAuth); + fateRuntime = ManagedRuntime.make(makeFateLayer(db, fakeAuth)); // Seed users directly via raw SQL (better-auth owns `user` in prod; here the // node pool can't forge a session, so we insert the rows the services read). @@ -119,7 +117,7 @@ beforeAll(async () => { // Seed one post + five chronological comments through the live Pano service — // the same lifecycle a user-driven submit would take, so the view rows + stats // land identically. - const seeded = await Effect.runPromise( + const seeded = await fateRuntime.runPromise( Effect.gen(function* () { const pano = yield* Pano; const post = yield* pano.submitPost({ @@ -141,22 +139,23 @@ beforeAll(async () => { ids.push(c.commentId); } return {postId: post.postId, commentIds: ids}; - }).pipe(Effect.provide(WorkerLive)), + }), ); POST_ID = seeded.postId; COMMENT_IDS.push(...seeded.commentIds); // Give AUTHOR a username + profile + one definition contribution so the // pasaport profile feed is a mixed discriminant (post + comment + definition). - await Effect.runPromise( + await fateRuntime.runPromise( Effect.gen(function* () { const pasaport = yield* Pasaport; yield* pasaport.setUsername({userId: AUTHOR.id, value: "umut-author"}); - }).pipe(Effect.provide(WorkerLive)), + }), ); }); -afterAll(() => { +afterAll(async () => { + await fateRuntime?.dispose(); sqlite?.close(); }); diff --git a/apps/web/worker/features/fate/bridge-sozluk.test.ts b/apps/web/worker/features/fate/bridge-sozluk.test.ts index b6b15b9..a9b572e 100644 --- a/apps/web/worker/features/fate/bridge-sozluk.test.ts +++ b/apps/web/worker/features/fate/bridge-sozluk.test.ts @@ -5,14 +5,15 @@ * `ManagedRuntime`: * * 1. Build `Drizzle` + the feature services ONCE from a bound D1 (here a - * `node:sqlite`-backed stand-in) via `makeFateLayer` — the same layer the - * worker init builds. - * 2. Per "request", provide only `Auth` + `RequestContext`, capture the live - * service map with `Effect.context()`, and hand it to - * `fateServer.handleRequest` through `adapterContext` (`{context, request}`). - * 3. The bridge runs each resolver with - * `Effect.runPromiseExit(Effect.provide(effect, ctx.context))` — nothing is - * built or disposed per request. + * `node:sqlite`-backed stand-in) via `makeFateLayer`, wrapped in ONE + * worker-level `ManagedRuntime` — the same isolate runtime the worker init + * builds. + * 2. Per "request", build the two per-request service VALUES — `Auth` (the + * session) and a capturing `LiveBus` — and hand fate a `FateContext` of + * `{runtime, request, auth, liveBus}` (the F4 shape). + * 3. The bridge runs each resolver THROUGH `ctx.runtime`, providing + * `Auth`/`LiveBus` onto each resolver effect — nothing is built or disposed + * per request. * * This runs in the node pool (no workerd): the alchemy worker can't load into * `@cloudflare/vitest-pool-workers` yet (task 7 migrates the harness). The proof @@ -27,8 +28,7 @@ * re-resolves over the same bridge. */ import {liveConnectionTopic, liveEntityTopic} from "@nkzw/fate/server"; -import {Effect, Layer} from "effect"; -import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import {ManagedRuntime} from "effect"; import {afterAll, beforeAll, describe, expect, it} from "vitest"; import {createDrizzle} from "../../db/Drizzle"; // `?raw` so the node/unit pool imports the SQL as a string (the pool-workers @@ -37,13 +37,16 @@ import baselineMigration from "../../db/drizzle/migrations/0000_d1_baseline.sql? import * as schema from "../../db/drizzle/schema"; import {makeSqliteD1, type SqliteD1} from "../../db/sqlite-d1.fake"; import {makeLiveBusTest} from "../fate-live/event-bus"; -import {Auth} from "../pasaport/Auth"; -import {type FateEnv, makeFateLayer, type WorkerFateServices} from "./layers"; +import {makeFateLayer, type WorkerRuntime} from "./layers"; import {fateServer} from "./server"; let sqlite: SqliteD1; -/** The worker-level layer (Drizzle + features), built once over the bound D1. */ -let WorkerLive: Layer.Layer; +/** + * The worker-level `ManagedRuntime` (Drizzle + features), built ONCE over the + * bound D1 — exactly the F4 isolate runtime the worker init builds. Each `fateOp` + * hands it to fate on a `FateContext`; the bridge runs each resolver on it. + */ +let fateRuntime: WorkerRuntime; const SESSION_USER: {id: string; name: string; email: string} = { id: "u-writer", @@ -53,9 +56,9 @@ const SESSION_USER: {id: string; name: string; email: string} = { /** * Drive one fate operation through the bridge the way the `/fate` route does: - * provide per-request `Auth` + `HttpServerRequest`, capture the `Context`, and - * run `fateServer.handleRequest`. `auth` chooses the session (anonymous by - * default). + * build the per-request `Auth` + `LiveBus` VALUES and hand fate a `FateContext` + * of `{runtime, request, auth, liveBus}`, then run `fateServer.handleRequest`. + * `auth` chooses the session (anonymous by default). */ async function fateOp( operation: Record, @@ -67,31 +70,19 @@ async function fateOp( body: JSON.stringify({version: 1, operations: [{id: "1", ...operation}]}), }); - // Provide a capturing `LiveBus` (ADR 0039) the way the `/fate` route provides - // the real one — structurally, through the same context every service flows - // through. `published` records the RESOLVED topic keys a mutation's `live.*` - // fans out to (run through the real `topicsForPublish`), so the round-trip - // tests can assert which topics each write published to. - const {layer: LiveBusTest, published} = makeLiveBusTest(); + // A capturing `LiveBus` (ADR 0039) carried as the per-request VALUE the way the + // `/fate` route carries the real one. `published` records the RESOLVED topic + // keys a mutation's `live.*` fans out to (run through the real + // `topicsForPublish`), so the round-trip tests can assert which topics each + // write published to. + const {service: liveBus, published} = makeLiveBusTest(); - const captureAndServe = Effect.gen(function* () { - // The captured map carries the worker-level services PLUS the per-request - // Auth/HttpServerRequest provided just below — the full FateEnv. - const context = yield* Effect.context(); - const res = yield* Effect.promise(() => fateServer.handleRequest(request, {request, context})); - return res; - }).pipe( - Effect.provideService(Auth, { - user: opts.auth as never, - session: undefined, - }), - Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)), - // One merged provide (the capturing `LiveBus` + the worker-level services) — - // chaining two `Effect.provide` calls trips the multipleEffectProvide lint. - Effect.provide(Layer.mergeAll(LiveBusTest, WorkerLive)), - ); - - const res = await Effect.runPromise(captureAndServe); + const res = await fateServer.handleRequest(request, { + runtime: fateRuntime, + request, + auth: {user: opts.auth as never, session: undefined}, + liveBus, + }); const body = (await res.json()) as { version: number; results: Array< @@ -115,7 +106,7 @@ beforeAll(async () => { const fakeAuth = {api: {getSession: async () => null}} as unknown as Parameters< typeof makeFateLayer >[1]; - WorkerLive = makeFateLayer(db, fakeAuth); + fateRuntime = ManagedRuntime.make(makeFateLayer(db, fakeAuth)); // Seed three definitions with distinct scores so the keyset order is // deterministic: (score desc, created_at asc, id asc). Written straight to the @@ -158,7 +149,8 @@ beforeAll(async () => { }); }); -afterAll(() => { +afterAll(async () => { + await fateRuntime?.dispose(); sqlite?.close(); }); diff --git a/apps/web/worker/features/fate/context.ts b/apps/web/worker/features/fate/context.ts index 814ff50..8db2e42 100644 --- a/apps/web/worker/features/fate/context.ts +++ b/apps/web/worker/features/fate/context.ts @@ -1,27 +1,44 @@ -import type * as Context from "effect/Context"; -import type {FateEnv} from "./layers.ts"; +import type * as ManagedRuntime from "effect/ManagedRuntime"; +import type {LiveBus} from "../fate-live/event-bus.ts"; +import type {Auth} from "../pasaport/Auth.ts"; +import type {WorkerFateServices} from "./layers.ts"; /** * The per-request context fate hands to every resolver and source executor as * `ctx`. * - * Per ADR 0029 it carries a captured `Context.Context` (effect v4's - * service map — the `ServiceMap` the patterns name), **not** a `ManagedRuntime`. - * Worker init builds the worker-level services once (`Drizzle` + features); the - * `/fate` route provides `Auth` per request and picks up the upstream - * `HttpServerRequest` Tag the alchemy/HttpRouter runtime already provides - * (replacing the hand-rolled `RequestContext`), then captures the live map with - * `Effect.context()` and hands it here. The bridge runs each resolver - * with `Effect.runPromiseExit(Effect.provide(effect, ctx.context))` — nothing is - * built or disposed per request. + * The F4 shape: ONE worker-level `ManagedRuntime` (built once per isolate in + * worker init from the fate layer — `Drizzle` + the feature services) carries + * the {@link WorkerFateServices}, and the two genuinely per-request services ride + * here as VALUES — `auth` (the validated session) and `liveBus` (the publish + * capability, ADR 0039). The bridge (`effect.ts`) provides `auth`/`liveBus` onto + * EACH resolver effect with `Effect.provideService` and runs it on `runtime`, so + * resolver spans nest under the runtime's request span and nothing is built or + * disposed per request. * - * Session is **not** a field here. It's provided into the captured context's - * `Auth` service when the route runs, so resolvers read the caller with - * `yield* Auth.required` rather than off the context. + * Carrying the two service VALUES (rather than a captured `Context` or a bundled + * `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. * - * See `.patterns/alchemy-runtime.md` and `.patterns/fate-effect-bridge.md`. + * `request` is the raw `Request` for the rare resolver/source that needs headers + * directly (fate also forwards it). Session is NOT a field — it's inside `auth`, + * read with `yield* Auth.required`. + * + * The interface is generic in the runtime environment `R` (defaulting to the + * production {@link WorkerFateServices}) so a test can wrap a `FateContext` over a + * tiny marker runtime with no cast; production call sites (`route.ts` / + * `index.ts` / `server.ts`) take the default and carry zero churn. + * + * See `.patterns/fate-effect-bridge.md` and `.patterns/alchemy-runtime.md`. */ -export interface FateContext { - readonly context: Context.Context; +export interface FateContext { + /** The worker-level runtime carrying `R` (the {@link WorkerFateServices} by default); one per isolate. */ + readonly runtime: ManagedRuntime.ManagedRuntime; + /** The raw request, for resolvers/sources that read headers directly. */ readonly request: Request; + /** The per-request validated session, provided onto each resolver effect. */ + readonly auth: typeof Auth.Service; + /** The per-request publish capability (ADR 0039), provided onto each resolver effect. */ + readonly liveBus: typeof LiveBus.Service; } diff --git a/apps/web/worker/features/fate/effect.test.ts b/apps/web/worker/features/fate/effect.test.ts index db9b85c..a626b35 100644 --- a/apps/web/worker/features/fate/effect.test.ts +++ b/apps/web/worker/features/fate/effect.test.ts @@ -1,24 +1,23 @@ /** - * Bridge isolation tests — the fate ↔ Effect seam's success/failure mapping. + * Bridge isolation tests — the fate ↔ Effect seam over ONE worker-level + * `ManagedRuntime` (the F4 refactor). * - * These verify the three branches of {@link runEffect} (via the public - * wrappers) in isolation — no workerd, no real D1, no fate server: + * These verify the bridge through its public wrappers in isolation — no workerd, + * no real D1, no fate server — driving each helper exactly as fate does: + * `await wrapped({ctx, input, select})`. * - * - `Exit.Success` → resolves with the value. - * - tagged domain failure → rejects with a `FateRequestError` whose `code` - * is what `encodeFateError` maps the `_tag` to. - * - `FateRequestError` → passes through verbatim (not re-encoded). - * - defect (uncaught throw) → squashed → `encodeFateError` → `INTERNAL_*`. - * - * Per ADR 0029 the bridge provides a captured `Context` and runs on the default - * runtime — no `ManagedRuntime`. A `FateContext` only needs `{context, request}`; - * we build a `Context` carrying just the services each test body yields (`Auth`), - * so the tests stay focused on the seam, not the full feature graph. + * The seam: a worker-level `ManagedRuntime` carries the worker services; the + * per-request `Auth` + `LiveBus` service VALUES ride on the `FateContext` and are + * provided onto EACH resolver effect at run time (not baked into the runtime). + * Tests build a tiny test runtime from a marker-service layer (+ a tracer for the + * span-nesting test), wrap a `FateContext` around it, and assert observable + * behavior through the public helpers. */ import {FateRequestError} from "@nkzw/fate/server"; -import {Context, Data, Effect} from "effect"; +import {Context, Data, Effect, Exit, Layer, ManagedRuntime, Option, Tracer} from "effect"; import {describe, expect, it} from "vitest"; +import {LiveBus, makeLiveBusTest} from "../fate-live/event-bus"; import {Auth, Unauthorized} from "../pasaport/Auth"; import type {FateContext} from "./context"; import {fateMutation, fateQuery, fateSource} from "./effect"; @@ -28,23 +27,32 @@ class BodyRequired extends Data.TaggedError("sozluk/BodyRequired")<{ readonly message: string; }> {} +// A marker service that ONLY the worker-level runtime provides — yielding it from +// a resolver proves the resolver ran on the injected runtime (behavior ①). +class Marker extends Context.Service()( + "@phoenix/test/fate/Marker", +) {} + /** - * Build a `FateContext` whose captured `Context` carries an `Auth` service with - * the given user (or anonymous). The bridge only reads `ctx.context`; the cast - * to the full `FateEnv` is safe because the test bodies yield only `Auth`. + * Build a `FateContext` over a worker-level `ManagedRuntime` carrying the + * `Marker` service, plus the per-request `Auth` + `LiveBus` VALUES. The bridge + * provides `Auth`/`LiveBus` onto each resolver effect and runs it on the runtime. */ -const makeCtx = (user?: {id: string}): FateContext => { - // biome-ignore lint/plugin: a `Context` can't be statically widened to the full `Context` that `FateContext` carries; the bridge under test reads only `Auth` (see the doc comment above). - const context = Context.make(Auth, { - user: user as never, +const makeCtx = ( + opts: {user?: {id: string}; liveBus?: typeof LiveBus.Service; marker?: string} = {}, +): FateContext => { + const runtime = ManagedRuntime.make(Layer.succeed(Marker)({value: opts.marker ?? "marker"})); + const auth: typeof Auth.Service = { + user: opts.user as never, session: undefined, - }) as unknown as FateContext["context"]; - return {context, request: new Request("http://test/fate")}; + }; + const liveBus = opts.liveBus ?? makeLiveBusTest().service; + return {runtime, request: new Request("http://test/fate"), auth, liveBus}; }; -const invoke = ( - fn: (o: {ctx: FateContext; input: {args?: undefined}; select: Array}) => Promise, - ctx: FateContext, +const invoke = ( + fn: (o: {ctx: FateContext; input: {args?: undefined}; select: Array}) => Promise, + ctx: FateContext, ): Promise => fn({ctx, input: {args: undefined}, select: []}); // fate's source handlers receive a `plan` (the masking plan) that the bridge @@ -53,42 +61,27 @@ const invoke = ( const PLAN = undefined as never; describe("fateQuery", () => { - it("resolves with the Effect's success value", async () => { - const resolve = fateQuery(function* () { - yield* Effect.void; - return {ok: true}; + it("runs the resolver on the injected runtime (yields a runtime service)", async () => { + const resolve = fateQuery(function* () { + const marker = yield* Marker; + return {value: marker.value}; }); - await expect(invoke(resolve, makeCtx())).resolves.toEqual({ok: true}); - }); - - it("resolves data produced by a service method (Auth.required)", async () => { - const resolve = fateQuery(function* () { - const {user} = yield* Auth.required; - return {id: user.id}; + await expect(invoke(resolve, makeCtx({marker: "from-runtime"}))).resolves.toEqual({ + value: "from-runtime", }); - await expect(invoke(resolve, makeCtx({id: "u1"}))).resolves.toEqual({id: "u1"}); }); - it("maps a tagged domain failure to its FateRequestError wire code", async () => { + it("throws encodeFateError(tagged error) — the wire-shaped FateRequestError", async () => { const resolve = fateQuery(function* () { return yield* new BodyRequired({message: "tanım boş olamaz"}); }); - await expect(invoke(resolve, makeCtx())).rejects.toMatchObject({ - code: "BODY_REQUIRED", - }); - }); - - it("maps Unauthorized → UNAUTHORIZED", async () => { - const resolve = fateQuery(function* () { - const {user} = yield* Auth.required; // anonymous → Unauthorized - return {id: user.id}; - }); const err = await invoke(resolve, makeCtx()).catch((e) => e); expect(err).toBeInstanceOf(FateRequestError); - expect(err.code).toBe("UNAUTHORIZED"); + expect(err.code).toBe("BODY_REQUIRED"); + expect(err.message).toBe("tanım boş olamaz"); }); - it("passes a pre-built FateRequestError through verbatim", async () => { + it("passes a pre-built FateRequestError through verbatim (not re-encoded)", async () => { const sentinel = new FateRequestError("NOT_FOUND", "nope"); const resolve = fateQuery(function* () { return yield* Effect.fail(sentinel); @@ -97,7 +90,7 @@ describe("fateQuery", () => { expect(err).toBe(sentinel); }); - it("squashes a defect (uncaught throw) to an internal error", async () => { + it("squashes a defect (uncaught throw) → encodeFateError → INTERNAL_SERVER_ERROR", async () => { const resolve = fateQuery(function* () { yield* Effect.void; throw new Error("boom"); @@ -107,13 +100,80 @@ describe("fateQuery", () => { expect(err.code).toBe("INTERNAL_SERVER_ERROR"); }); - it("maps an unknown tagged error to an internal error", async () => { - class Weird extends Data.TaggedError("weird/Unknown")<{readonly message: string}> {} - const resolve = fateQuery(function* () { - return yield* new Weird({message: "?"}); + it("sees the per-request Auth session carried by the FateContext (not the runtime)", async () => { + const resolve = fateQuery(function* () { + const {user} = yield* Auth.required; + return {id: user.id}; + }); + // The session rides on the ctx, NOT the runtime layer — proves Auth is + // provided onto the resolver effect per request. + await expect(invoke(resolve, makeCtx({user: {id: "u1"}}))).resolves.toEqual({id: "u1"}); + }); + + it("an anonymous per-request Auth → Unauthorized → UNAUTHORIZED wire code", async () => { + const resolve = fateQuery(function* () { + const {user} = yield* Auth.required; + return {id: user.id}; }); const err = await invoke(resolve, makeCtx()).catch((e) => e); - expect(err.code).toBe("INTERNAL_SERVER_ERROR"); + expect(err).toBeInstanceOf(FateRequestError); + expect(err.code).toBe("UNAUTHORIZED"); + }); + + it("sees the per-request LiveBus carried by the FateContext and publishes through it", async () => { + const bus = makeLiveBusTest(); + const resolve = fateMutation<{id: string}, {ok: true}>(function* ({input}) { + const liveBus = yield* LiveBus; + yield* liveBus.useIgnore((b) => b.update("Definition", input.id, {changed: ["score"]})); + return {ok: true}; + }); + await expect( + resolve({ctx: makeCtx({liveBus: bus.service}), input: {id: "t1"}, select: []}), + ).resolves.toEqual({ok: true}); + // The capturing bus on the ctx recorded the resolved topic key — proves the + // per-request bus VALUE (not a runtime singleton) was provided onto the effect. + expect(bus.published.length).toBeGreaterThan(0); + expect(bus.published).toContain("entity:Definition:t1"); + }); + + it("nests a resolver span under the runtime's request span (F4 win) — old path is detached", async () => { + // The worker runtime carries a "request span" as its ambient parent + // (`Tracer.ParentSpan`). Because the bridge runs each resolver THROUGH the + // runtime, a resolver's `Effect.withSpan` parents to it. + const requestSpan = Tracer.externalSpan({spanId: "req-span", traceId: "req-trace"}); + const runtime = ManagedRuntime.make( + Layer.mergeAll( + Layer.succeed(Marker)({value: "marker"}), + Layer.succeed(Tracer.ParentSpan)(requestSpan), + ), + ); + const ctx: FateContext = { + runtime, + request: new Request("http://test/fate"), + auth: {user: undefined, session: undefined}, + liveBus: makeLiveBusTest().service, + }; + + // A resolver that opens its own span and returns it — observable through the + // bridge exactly as a value would be. + const resolve = fateQuery(function* () { + return yield* Effect.currentSpan.pipe(Effect.withSpan("resolver")); + }); + const span = await invoke(resolve, ctx); + + // F4: the resolver span's PARENT is the runtime's request span. + expect(Option.isSome(span.parent)).toBe(true); + expect(Option.getOrThrow(span.parent).spanId).toBe("req-span"); + + // Contrast — the OLD bridge path: `Effect.runPromiseExit(Effect.provide( + // probe, servicesOnlyContext))` on the default runtime, with a services-only + // Context that carries NO parent span. The resolver span is a DETACHED root. + const servicesOnly = Context.make(Marker, {value: "marker"}); + const probe = Effect.currentSpan.pipe(Effect.withSpan("resolver")); + const exit = await Effect.runPromiseExit(Effect.provide(probe, servicesOnly)); + const detached = Exit.isSuccess(exit) ? exit.value : undefined; + expect(detached).toBeDefined(); + expect(Option.isNone(detached!.parent)).toBe(true); }); }); @@ -137,7 +197,7 @@ describe("fateMutation", () => { describe("fateSource", () => { it("byId resolves a raw row through the runtime", async () => { - const executor = fateSource<{id: string; name: string}>({ + const executor = fateSource<{id: string; name: string}, Marker>({ byId: function* (id) { yield* Effect.void; return {id, name: `row-${id}`}; @@ -148,7 +208,7 @@ describe("fateSource", () => { }); it("byIds returns a mutable array (spread) of rows", async () => { - const executor = fateSource<{id: string}>({ + const executor = fateSource<{id: string}, Marker>({ byIds: function* (ids) { yield* Effect.void; return ids.map((id) => ({id})); @@ -160,7 +220,7 @@ describe("fateSource", () => { }); it("maps a failing source executor to a wire error", async () => { - const executor = fateSource<{id: string}>({ + const executor = fateSource<{id: string}, Marker>({ byId: function* () { return yield* new Unauthorized({message: "no"}); }, @@ -170,7 +230,7 @@ describe("fateSource", () => { }); it("only defines the handlers that were provided", () => { - const executor = fateSource<{id: string}>({ + const executor = fateSource<{id: string}, Marker>({ byId: function* (id) { yield* Effect.void; return {id}; diff --git a/apps/web/worker/features/fate/effect.ts b/apps/web/worker/features/fate/effect.ts index 6f05128..7ba180d 100644 --- a/apps/web/worker/features/fate/effect.ts +++ b/apps/web/worker/features/fate/effect.ts @@ -5,22 +5,32 @@ * domain lives in Effect `Context.Service`s. This module is the single seam * between them: a small helper family — `fateQuery`, `fateList`, * `fateMutation`, `fateSource` — wraps an Effect generator into the async - * function fate expects, running it against the captured `Context` - * ({@link FateContext.context}) — no per-request `ManagedRuntime` (ADR 0029). + * function fate expects. * - * **No `Effect.runPromiseExit` appears anywhere outside this file.** Resolvers - * and executors are generators; the runner here does the - * `provide(context) → Exit → wire-error` dance once. + * The F4 model (ADR 0029): there is ONE worker-level `ManagedRuntime`, built once + * per isolate in worker init (`index.ts`) and carried on every {@link FateContext} + * as `ctx.runtime`. The bridge runs each resolver THROUGH that runtime, providing + * only the two genuinely per-request services — `Auth` (the validated session) + * and `LiveBus` (the publish capability, ADR 0039) — onto each resolver effect + * with `Effect.provideService`. Nothing is built or disposed per request, and + * because resolvers run through the runtime their spans nest under the runtime's + * request span rather than on a detached default-runtime root. + * + * **No `runPromiseExit` appears anywhere outside this file.** Resolvers and + * executors are generators; the runner here does the + * `provide(Auth/LiveBus) → run-on-runtime → Exit → wire-error` dance once. * * See `.patterns/fate-effect-bridge.md`, `.patterns/alchemy-runtime.md`, and * ADR 0016 / 0029. */ import type {ConnectionResult, SourceDefinition, SourceRegistry} from "@nkzw/fate/server"; import {FateRequestError} from "@nkzw/fate/server"; -import {Cause, Effect, Exit} from "effect"; +import {Cause, Effect, Exit, Option} from "effect"; +import {LiveBus} from "../fate-live/event-bus.ts"; +import {Auth} from "../pasaport/Auth.ts"; import type {FateContext} from "./context.ts"; import {encodeFateError} from "./errors.ts"; -import type {FateEnv} from "./layers.ts"; +import type {FateEnv, WorkerFateServices} from "./layers.ts"; type Selection = ReadonlyArray; @@ -39,66 +49,90 @@ export type AnySourceDefinition = SourceDefinition, unkn export type AnyDataView = AnySourceDefinition["view"]; /** - * Build an Effect from a resolver/executor generator. The generator yields - * heterogeneous services (`yield* Stats`, `yield* Auth`, …), so its element - * type is `any` and `Effect.gen` infers the environment as `unknown`; we assert - * it back to {@link FateEnv}, which the captured `Context` provides. This is the - * single assertion the bridge makes — see the note on {@link runEffect}. + * Build an Effect from a resolver/executor generator. The generator body is + * typed `Generator` — its `any` element type makes `Effect.gen` + * infer the environment as `unknown`, so we assert it back to `R`. This is the + * bridge's single contained boundary cast. + * + * The generator shape is phoenix's OWN choice (fate's resolver contract is + * `(args) => Promise` — fate never sees a generator), so the cast is + * reducible in principle, by pinning `R` via `Effect.gen.Return`. In + * practice it isn't cheap: `R` in the generator yield position is contravariant, + * so a narrow-`R` body (`yield* Sozluk`) fails against the wider `FateEnv`, and + * the friction cascades into fate's `QueryDefinition>` + * server constraint. Kept as one cast: resolver bodies are still checked at their + * own definition sites, and `runEffect` runs them on a runtime that surfaces a + * wrong environment as a runtime "service not found", not a silent miss. */ -const genEffect = (body: () => Generator): Effect.Effect => - Effect.gen(body) as Effect.Effect; +const genEffect = (body: () => Generator): Effect.Effect => + Effect.gen(body) as Effect.Effect; /** - * The one place `runPromiseExit` is called. Provides the captured worker + - * per-request `Context` ({@link FateContext.context}) onto the resolver Effect, - * runs it on the default runtime (ADR 0029 — no per-request `ManagedRuntime`, - * nothing to dispose), and resolves the `Exit`: + * The one place an effect is run. Provides the two per-request service VALUES + * carried by the {@link FateContext} — `Auth` (the validated session) and + * `LiveBus` (the publish capability) — onto the resolver Effect, then runs it on + * the worker-level `ManagedRuntime` ({@link FateContext.runtime}), which supplies + * the worker singletons (built once per isolate, never disposed per request). + * Because the resolver runs THROUGH the runtime, its spans nest under the + * runtime's request span (the F4 observability win) rather than on a detached + * default-runtime root. The `Exit` resolves identically to before: * * - `Exit.Success` → the value. * - tagged failure → `encodeFateError` → throw (fate serializes it). * - `FateRequestError` → pass through verbatim (already wire-shaped). * - defect (uncaught throw) → `Cause.squash` → `encodeFateError` → throw. * + * Generic in the runtime environment `R` (defaulting to the production worker + * services) so a test can run a resolver on a tiny marker runtime; production + * passes the default with zero churn. The effect's environment is `R | Auth | + * LiveBus`; providing `Auth`/`LiveBus` here discharges those two, leaving exactly + * `R` — the runtime's own environment. + * * fate's `executeOperation` catches the throw and turns it into * `{ok: false, error: {code, message, issues?}}`. */ -const runEffect = ( - ctx: FateContext, - // The generator wrappers below build this via `Effect.gen` over a - // `Generator`, so the environment channel erases to `unknown` - // at this boundary. The resolver/executor bodies are checked at their - // own definition site, where `yield* Service` carries the real types; the - // captured `Context` provides `FateEnv` at run time. We assert that shape - // into `Effect.provide` rather than leaking `any` outward. - effect: Effect.Effect, +const runEffect = ( + ctx: FateContext, + effect: Effect.Effect, ): Promise => - 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; + ctx.runtime + .runPromiseExit( + effect.pipe( + Effect.provideService(Auth, ctx.auth), + Effect.provideService(LiveBus, ctx.liveBus), + ), + // Wire the request's abort signal so a disconnected fate client interrupts + // the resolver fiber (matches `HttpEffect`'s run-with-signal contract). + {signal: ctx.request.signal}, + ) + .then((exit) => { + if (Exit.isSuccess(exit)) { + return exit.value; } - throw encodeFateError(e); - } - // Defects (uncaught throw that never became an Effect failure). - throw encodeFateError(Cause.squash(exit.cause)); - }); + // Unwind the Cause with `findErrorOption` (an `Option`) so no `Result` + // tag leaks into 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)); + }, + }); + }); /** A root-query resolver argument bag fate hands the wrapped function. */ -export interface QueryArgs { - readonly ctx: FateContext; +export interface QueryArgs { + readonly ctx: FateContext; readonly input: {readonly args?: Args}; readonly select: Array; } /** A mutation resolver argument bag fate hands the wrapped function. */ -export interface MutationArgs { - readonly ctx: FateContext; +export interface MutationArgs { + readonly ctx: FateContext; readonly input: Input; readonly select: Array; } @@ -114,7 +148,7 @@ export interface MutationArgs { */ export const fateQuery = (body: (o: {args: Args | undefined; select: Selection}) => Generator) => - ({ctx, input, select}: QueryArgs): Promise => + ({ctx, input, select}: QueryArgs): Promise => runEffect( ctx, genEffect(() => body({args: input.args, select})), @@ -132,7 +166,7 @@ export const fateList = select: Selection; }) => Generator, any>, ) => - ({ctx, input, select}: QueryArgs): Promise> => + ({ctx, input, select}: QueryArgs): Promise> => runEffect( ctx, genEffect(() => body({args: input.args, select})), @@ -146,7 +180,7 @@ export const fateList = */ export const fateMutation = (body: (o: {input: Input; select: Selection}) => Generator) => - ({ctx, input, select}: MutationArgs): Promise => + ({ctx, input, select}: MutationArgs): Promise => runEffect( ctx, genEffect(() => body({input, select})), @@ -158,7 +192,12 @@ export const fateMutation = * recover it from the exported `SourceRegistry` Map's value type rather than * naming it directly. */ -export type SourceExecutor = SourceRegistry extends Map ? V : never; +// Generic in the runtime env `R` (defaulting to the production worker services) +// so the per-feature `sources.ts` registries slot into `createFateServer`'s +// `FateContext` Context unchanged, while the isolation tests can name a marker +// `R` and drive an executor with a marker-runtime ctx — both cast-free. +export type SourceExecutor = + SourceRegistry> extends Map ? V : never; /** * Wrap a set of Effect-generator source handlers as a fate `SourceExecutor`. @@ -170,7 +209,7 @@ export type SourceExecutor = SourceRegistry extends Map>(handlers: { +export const fateSource = , R = WorkerFateServices>(handlers: { byId?: (id: string) => Generator; byIds?: (ids: ReadonlyArray) => Generator, any>; connection?: (page: { @@ -180,7 +219,7 @@ export const fateSource = >(handlers: { take: number; skip?: number; }) => Generator, any>; -}): SourceExecutor => { +}): SourceExecutor => { const {byId, byIds, connection} = handlers; // Build as one literal with conditional spreads: under // `exactOptionalPropertyTypes`, assigning to declared-optional fields would @@ -188,7 +227,7 @@ export const fateSource = >(handlers: { return { ...(byId ? { - byId: ({ctx, id}: {ctx: FateContext; id: string}) => + byId: ({ctx, id}: {ctx: FateContext; id: string}) => runEffect( ctx, genEffect(() => byId(id)), @@ -197,7 +236,7 @@ export const fateSource = >(handlers: { : {}), ...(byIds ? { - byIds: ({ctx, ids}: {ctx: FateContext; ids: Array}) => + byIds: ({ctx, ids}: {ctx: FateContext; ids: Array}) => runEffect( ctx, genEffect(() => byIds(ids)), @@ -214,7 +253,7 @@ export const fateSource = >(handlers: { skip, plan, }: { - ctx: FateContext; + ctx: FateContext; cursor?: string; direction: "forward" | "backward"; take: number; diff --git a/apps/web/worker/features/fate/layers.ts b/apps/web/worker/features/fate/layers.ts index 495b186..cd73bfa 100644 --- a/apps/web/worker/features/fate/layers.ts +++ b/apps/web/worker/features/fate/layers.ts @@ -4,20 +4,23 @@ * The departure from phoenix's original per-request `FateRuntime`: there is **no * per-request `ManagedRuntime`**. `Drizzle` (built once from the bound D1) and * the feature services (`Sozluk`, `Pano`, `Vote`, `Pasaport`, `Stats`) are - * **worker-level layers**, constructed once in the worker init and provided onto - * the worker body. Per request the `/fate` route provides only `Auth` + - * `HttpServerRequest` (see `route.ts`) and captures the live service map with - * `Effect.context()`. + * **worker-level layers**, constructed once in the worker init and carried by + * ONE isolate-level `ManagedRuntime` (the {@link WorkerRuntime}). The `/fate` + * bridge runs every resolver THROUGH that runtime, providing only the two + * genuinely per-request services — `Auth` + `LiveBus` — onto each resolver + * effect (`effect.ts`); the routes that yield a worker service directly take it + * from the same runtime's built context (`Layer.effectContext`, see `route.ts` + * / `index.ts`). * * `FateEnv` is the union of every service a fate resolver or source executor may - * touch — the type parameter of the captured `Context` the bridge runs against. + * touch — the environment its generator bodies are checked against. * * The layer graph (mergeAll / provide / provideMerge) is the one in * `.patterns/effect-layer-composition.md`; only *where* it's provided moved, * from a per-request runtime to here. */ import {Layer} from "effect"; -import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import type * as ManagedRuntime from "effect/ManagedRuntime"; import type {Drizzle, DrizzleDb} from "../../db/Drizzle.ts"; import {makeDrizzleLayer} from "../../db/Drizzle.ts"; import type {LiveBus} from "../fate-live/event-bus.ts"; @@ -33,33 +36,37 @@ import {type Stats, StatsLive} from "../stats/Stats.ts"; import {type Vote, VoteLive} from "../vote/Vote.ts"; /** - * Every service available inside a fate resolver / source executor. This is the - * type parameter of the `Context` the `/fate` route captures and the bridge - * provides — `Auth` + `LiveBus` + `HttpServerRequest` are supplied per request - * (`LiveBus` is the per-request publish capability, ADR 0039), the rest are - * worker-level singletons. `HttpServerRequest` is the upstream effect Tag - * (`effect/unstable/http/HttpServerRequest`) the alchemy worker runtime - * provides — it carries `headers`, `url`, `method` directly, so the hand-rolled - * `RequestContext` Tag is gone. + * Every service a fate resolver / source executor may touch — the environment + * the bridge's generator bodies are checked against. It is the worker-level + * {@link WorkerFateServices} plus the two genuinely per-request services the + * bridge provides onto each resolver effect at run time: `Auth` (the validated + * session) and `LiveBus` (the publish capability, ADR 0039). + * + * `HttpServerRequest` is deliberately NOT here: no resolver yields it, and the + * F4 bridge runs each resolver on the worker `ManagedRuntime` (carrying + * {@link WorkerFateServices}) rather than capturing the whole HttpRouter context + * — so the upstream Tag never reaches a resolver. The raw `Request` rides the + * `FateContext` (`ctx.request`) for the rare resolver that needs headers. */ -export type FateEnv = - | Drizzle - | Pasaport - | Vote - | Sozluk - | Pano - | Stats - | Auth - | LiveBus - | HttpServerRequest.HttpServerRequest; +export type FateEnv = WorkerFateServices | Auth | LiveBus; /** - * The worker-level services `makeFateLayer` provides — the `FateEnv` minus the - * two per-request services (`Auth`, `HttpServerRequest`) the `/fate` route - * layers on itself. + * The worker-level services `makeFateLayer` provides — the singletons the worker + * `ManagedRuntime` carries. The per-request `Auth` + `LiveBus` ({@link FateEnv} + * minus these) are provided onto each resolver effect by the bridge, not baked + * into the runtime. */ export type WorkerFateServices = Drizzle | Pasaport | Vote | Sozluk | Pano | Stats; +/** + * The ONE isolate-level `ManagedRuntime` the worker init builds from + * {@link makeFateLayer} — it carries the {@link WorkerFateServices} singletons and + * fails for nothing (`E = never`). The `/fate` bridge runs every resolver through + * it; `route.ts`, `app.ts`, `context.ts`, and the bridge tests all name this exact + * shape, so it lives here once rather than being re-spelled at each site. + */ +export type WorkerRuntime = ManagedRuntime.ManagedRuntime; + /** * Build the worker-level data-plane layer from the bound D1. * diff --git a/apps/web/worker/features/fate/route.ts b/apps/web/worker/features/fate/route.ts index 5510dc5..5d840b3 100644 --- a/apps/web/worker/features/fate/route.ts +++ b/apps/web/worker/features/fate/route.ts @@ -1,97 +1,96 @@ /** - * The `POST /fate` route (ADR 0029, `.patterns/alchemy-http-router.md`). + * The `POST /fate` route (`.patterns/alchemy-http-router.md`, ADR 0029/0039). * - * The worker provides `Drizzle` + the feature services as worker-level layers - * (built once in init — see `layers.ts`). This route is the per-request seam: + * The worker builds ONE `ManagedRuntime` per isolate carrying the worker-level + * services (`Drizzle` + the feature services — see `index.ts` / `layers.ts`). + * This route is the per-request seam: * - * 1. Read the raw `Request` (`Cloudflare.Request`), the env, and the execution - * context. - * 2. Validate the session through the worker-level `Pasaport` — no throwaway - * runtime (this replaces the old `validateSessionCookie`). - * 3. Provide the genuinely per-request services — `Auth` (the validated - * session) and `LiveBus` (the publish capability, ADR 0039) — and pick up - * the upstream `HttpServerRequest` Tag the alchemy/HttpRouter runtime already - * provides; then capture the live service map with `Effect.context()` - * and hand it to fate through `adapterContext` as `{context, request}`. + * 1. Read the raw `Request` and the execution context. + * 2. Validate the session through the worker-level `Pasaport` (provided to the + * route's own context from the runtime's built context, `app.ts`). + * 3. Build the two genuinely per-request service VALUES — `Auth` (the validated + * session) and `LiveBus` (the publish capability, ADR 0039) — and hand them + * to fate on the `FateContext` alongside the worker `runtime`. The bridge + * provides `auth`/`liveBus` onto EACH resolver effect and runs it on the + * runtime (see `effect.ts`). * 4. `LiveBus` closes over a per-request publisher so a mutation's `live.*` * fan-out reaches the topic DO without blocking the response — `waitUntil` - * comes from `Cloudflare.WorkerExecutionContext` (ADR 0029), not a disposed - * runtime. There is no `AsyncLocalStorage` bridge (ADR 0039): the bus is - * provided into the captured context like `Auth`, so a missing provide fails - * loudly instead of silently no-opping. + * comes from `Cloudflare.WorkerExecutionContext`. There is no + * `AsyncLocalStorage` bridge (ADR 0039): the bus is a value on the + * `FateContext`, so a missing provide fails loudly instead of no-opping. * - * Nothing is built or disposed per request: the bridge runs each resolver with - * `Effect.runPromiseExit(Effect.provide(effect, ctx.context))` (see `effect.ts`). + * Nothing is built or disposed per request: the runtime is the isolate-level one, + * and `Auth`/`LiveBus` are plain values provided onto each resolver effect. */ import * as Cloudflare from "alchemy/Cloudflare"; import * as Effect from "effect/Effect"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import {LiveBus, liveBusFor} from "../fate-live/event-bus.ts"; +import {liveBusFor} from "../fate-live/event-bus.ts"; import {defaultLiveLimits} from "../fate-live/route.ts"; import {LiveTopics} from "../fate-live/topics.ts"; -import {Auth} from "../pasaport/Auth.ts"; import {Pasaport} from "../pasaport/Pasaport.ts"; -import type {FateEnv} from "./layers.ts"; +import type {WorkerFateServices, WorkerRuntime} from "./layers.ts"; import {fateServer} from "./server.ts"; /** - * `POST /fate` — the fate data plane. Resolves the session through the - * worker-level `Pasaport`, captures the per-request `Context`, provides the - * per-request `Auth` + `LiveBus`, and serves fate over the captured map. + * Build the `POST /fate` handler over the isolate's worker `ManagedRuntime`. + * + * The runtime carries the {@link WorkerFateServices}; the handler resolves the + * per-request session + publisher and hands fate a `FateContext` of + * `{runtime, request, auth, liveBus}`. The bridge runs each resolver on the + * runtime with `auth`/`liveBus` provided onto it. */ -export const handleFate = Effect.gen(function* () { - const raw = yield* Cloudflare.Request; - const executionCtx = yield* Cloudflare.WorkerExecutionContext; - const pasaport = yield* Pasaport; - const liveTopics = yield* LiveTopics; +export const makeHandleFate = (runtime: WorkerRuntime) => + Effect.gen(function* () { + const raw = yield* Cloudflare.Request; + const executionCtx = yield* Cloudflare.WorkerExecutionContext; + const pasaport = yield* Pasaport; + const liveTopics = yield* LiveTopics; - const session = yield* pasaport.validateSession(raw.headers); + const session = yield* pasaport.validateSession(raw.headers); - // The per-request live publisher (ADR 0028/0029/0039): a mutation's synchronous - // `live.*` call resolves topic keys and fires the typed `TopicDO.publish` RPC - // here. The topic namespace is worker-init-resolved (carried by `LiveTopics`, - // no `env` lookup, no `idFromName`, no string-URL `stub.fetch`); `waitUntil` - // comes from `Cloudflare.WorkerExecutionContext` so the best-effort fan-out - // doesn't block the response. A failed publish is swallowed loudly — the - // mutation response already succeeded. - const publisher = (topicKey: string, message: Parameters[1]) => { - executionCtx.waitUntil( - // Deliberate Effect→Promise boundary: this fire-and-forget publish is - // handed to `waitUntil` (a Promise sink) outside the request fiber. - // `liveTopics.publish` is self-contained (R = never), so it needs no - // surrounding services — `runPromise` is correct, not `runPromiseWith`. - // @effect-diagnostics-next-line effect/runEffectInsideEffect:off - Effect.runPromise(liveTopics.publish(topicKey, message, defaultLiveLimits)).catch( - (error: unknown) => { - console.error(`live publish to topic:${topicKey} failed`, error); - }, - ), - ); - }; + // The per-request live publisher (ADR 0028/0029/0039): a mutation's synchronous + // `live.*` call resolves topic keys and fires the typed `TopicDO.publish` RPC + // here. The topic namespace is worker-init-resolved (carried by `LiveTopics`, + // no `env` lookup, no `idFromName`, no string-URL `stub.fetch`); `waitUntil` + // comes from `Cloudflare.WorkerExecutionContext` so the best-effort fan-out + // doesn't block the response. A failed publish is swallowed loudly — the + // mutation response already succeeded. + const publisher = (topicKey: string, message: Parameters[1]) => { + executionCtx.waitUntil( + // Deliberate Effect→Promise boundary: this fire-and-forget publish is + // handed to `waitUntil` (a Promise sink) outside the request fiber. + // `liveTopics.publish` is self-contained (R = never), so it needs no + // surrounding services — `runPromise` is correct, not `runPromiseWith`. + // @effect-diagnostics-next-line effect/runEffectInsideEffect:off + Effect.runPromise(liveTopics.publish(topicKey, message, defaultLiveLimits)).catch( + (error: unknown) => { + console.error(`live publish to topic:${topicKey} failed`, error); + }, + ), + ); + }; - const res = yield* Effect.gen(function* () { - // Capture the live service map — at this point it holds the worker-level - // services (Drizzle, features), the per-request `Auth` + `LiveBus` provided - // just below, and the `HttpServerRequest` Tag the alchemy/HttpRouter runtime - // already provides — so it carries the full `FateEnv`. The bridge provides it - // onto each resolver Effect. - const context = yield* Effect.context(); - return yield* Effect.promise(() => fateServer.handleRequest(raw, {request: raw, context})); - }).pipe( - Effect.provideService(Auth, { - user: session?.user, - session: session?.session, - }), - // The per-request publish capability (ADR 0039): mutations acquire it with - // `yield* LiveBus` and wrap each publish in `useIgnore`. Provided here exactly - // where `Auth` is — there is no `AsyncLocalStorage` bridge; a missing provide - // fails loudly instead of silently no-opping. - Effect.provideService(LiveBus, liveBusFor(publisher)), - ); + // Hand fate the `FateContext`: the isolate runtime plus the two per-request + // service VALUES. The bridge provides `auth`/`liveBus` onto each resolver + // effect (it never reads them off a captured context), so they are passed as + // values here — not provided onto this route effect. + const res = yield* Effect.promise(() => + fateServer.handleRequest(raw, { + runtime, + request: raw, + auth: {user: session?.user, session: session?.session}, + liveBus: liveBusFor(publisher), + }), + ); - return HttpServerResponse.fromWeb(res); -}); + return HttpServerResponse.fromWeb(res); + }); -/** The `/fate` route as a router layer, ready to merge into `AppLive`. */ -export const fateRoute = HttpRouter.add("POST", "/fate", handleFate); +/** + * The `/fate` route as a router layer, ready to merge into `AppLive`. Built from + * the isolate's worker `ManagedRuntime` in `app.ts`. + */ +export const makeFateRoute = (runtime: WorkerRuntime) => + HttpRouter.add("POST", "/fate", makeHandleFate(runtime)); diff --git a/apps/web/worker/features/fate/server.ts b/apps/web/worker/features/fate/server.ts index 225b794..89c9d0f 100644 --- a/apps/web/worker/features/fate/server.ts +++ b/apps/web/worker/features/fate/server.ts @@ -3,12 +3,12 @@ * * `createFateServer` produces plain `Request → Response` handlers * (`handleRequest` / `handleLiveRequest`) that the `HttpRouter.add` for the - * `POST /fate` route invokes directly — no Hono in between. The route resolves - * `Effect.context()` once per request and hands the resulting `Context` - * down through `adapterContext`; fate's `context` factory passes it through to - * each resolver as `FateContext`. The bridge runner (`features/fate/effect.ts`) - * then runs each resolver Effect via `Effect.provide(effect, ctx.context)` — - * no `ManagedRuntime` (ADR 0029). See `.patterns/fate-server-wiring.md`. + * `POST /fate` route invokes directly — no Hono in between. The route hands a + * `FateContext` of `{runtime, request, auth, liveBus}` down through + * `adapterContext`; fate's `context` factory passes it through to each resolver + * as `ctx`. The bridge runner (`features/fate/effect.ts`) runs each resolver + * Effect on the worker `ManagedRuntime` (`ctx.runtime`) with the per-request + * `Auth`/`LiveBus` provided onto it. See `.patterns/fate-server-wiring.md`. * * Wired surface: sozluk (`term`/`terms`/`definition.*`) + pano * (`post`/`posts`/`post.*`/`comment.*`) + pasaport (`me`/`profile`/ diff --git a/apps/web/worker/http/app.test.ts b/apps/web/worker/http/app.test.ts index 516d7bd..fa287b9 100644 --- a/apps/web/worker/http/app.test.ts +++ b/apps/web/worker/http/app.test.ts @@ -26,6 +26,7 @@ import {drizzle} from "drizzle-orm/d1"; import {Effect} from "effect"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -146,11 +147,20 @@ beforeAll(() => { // `Parameters[1]` (the `Auth` type re-export, which // the deployed worker also satisfies). The concrete and generic `Auth` types // don't statically overlap (TS2352), so the widen needs the `unknown` hop. - const fateLayer = makeFateLayer( - db, - // biome-ignore lint/plugin: see above — concrete `Auth<…>` vs the generic `Auth` re-export don't overlap (TS2352), so this widen needs the hop. - testAuthInstance as unknown as Parameters[1], + // Mirror the production wiring (`index.ts`): build ONE worker `ManagedRuntime` + // from `makeFateLayer`, then derive the route-context `fateLayer` from that + // runtime's built context (`Layer.effectContext(runtime.contextEffect)`) so the + // worker services are constructed exactly once — shared by the resolver runtime + // and the routes that yield them directly — rather than hand-building a parallel + // layer. + const fateRuntime = ManagedRuntime.make( + makeFateLayer( + db, + // biome-ignore lint/plugin: see above — concrete `Auth<…>` vs the generic `Auth` re-export don't overlap (TS2352), so this widen needs the hop. + testAuthInstance as unknown as Parameters[1], + ), ); + const fateLayer = Layer.effectContext(fateRuntime.contextEffect); // A minimal `BaseRuntimeContext` stub. The HTTP-surface cases here never reach // the `/api/auth/*` route's RuntimeContext-consuming secret resolution (sign-up @@ -165,6 +175,7 @@ beforeAll(() => { }; appLayer = makeAppLive({ + fateRuntime, fateLayer, liveLayer, betterAuthLayer, diff --git a/apps/web/worker/http/app.ts b/apps/web/worker/http/app.ts index 7e47202..b4ef754 100644 --- a/apps/web/worker/http/app.ts +++ b/apps/web/worker/http/app.ts @@ -23,8 +23,8 @@ import type * as BetterAuth from "@alchemy.run/better-auth"; import {type BaseRuntimeContext, RuntimeContext} from "alchemy"; import * as Layer from "effect/Layer"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; -import type {WorkerFateServices} from "../features/fate/layers.ts"; -import {fateRoute} from "../features/fate/route.ts"; +import type {WorkerFateServices, WorkerRuntime} from "../features/fate/layers.ts"; +import {makeFateRoute} from "../features/fate/route.ts"; import {liveRoute} from "../features/fate-live/route.ts"; import type {LiveConnections, LiveTopics} from "../features/fate-live/topics.ts"; import {authRoute} from "../features/pasaport/route.ts"; @@ -33,9 +33,18 @@ import {healthApiLayer} from "./health.ts"; /** * Build the application router layer. * - * @param fateLayer the worker-level fate services (Drizzle + features), - * built once in init — discharges the fate, auth, and live - * routes' service requirements via `HttpRouter.provideRequest`. + * @param fateRuntime the isolate's worker `ManagedRuntime` carrying the + * {@link WorkerFateServices} — the `/fate` route runs every + * fate resolver on it (the bridge provides per-request + * `Auth`/`LiveBus` onto each resolver effect, `effect.ts`). + * @param fateLayer the worker-level fate services as a route-context layer, + * derived ONCE from `fateRuntime.contextEffect` in init + * (`Layer.effectContext`) — discharges the fate/auth/live + * routes' direct service requirements (`Pasaport` for the + * fate + live routes' session validation) via + * `HttpRouter.provideRequest`. Sharing the runtime's built + * context means the worker services are constructed exactly + * once per isolate, not once for the runtime and once here. * @param liveLayer the worker-init-resolved DO namespace handles * (`LiveTopics` for the `/fate` publish path, `LiveConnections` * for the `/fate/live` SSE transport), built from the bound @@ -46,6 +55,7 @@ import {healthApiLayer} from "./health.ts"; * scope, so this layer no longer needs the worker env passed in. */ export const makeAppLive = (options: { + readonly fateRuntime: WorkerRuntime; readonly fateLayer: Layer.Layer; readonly liveLayer: Layer.Layer; /** @@ -87,7 +97,7 @@ export const makeAppLive = (options: { // `provideMerge` makes the build order explicit: fateLayer + liveLayer // first, then betterAuthLayer's outputs merged on top with its own // requirements left to the outer worker `Effect.provide`. - const rawRoutes = Layer.mergeAll(fateRoute, authRoute, liveRoute).pipe( + const rawRoutes = Layer.mergeAll(makeFateRoute(options.fateRuntime), authRoute, liveRoute).pipe( HttpRouter.provideRequest( Layer.mergeAll( options.fateLayer, diff --git a/apps/web/worker/index.ts b/apps/web/worker/index.ts index 752bc64..18ff585 100644 --- a/apps/web/worker/index.ts +++ b/apps/web/worker/index.ts @@ -32,6 +32,7 @@ import {RuntimeContext} from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import {betterAuthSecret, environment} from "./config.ts"; import {createDrizzle} from "./db/Drizzle.ts"; @@ -151,7 +152,23 @@ export default Phoenix.make( // validate with the same secret. const betterAuth = yield* BetterAuth.BetterAuth; const authInstance = yield* betterAuth.auth; - const fateLayer = makeFateLayer(createDrizzle(raw), authInstance); + + // Build the ONE worker-level `ManagedRuntime` for this isolate from the fate + // layer (`Drizzle` + the feature services). It carries the + // `WorkerFateServices` singletons; the `/fate` bridge runs every resolver on + // it, providing the per-request `Auth`/`LiveBus` onto each resolver effect + // (`features/fate/effect.ts`). Built once here, lives for the isolate, never + // per request — so resolver spans nest under the runtime's request span and + // there is nothing to dispose between requests. + const fateRuntime = ManagedRuntime.make(makeFateLayer(createDrizzle(raw), authInstance)); + + // The route-context fate services, derived from the SAME runtime's built + // context (`Layer.effectContext(runtime.contextEffect)`) — so the worker + // services are constructed exactly once per isolate, then shared by both the + // resolver runtime and the routes that yield them directly (the fate + live + // routes' `yield* Pasaport` for session validation). `provideRequest` + // (in `app.ts`) discharges those direct route requirements with this layer. + const fateLayer = Layer.effectContext(fateRuntime.contextEffect); // The live path (ADR 0028/0029): the unified `LiveDO` namespace is resolved // ONCE in init (`live`, above) and wrapped as worker-level services. One @@ -219,6 +236,7 @@ export default Phoenix.make( // `auth` cache, so the `/api/auth/*` route's `betterAuth.fetch` reuses it // with no per-request reconstruction. const AppLive = makeAppLive({ + fateRuntime, fateLayer, liveLayer, betterAuthLayer: Layer.succeed(BetterAuth.BetterAuth)(betterAuth),