Phoenix framework — alchemy + Effect + fate foundation#12
Merged
Conversation
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>
3ffe06b to
1f1084b
Compare
…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>
`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>
3 tasks
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phoenix framework — alchemy + Effect + fate foundation
mainhas no alchemy, noHttpRouter, 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:apps/web/alchemy.run.ts), with both fate's data plane and HTTP transport composing ontoHttpRouter— nowrangler.jsonc, no Hono.Layercomposition, worker-level singletons (no per-requestManagedRuntime), and a capturedServiceMapbridge between fate resolvers and Effect domain code./fatefor query/mutation,/fate/livefor SSE-pushed live views — implemented over a single unified Durable Object.features/; noservices/,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 (0026–0039) 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.tsdeclares phoenix as aCloudflare.Workerwhose init phase binds D1, the live-DO namespace, and the SPAassets. Dev runs offline viaAlchemy.localState(); deploy uses the Cloudflare-hostedCloudflare.state(), selected by the dev-vs-deploy signal (not bareCItruthiness — ADR0031).wrangler.jsoncis 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
ManagedRuntimepattern is rejected in favor of worker-level singletons.Drizzleand the feature services are built once from the bound D1 (makeFateLayer(db, auth)).POST /fateprovidesAuth+HttpServerRequestper request, captures the live service map viaEffect.context<FateEnv>(), and the bridge runs each resolver withEffect.provide(effect, ctx.context). ResolverR = neverafter provision. See ADR0029and.patterns/alchemy-runtime.md.Live fan-out via a unified Durable Object
A single
LiveDOclass plays both roles — per-connection SSE stream holder and per-topic subscriber registry + fan-out + reap — distinguished by instance-name prefix (connection:vstopic:). It addresses its sibling instances through its ownDurableObjectNamespaceScope, resolved once at init, so every RPC method'sRstaysnever. There is no per-callyield* Siblingdance.This unification (ADR
0037) supersedes the earlierConnectionDO/TopicDOsplit (ADR0025) and retires per-call sibling resolution (ADR0033). The split could not init-bind its siblings without leaking the sibling Tag into the Layer's requirements channel — a cycleLayer.mergeAllcan't satisfy;0033worked around it with per-call resolution,0037removes the cycle outright by collapsing to one class.DO state is
state.storageKV, not SQL: subscriber rows + a monotonic per-connectiongenerationscalar (used to invalidate dead-instance registry rows) persist as KV entries. There is no@effect/sql-sqlite-doschema and no migration directory.SSE construction follows Effect canon:
Queue.dropping<Uint8Array>+Stream.fromQueue+ a heartbeat merged viaStream.tick("15 seconds")+Stream.merge+Stream.ensuringcleanup +HttpServerResponse.stream. Captured as.patterns/effect-sse-externally-driven.md. Frame bytes are unchanged from the prior protocol (ADR0034: stay on SSE+POST, don't redesign to WebSocket).Auth via
@alchemy.run/better-authapps/web/worker/features/pasaport/better-auth-live.tsprovides the upstreamBetterAuthContext.Service tag via a forkedCloudflareD1Layer (~160 lines) that injects the magic-link + bearer + emailAndPassword plugin stack while reusing phoenix's shared D1. The session secret is read from theBETTER_AUTH_SECRETbinding as aConfig.redacted(no default;Effect.orDieif absent) — fail-closed, not minted at random.The
BetterAuth.BetterAuth.authvalue isEffect<Auth, never, RuntimeContext>— yielding it inside fate resolvers would leakRuntimeContextinto every resolver'sR. The escape: yield once in worker init (whereRuntimeContextis in scope), thread the resolvedAuthinstance throughmakePasaportLive(auth)+makeFateLayer(db, auth)as a plain factory parameter. Pasaport's methodRstaysnever. Captured as.patterns/better-auth-with-plugins-on-d1.md.Feature-colocated worker tree
apps/web/worker/has five top-level concepts (ADR0036):No
services/,shared/,infra/,auth/,admin/,runtime/. Each feature owns its full footprint —Service, errors, plusqueries.ts/lists.ts/views.ts/shapers.ts/sources.ts/mutations.tsper the per-feature aggregator pattern. The cross-feature aggregators infeatures/fate/are barrels re-exporting from each feature, so the SPA's import surface is preserved despite the split (zerosrc/file touched by the restructure). The*Admin/seedservices and their routes were deleted, not relocated (ADR0012retired; the/api/admin/*seeders were a fail-open hole).Hand-rolled Tags deleted in favor of upstream primitives:
CloudflareEnv(useCloudflare.WorkerEnvironment+Cloudflare.InferEnv),RequestContext(use Effect'sHttpServerRequest).Integration test harness
apps/web/tests/integration/_global-setup.tsdeploys the real Stack to local workerd viaalchemy/Test/Corein the main process (works around a pool-workerLoopbackServerrace), 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.
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.mdeffect-sse-externally-driven.md,live-fan-out-options-considered.mdworker/has five top-level concepts.feature-services.md(updated),per-feature-fate-aggregators.mdLiveDO— one class, two roles, KV storage, self-namespace addressing. Supersedes0025, retires0033.effect-sse-externally-driven.mdSupporting decisions from the same arc (
0026–0031) cover the alchemy adoption (0026), dropping Hono forHttpRouter(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 that0037ultimately replaced.0035records the CLI conventions,0038the local-only dependency-patch policy, and0039the LiveBusContext.Servicethat drives the live fan-out.What was considered and rejected
The pattern docs and
.patterns/live-fan-out-options-considered.mdcarry the full record; the most consequential:0033). Implemented, then retired within this PR (ADR0037): collapsing the two DOs into one class that addresses siblings through its own init-resolved namespace removes the Layer cycle entirely, so no per-callyield* Siblingis needed and every RPC method'sRstaysnever.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.partysubis too new (no durability story, no QoS);partyserveris the wrong abstraction layer. Track quarterly; revisit ifpartysubships durability + QoS.Context.Servicefor parameterized Layers (e.g. anAuthservice injected intoPasaport). Rejected for the auth-threading case: factory functions (makePasaportLive(auth)) stay simpler and keepR = neverwithout adding a tag-resolution dance.Test plan
pnpm typecheck— clean (effect-tsgo across project references)pnpm lint— clean (biome), including the customno-type-assertionsGritQL rulevitest --project unit) — greenvitest --project integration) —globalSetupdeploys the real Stack to local workerd and tears it down; 10 feature files + 3 harness helpers cover the full HTTP surfacefate-live.test.tsframe assertions)src/file touched by the restructure)Production cutover notes
LiveDO storage is
state.storageKV. The unifiedLiveDOpersists subscriber rows + the per-connectiongenerationscalar 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-domigration plan from the superseded two-DO design.)Bearer plugin is retained in
better-auth-live.ts. The SPA'sauthClientatapps/web/src/auth/client.tsactively sendsAuthorization: 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.tsresolvesENVIRONMENTfrom config withConfig.withDefault("production"), andBETTER_AUTH_SECREThas no default — a deploy that forgets it dies at runtime init (Effect.orDie) rather than booting with a fallback key.Follow-ups (non-blocking)
alchemy devonce theRedacted.value(undefined)OAuth-credentials bug is worked around withCLOUDFLARE_API_TOKEN.phoenix-migratepackage was dropped because the unified KV-backedLiveDOhas no live SQL-migration target. The CLI conventions are recorded in ADR0035, so the next focused tool starts from the established shape; revisit when a feature reintroduces per-DO SQL.partysubrevisit cadence — quarterly check on Cloudflare's experimental topic-pubsub abstraction. Clean migration target if it ships durability + QoS.