# 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: ```bash pnpm add @atmo-dev/contrail-sync ``` ## Basic use ```ts 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: ```ts 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 ```ts 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: ```ts 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: ```ts realtime: { ticketSecret: ENV.REALTIME_TICKET_SECRET, // 32 bytes pubsub: new DurableObjectPubSub(env.PUBSUB), // or in-memory for dev } ``` See [Indexing](./01-indexing.md) 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.