Skip to content

Phoenix framework — alchemy + Effect + fate foundation#12

Merged
usirin merged 118 commits into
mainfrom
umut/alchemy-effect-patterns
Jun 1, 2026
Merged

Phoenix framework — alchemy + Effect + fate foundation#12
usirin merged 118 commits into
mainfrom
umut/alchemy-effect-patterns

Conversation

@usirin
Copy link
Copy Markdown
Member

@usirin usirin commented May 25, 2026

Phoenix framework — alchemy + Effect + fate foundation

main has no alchemy, no HttpRouter, no modular Durable Objects, no fate aggregator split. This PR is the framework establishment, not a modernization sweep on an existing stack. By the time it lands, phoenix is a coherent set of opinionated choices that future tooling can assume:

  • One Cloudflare Worker declared by an alchemy stack (apps/web/alchemy.run.ts), with both fate's data plane and HTTP transport composing onto HttpRouter — no wrangler.jsonc, no Hono.
  • Effect-native runtime with typed Layer composition, worker-level singletons (no per-request ManagedRuntime), and a captured ServiceMap bridge between fate resolvers and Effect domain code.
  • fate as the data-layer protocol — /fate for query/mutation, /fate/live for SSE-pushed live views — implemented over a single unified Durable Object.
  • Feature-colocated worker tree — every named app concern lives in one folder under features/; no services/, shared/, infra/, auth/, admin/, runtime/ buckets.

Every reusable shape lands as a pattern doc (.patterns/); every architectural fork lands as an ADR (.decisions/). The framework character itself is the deliverable — 14 new ADRs (00260039) plus 9 updated, 16 new patterns + 16 updated, and a worker entry whose docstrings record the load-bearing invariants.

Diff scope: ~18.8k additions / ~14.8k deletions across ~237 files. The breadth is intentional — this is the floor every future phoenix change builds on.

What this PR establishes

The alchemy stack

apps/web/alchemy.run.ts declares phoenix as a Cloudflare.Worker whose init phase binds D1, the live-DO namespace, and the SPA assets. Dev runs offline via Alchemy.localState(); deploy uses the Cloudflare-hosted Cloudflare.state(), selected by the dev-vs-deploy signal (not bare CI truthiness — ADR 0031). wrangler.jsonc is deleted; bindings are type-enforced at compile time. The CI/deploy story (.github/workflows/deploy.yml) ships alongside.

The Effect runtime + fate bridge

The per-request ManagedRuntime pattern is rejected in favor of worker-level singletons. Drizzle and the feature services are built once from the bound D1 (makeFateLayer(db, auth)). POST /fate provides Auth + HttpServerRequest per request, captures the live service map via Effect.context<FateEnv>(), and the bridge runs each resolver with Effect.provide(effect, ctx.context). Resolver R = never after provision. See ADR 0029 and .patterns/alchemy-runtime.md.

Live fan-out via a unified Durable Object

A single LiveDO class plays both roles — per-connection SSE stream holder and per-topic subscriber registry + fan-out + reap — distinguished by instance-name prefix (connection: vs topic:). It addresses its sibling instances through its own DurableObjectNamespaceScope, resolved once at init, so every RPC method's R stays never. There is no per-call yield* Sibling dance.

This unification (ADR 0037) supersedes the earlier ConnectionDO/TopicDO split (ADR 0025) and retires per-call sibling resolution (ADR 0033). The split could not init-bind its siblings without leaking the sibling Tag into the Layer's requirements channel — a cycle Layer.mergeAll can't satisfy; 0033 worked around it with per-call resolution, 0037 removes the cycle outright by collapsing to one class.

DO state is state.storage KV, not SQL: subscriber rows + a monotonic per-connection generation scalar (used to invalidate dead-instance registry rows) persist as KV entries. There is no @effect/sql-sqlite-do schema and no migration directory.

