Sync
Client-side reactive store over contrail’s watchRecords endpoints. Subscribes once, reconciles forever, ships with optimistic updates and an optional IndexedDB cache.
Lives in its own package:
pnpm add @atmo-dev/contrail-sync
Basic use
import { createWatchStore } from "@atmo-dev/contrail-sync";
const store = createWatchStore({
url: "/xrpc/com.example.message.watchRecords?roomUri=at://...",
transport: "sse", // or "ws"
});
store.subscribe(({ records, status }) => { /* re-render */ });
store.start();
Framework-agnostic — wrap it in Svelte $state, React useSyncExternalStore, Vue ref, whatever.
Transports
- SSE (default) — one HTTP request, simplest. Works everywhere.
- WS — a two-step handshake: HTTP GET returns a snapshot + watch-scoped ticket, then you upgrade to WS. On Cloudflare, the WS terminates on a Durable Object that hibernates idle connections. Same event stream either way.
Authenticated watches
Pass mintTicket for any non-public endpoint. One-shot string for SSR-minted tickets, function for fresh tickets per reconnect:
mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()),
Tickets are minted server-side via com.example.realtime.ticket (or any app-specific route).
Optimistic updates
store.addOptimistic({ rkey, did, value: { text: "hi" } });
// later, on mutation failure:
store.markFailed(rkey, err);
// or explicit rollback:
store.removeOptimistic(rkey);
When a real record with the same rkey arrives via the stream, the optimistic entry is dropped automatically.
IndexedDB cache
Instant first paint from last session’s records:
import { createIndexedDBCache } from "@atmo-dev/contrail-sync/cache-idb";
createWatchStore({
url,
cache: createIndexedDBCache(),
cacheMaxRecords: 200,
});
Cached records show immediately; the live snapshot reconciles when the connection opens.
Server-side config
Enable watchRecords emission in your Contrail config:
realtime: {
ticketSecret: ENV.REALTIME_TICKET_SECRET, // 32 bytes
pubsub: new DurableObjectPubSub(env.PUBSUB), // or in-memory for dev
}
See Indexing for the full config surface.
Lifecycle
idle → connecting → snapshot → live
↓ (disconnect)
reconnecting → snapshot → live
↓ (stop)
closed
Stale records stay visible across reconnects until the fresh snapshot arrives, at which point anything the server didn’t re-send is evicted. Survives offline periods cleanly.