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 an opaque storage-issued cursor. Postgres orders row
changes, tombstones, and bucket-exit removals with the global _nizhal_row_version sequence; wall
clock timestamps are not part of pagination. The server evaluates sync rules server-side, so only
rows matching the actor’s bucket scope are included.
Pull responses include:
changed— upserts per tabletombstoned— hard- or soft-deleted row IDsremoved— rows that moved out of the client’s bucketsremovedBuckets— 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
Malformed or impossible cursors reset to the initial cursor and set cursorReset: true.
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”Hard deletes and deleted_at transitions are tracked in _nizhal_tombstones. Bucket-column changes
record a bucket-exit removal for the old scope. Pull includes the row’s correlation key so clients
remove the same local key used during optimistic ID reconciliation.
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) _nizhal_row_versionindexes 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