···11-/** Helpers for assembling space URIs in the `tools.atmo.chat` namespace. */
11+/** Helpers for assembling space URIs in the `tools.atmo.chat` namespace.
22+ *
33+ * The URIs are returned as `ResourceUri` (branded) so they flow into
44+ * lexicon-typed XRPC params without per-call casts. We apply the brand
55+ * once here — structurally these are just `at://…` strings. */
66+77+import type { ResourceUri } from '@atcute/lexicons';
2839const SPACE_TYPE = 'tools.atmo.chat.space';
41055-export function buildSpaceUri(communityDid: string, key: string): string {
66- return `at://${communityDid}/${SPACE_TYPE}/${key}`;
1111+export function buildSpaceUri(communityDid: string, key: string): ResourceUri {
1212+ return `at://${communityDid}/${SPACE_TYPE}/${key}` as ResourceUri;
713}
81499-export function buildMembersUri(communityDid: string): string {
1515+export function buildMembersUri(communityDid: string): ResourceUri {
1016 return buildSpaceUri(communityDid, 'members');
1117}
12181313-export function buildAdminUri(communityDid: string): string {
1919+export function buildAdminUri(communityDid: string): ResourceUri {
1420 return buildSpaceUri(communityDid, '$admin');
1521}
1622
···11+/// <reference lib="dom" />
22+/** IndexedDB-backed implementation of `WatchCache`. Browser-only.
33+ *
44+ * Single object store keyed by the watch query's `cacheKey` (defaults to
55+ * the watch URL). Values are `{records, updatedAt}`. Persists across page
66+ * reloads; cleared by the user clearing site data. */
77+88+import type { WatchCache, WatchRecord } from "./index.js";
99+1010+interface StoredEntry {
1111+ records: WatchRecord[];
1212+ updatedAt: number;
1313+}
1414+1515+export interface IndexedDBCacheOptions {
1616+ /** Database name. Default: 'contrail-watch-cache'. Bump this (or delete
1717+ * via DevTools) if you need to invalidate all cached watch data. */
1818+ dbName?: string;
1919+ /** Object store name within the DB. Default: 'watches'. */
2020+ storeName?: string;
2121+ /** IDB schema version. Default: 1. Only bump when changing
2222+ * storeName / schema. */
2323+ version?: number;
2424+}
2525+2626+export function createIndexedDBCache(
2727+ options: IndexedDBCacheOptions = {}
2828+): WatchCache {
2929+ const dbName = options.dbName ?? "contrail-watch-cache";
3030+ const storeName = options.storeName ?? "watches";
3131+ const version = options.version ?? 1;
3232+3333+ let dbPromise: Promise<IDBDatabase> | null = null;
3434+ const openDb = (): Promise<IDBDatabase> => {
3535+ if (dbPromise) return dbPromise;
3636+ dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
3737+ if (typeof indexedDB === "undefined") {
3838+ reject(new Error("IndexedDB not available in this environment"));
3939+ return;
4040+ }
4141+ const req = indexedDB.open(dbName, version);
4242+ req.onupgradeneeded = () => {
4343+ const db = req.result;
4444+ if (!db.objectStoreNames.contains(storeName)) {
4545+ db.createObjectStore(storeName);
4646+ }
4747+ };
4848+ req.onsuccess = () => resolve(req.result);
4949+ req.onerror = () => reject(req.error);
5050+ req.onblocked = () =>
5151+ reject(new Error("IDB open blocked (another tab holds an older version)"));
5252+ });
5353+ return dbPromise;
5454+ };
5555+5656+ return {
5757+ async read(key) {
5858+ try {
5959+ const db = await openDb();
6060+ return await new Promise<WatchRecord[] | null>((resolve, reject) => {
6161+ const tx = db.transaction(storeName, "readonly");
6262+ const store = tx.objectStore(storeName);
6363+ const req = store.get(key);
6464+ req.onsuccess = () => {
6565+ const entry = req.result as StoredEntry | undefined;
6666+ resolve(entry?.records ?? null);
6767+ };
6868+ req.onerror = () => reject(req.error);
6969+ });
7070+ } catch {
7171+ return null;
7272+ }
7373+ },
7474+ async write(key, records) {
7575+ try {
7676+ const db = await openDb();
7777+ await new Promise<void>((resolve, reject) => {
7878+ const tx = db.transaction(storeName, "readwrite");
7979+ const store = tx.objectStore(storeName);
8080+ const entry: StoredEntry = { records, updatedAt: Date.now() };
8181+ store.put(entry, key);
8282+ tx.oncomplete = () => resolve();
8383+ tx.onerror = () => reject(tx.error);
8484+ tx.onabort = () => reject(tx.error ?? new Error("tx aborted"));
8585+ });
8686+ } catch {
8787+ // Intentional: caching is best-effort; swallow.
8888+ }
8989+ },
9090+ };
9191+}
+164-12
src/sync/index.ts
···2323 cid?: string | null;
2424 /** Set when the record originates from a per-space table. */
2525 _space?: string;
2626+ /** Present on optimistic entries added via `addOptimistic` — not set by
2727+ * records arriving from the stream. Auto-dropped when a real record
2828+ * with the same rkey arrives via `record.created`. */
2929+ optimistic?: "pending" | "failed";
3030+ /** Error attached via `markFailed` after a failed mutation. */
3131+ optimisticError?: Error;
2632 /** Additional hydrated relations / references populated server-side. */
2733 [k: string]: unknown;
2834}
···3339 /** Transport. Default: 'sse'. Use 'ws' on Cloudflare for DO-terminated
3440 * long-lived subscriptions that hibernate while idle. */
3541 transport?: "sse" | "ws";
3636- /** Mint a watch-scoped ticket to auth the connection. Called once per
3737- * connect attempt; the returned ticket is sent as `?ticket=...` on the
3838- * SSE connection or on the `mode=ws` handshake fetch. Omit for public
3939- * (no-auth) endpoints.
4242+ /** Watch-scoped ticket auth for the connection. Sent as `?ticket=...`
4343+ * on the SSE connection or on the `mode=ws` handshake fetch.
4444+ *
4545+ * - `string` — used as-is on every connect (good for one-shot SSR-minted
4646+ * tokens; reconnects after expiry will surface a 401).
4747+ * - `() => Promise<string>` — called once per connect attempt; a fresh
4848+ * ticket is minted for every (re)connect.
4949+ * - omitted — public (no-auth) endpoints.
4050 *
4151 * For atproto-based services, tickets are typically minted server-side
4252 * via `<ns>.realtime.ticket` (or via an app-specific route that runs
4353 * the `mode=ws` handshake in-process and returns the signed ticket). */
4444- fetchTicket?: () => Promise<string>;
5454+ mintTicket?: string | (() => Promise<string>);
4555 /** Custom compare. Default: sort by `time_us` descending (newest first),
4656 * tie-breaking by rkey. */
4757 compareRecords?: (a: WatchRecord, b: WatchRecord) => number;
4858 /** Reconnect on error with backoff. Default: true, 1s → 30s exponential. */
4959 reconnect?: boolean;
6060+ /** Optional persistent cache. When provided, the store loads cached
6161+ * records at `start()` for instant first paint, then reconciles against
6262+ * the live snapshot as usual. Writes are debounced and exclude
6363+ * optimistic entries. */
6464+ cache?: WatchCache;
6565+ /** Key for this query in the cache. Defaults to `url`. */
6666+ cacheKey?: string;
6767+ /** Max records retained in the cache per key. Excess records (by sort
6868+ * order) are dropped on write. Default: 200. */
6969+ cacheMaxRecords?: number;
5070 /** Optional logger for debug output. */
5171 logger?: {
5272 log?: (...args: unknown[]) => void;
···5575 };
5676}
57777878+/** Adapter interface for persisting the last-seen records of a watch query.
7979+ * Implementations can back this with IndexedDB, localStorage, fs, etc.
8080+ * Errors should be caught internally — caching is a best-effort optimization
8181+ * and must never break the live stream. */
8282+export interface WatchCache {
8383+ read(key: string): Promise<WatchRecord[] | null>;
8484+ write(key: string, records: WatchRecord[]): Promise<void>;
8585+}
8686+5887export type WatchStoreStatus =
5988 | "idle"
6089 | "connecting"
···73102 start(): void;
74103 /** Close the connection and clear the store. */
75104 stop(): void;
105105+106106+ /** Insert an optimistic record immediately, before the server confirms.
107107+ * The entry is merged into `.records` with `optimistic: 'pending'`. When
108108+ * a real record arrives via the stream with the same `rkey`, the
109109+ * optimistic entry is dropped automatically. */
110110+ addOptimistic(input: {
111111+ rkey: string;
112112+ did: string;
113113+ collection?: string;
114114+ record: Record<string, unknown>;
115115+ time_us?: number;
116116+ uri?: string;
117117+ }): void;
118118+ /** Flip an optimistic entry to `optimistic: 'failed'` and attach an error.
119119+ * No-op if no optimistic entry matches. */
120120+ markFailed(rkey: string, err: Error): void;
121121+ /** Remove an optimistic entry (explicit rollback). */
122122+ removeOptimistic(rkey: string): void;
76123}
7712478125export interface WatchStoreState {
···95142 const reconnect = options.reconnect !== false;
96143 const transport = options.transport ?? "sse";
97144 const log = options.logger ?? {};
145145+ const cache = options.cache ?? null;
146146+ const cacheKey = options.cacheKey ?? options.url;
147147+ const cacheMax = options.cacheMaxRecords ?? 200;
9814899149 const byKey = new Map<string, WatchRecord>();
150150+ /** Optimistic entries keyed by rkey. Merged into `.records`; dropped when
151151+ * a real record with the same rkey arrives via `record.created`. */
152152+ const optimisticByRkey = new Map<string, WatchRecord>();
100153 let sorted: WatchRecord[] = [];
101154 let status: WatchStoreStatus = "idle";
102155 let error: Error | null = null;
103156 const listeners = new Set<(state: WatchStoreState) => void>();
104157158158+ // Debounced cache writer. Schedules one write after the current burst of
159159+ // state changes settles, to avoid writing on every incoming event.
160160+ let cacheWriteTimer: ReturnType<typeof setTimeout> | null = null;
161161+ const scheduleCacheWrite = () => {
162162+ if (!cache) return;
163163+ if (cacheWriteTimer) return;
164164+ cacheWriteTimer = setTimeout(() => {
165165+ cacheWriteTimer = null;
166166+ void flushCache();
167167+ }, 200);
168168+ };
169169+ const flushCache = async () => {
170170+ if (!cache) return;
171171+ // Snapshot the current server-confirmed records (exclude optimistic)
172172+ // and trim to cacheMax.
173173+ const confirmed: WatchRecord[] = [];
174174+ for (const r of byKey.values()) confirmed.push(r);
175175+ confirmed.sort(compare);
176176+ const trimmed = confirmed.slice(0, cacheMax);
177177+ try {
178178+ await cache.write(cacheKey, trimmed);
179179+ } catch (err) {
180180+ log.warn?.("cache write failed", err);
181181+ }
182182+ };
183183+105184 // Per-snapshot reconcile: during a snapshot we track which keys were
106185 // included, and on snapshot.end we evict any stale keys the previous
107186 // snapshot had but this one didn't. Keeps data visible across reconnects
···119198 const notify = () => {
120199 const s = stateSnapshot();
121200 for (const l of listeners) l(s);
201201+ scheduleCacheWrite();
122202 };
123203124204 const resort = () => {
125125- sorted = Array.from(byKey.values()).sort(compare);
205205+ const merged: WatchRecord[] = [];
206206+ for (const r of byKey.values()) merged.push(r);
207207+ for (const r of optimisticByRkey.values()) merged.push(r);
208208+ sorted = merged.sort(compare);
126209 };
127210128211 const setStatus = (next: WatchStoreStatus, nextError: Error | null = null) => {
···141224 const k = key(record);
142225 byKey.set(k, record);
143226 snapshotSeen?.add(k);
227227+ // If this rkey was optimistic, it's now server-confirmed.
228228+ optimisticByRkey.delete(record.rkey);
144229 };
145230146231 const applyCreated = (record: WatchRecord) => {
232232+ // Drop any optimistic entry with the same rkey — the server-confirmed
233233+ // record replaces it.
234234+ optimisticByRkey.delete(record.rkey);
147235 byKey.set(key(record), record);
148236 resort();
149237 notify();
···270358 }
271359 };
272360361361+ const resolveTicket = async (): Promise<string | null> => {
362362+ const src = options.mintTicket;
363363+ if (!src) return null;
364364+ return typeof src === "string" ? src : await src();
365365+ };
366366+273367 const openSse = async () => {
274368 let url = options.url;
275275- if (options.fetchTicket) {
276276- const ticket = await options.fetchTicket();
369369+ const ticket = await resolveTicket();
370370+ if (ticket) {
277371 const sep = url.includes("?") ? "&" : "?";
278372 url += `${sep}ticket=${encodeURIComponent(ticket)}`;
279373 }
···310404311405 const openWs = async () => {
312406 // Step 1: snapshot + handshake. Authenticated by the watch-scoped
313313- // ticket from `fetchTicket`, passed as `?ticket=...`. The server
407407+ // ticket from `mintTicket`, passed as `?ticket=...`. The server
314408 // returns a ticket bound to (did, spaceUri, querySpec); we pass that
315409 // back embedded in the wsUrl on the WS upgrade so the WS itself
316410 // doesn't need any other auth.
317411 let snapshotUrl = options.url;
318412 const sep = snapshotUrl.includes("?") ? "&" : "?";
319413 snapshotUrl += `${sep}mode=ws`;
320320- if (options.fetchTicket) {
321321- const ticket = await options.fetchTicket();
414414+ const ticket = await resolveTicket();
415415+ if (ticket) {
322416 snapshotUrl += `&ticket=${encodeURIComponent(ticket)}`;
323417 }
324418 const res = await fetch(snapshotUrl, { headers: { accept: "application/json" } });
···428522 if (started) return;
429523 started = true;
430524 stopped = false;
431431- void openOnce();
525525+ void (async () => {
526526+ // Cache warm: populate from disk before opening the connection
527527+ // so listeners see instant records on first paint.
528528+ if (cache) {
529529+ try {
530530+ const cached = await cache.read(cacheKey);
531531+ if (cached && cached.length > 0 && !stopped) {
532532+ for (const r of cached) byKey.set(key(r), r);
533533+ resort();
534534+ notify();
535535+ }
536536+ } catch (err) {
537537+ log.warn?.("cache read failed", err);
538538+ }
539539+ }
540540+ if (stopped) return;
541541+ void openOnce();
542542+ })();
432543 },
433544 stop() {
434545 stopped = true;
435546 closeConnections();
547547+ if (cacheWriteTimer) {
548548+ clearTimeout(cacheWriteTimer);
549549+ cacheWriteTimer = null;
550550+ }
551551+ // Best-effort final flush so the cache reflects the last-known
552552+ // confirmed state before teardown.
553553+ void flushCache();
436554 byKey.clear();
555555+ optimisticByRkey.clear();
437556 sorted = [];
438557 setStatus("closed");
558558+ },
559559+ addOptimistic(input) {
560560+ const now = Date.now();
561561+ const record: WatchRecord = {
562562+ uri: input.uri ?? `at://${input.did}/${input.collection ?? ""}/${input.rkey}`,
563563+ did: input.did,
564564+ rkey: input.rkey,
565565+ collection: input.collection ?? "",
566566+ record: input.record,
567567+ time_us: input.time_us ?? now * 1000,
568568+ indexed_at: now,
569569+ cid: null,
570570+ optimistic: "pending"
571571+ };
572572+ optimisticByRkey.set(input.rkey, record);
573573+ resort();
574574+ notify();
575575+ },
576576+ markFailed(rkey, err) {
577577+ const existing = optimisticByRkey.get(rkey);
578578+ if (!existing) return;
579579+ optimisticByRkey.set(rkey, {
580580+ ...existing,
581581+ optimistic: "failed",
582582+ optimisticError: err
583583+ });
584584+ resort();
585585+ notify();
586586+ },
587587+ removeOptimistic(rkey) {
588588+ if (!optimisticByRkey.delete(rkey)) return;
589589+ resort();
590590+ notify();
439591 }
440592 };
441593}
+120
todo/cross-space-watch-records.md
···11+# Cross-space watchRecords (actor-scoped live queries)
22+33+## Context
44+55+Today `<collection>.watchRecords` is per-space only — the endpoint rejects
66+requests without `spaceUri`:
77+88+```ts
99+// src/core/router/collection.ts:563-568
1010+if (!spaceUri) {
1111+ return c.json(
1212+ { error: "InvalidRequest", message: "spaceUri required (cross-space watch is deferred)" },
1313+ 400
1414+ );
1515+}
1616+```
1717+1818+This means the app's `createWatchQuery` helper only works for queries
1919+scoped to one permissioned space. `listRecords` has no such restriction —
2020+it already supports `?actor=<did>` for cross-space listings.
2121+2222+## The concrete gap in the group-chat example
2323+2424+The channel list spans multiple spaces: each channel lives in its own
2525+space, authored by the community DID. Current flow:
2626+2727+- **SSR**: `+layout.server.ts` calls `<ns>.channel.listRecords` with
2828+ `actor=<communityDid>`.
2929+- **Live updates**: `connectCommunityRealtime` in `realtime.svelte.ts`
3030+ opens an EventSource on `<ns>.realtime.subscribe?ticket=...` keyed to
3131+ `community:<did>`, filters for `tools.atmo.chat.channel` events, and
3232+ calls `invalidateAll()` to re-run the loader.
3333+3434+That works, but it means:
3535+- Two realtime mechanisms in the app (watchRecords for messages, raw
3636+ `realtime.subscribe` + `invalidateAll` for channels).
3737+- Channel list is re-fetched from the server on every update rather than
3838+ being incrementally maintained client-side.
3939+4040+## The fix
4141+4242+Allow `watchRecords` to be actor-scoped, mirroring `listRecords`:
4343+4444+- Remove the "spaceUri required" gate when `actor` is supplied.
4545+- Snapshot: reuse the existing `runPipeline` path (already handles
4646+ actor-filtered + permissioned-space union for the caller).
4747+- Live topic: `actor:<did>` (contrail already exposes `actorTopic`)
4848+ or `community:<did>` (via `communityTopic` when the actor is a
4949+ registered community DID — the ticket endpoint already resolves this).
5050+- Ticket minting: the `<ns>.realtime.ticket` endpoint already accepts
5151+ `{ topic: "community:<did>" }` and returns a scoped ticket.
5252+5353+Once done, the app reads:
5454+5555+```ts
5656+const channelsQuery = $derived(
5757+ createWatchQuery({
5858+ endpoint: 'tools.atmo.chat.channel',
5959+ params: { actor: data.communityDid, limit: 100 }
6060+ })
6161+);
6262+```
6363+6464+And `connectCommunityRealtime` + the `invalidateAll` dance goes away
6565+(channel list is maintained incrementally; messages already use their
6666+own per-space watch query).
6767+6868+## Open question to resolve before implementing
6969+7070+**ACL on the live stream.** `listRecords` filters per-space membership
7171+on the query (via `runPipeline`'s `memberDid` path). For `watchRecords`
7272+in this mode, events come from the pubsub via the `community:<did>` (or
7373+`actor:<did>`) topic. **It needs to be verified** that the stream
7474+actually filters events against the caller's per-space memberships —
7575+otherwise a non-member could see `record.created` for a private channel
7676+they're not in.
7777+7878+Audit targets:
7979+- `src/core/realtime/publishing-adapter.ts` — what topics does it publish
8080+ a space write to? If it publishes to `community:<did>` unconditionally,
8181+ that event is delivered to every community-topic subscriber regardless
8282+ of their per-space access.
8383+- `src/core/realtime/router.ts` / `resolveTopicForCaller` — does the
8484+ ticket issuer verify the caller's membership at ticket-mint time, or
8585+ is `community:<did>` treated as a public-ish topic available to anyone
8686+ who can name the community?
8787+- `src/core/router/collection.ts:653-697` — the watchRecords WS path
8888+ filters by `querySpec`; the SSE path has its own filtering. Either
8989+ way, filtering happens server-side; make sure it evaluates
9090+ per-space membership, not just collection/actor match.
9191+9292+If the existing filter story is insufficient, a small gate needs to be
9393+added: each event that leaves the stream is checked against the
9494+caller's membership of the event's `space`, and dropped if the caller
9595+has no access.
9696+9797+## Scope estimate
9898+9999+- If ACL is already correct: ~1 hour. Small change to remove the
100100+ spaceUri gate and wire actor → topic resolution.
101101+- If ACL filter needs adding: ~half-day. Introduce a per-event
102102+ membership check in the stream's filter path and cache memberships
103103+ on the connection to keep it cheap.
104104+105105+## Out of scope for this work
106106+107107+- Combining multiple `actor` values (single-actor queries are enough
108108+ for the community-channels use case).
109109+- Hydration relations across the actor-scoped snapshot — same machinery
110110+ as today; just confirm it works without `spaceUri` context.
111111+112112+## Cleanup after this ships
113113+114114+- Delete `connectCommunityRealtime` from the example's
115115+ `lib/rooms/realtime.svelte.ts` (and its ticket fetching helper).
116116+- `+layout.server.ts` stops fetching channels and pass them via page
117117+ data; the layout uses `createWatchQuery` directly.
118118+- Consider whether `channelMessages` (the per-space message store) is
119119+ still needed — it's there for the prior SSR-message pattern; messages
120120+ are now read directly from `messagesQuery.records`. Probably delete.
+104
todo/realtime-do-dev-wiring.md
···11+# Fix DO wiring in Vite dev (currently papered over with a `dev` flag)
22+33+## Context
44+55+In `examples/sveltekit-group-chat/src/lib/contrail/index.ts` the realtime
66+PubSub is selected with:
77+88+```ts
99+const pubsub = dev ? new InMemoryPubSub() : new DurableObjectPubSub(env.REALTIME);
1010+```
1111+1212+That `dev` check is standing in for "the `RealtimePubSubDO` class isn't
1313+actually registered with miniflare right now." It happens to line up with
1414+Vite dev today, but it's not really a dev-vs-prod concern — it's a
1515+build-wiring concern.
1616+1717+### Why the DO isn't available in Vite dev
1818+1919+- `wrangler.jsonc` declares the DO binding `REALTIME` with
2020+ `class_name: "RealtimePubSubDO"` and `main: ".svelte-kit/cloudflare/_worker.js"`.
2121+- `@sveltejs/adapter-cloudflare@7.x` sets up miniflare via `platformProxy`
2222+ automatically in dev, so `platform.env.REALTIME` is bound.
2323+- But the class lives inside the built worker. It's re-exported into
2424+ `_worker.js` by `scripts/append-scheduled.ts` *after* `pnpm build`.
2525+- Vite dev doesn't run that post-build step, so the DO binding points at
2626+ nothing and `stub.fetch(...)` crashes with `Internal Error`.
2727+2828+## Three fixes, smallest → largest
2929+3030+### 1. App-level probe (smallest)
3131+3232+In the example only. Add a helper that tries a cheap `stub.fetch(health)`
3333+at bundle-build time and falls back to `InMemoryPubSub` if it throws.
3434+3535+```ts
3636+async function pickPubSub(ns: DurableObjectNamespace | undefined): Promise<PubSub> {
3737+ if (!ns) return new InMemoryPubSub();
3838+ try {
3939+ const stub = ns.get(ns.idFromName('__probe__'));
4040+ const res = await stub.fetch('http://probe/__health', { signal: AbortSignal.timeout(250) });
4141+ if (res.ok) return new DurableObjectPubSub(ns);
4242+ } catch {}
4343+ return new InMemoryPubSub();
4444+}
4545+```
4646+4747+- Pro: no library change, no build change.
4848+- Con: async bundle construction (currently sync), every app using contrail
4949+ has to reinvent this.
5050+5151+### 2. Library helper with probe (medium, recommended)
5252+5353+Ship the probe in `@atmo-dev/contrail` as something like:
5454+5555+```ts
5656+export async function resolveRealtimePubSub(
5757+ ns?: DurableObjectNamespace,
5858+ fallback: () => PubSub = () => new InMemoryPubSub(),
5959+): Promise<PubSub>
6060+```
6161+6262+Every app using contrail gets the correct behavior for free. Still async
6363+construction, but now that lives at the library boundary where it's
6464+easier to swallow.
6565+6666+- Pro: reusable primitive, fixes the smell at the right layer.
6767+- Con: contrail now has opinions about "is the DO actually usable"; async
6868+ bundle setup ripples to every consumer (our current `getBundle` is sync).
6969+7070+### 3. Proper dev-prod parity (largest, most correct)
7171+7272+Actually register the DO class with miniflare in Vite dev so `env.REALTIME`
7373+works identically to prod.
7474+7575+- Restructure the DO re-export so it lives at a stable source path (no
7676+ post-build patching).
7777+- Configure `adapter({ platformProxy: { ... } })` with miniflare options
7878+ that point at that stable entry for DO hosting. Likely needs a
7979+ dedicated DO worker script in miniflare's `workers` config.
8080+- Adjust `scripts/append-scheduled.ts` so it no longer duplicates the
8181+ re-export; it only keeps the `scheduled` handler append.
8282+- Drop the `dev ? InMemory : DO` branch in the example entirely — always
8383+ `new DurableObjectPubSub(env.REALTIME)`.
8484+8585+- Pro: dev === prod, no branching anywhere, also unblocks WS-in-dev
8686+ (which today forces `transport: dev ? 'sse' : 'ws'` in
8787+ `+page.svelte`).
8888+- Con: touches build config, wrangler config, and adapter-cloudflare
8989+ internals. Non-trivial to get right; worth its own session.
9090+9191+## Related cleanup (do in the same pass)
9292+9393+- Once the DO works in dev, drop `transport: dev ? 'sse' : 'ws'` at
9494+ `src/routes/c/[communityDid]/[channelKey]/+page.svelte` — always `'ws'`.
9595+- Remove the `dev` import from `src/lib/contrail/index.ts` and
9696+ `+page.svelte` (only remaining consumer).
9797+- Revisit whether `InMemoryPubSub` should stay exported at all from the
9898+ example config; if option 3 lands, it's only needed for non-CF hosts.
9999+100100+## Recommendation
101101+102102+Go with **(2)** next. It's the right layer, and the async-construction
103103+cost is modest (one-time probe at first use). Tackle (3) when we do the
104104+broader "unify transport" pass.