diff --git a/apps/web/worker/index.ts b/apps/web/worker/index.ts index 752bc64..e2018df 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"; @@ -42,6 +43,9 @@ import type {DeliverFrame, PublishMessage} from "./features/fate-live/protocol.t import {LiveConnections, LiveTopics} from "./features/fate-live/topics.ts"; import {BetterAuthLive} from "./features/pasaport/better-auth-live.ts"; import {makeAppLive} from "./http/app.ts"; +// SPIKE (throwaway): static Effect-DI graph + worker-init ManagedRuntime probe. +import {AppLayer} from "./spike/graph.ts"; +import {makeSpikeRoute} from "./spike/route.ts"; /** * Lift a publish-side {@link PublishMessage} to the {@link DeliverFrame} the @@ -225,8 +229,47 @@ export default Phoenix.make( runtimeContext, }); + // ── SPIKE (throwaway): static-graph ManagedRuntime, wired LAZILY ── + // Capture the worker's REAL ambient context — the alchemy services that + // `AppLayer`'s leaf layers still demand in their residual `R` + // (`RuntimeContext | Providers | WorkerEnvironment | D1ConnectionPolicy`). + // These are live in THIS init scope: the outer `Effect.provide` below + // supplies `D1ConnectionLive` (which carries `WorkerEnvironment` + + // `D1ConnectionPolicy`), and the alchemy worker runtime provides + // `RuntimeContext` + `Providers`. `Effect.context<...>()` materializes the + // whole map; `Layer.succeedContext(ambient)` turns it into a leaf layer that + // discharges `AppLayer`'s residual `R` to `never` — the precondition for + // `ManagedRuntime.make`. ZERO casts: the captured context's type IS the + // residual `R`, so `Layer.provide` checks the discharge exactly. + const spikeAmbient = yield* Effect.context< + | RuntimeContext + | Cloudflare.Providers + | Cloudflare.WorkerEnvironment + | Cloudflare.D1ConnectionPolicy + >(); + // LAZY (primary wiring): `ManagedRuntime.make` does NOT build `AppLayer` + // here — a ManagedRuntime constructs its layer on FIRST use. The only thing + // that forces it is the `/api/spike/whoami` route at REQUEST time. So at + // `alchemy deploy` PLAN time this runtime is constructed but the graph is + // never built, and `DrizzleLive`'s eager `connection.raw` is never pulled — + // Q1 is "safe by lazy construction". (EAGER toggle: to instead STRESS Q1 and + // force the graph at plan time, uncomment the `yield*` below — it builds the + // whole layer in init, attempting `connection.raw` at plan and proving + // whether the eager `raw` pull fires there.) + const spikeRuntime = ManagedRuntime.make( + AppLayer.pipe(Layer.provide(Layer.succeedContext(spikeAmbient))), + ); + // EAGER toggle (one line) — uncomment to force the graph at init/plan time: + // yield* Effect.promise(() => spikeRuntime.runPromise(Effect.void)); + const spikeRoute = makeSpikeRoute(spikeRuntime); + // ── RUNTIME PHASE (per request) ── - return {fetch: AppLive.pipe(HttpRouter.toHttpEffect)}; + // Merge the spike route additively — it runs through `spikeRuntime`, so it + // is NOT part of `AppLive`'s `provideRequest` service wiring and cannot + // disturb `/fate`, `/api/auth/*`, `/fate/live`, or `/api/health`. + return { + fetch: Layer.mergeAll(AppLive, spikeRoute).pipe(HttpRouter.toHttpEffect), + }; }).pipe( // One combined provide (chaining multiple `Effect.provide` can break layer // lifecycle). Three Layers: diff --git a/apps/web/worker/spike/graph.ts b/apps/web/worker/spike/graph.ts new file mode 100644 index 0000000..c8af7d1 --- /dev/null +++ b/apps/web/worker/spike/graph.ts @@ -0,0 +1,193 @@ +// SPIKE (throwaway): idiomatic static Effect-DI layer graph under deploy test. +// +// Brought over from branch `umut/spike-effect-di-graph` (commit 8959368), then +// adapted for the REAL deploy path: the STRUCTURAL STUB ambient services and the +// stub-backed `ManagedRuntime`/`bridgeSketch` from that branch are DROPPED here. +// In the real worker the ambient services (`RuntimeContext`, `Providers`, +// `WorkerEnvironment`, `D1ConnectionPolicy`) come from the actual alchemy worker +// scope (`worker/index.ts` init), not hand-built stubs — so this file exports +// ONLY the static layer definitions (`DrizzleLive`, `PasaportLive`, `AppLayer`). +// The `ManagedRuntime` is built in `worker/index.ts` from `AppLayer` using the +// captured real ambient context (see the SPIKE block there). +// +// Thesis under test: the worker's "function DI" (yield services to plain values +// in an imperative init block, thread them into `(deps) => Layer` factories) can +// be replaced by idiomatic Effect v4 DI — every layer declares its deps in its +// own `R` channel and `yield*`s Tags, composed into ONE static graph. +// +// The worst case is better-auth, because its `auth` field is a *deferred* effect +// `Effect`. If that wires through pure Effect DI +// with honest types, the thesis holds a fortiori. +// +// HARD RULE: ZERO `as`/`as any`/`as unknown as` in this file. +// +// Q1 (plan-phase safety) lives HERE: `DrizzleLive` is a `Layer.effect` whose body +// pulls `connection.raw` EAGERLY (the same `Effect` runtime-only binding `BetterAuthLive` defers under +// `Effect.cached`). Whether building this layer fires `raw` at `alchemy deploy` +// PLAN time depends on WHEN the layer is built — see `worker/index.ts`. + +import * as BetterAuth from "@alchemy.run/better-auth"; +import type {RuntimeContext} from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import {createDrizzle, Drizzle, type DrizzleError, makeDrizzleAccess} from "../db/Drizzle.ts"; +import {PhoenixDb} from "../db/resources.ts"; +import {BetterAuthLive} from "../features/pasaport/better-auth-live.ts"; +import { + type Auth as BetterAuthInstance, + type ContributionConnection, + Pasaport, + type ProfileRow, + type Session, + type SetUsernameResult, + type UserRow, +} from "../features/pasaport/Pasaport.ts"; +import {type Stats, StatsLive} from "../features/stats/Stats.ts"; + +/* ────────────────────────────────────────────────────────────────────────── + * 1. DrizzleLive — `Layer.effect(Drizzle)` that yields the D1Connection Tag and + * binds `PhoenixDb`, then `connection.raw` EAGERLY, then `makeDrizzleAccess`. + * + * This is the same shape `BetterAuthLive` ALREADY uses, EXCEPT BetterAuthLive + * defers its `connection.raw` pull under `Effect.cached`; DrizzleLive pulls it + * eagerly in the layer-build generator. That eager `raw` pull is exactly the + * runtime-only binding Q1 asks about — it is plan-safe ONLY if this layer is + * never BUILT at plan time. + * + * Residual build-time `R` = `D1Connection | Providers | RuntimeContext` + * (`.bind` drags in `Providers`; `connection.raw` drags in `RuntimeContext`). + * The METHODS (`run`/`batch`) are `R = never` (closures over resolved `raw`). + * ────────────────────────────────────────────────────────────────────────── */ +export const DrizzleLive: Layer.Layer< + Drizzle, + never, + Cloudflare.D1Connection | Cloudflare.Providers | RuntimeContext +> = Layer.effect(Drizzle)( + Effect.gen(function* () { + const connection = yield* Cloudflare.D1Connection.bind(PhoenixDb); + const raw = yield* connection.raw; + return makeDrizzleAccess(createDrizzle(raw)); + }), +); + +/* ────────────────────────────────────────────────────────────────────────── + * 2. PasaportLive — `Layer.effect(Pasaport)` that yields `Drizzle` AND the + * `BetterAuth.BetterAuth` Context tag, then `yield*`s better-auth's DEFERRED + * `auth` field to obtain the resolved `Auth` instance. + * + * `yield* betterAuth.auth` pulls `RuntimeContext` into the LAYER's BUILD-TIME + * `R` (discharged once at graph assembly). It does NOT leak into each method's + * `R`, because the methods close over the already-resolved `auth` VALUE. + * + * Residual build-time `R` = `Drizzle | BetterAuth.BetterAuth | RuntimeContext`. + * + * NOTE: only `validateSession` is exercised by the spike route; the other + * methods keep honest signatures with stubbed bodies so `Pasaport.of` (the + * service-shape check) holds — proving the whole surface composes. + * ────────────────────────────────────────────────────────────────────────── */ +export const PasaportLive: Layer.Layer< + Pasaport, + never, + Drizzle | BetterAuth.BetterAuth | RuntimeContext +> = Layer.effect(Pasaport)( + Effect.gen(function* () { + const {run} = yield* Drizzle; + const betterAuth = yield* BetterAuth.BetterAuth; + // The RuntimeContext-leak experiment: yielding the deferred `auth` here + // resolves it to a plain `Auth` value. + const auth: BetterAuthInstance = yield* betterAuth.auth; + + // `R = never` annotations are explicit so the compiler checks that yielding + // `auth` above did NOT leak `RuntimeContext` into the method signatures. + const validateSession = (headers: Headers): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const session = await auth.api.getSession({headers}); + if (!session?.user) return null; + return session; + }, + catch: (cause): {readonly _tag: "ValidateSessionError"; readonly cause: unknown} => ({ + _tag: "ValidateSessionError", + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + console.error("[spike.validateSession]", error.cause); + const out: Session | null = null; + return out; + }), + ), + ); + + const getUserById = (userId: string): Effect.Effect => + Effect.gen(function* () { + const row = yield* run((db) => db.query.user.findFirst()); + if (!row) return null; + const out: UserRow = { + id: row.id, + email: row.email, + name: row.name ?? null, + image: row.image ?? null, + username: row.username ?? null, + }; + return out.id === userId ? out : out; + }); + + const notImplemented = Effect.die("spike: body omitted"); + const getUsersByIds = ( + _userIds: ReadonlyArray, + ): Effect.Effect => notImplemented; + const setUsername = (_input: { + userId: string; + value: string; + }): Effect.Effect => notImplemented; + const lookupProfile = ( + _username: string, + ): Effect.Effect => notImplemented; + const lookupProfileById = ( + _userId: string, + ): Effect.Effect => notImplemented; + const listContributions = (_input: { + authorId: string; + after: string | null; + first: number; + }): Effect.Effect => notImplemented; + + return { + validateSession, + getUserById, + getUsersByIds, + setUsername, + lookupProfile, + lookupProfileById, + listContributions, + }; + }), +); + +/* ────────────────────────────────────────────────────────────────────────── + * 3. AppLayer — compose DrizzleLive + PasaportLive + StatsLive, then + * `Layer.provide` the leaf layers `D1ConnectionLive` and the REAL + * `BetterAuthLive`. + * + * Residual `R` of AppLayer (the alchemy-ambient services the leaf layers still + * demand): `RuntimeContext | Providers | WorkerEnvironment | D1ConnectionPolicy`. + * The REAL worker provides all four at its init scope (where these ambient + * services are live) via `Layer.succeedContext(capturedAmbient)`. + * ────────────────────────────────────────────────────────────────────────── */ +const Leaves = Layer.mergeAll(Cloudflare.D1ConnectionLive, BetterAuthLive); + +export const AppLayer: Layer.Layer< + Stats | Pasaport | Drizzle, + never, + | RuntimeContext + | Cloudflare.Providers + | Cloudflare.WorkerEnvironment + | Cloudflare.D1ConnectionPolicy +> = Layer.mergeAll(StatsLive, PasaportLive).pipe( + Layer.provideMerge(DrizzleLive), + Layer.provide(Leaves), +); diff --git a/apps/web/worker/spike/route.ts b/apps/web/worker/spike/route.ts new file mode 100644 index 0000000..6583525 --- /dev/null +++ b/apps/web/worker/spike/route.ts @@ -0,0 +1,76 @@ +// SPIKE (throwaway): `GET /api/spike/whoami` — the runtime-wiring probe (Q2). +// +// This route runs THROUGH the spike `ManagedRuntime` (built in `worker/index.ts` +// init from the static `AppLayer` + the real captured ambient context). Running +// any effect on that runtime forces the lazy `AppLayer` to BUILD on first use — +// which is what forces `DrizzleLive` (eager `connection.raw`) and `PasaportLive` +// (deferred better-auth `auth` yield) to construct. So a successful response to +// this route proves Q2: the static-graph + worker-init-ManagedRuntime +// architecture resolves REAL data (a real session lookup / DB-backed auth). +// +// Q1 (plan-phase safety) is decided by WHERE this runtime is forced. Because a +// `ManagedRuntime` builds its layer LAZILY on first use, and the ONLY thing that +// forces it is this route at REQUEST time, the graph never builds at `alchemy +// deploy` PLAN time. That makes Q1 "safe by lazy construction" — see the SPIKE +// block in `worker/index.ts` for the lazy-vs-eager toggle and what each proves. + +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import type * as Layer from "effect/Layer"; +import type * as ManagedRuntime from "effect/ManagedRuntime"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import {Pasaport} from "../features/pasaport/Pasaport.ts"; +import type {AppLayer} from "./graph.ts"; + +/** + * Build the `GET /api/spike/whoami` route from the spike `ManagedRuntime`. + * + * The runtime is built in worker init (where the real ambient `RuntimeContext` / + * `Providers` / `WorkerEnvironment` / `D1ConnectionPolicy` are in scope), so this + * route only needs to RUN effects on it. It reads the raw `Request`, runs a + * `Pasaport.validateSession` through the spike graph, and returns JSON. The graph + * builds on the FIRST request (lazy) — that first request is what proves Q2. + * + * @param runtime the `ManagedRuntime` made from `AppLayer` with ambient context + * discharged (so its `R` is `Stats | Pasaport | Drizzle`). + */ +export const makeSpikeRoute = ( + runtime: ManagedRuntime.ManagedRuntime, never>, +) => + HttpRouter.add( + "GET", + "/api/spike/whoami", + Effect.gen(function* () { + const raw = yield* Cloudflare.Request; + // Run the probe on the spike runtime. This is a deliberate Effect→Promise + // boundary into a SEPARATE ManagedRuntime (the whole point of the spike: + // the static graph runs on its own runtime, not the worker's request + // fiber). `runPromiseExit` so a build/resolve failure surfaces as JSON, + // not a thrown 500 — making Q1/Q2 observable from a `curl`. + const exit = yield* Effect.promise(() => + runtime.runPromiseExit( + Effect.gen(function* () { + const pasaport = yield* Pasaport; + const session = yield* pasaport.validateSession(raw.headers); + return { + ok: true as const, + hasSession: session !== null, + user: session?.user?.id ?? null, + }; + }), + ), + ); + + if (Exit.isSuccess(exit)) { + return HttpServerResponse.jsonUnsafe(exit.value); + } + // The graph failed to build or the probe died — report it so a plan-phase + // or runtime failure is legible from the response body. + return HttpServerResponse.jsonUnsafe( + {ok: false as const, error: String(exit.cause)}, + {status: 500}, + ); + }), + );