Sync & Offline
Rocicorp Zero for query-driven partial sync over PostgreSQL. Clients read locally, resume instantly, and durably queue message sends through an app-owned outbox.
Message Send Outbox Lifecycle
Every outbound message travels through a durable state machine. The client never fires and forgets — it writes to encrypted local storage first, then progresses through the lifecycle with server acknowledgement as the terminal success condition.
The Offline Contract
The offline contract is deliberately narrow. Three and only three guarantees are made:
1. Offline-readable
Previously synced servers, channels, and messages render immediately from the local Zero replica without any network request. The client never shows a blank screen or loading spinner for already-visited content.
2. Instant resume
On reconnection, the Zero sync engine catches up incrementally using its logical-replication position. Valid local data is never discarded or replaced wholesale. The client resumes from exactly where it left off, with new rows streamed in as they arrive.
3. Durable message sends
Only message sends use the app-owned durable outbox. All other mutations (reactions, read states, role changes) are online-only. The outbox isolates the one write path that users care most about preserving across connection loss, without generalizing to an overly broad offline write surface that would compromise server authority.
Query Primitives
Six message-paging primitives cover every client navigation pattern. All are defined once in the shared zero-queries package and executed locally against the client replica.
| Operation | Signature | Description |
|---|---|---|
| recent | recent(channelId, limit) |
Live tail for a channel — returns the newest N messages and re-renders as new messages arrive via Zero subscription. |
| before | before(channelId, cursor, limit) |
Scroll backward from a sort_id cursor. Powers infinite-scroll history loading. |
| after | after(channelId, cursor, limit) |
Scroll forward from a sort_id cursor. Used when jumping to a message in the past and scrolling toward present. |
| around | around(channelId, messageId, before, after) |
Jump to an anchor message with symmetric context. Used for notification deep-links and search result navigation. |
| byIds | byIds(messageIds) |
Fetch a specific set of messages by ID. Used for reply previews and mention resolution. |
| threadRoot | threadRoot(threadId) |
Fetch the root message of a thread channel. Threads are modeled as channels; this retrieves the entry-point message. |
Hot vs Archive Boundary
Messages within the 90-day window are published via Zero's query-driven partial sync. Clients receive live updates and can page through recent history entirely from the local replica.
Archive rows (older than 90 days) are fetched through an authorized read-only history API. They are never promoted into fake Zero rows, never inserted into the client replica as synthetic local-first data. The source-tier seam is explicit and enforced at the query layer.
The physical hot/archive boundary is enforced by the Zero publication definition itself. The zero-cache only replicates rows within the 90-day window. When the boundary moves forward in time, old rows naturally leave the publication and are evicted from client replicas.
Zero Viability Gates
Six spike-gated risks must be resolved before committing to Zero as the sync engine. Each has an assigned spike and explicit pass/fail criteria.
| Gate | Risk | Mitigation / Spike |
|---|---|---|
| RN lifecycle/storage | Zero's kvStore may not survive React Native process death or account switching correctly. | Crash-test kvStore across process death and account switch. S1 |
| Operational maturity | zero-cache is pre-1.0; failover, replica rebuild, and rolling upgrades are untested in production. | Validate zero-cache failover, replica rebuild, and rolling upgrade procedures. S2 |
| Dataset headroom | Published replica and client working sets may exceed practical size limits. | Physical hot/archive boundary limits the publication to the 90-day window. S2 |
| Query fan-out | Distant jumps, archive transitions, and permission revocation may cause unbounded query fan-out. | Characterize query fan-out in jump-to-message, archive transition, and revocation scenarios. S3 |
| Auth refresh | Refreshing sync credentials may require recreating Zero storage, losing local state. | Test credential refresh path without storage recreation or data loss. S4 |
| Write durability | Outbox crash scenarios may produce duplicate or lost messages without proper idempotency. | Outbox crash matrix, receipt acknowledgement, and client_nonce idempotency testing. S5 |
Architectural Invariants
Ten invariants govern the sync architecture. Violation of any one is a design defect, not a configuration choice.
- PostgreSQL is the sole authoritative source of truth. No record is canonical unless it exists in PostgreSQL. Zero, the client replica, and the outbox are all derived views or queues — not sources of authority.
- Zero is the only app-wide client data store. No secondary local database, no ad-hoc AsyncStorage for app data. One store, one eviction policy, one sync lifecycle.
- Server identity and memberships are never cached in Zero directly. The sync publication includes only data the actor may currently read; membership changes propagate through the permission layer, not through stale cached rows.
- The outbox is exclusively for message sends. No other mutation type enters the durable outbox. Reactions, read states, and other writes are online-only operations.
- Archive data is never synthesized as fake Zero rows. The hot/archive boundary is a hard seam. Archive reads go through the authorized API. Blurring this boundary invalidates the publication size model.
- Client-side permission prediction uses the same calculator as server-side authority. The shared pure-TypeScript calculator runs identically in both environments. No separate "client permission" logic exists.
- Nonce-based idempotency is non-negotiable. Every message send carries a
client_nonce. The server rejects duplicates at the database level. Retrying an outbox item never creates duplicate messages. - Zero storage is isolated per user identity. Account switching never exposes one user's local rows to another. Storage isolation is enforced by userId-keyed kvStore paths.
- Revocation propagates through the sync engine. When a user loses access to a channel, the Zero publication removes those rows from the client replica. No stale local reads survive revocation.
- No global loading spinner is acceptable. The client must render cached state immediately on launch. Auth revalidation and sync catch-up happen in the background. A blocking sessionPending state is a design violation.