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.

contrail