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
Delta-read model
Section titled “Delta-read model”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.
Why pure
Section titled “Why pure”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).