How sync works
Copy page
Nizhal sync is pull-authoritative with push-notify hints. No logical replication, no WAL tailing.
Pull — cursor delta
Section titled “Pull — cursor delta”POST /sync/pull returns rows changed since a per-collection cursor (updated_at ordering). The server evaluates sync rules server-side: only rows matching the actor’s bucket scope are included.
Pull responses include:
changed— upserts per tabletombstoned— soft-deleted row IDsremovedBuckets— buckets that left the client’s scope (access revocation)cursor— advance marker for the next pullcursorReset— when the client sent a garbage/future cursor, force full re-bootstrap
Garbage cursors (>24h ahead of wall clock) clamp to 0 and set cursorReset: true so clients do not paginate into a void.
Push — idempotent mutators
Section titled “Push — idempotent mutators”POST /sync/push accepts batches of mutations keyed by clientMutationId. The server:
- Claims the mutation (
claimMutation) — duplicate delivery is a no-op - Runs the mutator in one
transaction - Records applied state + client-id → server-id map (
recordApplied) - Publishes bucket pings from the commit chokepoint only
Replaying the entire outbox twice yields byte-identical server state.
Tombstones
Section titled “Tombstones”Deletes are soft (deleted_at) and tracked in _nizhal_tombstones. Pull includes tombstone IDs so clients remove rows locally. Hard deletes are not synced.
Change tracking without WAL
Section titled “Change tracking without WAL”postgresStorage.provision installs:
updated_at/deleted_aton synced tables- Triggers gated by
_nizhal_sync_control(loop-free — mutations do not re-trigger themselves) - Indexes for cursor scans
This runs on any Postgres — RDS, Neon, Supabase — without wal_level = logical or replication slots.
Client collection loop
Section titled “Client collection loop”nizhalCollectionOptions implements TanStack DB’s SyncConfig:
sync()callsecho.pullwith cursor + bucketsbegin/write/commitapply rows into the collection- On bucket ping or reconnect, pull again
pull.intervalMs on createNizhalClient is an opt-in fallback interval when realtime is down — the cursor pull still converges (chaos scenario SYNC-5).
Contract decoupling
Section titled “Contract decoupling”Clients should type rows and mutator inputs from GET /nizhal/contract via nizhal gen (planned) — not by importing server Drizzle schema. See The contract.
- Realtime — pings are hints
- Sync rules & buckets