Skip to content

Sync-Core Reconcilers

src/lib/offline/sync-core.ts holds the pure functions that reconcile server delta reads into the Dexie cache. They are side-effect-free (testable in isolation) and shared by both the polling fallback and the live transport — the data path is identical regardless of how a refresh is triggered.

The diagram below shows how both triggers funnel a ?since delta through the pure reconcilers into Dexie.

flowchart LR
  PC["PollController<br/>poll.ts"] -->|"refresh(surfaces)"| RD["?since delta read<br/>sync.ts pullAndReconcile*"]
  LC["createLiveConnection()<br/>live.ts"] -->|"refresh(surfaces)"| RD
  D1{{"D1 — source of truth"}} -->|"delta rows + tombstones"| RD
  RD --> REC["reconcile() / reconcileTasks()<br/>reconcileSteps() / reconcileMyDay()<br/>reconcileMemberships() — sync-core.ts"]
  RD --> DEL["deltaDeleteIds()<br/>evictRevoked()"]
  RD --> CUR["maxUpdatedAt()<br/>next cursor"]
  REC --> DX[("Dexie read cache")]
  DEL --> DX

The client fetches changes since a cursor (a ?since read keyed by maxUpdatedAt(...)) per surface, then reconciles:

  • reconcile(...) / reconcileGroups(...) — fold lists/groups deltas.
  • reconcileTasks(...), reconcileSteps(...), reconcileAllSteps(...) — fold task/step deltas.
  • reconcileMyDay(...) — fold “My Day” view membership.
  • reconcileMemberships(...) — fold list-sharing changes.
  • deltaDeleteIds(...) — extract tombstoned (deletedAt) ids to remove locally.
  • evictRevoked(...) — drop cached data for a List the user has lost access to (revocation boundary).
  • maxUpdatedAt(...) — compute the next cursor from a batch.

Because reconcilers are pure, the transport (polling timer vs. WebSocket signal) only decides when to call them — never what they do. This is the invariant that let the event-push upgrade (spec 021) replace the polling timer without touching read/reconcile logic. Optimistic local mutations are issued separately from src/lib/offline/sync.ts (createList, renameList, moveList, duplicateList, …).

A single delta read drives the upsert / delete / cursor split for one surface:

sequenceDiagram
  participant T as "Transport (poll or live)"
  participant S as "sync.ts (pull)"
  participant D1 as "D1"
  participant C as "sync-core.ts (pure)"
  participant DX as "Dexie"
  T->>S: "refresh(surface)"
  S->>D1: "GET ...?since=cursor"
  D1-->>S: "delta rows (incl. tombstones)"
  S->>C: "reconcileTasks(delta, local, pendingIds)"
  C-->>S: "merged rows (pending preserved)"
  S->>C: "deltaDeleteIds(delta, pendingIds)"
  C-->>S: "tombstoned ids to drop"
  S->>C: "maxUpdatedAt(delta)"
  C-->>S: "next cursor"
  S->>DX: "upsert rows + delete ids (one tx)"

Related: Offline Outbox & Checklists (the write side), LiveConnection WS Client and Poll Controller (the triggers).