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
45 changes: 44 additions & 1 deletion apps/web/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
193 changes: 193 additions & 0 deletions apps/web/worker/spike/graph.ts
Original file line number Diff line number Diff line change
@@ -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<Auth, never, RuntimeContext>`. 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<D1Database, never,
// RuntimeContext>` 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<Session | null, never, never> =>
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<UserRow | null, DrizzleError, never> =>
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<string>,
): Effect.Effect<UserRow[], DrizzleError, never> => notImplemented;
const setUsername = (_input: {
userId: string;
value: string;
}): Effect.Effect<SetUsernameResult, never, never> => notImplemented;
const lookupProfile = (
_username: string,
): Effect.Effect<ProfileRow | null, DrizzleError, never> => notImplemented;
const lookupProfileById = (
_userId: string,
): Effect.Effect<ProfileRow | null, DrizzleError, never> => notImplemented;
const listContributions = (_input: {
authorId: string;
after: string | null;
first: number;
}): Effect.Effect<ContributionConnection, DrizzleError, never> => 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),
);
76 changes: 76 additions & 0 deletions apps/web/worker/spike/route.ts
Original file line number Diff line number Diff line change
@@ -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<Layer.Success<typeof AppLayer>, 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},
);
}),
);
Loading