| id | 31 | |||||
|---|---|---|---|---|---|---|
| title | Unified void-aligned LiveDO — one class, two roles, KV storage | |||||
| status | accepted | |||||
| date | 2026-05-30 | |||||
| tags |
|
phoenix's live fan-out was two co-hosted Durable Objects with a bidirectional
RPC edge: ConnectionDO (one client's held SSE stream + subscription list) and
TopicDO (the durable subscriber registry + the publish fan-out + the reap
alarm). 0025 split them out of the
original one-class LiveDO (0023) to make
invalid cross-role calls unrepresentable; 0028
ported both onto alchemy's Effect DO model;
0033 established that the
two classes referencing each other could not Init-bind the sibling without a
circular Layer dependency, so the sibling was resolved per RPC call.
Two costs accumulated from the split:
- The mutual-DO Layer cycle.
ConnectionDOLive↔TopicDOLivecould not resolve each other in init without producingLayer<A, _, B>+Layer<B, _, A>, which doesn't compose. The whole of 0033 exists to work around this with per-call sibling resolution — extra ceremony in every cross-role RPC method, plus a sibling-pair pattern doc to teach it. - SQLite the registry never needed.
TopicDOkept its subscriber registry instate.storage.sql(asubscriberstable + av2migration +@effect/sql-sqlite-do). The registry is a flat keyed set of rows looked up by(topicKey, connectionId, subId)— a KV access pattern, not a relational one. The SQLite layer bought nothing the KV storage API doesn't.
void's VoidLiveStreamDurableObject
(void/dist/runtime/live-server.mjs) — the upstream phoenix's live channel
mirrors — solves the same fan-out with one DO class that plays both roles,
KV storage, and a stale model built on a per-connection generation and a
per-subscription revision. Aligning to void erases both costs at once.
Replace the split ConnectionDO/TopicDO pair with a single LiveDO class
that plays both roles, distinguished by instance-name prefix. It is a
void-aligned rewrite, not a mechanical re-merge of the pre-0025 one-class form.
- One class, two roles by instance name. A
LiveDOinstance is named eitherconnection:<connectionId>(connection role — owns one client's held SSE stream + its subscription list) ortopic:<topicKey>(topic role — owns that topic's subscriber registry + the publish fan-out + the reap alarm).resolveRole(state.id.name)reads the prefix to pick the role at request time. A misrouted call (e.g.registeron aconnection:instance) hits an instance whose role doesn't match and harmlessly returns a no-op result — void has no role guard either. DurableObjectNamespaceScopeself-namespace. (Superseded for the alchemy beta.59 substrate by 0124: beta.59 removedDurableObjectNamespaceScope; the self-namespace is now resolved at per-instance runtime viaCloudflare.DurableObjectwith a localizedReqdischarge. The one-class/two-role shape here still stands.) The DO's OWN namespace is resolved once in shared init fromCloudflare.DurableObjectNamespaceScope(the LOCAL, scriptName-less self-binding that.make()provides) and held in the closure for cross-role addressing (live.getByName(\topic:${key}`)/live.getByName(`connection:${id}`)). This is void'sthis.env[bindingName]self-reference. Because the scope is provided by.make(), it adds no requirement, so the Layer isLayer<LiveDO, never, Worker>with every RPC method'sRchannelnever. A bareyield* LiveDOin init would instead leakLiveDO` as an unsatisfiable self-requirement — the very Tag the Layer outputs, which no merge can discharge.- Why NOT
LiveDO.from("phoenix")(the shape this ADR first prescribed): every.from(...)overload sets ascriptName— the string directly, or the worker passed to.from(Worker)— which declares a CROSS-SCRIPT binding. Underalchemy devthat routes through the dev-registry proxy and dies withWorker "phoenix" not found; a DO reaching its own siblings must use the local binding, not a cross-worker reference. So there is no host-script-name string to keep in sync withworker/index.ts— the local scope carries no name. The scope is typedDurableObjectNamespace<unknown>(alchemy can't know each host's DO shape), widened once to this DO's statically-knownLiveRpcSurface— the oneasinlive-do.ts.
- Why NOT
- KV storage, not SQLite. Storage is
state.storage's KV API, mirroring void's flat keys: subscriber rows undersub:${topicKey}:${connectionId}:${subId}:${generation}:${revision}, and the per-connection generation scalar underconnection:generation. Topic-role reads usestate.storage.list({prefix: "sub:${topicKey}:"}); deletes batchstate.storage.delete(keys). No SQL table, no migration, no@effect/sql-sqlite-do. generation+revisionstale model. Each connection persists agenerationscalar, bumped on every (re)connect. Each subscription carries arevision, bumped on every re-subscribe under the same id. A topic-held subscriber row is stale when itsgenerationdoesn't match the connection's current generation, or its subscription is inactive, or itsrevisiondiffers — the connection answersdeliver/checkfrom its in-memory subscription map- the persisted generation, with no read back to the topic.
- First-failed-probe reap, no miss counter. The topic role schedules a 60s
alarm that probes each subscriber's connection via
check. The first failed/timed-out probe (or apublishthat can't reach a connection) reaps all of that connection's rows for the topic — void-faithful, no consecutive-miss counter. A reachable connection reports which of its probed rows are stale and exactly those are reaped. - 15s SSE keep-alive. The connection role holds a
Queueof frames merged with a 15s keep-alive tick (Stream.tick("15 seconds")with the immediate tick dropped), returned as a streamingHttpServerResponse— the one interaction kept asfetch, not RPC. - Per-subscriber
frame.idstamped at delivery. Onepublishfans out to many subscriptions; the publish frame'sidis left empty and the topic instance stamps each delivered frame'sidfrom the subscriber row ({...frame, id: row.subId}) at delivery, so every subscriber sees its own id.
The split decided in 0025 is reversed; the SSE transport, DO-fan-out, and cookie-auth decisions of 0023 all stand. fate's native SSE wire protocol (0034) is unchanged — this is a server-side fan-out rewrite the client never sees.
- No sibling Layer cycle. The self-namespace is the local
DurableObjectNamespaceScope.make()provides, so every RPC method'sRchannel isneverand the implementation Layer requires onlyWorker. The whole per-call sibling-resolution dance (0033) is eliminated, not worked around — there is no second DO Tag to resolve. At the worker call seam there is nothing to discharge either: the old split-DOas never/Effect.provide(workerContext)cast is gone. (A cross-scriptLiveDO.from("phoenix")would have aWorkerrequirement too, but it doesn't work underalchemy dev— see the Decision.) - No
@effect/sql-sqlite-do, no migration dir. KV storage drops thesubscribersSQL table, thev2DO migration, and the@effect/sql-sqlite-dodependency. alchemy derives the singleLiveDOclass's DO migration from the binding; there is no hand-writtenwrangler.jsoncmigrations block. - One binding, one class.
LiveDOreplaces theCONNECTION_DO/TOPIC_DOpair. The worker declares the single class as itsDepscontract (Cloudflare.Worker<Phoenix, {}, LiveDO>) and providesLiveDOLive. - Invalid cross-role calls are no longer unrepresentable at the type level.
This is the one thing 0025 bought that
this gives back: a misrouted call now type-checks and no-ops at runtime
(role-guarded by
resolveRole) rather than failing to compile. Accepted as void-faithful: the role guard is one branch, and the alignment to void's proven shape (which the live-fan-out survey live-fan-out-options-considered.md already settled on) outweighs the lost compile-time guarantee. - Supersedes 0025 (the split is reversed). Amends 0023 (one-class packaging is restored, with KV + revision added; the rest of 0023 stands). Retires 0033 (the mutual-DO problem it solves no longer exists; the file is kept as reference).
- See alchemy-durable-objects.md (the unified DO recipe), fate-live-views.md (the protocol + the DO in the wider live picture), and effect-sse-externally-driven.md (the held-stream queue + keep-alive shape).