SSE construction follows Effect canon: Queue.dropping<Uint8Array> + Stream.fromQueue + a heartbeat merged via Stream.tick("15 seconds") + Stream.merge + Stream.ensuring cleanup + HttpServerResponse.stream. Captured as .patterns/effect-sse-externally-driven.md. Frame bytes are unchanged from the prior protocol (ADR 0034: stay on SSE+POST, don't redesign to WebSocket).

Auth via @alchemy.run/better-auth

apps/web/worker/features/pasaport/better-auth-live.ts provides the upstream BetterAuth Context.Service tag via a forked CloudflareD1 Layer (~160 lines) that injects the magic-link + bearer + emailAndPassword plugin stack while reusing phoenix's shared D1. The session secret is read from the BETTER_AUTH_SECRET binding as a Config.redacted (no default; Effect.orDie if absent) — fail-closed, not minted at random.

The BetterAuth.BetterAuth.auth value is Effect<Auth, never, RuntimeContext> — yielding it inside fate resolvers would leak RuntimeContext into every resolver's R. The escape: yield once in worker init (where RuntimeContext is in scope), thread the resolved Auth instance through makePasaportLive(auth) + makeFateLayer(db, auth) as a plain factory parameter. Pasaport's method R stays never. Captured as .patterns/better-auth-with-plugins-on-d1.md.

Feature-colocated worker tree

apps/web/worker/ has five top-level concepts (ADR 0036):

worker/
├── index.ts          (entry — DO host, bindings, env block)
├── env.ts            (deploy-env resolution; fail-closed)  +  config.ts, env.test.ts, types.d.ts
├── db/               (Drizzle + schema + migrations + keyset + resources)
├── http/             (app.ts composition + health route)
└── features/         (every named app-level grouping)
    ├── text/         (utilities)
    ├── fate/         (framework shell + per-feature aggregator barrels)
    ├── fate-live/    (SSE fan-out — the unified LiveDO + protocol + route + event-bus + topics)
    ├── pasaport/     (auth — Pasaport + BetterAuthLive + route + mutations + karma)
    ├── sozluk/, pano/, vote/, stats/

No services/, shared/, infra/, auth/, admin/, runtime/. Each feature owns its full footprint — Service, errors, plus queries.ts / lists.ts / views.ts / shapers.ts / sources.ts / mutations.ts per the per-feature aggregator pattern. The cross-feature aggregators in features/fate/ are barrels re-exporting from each feature, so the SPA's import surface is preserved despite the split (zero src/ file touched by the restructure). The *Admin/seed services and their routes were deleted, not relocated (ADR 0012 retired; the /api/admin/* seeders were a fail-open hole).

Hand-rolled Tags deleted in favor of upstream primitives: CloudflareEnv (use Cloudflare.WorkerEnvironment + Cloudflare.InferEnv), RequestContext (use Effect's HttpServerRequest).

Integration test harness

apps/web/tests/integration/_global-setup.ts deploys the real Stack to local workerd via alchemy/Test/Core in the main process (works around a pool-worker LoopbackServer race), then exposes a black-box HTTP harness (_harness.ts, _localhost-dns.ts) to the test pool. The previous 27 small integration files collapsed to 10 organized by feature (pano-read, pano-mutations, pano-posts-lifecycle, pano-comments, sozluk-read, sozluk-mutations, pasaport, stats, seam, fate-live) plus the 3 harness helpers. Captured as .patterns/alchemy-test-harness.md.

Key architectural decisions

The ADRs that carry the framework character. The pattern docs land next to them; read the pattern when adding code, read the ADR for the why.

ADR Decision Pattern
0032 Upgrade to alchemy@2.0.0-beta.45 + effect@4.0.0-beta.74; accept "deploy infra to cloud, run worker locally" as the dev model. alchemy-overview.md, alchemy-stack-deploy.md
0034 Stay on fate's native SSE + POST protocol; do not redesign to WebSocket. effect-sse-externally-driven.md, live-fan-out-options-considered.md
0036 Features = any named app-level grouping; worker/ has five top-level concepts. feature-services.md (updated), per-feature-fate-aggregators.md
0037 Unify the live plane into one void-aligned LiveDO — one class, two roles, KV storage, self-namespace addressing. Supersedes 0025, retires 0033. effect-sse-externally-driven.md

Supporting decisions from the same arc (00260031) cover the alchemy adoption (0026), dropping Hono for HttpRouter (0027), the alchemy Effect DO model (0028), dissolving the per-request runtime (0029), the single-worker + two-process Vite dev loop (0030), and local-first state for dev / remote state for deploy (0031). 0033 (per-call sibling resolution) is preserved as a historical record of the path that 0037 ultimately replaced. 0035 records the CLI conventions, 0038 the local-only dependency-patch policy, and 0039 the LiveBus Context.Service that drives the live fan-out.

What was considered and rejected

The pattern docs and .patterns/live-fan-out-options-considered.md carry the full record; the most consequential:

  • Per-call sibling resolution for co-hosted mutual DOs (ADR 0033). Implemented, then retired within this PR (ADR 0037): collapsing the two DOs into one class that addresses siblings through its own init-resolved namespace removes the Layer cycle entirely, so no per-call yield* Sibling is needed and every RPC method's R stays never.
  • WebSocket transport for live views. Considered. Rejected (ADR 0034): fate's SSE+POST works, the BYO protocol gives us cursors/backfill we'd reimplement on WS, and the SSE format is the simpler thing to debug.
  • partyserver / partysub (Cloudflare's experimental pub/sub). Surveyed. partysub is too new (no durability story, no QoS); partyserver is the wrong abstraction layer. Track quarterly; revisit if partysub ships durability + QoS.
  • Cloudflare Pub/Sub (MQTT). Dead product line. Off the table.
  • SaaS pub/sub (Ably / PubNub / Pusher). Rejected: introduces a non-Cloudflare dependency for a problem one DO already solves well.
  • Context.Service for parameterized Layers (e.g. an Auth service injected into Pasaport). Rejected for the auth-threading case: factory functions (makePasaportLive(auth)) stay simpler and keep R = never without adding a tag-resolution dance.

Test plan

  • pnpm typecheck — clean (effect-tsgo across project references)
  • pnpm lint — clean (biome), including the custom no-type-assertions GritQL rule
  • Unit suite (vitest --project unit) — green
  • Integration suite (vitest --project integration) — globalSetup deploys the real Stack to local workerd and tears it down; 10 feature files + 3 harness helpers cover the full HTTP surface
  • SSE wire protocol byte-identical (verified by fate-live.test.ts frame assertions)
  • SPA import surface preserved via barrel files (zero src/ file touched by the restructure)

Production cutover notes

LiveDO storage is state.storage KV. The unified LiveDO persists subscriber rows + the per-connection generation scalar as KV entries — no SQL schema, no migration ledger, no first-wake migration step. Any legacy DO carrying old SQL state is irrelevant: the new class never reads it. (This replaces the @effect/sql-sqlite-do migration plan from the superseded two-DO design.)

Bearer plugin is retained in better-auth-live.ts. The SPA's authClient at apps/web/src/auth/client.ts actively sends Authorization: Bearer <token> with localStorage-backed tokens — don't strip the bearer plugin on a "no worker-side consumer" audit without checking the SPA first.

Deploy-env is fail-closed. apps/web/worker/config.ts resolves ENVIRONMENT from config with Config.withDefault("production"), and BETTER_AUTH_SECRET has no default — a deploy that forgets it dies at runtime init (Effect.orDie) rather than booting with a fallback key.

Follow-ups (non-blocking)

  • Live-fire smoke test against alchemy dev once the Redacted.value(undefined) OAuth-credentials bug is worked around with CLOUDFLARE_API_TOKEN.
  • Framework CLI tooling (e.g. a per-DO SQL-migration scaffolder) is deferred — the phoenix-migrate package was dropped because the unified KV-backed LiveDO has no live SQL-migration target. The CLI conventions are recorded in ADR 0035, so the next focused tool starts from the established shape; revisit when a feature reintroduces per-DO SQL.
  • partysub revisit cadence — quarterly check on Cloudflare's experimental topic-pubsub abstraction. Clean migration target if it ships durability + QoS.

@github-actions
Copy link
Copy Markdown

@usirin usirin changed the title patterns: alchemy-effect target-architecture docs Rebuild infrastructure on alchemy + Effect (worker, fate bridge, DOs, deploy) May 25, 2026
usirin and others added 21 commits May 25, 2026 09:13
Document how phoenix's Cloudflare infra would be rebuilt on alchemy-effect
(one Effect program for infra + runtime, replacing wrangler.jsonc, the Hono
entry, manual binding access, and the hand-written DO classes). Not an Effect
migration — both are on effect v4; only the infra seam changes.

New .patterns/alchemy-* set (clearly fenced as target architecture, distinct
from the evergreen effect-*/fate-* docs):

- alchemy-overview     mental model; what-changes-vs-stays; reading order
- alchemy-worker       Cloudflare.Worker<T>()(...), the two phases, props
- alchemy-bindings     bind() deploy-policy + runtime-service; Live-layer convention
- alchemy-runtime      no per-request ManagedRuntime; Effect.services() capture; bridge delta
- alchemy-http-router  HttpApiBuilder (typed JSON) + imperative HttpRouter (raw/SSE)
- alchemy-durable-objects  Effect DO model; typed RPC; state.storage.sql; ConnectionDO/TopicDO port
- alchemy-drizzle-d1   D1Connection.bind -> raw -> drizzle; Drizzle as worker singleton; Drizzle.Schema
- alchemy-stack-deploy alchemy.run.ts + Stack; wrangler.jsonc->alchemy map; dev/deploy; stages

index.md: add the alchemy infra-layer section and reconcile the evergreen note.

Grounded in alchemy-effect source + Effect v4 dist. Two surfaces flagged in-doc
as verify-on-scaffold: the HttpApiBuilder spec API and the pnpm-vs-bun CLI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verified against effect v4 source (unstable/httpapi) and the alchemy
cloudflare-git-artifacts example:

- payload/success/error go in HttpApiEndpoint.post(name, path, {...})
  options object — there is no .setPayload() builder method
- payloads are Schema.Struct, responses Schema.Class, errors
  Schema.TaggedErrorClass; HttpApiSchema.NoContent for empty success
- per-module imports + effect/Schema (capital S), not the lowercase
  effect/schema or a barrel import
- class-extends HttpApi.make(id).add(group) / HttpApiGroup.make(name).add(...)
  confirmed as the canonical naming form

The .add (variadic), HttpApiBuilder.group/.layer, and handler ({payload}/
{params}) shapes in the doc were already correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… plugin)

Verified against alchemy's vite-spa tutorial + Cloudflare.Vite source:

- phoenix's single-worker-serves-both shape is Cloudflare.Worker + assets,
  NOT Cloudflare.Vite (which sets main: undefined — assets-only/SSR-entry,
  can't host the hand-written backend on the same worker)
- @cloudflare/vite-plugin must be removed — alchemy is explicitly
  incompatible with it; react() + the fate() codegen plugin stay
- the SPA's `vite build → dist/client` remains a normal build step,
  uploaded via the assets prop (pnpm build && alchemy deploy)
- flagged: integrated alchemy dev + Vite HMR for this layout is
  "coming soon" upstream — local-dev ergonomics need verifying

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Traced alchemy dev end to end (Cli/commands/dev.ts -> LocalWorkerProvider):
forks the stack under --watch against a local workerd runtime (no CF auth),
then branches on props.vite:

- Cloudflare.Worker + assets (phoenix's shape): runServer -> bundler.watch
  rebuilds/reloads the BACKEND on change, but serves dist/client statically
  -> NO client HMR
- Cloudflare.Vite path: real Vite dev server with HMR, but main: undefined
  so it can't host phoenix's hand-written backend

Conclusion: dev is two processes by design — `vite dev` (SPA + HMR + fate
plugin) proxying /api and /fate* to `alchemy dev` (worker + DOs + D1, live
bindings, backend watch-rebuild). Documented the proxy config and the
simpler `alchemy dev` + `vite build --watch` fallback. Flagged the honest
DX regression vs the current single-process @cloudflare/vite-plugin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decision: keep the Effect-native single worker (bind(), Effect DO model,
ServiceMap runtime). Client HMR comes from a vite dev terminal proxying the
API to alchemy dev — still ONE worker (the 2nd terminal is the Vite dev
server, gone at deploy).

Reframe the dev docs from "regression/soft spot to verify" to the decided
model, and capture WHY the proxy is required: alchemy has two non-mixable
worker runtime paths — the Effect-native path (alchemy dev, no client HMR)
and the Cloudflare.Vite path (@distilled.cloud/cloudflare-vite-plugin, HMR,
but plain-handler entry that can't host the Effect-native worker). Add a
"don't try to add a vite plugin to fix this" note so a future agent doesn't
chase the unmixable path. Note the dev/prod routing-fidelity gap as the
second honest cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ran a real POC (alchemy@2.0.0-beta.44, effect@4.0.0-beta.70): an
Effect-native Cloudflare.Worker under `alchemy dev` + a vite dev SPA
proxying to it, driven in a browser via Playwright.

Confirmed: SSE (HttpServerResponse.stream) flows through the Vite proxy
live, and editing a React component does NOT drop the SSE connection
(stream ran unbroken across edits — good for live views).

Corrected the docs with what the run actually required:
- alchemy dev REQUIRES Cloudflare auth (alchemy login / CLOUDFLARE_API_TOKEN)
  — it is not offline.
- The worker is vhost-routed at http://<name>.localhost:1337; Node can't
  resolve *.localhost, so the Vite proxy must target 127.0.0.1:1337 and
  force the Host header (fixed the placeholder :8787 config).
- Install peers are non-obvious: effect@beta (v4; npm latest is v3),
  @effect/platform-node, @effect/platform-bun.
- Flagged an orthogonal Fast Refresh render-apply quirk on bleeding-edge
  Vite8-beta/plugin-react6/React19 (independent of alchemy) to validate
  against the real app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extended the POC with the two-DO split: ConnectionDO holding an SSE stream
in its per-instance closure + TopicDO keeping subscribers in state.storage.sql
and fanning out. Ran end-to-end on alchemy dev's local runtime — subscribe →
publish → frame arrived on the held SSE stream ({"delivered":1}).

Confirms the load-bearing pieces work locally: typed RPC, DO→DO binding
(TopicDO resolves ConnectionDO via `yield* ConnectionDO` in its init),
state.storage.sql.exec, and enqueueing into a stream held in one DO from
another DO's RPC. The ADR 0023/0025 architecture ports to the Effect DO
model intact. Removes the last "not spiked" caveat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier note ("alchemy dev requires CF auth, not offline") was wrong: it was
conditional on the state-store choice. Verified both ways:

- state: Cloudflare.state()  -> remote store, needs alchemy login + network
  on every dev run (this is what prompted for auth).
- state: Alchemy.localState() -> file-based store (Layer needs only
  FileSystem/Path). alchemy dev booted with NO auth prompt and ZERO external
  connections (lsof-verified); worker runs in local workerd, state in local
  files. Fully offline dev loop.

Recommend localState() for dev (offline) and a remote store for CI/shared
deploys, e.g. state: process.env.CI ? Cloudflare.state() : Alchemy.localState().
deploy still needs network/auth (it hits the CF API); install needs network once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The alchemy infra rebuild lands in this PR, so these describe what main will
be on merge — recorded as accepted, with the obsolete current-code ADRs flipped.

- 0026 Adopt alchemy-effect as the infrastructure layer (umbrella)
- 0027 Drop Hono; HTTP via Effect HttpRouter + HttpApiBuilder
- 0028 Port Durable Objects to alchemy's Effect DO model
- 0029 Dissolve the per-request runtime; worker-level layers + captured ServiceMap
- 0030 Single worker via Worker+assets, with a two-process HMR dev loop
- 0031 Local-first state for dev; remote state for CI and deploy

Supersedes 0017 (per-request runtime) → 0029. Amends-in-part 0023 and 0025
(live views / split DOs) → 0028: the architecture stands, only the DO authoring
model changes. Index + the three existing ADRs updated accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A critical review (verified against alchemy-effect + effect v4 source) found
gaps the docs glossed over. Addressed:

- DO model: switch examples to the modular `.make()` form (REQUIRED for the
  mutually-referencing ConnectionDO/TopicDO); document `getByName`-only
  addressing (idFromName/idFromString/get are unavailable); document the live
  publish-path redesign (liveBus → typed TopicDO.publish RPC, waitUntil via
  Cloudflare.WorkerExecutionContext, no env/AsyncLocalStorage). Downgrade the
  spike claim: only topic→connection verified; the bidirectional/circular
  DO↔DO binding is unverified and needs a spike (ADR 0028 risk).
- Runtime/0029: correct the observability framing — Effect.services captures
  the ServiceMap not FiberRefs; the bridge runs a fresh root fiber on the
  default runtime (drops worker logger/tracer/span), no regression vs today,
  but blocks future tracing; clarify the live fan-out still needs waitUntil
  (teardown ≠ fan-out). No Tracer is installed today, so spans are inert.
- http-router: fix handleLive to stub.fetch(HttpServerRequest) + getByName;
  flag the HttpApiBuilder+imperative-HttpRouter merge as API-sound but
  undemonstrated (spike route precedence/404/OPTIONS).
- drizzle/bindings: fix better-auth to the drizzleAdapter form (not
  database:raw); note raw is an Effect; hoist Pasaport's createAuth to
  worker-init; soften "CloudflareEnv disappears".
- stack-deploy: add the wrangler→alchemy DO-migration-tag handoff + D1
  adoption risk (must resolve before cutover); pin versions.

The PRD (vault) gained matching slices/risks: test-harness wrangler coupling,
live-publish redesign, connection addressing, D1 adoption, cookie-over-proxy,
bidirectional-DO spike.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser spike: the better-auth session cookie survives the Vite dev proxy and
/fate/live validates it via EventSource withCredentials — no config change
needed. Works because better-auth 1.6.10's dev cookie is Secure=false, no
Domain, SameSite=Lax, HttpOnly (host-only on the Vite origin, forwarded
through the proxy).

Two caveats baked in: (a) don't flip Secure on in dev (NODE_ENV=production /
https baseURL / advanced.useSecureCookies break http://localhost storage);
(b) the worker sees Host: 127.0.0.1:<port> via the proxy, so set better-auth
baseURL/trustedOrigins explicitly, not inferred from Host.

Resolves the last open dev-loop question (PRD updated in vault).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, D1)

Three spikes resolved the open blockers; correcting the docs accordingly
(incl. reverting a wrong fix from last round).

DO model (alchemy-durable-objects.md + ADR 0028):
- REVERT the modular `.make()` form — it is NOT implemented for DOs in
  alchemy 2.0.0-beta.44 (only Worker has `.make`; `…Namespace()("Name")` with
  no impl returns a plain object). Both DOs use the INLINE form.
- Bidirectional ConnectionDO<->TopicDO binding is SPIKED and works — but ONLY
  with lazy resolution: `yield* OtherDO` inside the RPC method, never in init
  (eager-in-both-inits OOMs the build, deterministic). Documented the pattern;
  upgraded 0028 from "partially verified" to verified-with-constraint.
- Kept state.storage.sql.exec(...).toArray() (source-confirmed; a spike side
  claim that it's missing was mistaken).

D1 / deploy (alchemy-stack-deploy.md): reframed the D1 "must adopt" blocker to
RESOLVED — phoenix has no irreplaceable prod data (ADR 0009 wipe-and-reseed),
so cutover goes fresh: new D1 + re-seed under a fresh worker script name
(avoids the Unowned/--adopt gate + wrangler->alchemy DO migration-tag
precondition). Adoption-by-name documented as available-but-unused.

Test harness (alchemy-stack-deploy.md): vitest-pool-workers CANNOT load the
alchemy worker (pulls in alchemy's bundler / Node-only modules workerd can't
run) — documented the move to alchemy/Test/Vitest (local-deploy + HTTP) and
the ~30-test porting shape.

PRD (vault) updated to match: stories 18-20, decisions, and all three
open-question blockers marked RESOLVED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stand up the alchemy-effect bottom layer (ADR 0026–0031) as the first
slice of the wrangler→alchemy cutover. No product behavior yet; this is
the bedrock tasks 2–5 build on.

- worker/index.ts is now `Cloudflare.Worker<Phoenix>()(id, props, body)`
  (was the Hono `export default {fetch}`): init binds D1 + the two DO
  namespaces + the SPA `assets`; the runtime phase returns `fetch` as an
  HttpRouter compiled with `HttpRouter.toHttpEffect`, serving GET
  /api/health → 200 JSON. The bound handles are captured in a typed
  WorkerResources record so dropping a bind()/yield* is a compile error.
- alchemy.run.ts is the Alchemy.Stack. It lives in apps/web (not repo
  root) because pnpm isolates node_modules — alchemy/effect only resolve
  from the package. State is `process.env.CI ? Cloudflare.state() :
  Alchemy.localState()` so dev boots fully offline (verified: no login,
  zero network).
- worker/infra/resources.ts declares PhoenixDb (D1) with a static
  migrationsDir + migrationsTable "drizzle_migrations". Avoids
  Drizzle.Schema, which would pull alchemy's drizzle-kit/orm >=1.0.0-rc
  peer and force a data-layer-breaking bump.
- worker/infra/{connection,topic}-do.ts are inline-form alchemy DO
  namespace stubs (ping-only) so the worker can bind them and alchemy can
  derive their migrations; task 5 ports the real RPC/SQL behavior off the
  legacy cloudflare:workers classes.

Deps: effect 4.0.0-beta.65 → 4.0.0-beta.70 (effect@4.0.0 isn't published;
beta.70 satisfies alchemy's >=4.0.0-beta.66 peer), + alchemy
2.0.0-beta.44, @effect/platform-node/-bun, @effect/sql-pg. The drizzle-kit
peer warning is expected and non-fatal.

Worker-init imports use explicit .ts extensions (the alchemy CLI loads the
declaration graph via Node's strict ESM loader); allowImportingTsExtensions
enabled in the worker + app tsconfigs. wrangler.jsonc and the
vitest-pool-workers harness stay until tasks 8/7.

Dev/prod asset note: bare `alchemy dev` routes all paths to the worker
(local runtime sets invoke_user_worker_ahead_of_assets for a list-form
runWorkerFirst), so non-API paths 500 there — by design (ADR 0030: the SPA
is served by `vite dev` in the dev loop, task 6; the assets+runWorkerFirst
precedence is honored at the Cloudflare edge in prod).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ven on sozluk

Replace the per-request ManagedRuntime with worker-level layers and a captured
service map (ADR 0029). Drizzle and the five feature services are now built ONCE
in the worker init from the bound D1 (createDrizzle + makeFateLayer); the /fate
route provides only Auth + RequestContext per request, captures the live context
with Effect.context<FateEnv>(), and hands it to fate. The bridge runs each
resolver with Effect.runPromiseExit(Effect.provide(effect, ctx.context)) —
nothing is built or disposed per request. FateContext carries a
Context.Context<FateEnv>, not a runtime.

New:
- worker/fate/layers.ts: FateEnv / WorkerFateServices + makeFateLayer(db, env)
- worker/fate/route.ts: handleFate / fateRoute (POST /fate), session via
  worker-level Pasaport, livePublishContext for the mutation fan-out
- worker/fate/bridge-sozluk.test.ts: the task-2 proof — sozluk query/list, a
  failing resolver → wire code, and a mutation round-trip + re-resolve, driven
  through fateServer.handleRequest in the node pool over a node:sqlite-backed D1
- worker/fate/__support__/sqlite-d1.ts: the D1-shaped SQLite stand-in

Changed:
- services/Drizzle.ts: createDrizzle + makeDrizzleLayer (DrizzleLive retained for
  the legacy harness/admin until tasks 3-7)
- fate/context.ts + effect.ts: ServiceMap (Context) replaces ManagedRuntime; the
  bridge's one runPromiseExit now provides the captured context
- index.ts: build the fate layer in init, mount fateRoute via
  HttpRouter.provideRequest(fateLayer)

Gotchas worth knowing:
- effect@4.0.0-beta.70 names the API Effect.context / Effect.provide /
  Context.Context — not the Effect.services / provideServices / ServiceMap the
  docs used. Corrected alchemy-runtime.md and ADR 0029 in place.
- HttpRouter.add lifts route requirements into Request markers; discharge them
  with HttpRouter.provideRequest, not Layer.provide.
- alchemy dev/deploy Node-loads the worker's whole runtime-reachable import
  graph, so every relative import under fate/features/services/db now carries a
  .ts extension (directory imports → /index.ts) — same convention task 1 set for
  infra/. Verified the worker boots offline and serves POST /fate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ew bridge

Task 2 established the worker-as-runtime seam and proved it on sozluk. The
/fate route already serves the full fateServer (every product), so pano,
pasaport, vote, and stats resolve through the same worker-level layer with no
per-request ManagedRuntime — they just lacked bridge coverage.

Add worker/fate/bridge-products.test.ts: drives fateServer.handleRequest the
way the route does (per-request Auth + RequestContext, captured Context,
Effect.provide(effect, ctx.context)) over a node:sqlite D1, asserting wire
parity with the pre-migration fate-pano-* / fate-pasaport-* surface:

  - pano: posts(sort/host) list, post(idOrSlug) detail + Post.comments keyset
    connection (no skips/dupes), post.submit / comment.add re-resolving the
    changed entity, Comment scalar surface (author/authorId/myVote).
  - vote: post.vote / comment.vote move the score and re-resolve the entity;
    retract returns to 0; anonymous write -> UNAUTHORIZED.
  - pasaport: me (anon -> UNAUTHORIZED, authed -> row), profile(username)
    identity + live counters, Profile.contributions discriminant feed,
    user.setUsername round-trip.
  - stats: landingStats counters + build version.
  - sources: User.byIds (post author) and Post.byId resolve their relations
    through the same captured context.

Fix the node:sqlite D1 stand-in's run() to return {success, meta, results}:
real D1 carries rows in results when a SELECT is run through .run() (the Pano
stats recompute reads r.results[0] off db.run(sql`SELECT ...`)). DML still
executes via stmt.run() and returns an empty results.

No resolver or source references the deleted per-request runtime or reads
CloudflareEnv — the only ManagedRuntime mentions left in the data plane are
doc comments describing its absence.

typecheck 0, lint 0 errors, unit 74/74. The 8 integration files remain the
pre-existing pool-workers breakage (task 7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…removed

Replace the last Hono-shaped wiring with the @effect/platform router. The
worker's whole HTTP surface now composes into one AppLive compiled with
HttpRouter.toHttpEffect:

- Typed JSON via HttpApiBuilder groups (worker/http/admin-api.ts +
  admin-handlers.ts): GET /api/health and the dev-only /api/admin/* seeders
  (sozluk upsert/clear, pano seed, pasaport backfill) with schema-decoded
  payloads and typed responses. The AdminAuth env gate maps to a 403 Forbidden.
- Raw Request via imperative HttpRouter.add (worker/http/auth-route.ts):
  * /api/auth/* forwards to better-auth (Pasaport.handleAuth); * /agents/*
  is the inert 404 stub. POST /fate stays as before.
- worker/http/app.ts assembles them. The HttpApi group handlers' domain
  requirements (AdminAuth + the …Admin services) surface as route markers, so
  they're discharged with HttpRouter.provideRequest — Layer.provide does not
  discharge route markers. HttpApiBuilder.layer needs HttpPlatform/FileSystem/
  Path/Etag, stubbed (Workers serve no files; the SPA is the assets binding).

Supporting changes:
- layers.ts: add makeAdminLayer (the admin layer set, ADR 0012) over the same
  bound Drizzle.
- Pasaport: hoist createAuth to once-per-isolate (alchemy-drizzle-d1.md) and
  feed it explicit baseURL/trustedOrigins/secret from env — dev runs behind the
  Vite proxy (Host is 127.0.0.1:<port>), and better-auth refuses its default
  secret. No Secure/Domain changes (dev cookie stays host-only on
  http://localhost). index.ts surfaces the bound raw D1 as env.PHOENIX_DB so
  better-auth's adapter and the data plane share one D1.
- live-route.ts: drop the hono Context type (takes raw Request/Env now) so no
  Hono import remains in the worker. Wiring /fate/live + the DO redesign is
  task 5.

Verified live on offline `alchemy dev`: health 200, the importer seeds 48
sozluk terms idempotently through the HttpApi endpoints and they re-resolve over
fate, and sign-up → host-only session cookie → authenticated fate `me` succeeds
end-to-end. Unit suite 78/78 (+4 new worker/http/app.test.ts), typecheck and
lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both live fan-out DOs move off the plain `cloudflare:workers` classes onto
alchemy's inline `DurableObjectNamespace` form (ADR 0028). Path-dispatch +
JSON (de)serialization become typed RPC; the subscriber registry stays in
`state.storage.sql`; the held SSE stream, persisted generation, bounded
fan-out, and the consecutive-miss alarm reap are a verbatim port.

The connection↔topic fan-out is a circular DO↔DO binding resolved LAZILY in
the RPC method body (`yield* SiblingDO` per call, never in init). Two cycle
hazards bit here:
  - the TS base-class cycle (each DO's class type depending on the other's),
    broken by reading the sibling through a deferred function that casts it to
    an opaque `Effect<{getByName}, never, Worker>`;
  - a runtime TDZ crash — a module-top-level sibling reference reads the
    binding mid-evaluation and throws on `alchemy dev` boot — fixed by deferring
    that read into the function (invoked at call time, after both modules load).
Typecheck passed but only the live boot caught the TDZ.

The shared per-instance logic lives in `infra/live-instance.ts` (generic over
the lazy resolver's requirement so it type-checks with `Worker` in the real DO
and `never` in tests). The publish path is redesigned: `liveBus` ->
`LiveTopics.publish` typed RPC, the namespace resolved once in worker init,
`getByName` addressing (no `idFromName`/`idFromString`/string-URL fetch), and
`waitUntil` from `Cloudflare.WorkerExecutionContext` (replacing the
AsyncLocalStorage-carried `{env, waitUntil}`). `/fate/live` becomes an
`HttpRouter.add` route forwarding to `ConnectionDO` via `LiveConnections`,
gated by the worker-level `Pasaport` session check.

The fan-out swallows defects too (`catchCause`, not `catch`) so a misbehaving
sibling RPC is treated as "couldn't reach" rather than crashing the publish.

Tests: a node-pool suite drives the real instance builders wired as in-process
siblings over a `node:sqlite`-backed DurableObjectState fake — subscribe ->
publish -> frame on the held stream, reconnect generation stale-detection, the
alarm reap, and the leave-on-unreachable invariant — since the workerd
black-box harness is task 7. Verified end-to-end on offline `alchemy dev`: a
`post.submit` mutation's live frame arrives on a held SSE stream.

Also drains the request body in the inert `/agents/*` 404 stub (silences the
workerd "can't read request stream" warning).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cloudflare vite plugin only drives alchemy's Cloudflare.Vite worker path
(a plain export-default fetch handler), which can't host our Effect-native
Cloudflare.Worker (bind() + the Effect DO model). Per ADR 0030 local dev is
two processes: `vite dev` serves the SPA with HMR + the fate() codegen plugin,
`alchemy dev` runs the one worker.

Add a server.proxy forwarding /api and /fate* to alchemy dev. It targets
127.0.0.1:1337 with a forced `Host: phoenix.localhost` header and
changeOrigin:false — alchemy dev serves the worker vhost-routed and Node can't
resolve *.localhost (a target of phoenix.localhost:1337 fails ENOTFOUND, and
changeOrigin would rewrite our forced Host back to the IP). A Vite proxy key is
a prefix match, so /fate covers /fate, /fate/live (SSE) and /fate/*.

With the plugin gone the SPA build is no longer nested under dist/client/client
(nor is the dist/client/phoenix worker bundle emitted) — it lands flat in
dist/client, so the worker's assets.directory is corrected back to ./dist/client
(was the task-1 temporary ./dist/client/client). The single-worker assets +
runWorkerFirst precedence is unchanged and still serves both at the edge.

Verified on offline `alchemy dev` + `vite dev`: /api/health proxies to 200 JSON,
/fate reaches the worker (400 on empty payload), /fate/live is session-gated
(401). A held /fate/live SSE stream received a heartbeat AFTER an App.tsx edit
fired `[vite] hmr update` — HMR doesn't tear down the proxied SSE connection
(it's not on the HMR'd module graph).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old integration suites ran inside workerd via @cloudflare/vitest-pool-workers,
which cannot load the alchemy Cloudflare.Worker (it transitively imports rolldown's
native binding + Node-only modules). The ~8 fate suites had been failing to load
since the alchemy rebuild; this replaces the whole harness.

New model: deploy the real stack to a local workerd (dev:true + Alchemy.localState,
fully offline) and assert black-box over HTTP. The deploy happens in a Vitest
globalSetup running in the MAIN process — the alchemy dev sidecar's Node-side
LoopbackServer (used for D1 callbacks) loses a net.Server address race inside a
Vitest pool worker and hangs the whole worker, but comes up cleanly in the main
process (the same context the alchemy CLI runs in). globalSetup publishes the URL
via PHOENIX_TEST_URL; the test fork only makes HTTP requests. installLocalhostDns()
resolves *.localhost in both. The integration project runs single-fork, no isolation,
console-intercept disabled (workerd inherits the fork's stdout; Vitest's console
wrapper otherwise EPIPEs it).

- tests/integration/_harness.ts — HTTP client + helpers (fate/fateBatch, signUp,
  seedTerm via the dev admin route, SSE openSse/liveControl, readFrame/readEvent).
- tests/integration/_global-setup.ts — deploy/health-poll/teardown, main process.
- tests/integration/fate.test.ts — sözlük/pano/pasaport/vote/stats reads, lists,
  mutations, re-resolution, wire-error parity (10 cases over /fate).
- tests/integration/fate-live.test.ts — live SSE: 401 without cookie, subscribe →
  post.submit → prependNode frame on the held stream, reconnect-bumps-generation
  (the two old runInDurableObject/alarm cases reworked black-box; the DO internals
  + alarm reap stay covered by worker/infra/live-instance.test.ts).
- Deleted the 25 pool-workers suites these two replace; moved the pure edited-after
  test to src/lib/datetime.test.ts (unit project).
- Deleted dead legacy files: worker/fate/{runtime,connection-do,topic-do}.ts (only
  the old pool-workers tests imported them). Fixed the doc comments that referenced
  them in infra/resources.ts, infra/live-instance.ts, fate/layers.ts.
- vitest.config.ts: integration (globalSetup + black-box HTTP) + unit (node) as two
  projects with distinct sequence.groupOrder (Vitest 4 requires it when maxWorkers
  differ). No pool-workers anywhere.

pnpm --filter @phoenix/web test = 109/109 green offline (12 files: 10 unit + 2
integration). pnpm typecheck = 0, pnpm lint = 0 (the 25 pre-existing pool-workers
suppression warnings are gone with the files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Delete wrangler.jsonc and move the whole toolchain onto alchemy (ADR
0026-0031). `alchemy.run.ts` is now the single source of deploy truth;
`alchemy deploy --stage <name>` ships an isolated worker + fresh D1 +
both DOs + assets per stage.

- Remove dead deps left by tasks 4/6/7: `hono` (no worker import remains),
  `@cloudflare/vite-plugin`, `@cloudflare/vitest-pool-workers`, and `wrangler`
  itself. Prune the matching catalog entries; lockfile re-synced.
- package.json scripts: drop the `cf:deploy*`/`cf-typegen` wrangler scripts;
  add `deploy` (build + alchemy deploy), `destroy`, and split dev into
  `dev`/`dev:web`/`dev:worker`. Root `pnpm dev` now runs both processes.
- turbo.json: add `deploy` (depends on build) and a persistent `dev:worker`.
- worker env: read `BETTER_AUTH_SECRET` from `process.env` at deploy time
  (the worker class body evals in the alchemy CLI process), falling back to
  the fixed dev value offline. Production secret comes from CI.
- CI: drop the `cf-typegen` step (the committed worker-configuration.d.ts
  carries the Env type). Deploy workflow rewritten onto alchemy: prod = push
  to main (`--stage prod`), PR previews = `--stage pr-<n>` (isolated), cleanup
  = `alchemy destroy --stage`.
- Docs: CLAUDE.md/README dev + deploy commands updated to the alchemy loop.

Verified live against the real account (OAuth, ddde48a2...): `alchemy deploy
--stage taskeight` created a fresh worker + D1, migrations applied (fate
queries resolve over the deployed D1), both DOs + assets uploaded; health 200
+ fate query ok smoke test passed; sozluk:import --base-url re-seeded 48 terms
(idempotent re-run skipped all 48); a second `--stage taskeight-iso` got its
own empty D1 (isolation proven); both verification stages destroyed after.
The old wrangler `phoenix` worker never existed on the account (10007); the
old wrangler-managed D1s (phoenix_db + _staging/_test) remain for the operator
to delete.

`pnpm install --frozen-lockfile`, lint, typecheck, build, and the full test
suite (109/109, offline) all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The alchemy-effect rebuild has landed — the worker runs on alchemy +
Effect HttpRouter/HttpApiBuilder, both DOs are inline Effect-model, the
fate bridge uses worker-level service singletons, tests run on
alchemy/Test, and deploy is alchemy-managed (wrangler deleted). So the
.patterns/alchemy-* docs that were written as "target architecture" now
describe the live codebase.

- index.md: drop the "target architecture" fence on the alchemy section;
  list the alchemy-* docs as evergreen alongside effect-*/fate-*.
- alchemy-*.md: remove "Today / On alchemy / not yet / spiked / verify
  with a spike" framing; rewrite to present-tense descriptions of the
  current worker.

Corrected a few stale claims against the source while here:
- DOs live in worker/infra/, not worker/fate/ (fixed code-comment paths
  in alchemy-durable-objects.md).
- alchemy-runtime.md body aligned to the v4 API the worker actually uses
  (Context.Context / Effect.context / Effect.provide), matching the
  correction note task 2 left.
- createAuth is hoisted to layer-build in Pasaport; reframed the
  drizzle-d1 note from "should hoist" to present-state.
- index.md fate-* rows no longer say "Hono" / "per-request runtime" /
  single "LiveDO" (Hono removed, runtime is the captured service map,
  LiveDO split into ConnectionDO + TopicDO per ADR 0025).
- version-pinning note reflects the exact catalog pins; wrangler gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@usirin usirin force-pushed the umut/alchemy-effect-patterns branch from 3ffe06b to 1f1084b Compare May 25, 2026 16:34
usirin and others added 6 commits May 25, 2026 11:19
…ype-check it

The harness migration (task 7) moved integration tests off
@cloudflare/vitest-pool-workers onto a local alchemy deploy + black-box HTTP,
but in doing so collapsed 27 pool-workers files (~177 cases) down to 2 files
(~13 cases) — a real coverage regression, not just a format change. This ports
the deleted behavioral coverage back onto the new harness, split across
per-area files instead of one grab-bag.

Integration suite is now 10 files / 116 black-box cases (was 1 grab-bag
fate.test.ts):
  seam, sozluk-read, sozluk-mutations, pano-read, pano-mutations,
  pano-comments, pano-posts-lifecycle, pasaport, stats, fate-live.

Every assertion is an HTTP round-trip against the deployed worker — sözlük /
pano / pasaport / vote / stats queries, lists, mutations, re-resolution,
wire-error codes, keyset pagination, ownership, vote idempotency, and the
soft-delete `[silindi]` placeholder semantics. Data is seeded via the admin
seed routes and fate mutations under signed-up cookies; D1 is shared across
the suite so every test uses uniquely-prefixed slugs/emails/usernames and
asserts stats by `>=` delta.

The ~61 non-ported cases are not black-box-observable and are either
re-expressed (direct D1 row/karma/hot_score reads → re-resolution over fate) or
covered elsewhere: the DO-internal/alarm/generation cases live in
worker/infra/live-instance.test.ts; `edited-after` in src/lib/datetime.test.ts;
the Vote table-rename atomicity rollback can't be driven over HTTP. Each is
documented inline.

Also closes a real hole the port exposed: tests/integration was in NO tsconfig
project, so `pnpm typecheck` never type-checked the integration suite at all
(it passed only by skipping the files, while the editor flagged the `.ts`
imports as TS5097). Added tests/integration to tsconfig.worker.json — it needs
the same `.ts` import specifiers + workers-runtime globals and imports the
alchemy stack in its global-setup. The suite is now type-checked.

While there, quieted the @effect/language-service suggestion noise that was
surfacing in tsgo/CI output: set includeSuggestionsInTsc:false (these are
suggestion-severity and never affected the exit code; they still show
in-editor) and annotated the handful of intentional patterns the plugin
flags — the inline DO factory's nested-Effect return, the typed
`Effect.succeed<T|undefined>(undefined)` needed for `=== undefined` narrowing,
the imperative Web Streams try/catch, and the waitUntil publisher bridge — with
per-line `@effect-diagnostics-next-line ...:off` directives + rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e harness

The integration global-setup constructs `Cloudflare.providers()`, which validates
`CLOUDFLARE_ACCOUNT_ID` (and token) from the environment at construction time —
even though `dev: true` + `localState()` runs the worker in a local workerd and
never calls the Cloudflare API. On a dev machine an account is resolvable from a
wrangler/alchemy profile so it passes silently; on a clean CI runner there is
none, so the whole suite died at startup with
`AuthError: Missing required env: CLOUDFLARE_ACCOUNT_ID` (this is why CI's test
job failed in ~10s — not the ~9-min offline run). The dev deploy is fully local,
so any value satisfies the check; inject inert placeholders when absent so the
suite is self-contained and runs in CI with no login, profile, or secrets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The worker's `env` block (evaluated in the alchemy CLI process at deploy
time) hardcoded `ENVIRONMENT: "development"` — opening every env-gated
surface (admin seeders, AdminAuth, magic-link token log) in production —
and silently fell back to a committed dev BETTER_AUTH_SECRET, shipping
forgeable sessions if a deploy forgot the real secret.

Extract a pure, tested `resolveDeployEnv` (shared/deploy-env.ts):
- ENVIRONMENT resolves from process.env.ENVIRONMENT, defaulting to
  "development" only when unset.
- BETTER_AUTH_SECRET prefers a real secret; on the offline/dev path
  (not CI, or under VITEST) falls back to the dev secret; on a real
  deploy (CI, non-VITEST) with no secret it throws at stack-eval rather
  than booting on the known dev key.

The dev-vs-deploy signal keys off explicit values (CI must be the literal
"true", VITEST always wins), mirroring the state-store selector, so
CI="false" is treated as the dev path.

app.test.ts gains a regression asserting the admin seeder is gated 403
when the gate is derived from a non-development ENVIRONMENT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The state-store selector keyed off process.env.CI, so a laptop
`alchemy deploy` (no CI) deployed real Cloudflare infra while recording
state only in the local .alchemy/ dir — diverging from the CI-shared
store and breaking diff/collab. CI is also set for both the deploy
workflow and the integration-test job, so it can't tell a real deploy
from a test run.

Replace it with `resolveStateMode`, a pure selector keyed off the real
dev-vs-deploy signal: a real `alchemy deploy` always resolves to
`Cloudflare.state()` (with or without CI); only `alchemy dev` and the
Vitest harness resolve to `localState()` (offline).

The `state` option is evaluated synchronously at module-eval, before any
Effect / AlchemyContext is in scope, so the runtime `AlchemyContext.dev`
and `ALCHEMY_PHASE` aren't reachable. The selector instead reads the dev
signal off process.env: `alchemy dev` spawns its exec subprocess with
`dev: true` in ALCHEMY_EXEC_OPTIONS (deploy runs inline and never sets
it), plus the ALCHEMY_DEV override the test harness honors, plus VITEST.
A malformed exec-options blob fails safe toward the shared store.

Lives in worker/shared/deploy-env.ts next to resolveDeployEnv so the
dev-vs-deploy policy stays in one tested place. 8 new unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The worker assembled its env then double-cast it (`... as unknown as
Env`, then read `ENVIRONMENT` back through `(env as unknown as
Record<string, unknown>)`). That laundering is what let the prod-gate
bug hide from the type checker: the wrangler-generated `Env` declares
`ENVIRONMENT: "development"` (a literal), so a direct `=== "development"`
comparison is always-true on paper and the `Record` cast was needed to
widen it back to a real string.

Add `shared/worker-env.ts` with a typed `WorkerEnv` (the alchemy runtime
env record overlaid with the injected `PHOENIX_DB` and a string-typed
`ENVIRONMENT`) and an `adminAllowed(env)` derivation. `index.ts` now
builds `env: WorkerEnv` with no cast and derives the admin gate as a
typed read. `CloudflareEnv`, `makeFateLayer`, and the dead `AdminRuntime`
helper carry `WorkerEnv` instead of the literal-bound `Env` so the widened
`ENVIRONMENT` is honest end-to-end.

Also corrected the stale module header that claimed only `GET /api/health`
was wired — the body wires health/fate/auth/admin/live via `makeAppLive`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`_global-setup.ts` drove the deploy through `Stack as never`,
`Core.deploy(...) as never` and a trailing `as {url: string}` — the last
of which was an unchecked promise cast, so a change to the stack's output
shape would only surface as a runtime `out.url` break.

Recover the stack's compiled output type from `typeof Stack` and thread it
through a thin `deployStack` helper: `Core.deploy<StackOutput>` infers the
shape from the stack and `Core.run` awaits its resolved output. The base
`StackEffect` requirements are a subset of what `deploy` accepts, so once
`A` is pinned no cast is needed — and `out.url` is now typed
`string | undefined` straight from `worker.url`. `teardown` drops its
`as never` casts the same way.

The main-process globalSetup strategy (the documented sidecar-race
workaround) is unchanged — this is purely the type link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
usirin and others added 17 commits May 31, 2026 11:19
`applyMigration` swallowed `no such table`/`no such index` alongside the
genuinely-idempotent `already exists`/`duplicate column` re-applies. That made
a misspelled-table CREATE INDEX or PK absorb silently and drop the constraint —
including the `(definition_id, voter_id)` PK the batch-atomicity tests rely on.
Let those throw; the baseline migration still applies clean (110 unit tests
green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ci.yml + deploy.yml: bump the four `node-version` fields from 25.2.1 to
  26.2.0 to match the volta `node` pin in apps/web/package.json.
- turbo.json: drop `"build"` from the `deploy` task's dependsOn (keep
  `"^build"`). The `@phoenix/web` deploy script already runs `pnpm build &&
  alchemy deploy`, so it owns the SPA build — CI (`pnpm --filter @phoenix/web
  deploy`) now builds exactly once instead of twice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The architecture diagram and "Two Durable Objects exist" paragraph still
described the superseded ConnectionDO/TopicDO split with per-call sibling
resolution (0033). The shipped code is a single LiveDO playing both roles
via instance-name prefix and self-namespace addressing — R stays never, no
per-call resolution, KV-backed with no SQL migrations. Aligns prose with the
already-correct Stack-table row (0037 supersedes 0025/0033).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Random

The Stack table still said the session secret is "minted by alchemy's Random
resource". The code reads it from the BETTER_AUTH_SECRET binding via
Config.redacted (no default) and Effect.orDie's if absent — fail-closed, not
Random-minted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`liveBus.connection(<procedure>)` rode a bare `string`: a typo silently
created a dead topic (publish and subscribe key off different strings and
miss each other with no failure), unlike `liveBus.update`, whose entity
name is gated against `LiveEntities`.

Close the seam with a `LiveConnectionProcedure` union ("posts" |
"Post.comments" | "Term.definitions", derived from the real call sites),
thread it through `PublishMessage`/`SubscribeControl` and the
subscribe-side schema literal (`Schema.Literals`, pinned to the union via
`satisfies`), and narrow the bus's `connection` signature. A typo is now a
compile error at the mutation site; an unknown procedure in a control
request fails decode to BAD_REQUEST instead of registering a dead topic.

A type-level `@ts-expect-error` negative test gates it (proven red:
widening the union to `string` makes both directives unused).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`WIRE_CODE_BY_TAG` is exhaustiveness-gated against `FateErrorTag`, but the
SPA's `MUTATION_ERROR_CODES` is a hand-kept `as const` list — a server code
the SPA omits silently decodes to the SPA's `INTERNAL_SERVER_ERROR`
fallback at runtime, untested.

Tag each registry arm with the closed set of wire `code`s it can emit
(`fixed` carries its one code; `upcased` declares the upcased domain
sub-codes), and export `WIRE_CODES` — the union of every arm's codes plus
the always-present fallbacks (`INTERNAL_SERVER_ERROR`, `BAD_REQUEST`),
derived from the registry so it can't drift from the encoder. No behavior
change to `encodeFateError`.

Add a worker unit test asserting the SPA list covers `WIRE_CODES`. It's a
guard, not a red→green feature (passes today, no drift); proven to bite by
temporarily adding a server-only code and watching it fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the two test-only fakes out of `__support__/` dirs to colocated
`*.fake.ts` modules (the convention documented in
`.patterns/effect-testing.md`):

- fate/__support__/sqlite-d1.ts -> db/sqlite-d1.fake.ts (the D1 fake's
  principled home, next to Drizzle.ts)
- fate-live/__support__/do-state.ts -> fate-live/do-state.fake.ts

Rewrites all importers, fixes the depth-relative paths, and updates the
stale doc-comment references. Pure relocation; behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… __support__

Context.Tag (id-first) is the Effect v3 service idiom and does not apply on
effect@4.0.0-beta — v4 is Context.Service<Self,Shape>()(id). Add a v3/v4 banner
+ table to effect-context-service.md, flag it in the index row, and drop the
sibling-repo local path + the deleted CloudflareEnv references from that doc.

effect-testing.md: document shared test fakes as colocated *.fake.ts (matching
the just-landed __support__ removal), forbid __support__/__tests__/test/ folders
per the colocate-by-feature rule, and fix the stale ConnectionDO/TopicDO ref.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add ADR 0038 — phoenix never depends on a dependency patch sourced from
outside the repo (fork branch, git dep, unmerged upstream PR); local
`pnpm patch` if a patch is unavoidable. Also add an in-repo docs
convention to CLAUDE.md: markdown links, not Obsidian wikilinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ONMENT)

Bare `alchemy dev` failed with a cryptic `ConfigError: Invalid data <redacted>
at [BETTER_AUTH_SECRET]` because the secret is a required `Config.redacted`
(no default) and only the `dev:worker` script injected it inline. alchemy's
`evalStack` resolves the stack (incl. the worker `env:` binding) under
`loadConfigProvider(None)`, which layers `./.env` over `process.env` when present
(verified in alchemy/lib/Util/ConfigProvider.js + Stack.js). So a gitignored
`apps/web/.env` now satisfies the binding for `alchemy dev`, `dev:worker`, and
`pnpm dev` alike. Ship `.env.example` + document the one-time copy; `.env` is
already covered by the `.env` / `.env.*` gitignore rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…035)

Fill the missing 0035 slot that ADR 0036 already references twice as the
CLI/scaffolding ADR. phoenix builds small, focused, single-purpose CLIs in
the monorepo (no catch-all `cli`); each has a specific descriptive name;
package name mirrors bin name; CLIs own brittle-by-hand repeatable ops
(migrations first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ypes

Sharpen the testing convention: an Effect service's test seam is a TestLayer
(Layer.succeed/Layer.effect providing the service, named Test<Service>), never a
loose fake object/"fake effect". A standalone *.fake.ts is reserved for a
non-Effect platform type you can't express as a service (raw D1Database,
DurableObjectState) — the service built over it is still a TestLayer. The two
relocated fakes (sqlite-d1.fake.ts, do-state.fake.ts) are platform fakes, which
is the correct category for the suffix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The README carried solutions to problems a new reader has no context on —
the retired <Feature>Admin convention (ADR 0012), the unused
createDrizzleSourceAdapter, the LiveDO supersession lineage (0025/0033),
and a whole "Sözlük seed" section justifying deletions nobody new ever saw.

Rewrite to state only the current shape — runtime, live plane, feature
layout, conventions — pitched at experienced builders, with the why and the
history left to .decisions/ where it belongs. Also fixes the stale `pnpm test`
description (local-workerd globalSetup, not a live test stage) and adds the
no-type-assertions convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Service

The per-request live publisher was made ambient via a Node AsyncLocalStorage
(`livePublishContext`), bound only in the `/fate` route. The bridge tests drive
`fateServer.handleRequest` directly and never wrapped it, so they bound the
publisher zero times — every `live.*` call silently no-opped and which topics a
mutation published to was unassertable.

Delete the ALS (and `node:async_hooks`). The publisher is now an Effect
`Context.Service` `LiveBus`, acquired in mutation resolvers with
`const liveBus = yield* LiveBus`; the synchronous `.connection().appendNode()`
caller surface is unchanged. `LiveBus` exposes two methods (modeled on
effect-smol `NodeRedis.use`): `use` surfaces a typed `LivePublishError` via
`Effect.try`; `useIgnore` swallows via `Effect.ignore({log:"Warn"})` →
`Effect<void, never>`. Mutations publish through `useIgnore` so a post-write
publish can never fail the committed mutation — the void contract is the type,
enforced by Effect short-circuit semantics.

Two layers: `LiveBusLive` (provided per request in the route, like `Auth`) and
a capturing `LiveBusTest` that records the resolved topic keys through the real
`topicsForPublish`. Bridge tests now provide `LiveBusTest` and assert published
topics — catching a wrong-but-valid mis-route (args key collapsing to the global
wildcard). Provision is mandatory, so the silent-no-op bug is impossible.

The fat `liveBusConfig` is kept for fate's subscribe-only role (fate never
publishes through it). See ADR 0039.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ret, verbose reporter

Integration D1 is the real (persistent) Cloudflare DB even under the local
harness, so a seed user left by a prior run made `signUp` 422
USER_ALREADY_EXISTS and failed the `sozluk-read` seed. Make `signUp` idempotent
(fall back to sign-in on an existing user) and keep the per-process seed stamp
unique, so re-seeding the same fixture is a no-op.

Replace the short, dictionary `dev-insecure-better-auth-secret` (which tripped
better-auth's <32-char + low-entropy startup warnings on every auth-instance
construction) with an `insecure_`-prefixed 32-byte hex value across `.env.example`
and the global-setup default — long + high-entropy so the checks stay quiet,
self-labelled as a throwaway. Production is unaffected (Cloudflare `secret_text`).

Add the `verbose` reporter (root-level — Vitest forbids per-project reporters) so
the slow single-fork integration suite emits a per-test heartbeat instead of going
silent. Drop the now-redundant inline secret from the `dev:worker` script; it
reads the fixed `.env` via alchemy's auto-load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…green)

Two lessons from debugging the integration run: the suite deploys its own
workerd stack and must not run alongside `pnpm dev` (double-stacking OOMs the
sidecar's pointer table at boot — `ExternalEntityTable::AllocateEntry`, not
machine RAM), and a fully-green run can still exit non-zero from a workerd
`All fibers interrupted` on SSE-stream teardown (tracked in #20).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
beta.46 shipped (npm next tag) WITHOUT the DurableObjectNamespaceScope union
fix — verified directly from its published DurableObjectNamespace.d.ts. The old
comment said 'remove once beta.46 ships the fix', which is now false. Per the
local-patch policy the patch stays and the fix goes to the usirin/alchemy-effect
fork; rewrite the comment to say so. No code/dependency change — comment only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
usirin and others added 11 commits May 31, 2026 15:53
…fake comment

Thermo-nuclear review minors on the B3 LiveBus work:
- event-bus.test.ts only type-gated the void contract; add an it.effect that
  drives a throwing publisher through useIgnore and asserts the publish fired
  (published=true) yet the Effect runs to a success void — the runtime half of
  the Effect<void, never> guarantee (ADR 0039).
- do-state.fake.ts comment claimed "no cast" but every method is an
  `as Storage[...]` member-typed cast; correct the comment to say so (still no
  banned as any/as unknown as; test-support file).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round out the current-state rewrite: note the monorepo shape up front, mention
the in-isolate LiveBus publisher that drives the DO fan-out (ADR 0039), and add
a "new here, read in this order" path so a contributor has a guided entry route
into the ADRs and patterns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atterns)

README is current-state for builders and must not carry retired or old-problem
context; the why + history (including superseded approaches) lives in
.decisions/, the how in .patterns/. Codifies the rule the README rewrite
followed so it doesn't get re-litigated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integration vitest project deploys the alchemy stack to a local
workerd, but D1 is always provisioned against real Cloudflare (no
offline D1). With no creds the harness falls back to placeholder
`local-dev-account` → `InvalidRoute`. Locally these resolve from a
wrangler/alchemy profile; a clean CI runner has none, so inject them
from secrets. Turbo 2.x strict env mode strips undeclared vars, so the
same three are listed under the `test` task's passThroughEnv.

deploy.yml: `pnpm --filter @phoenix/web deploy --stage …` made pnpm eat
the flags ("Unknown options: 'stage', 'yes'"). Build the SPA explicitly
and call `exec alchemy deploy --stage … --yes` so the flags reach the
CLI. Add BETTER_AUTH_SECRET (required at stack-build by config.ts) to
both deploy jobs and the cleanup/destroy job, mapped from the repo's
BETTER_AUTH_SECRET_TEST secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The single check job ran the full vitest suite, so the slow integration
project (real-D1 alchemy deploy per run) blocked the fast lint/typecheck/
unit signal. Split into three parallel jobs:

  - check        lint + format (biome) + typecheck — no creds
  - unit         `--project unit` — node pool, no worker, no creds
  - integration  `--project integration` — builds the SPA shell, deploys
                 the alchemy stack, needs Cloudflare creds

Adds `test:unit` / `test:integration` package scripts that select the
vitest project by name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion

The previous deploy failed with Cloudflare error 10000 not because the
token was expired (the integration job used the same token against real
D1 minutes earlier) but because it was scoped to D1 only — a worker
deploy needs Workers Scripts Write + KV + Tail + Account-Settings-Read.

Bring in #19's solution (Can Sirin / @cansirin): `stacks/github.ts`, a
one-shot run from an admin profile that mints a *scoped, non-expiring*
Cloudflare API token and pushes the four repo secrets
(CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, ALCHEMY_PASSWORD,
BETTER_AUTH_SECRET). The deploy workflow is replaced with #19's version
(single deploy job + cleanup with a prod-destroy safety check); the only
adaptation is the preview-ENVIRONMENT comment, since #12 already deleted
the admin seed/importer routes it referenced.

Also add `retry: 2` to the integration vitest project: D1 is always
remote, so reads occasionally hit a transient
`D1_ERROR: Network connection lost` from a CI runner — retry the
idempotent black-box assertions instead of reddening the suite.

`stacks/` is added to tsconfig.worker.json so the new entry typechecks,
and `.patterns/alchemy-ci-cd.md` documents the flow (indexed).

Co-Authored-By: Can Sirin <8138047+cansirin@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sozluk-read paginate/seed tests make several round-trips to real
Cloudflare D1; from a CI runner that exceeds the 30s wall and the fiber
gets interrupted (retry can't rescue a hang). 60s clears the slow reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The deploy 10000'd on its first call: `Cloudflare.state()` keeps the
state-store worker's bearer token + AES encryption key in the
account-wide Cloudflare Secrets Store and adopts them on every deploy,
but the minted token had no Secrets Store permission. Add Secrets Store
Read+Write to github.ts's permission groups; re-run the bootstrap to
rescope the token (clean diff — the token id is tracked in state).

Co-Authored-By: Can Sirin <8138047+cansirin@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…otent retry

seedTerm fires dozens of sequential remote-D1 writes (one definition.add +
one definition.vote per score point), and req() had no per-request timeout:
a single stalled D1 round-trip (the sidecar-loopback failure the harness
header warns about) made the whole test wait out the 60s wall and die with
"All fibers interrupted". The earlier retry:2 couldn't help — seedTerm's
process-level (slug, body) dedup makes a retried test report
created:false/inserted:0 and fail its own seed assertions.

Fix at the harness layer:
- Every JSON POST gets a 12s timeout → a stall aborts instead of hanging
  (SSE stays unbounded). A stall surfaces as TimeoutError, distinct from a
  connection error.
- Retry only idempotent ops: reads (query/list) + signups auto-retry;
  definition.vote retries via retry:true (idempotent — Vote.ts:304).
- definition.add (NOT idempotent — new id per call) is made retry-safe by
  adopting the landed row by body on a stall, never blind-retrying.
- Drop the counterproductive vitest retry:2; bump test/hook timeout to 90s.

Verified locally against real D1: sozluk-read 9/9 pass (the formerly-hung
seed+paginate tests now finish in 16s / 33s / 11s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on job

The 12s per-request timeout was too aggressive: every CI failure hit at
exactly 12002ms — legitimately slow requests (not hung ones) getting
chopped. The long single-fork run holds one workerd↔remote-D1 connection
whose latency creeps up over ~10 min, so single round-trips late in the run
take many seconds; 12s reddened whole files (pano-comments, sozluk-mutations)
and the wasted aborts ballooned the run 360s→570s. Raise the bound to 30s
(clears the degraded tail, still catches a true hang) and the test/hook
timeout to 120s.

Also gate the integration job behind a dorny/paths-filter `changes` job: it
runs only when backend-ish paths change (worker/**, tests/integration/**,
alchemy.run.ts, stacks/**, vitest.config.ts, package.json, lockfile, this
workflow). check + unit always run. Vitest's `--changed` can't express this —
the integration tests are black-box HTTP, so worker source edits never enter
their module graph; a path filter is the right mechanism. PRs that touch
worker/** (the framework PR included) still run the suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@usirin usirin merged commit 083d604 into main Jun 1, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant