| id | 84 | ||||
|---|---|---|---|---|---|
| title | LiveDO cross-role self-addressing on alchemy beta.59 — resolve the self-namespace at runtime, discharge the phantom Req leak | ||||
| status | accepted | ||||
| date | 2026-07-01 | ||||
| tags |
|
Amends 0037's self-addressing section.
LiveDO is one Durable Object class in two roles (connection / topic); an
instance named connection:<id> must reach topic:<key> siblings of its own
namespace, cross-role, from the reusable LiveDOLive Layer. 0037
did this cycle-free via Cloudflare.DurableObjectNamespaceScope — the local,
scriptName-less self-binding — which added no requirement, so the Layer was
Layer<LiveDO, never, Worker>.
The alchemy 2.0.0-beta.56 → beta.59 migration (epic #1610) is a ground-up
rewrite of alchemy/Cloudflare, and it removed DurableObjectNamespaceScope
(the local patch hunk that re-added it was dropped when re-keying to
patches/alchemy@2.0.0-beta.59.patch). Both obvious replacements fail, and ~211
residual typecheck errors all traced here:
-
Init-time
yield*leaks a DO tag into the LayerR. The beta.59 self-scope isDurableObjectScope(Context.Service()("Cloudflare.DurableObject"), TypeDurableObject<unknown>;alchemy/lib/Cloudflare/Workers/DurableObject.js:17,.d.ts:93), yielded viaCloudflare.DurableObject. The modular.make<Req>signature (DurableObject.d.ts) is:make<Req = never>( impl: Effect<Effect<Shape, never, RuntimeContext | DurableObjectState | Scope>, never, DurableObjectServices | Req> ): Layer<Self, never, Worker | Req>where
DurableObjectServices = DurableObject | DurableObjectState | WorkerServices | WorkerEnvironment | PlatformServices. The self-scope service (DurableObjectScope) is not a member ofDurableObjectServices, so ayield* DurableObjectScopein the outer init effect lands inReq— andReqpropagates verbatim intoLayer<Self, never, Worker | Req>, intoPhoenixLive, intoapps/web/alchemy.run.ts, whoseAlchemy.Stackprogram is constrained toStackServices | ProviderServicesand rejects a strayDurableObject<unknown>. That is the leak.makeabsorbs outer-initDurableObjectServicesyields but not the self-scope. -
LiveDO.from(Self)needs the hostWorker. The documented.from(Self)form binds the local namespace by passing the host worker, whichLiveDOLivecannot import without a worker↔DO import cycle — the exact cycle the removed scope mechanism existed to avoid.
Resolve the self-namespace at per-instance runtime, not at Layer/stack build,
and discharge the phantom Req leak with one localized type assertion. Grounded
against two authoritative sources.
- alchemy's circular-bindings guide (https://v2.alchemy.run/guides/circular-bindings/):
break a worker↔DO cycle by separating the class Tag (identity, import-free)
from the Layer (
.make()impl), and bind at runtime inside.make()— never import the host worker into the reusable Layer. phoenix keeps theLiveDOTag import-free:live-do.tsimports noWorker/worker/index.ts, so.from(Self)(and its cycle) is never reached. - beta.59's own self-namespace idiom.
RpcDurableObjectScope's JSDoc: "yield it from within a DO handler to refer back to the surrounding namespace (e.g. to fan a call out to sibling instances) … mirrorsyield* DurableObjecton the regularDurableObject" (RpcDurableObject.js:13,.d.ts"Yielding the surrounding namespace"). Soyield* Cloudflare.DurableObjectis the author-intended self-namespace yield.
Where the scope actually becomes available (why runtime, not build). .make
resolves the local (scriptName-less) namespace handle self = yield* binding() and
provides it to the constructor: impl.pipe(Effect.provide(Layer.succeed(DurableObjectScope, self))) (DurableObject.js:640). The bridge runs that constructor per DO-instance
boot under blockConcurrencyWhile (DurableObjectBridge.js:34–36), so the outer
init effect — which executes once per instance on the platform at runtime, not when
LiveDOLive/the alchemy.run.ts stack is built — has the scope in context and resolves
self, closing it over into the handlers. The inner, per-request handlers do not
get the scope: the bridge provides them DurableObjectState + services only, and
services was captured (Effect.context()) before the scope was provided — so the
yield must live in the outer init, closed into the handlers, never in a handler.
The resolution in LiveDOLive. Yield the self-namespace in the outer init and
discharge the phantom requirement:
const live = yield* (Cloudflare.DurableObject as unknown as Effect.Effect<LiveNamespace>);The runtime yield is unchanged (alchemy still provides DurableObjectScope at
DurableObject.js:640); the assertion only erases the Req the .make<Req> type
fails to subtract. Result (type-probe verified): LiveDOLive : Layer<LiveDO, never, Worker> — cross-role connection:↔topic: addressing intact, no DO tag in R, no
worker↔DO cycle.
RuntimeContext coloring (a coupled beta.59 fact). beta.59 colored DO
state.storage reads/writes and cross-role stub calls with RuntimeContext, so
LiveRpcSurface's methods now carry RuntimeContext in their R. It is discharged
where the methods are actually invoked: at the worker call seam via
Effect.provideService(RuntimeContext, runtimeContext) (worker/index.ts), and in
the do.test.ts in-process unit runs via RuntimeContext.phantom — sound because the
KV-only do-state fake is pure Effect.sync and never reads the context.
- The self-addressing pattern for a beta.59 DO addressing its own namespace from a
reusable Layer: keep the DO class Tag import-free;
yield* Cloudflare.DurableObjectin the outer init (never a handler); discharge the phantomReqwith a singleas unknown as Effect.Effect<Namespace>at the yield site, justified by the runtime provision atDurableObject.js:640that.make<Req>'s type does not model. This is the documented cost of the beta.59 typing gap — one localized assertion, not a structural change. - 0037's
DurableObjectNamespaceScopebullet is superseded by this ADR for the beta.59 substrate: the mechanism is gone; the "adds no requirement, soRisnever" property is now achieved by the explicit discharge above, and the cross-role RPC methods'RisRuntimeContext(discharged at the call seams), notnever. The one-class/two-role shape, KV storage, and thegeneration/revisionstale model of 0037 all stand; the live SSE fan-out contract is preserved, not changed. - Scope boundary. This ADR and its slice cover only the DO self-addressing leak.
alchemy.run.ts's residualRuntimeContextrequirement traces to a non-LiveDO sub-layer (EmailSenderLive's real ambientyield* RuntimeContext, plus the structuralProvidersthe stack's providers config discharges) — the binding-model migration finished under epic #1610's child #1613, which is gated on this one. - Real-deploy proof is CI-gated. The cross-role live SSE fan-out is only truly proven on a real Cloudflare deploy (typecheck-green ≠ deploy-green for the DO/SSE substrate); that verification rides the deploy CI job and the dedicated child #1615, not this typecheck/unit slice.
- See alchemy-durable-objects.md (the unified DO recipe) and fate-live-views.md.