Overview
Offline writes are queued in IndexedDB by src/services/offlineSync.ts and OfflineSyncManager.ts, replayed against the API by src/services/offlineApi.ts, with conflict handling delegated to src/lib/conflict/resolver.ts. The current design has three correctness gaps. First, replay is not idempotent — a mutation that is retried after a flaky network can apply twice on the server. Second, conflict detection is last-write-wins by wall-clock timestamp only, which is unsafe under clock drift between devices and has no per-record version. Third, a partial batch failure can leave the IndexedDB queue and the client store (src/store/synchronizationEngine.ts, src/store/persistenceLayer.ts) diverged, with no rollback and no resumable cursor across app restarts. The offline hooks (src/hooks/useOfflineMode.tsx, src/hooks/useOfflineSync.ts) also expose no deterministic conflict state to the UI, so src/components/ConflictResolver.tsx cannot reliably present conflicts.
This issue makes offline sync deterministic and convergent: per-record versioning with a logical clock, idempotent replay with operation ids and server dedupe, transactional all-or-nothing batch drain with a resumable cursor, and a UI-facing conflict state. It is a cross-cutting change spanning ~14 files and ~950 lines including tests.
Specifications
Features:
Per-record versioning (monotonic version + updatedBy + a logical clock) on offline records, replacing pure timestamp LWW with a deterministic per-entity-type merge strategy that is stable regardless of device clock drift
Idempotent replay using a client-generated operation id plus server ack/dedupe, with capped exponential backoff on retry and a dead-letter queue for operations that exhaust retries
Transactional batch drain that is all-or-nothing per batch with rollback, so the queue and the store never diverge, plus a resumable cursor that survives app restarts
Conflict state surfaced to the UI via useOfflineSync (pending / conflicted / resolved) and consumed by the ConflictResolver component, with a retention/GC policy for successfully synced records
Tasks:
Extend conflict/resolver.ts and conflict/types.ts with version vectors and per-type resolution strategies, and add unit tests asserting merge determinism across reordered/clock-skewed inputs
Add operation ids and a dedupe map in offlineSync, and make offlineApi replay idempotent with ack handling and dead-lettering
Implement transactional batch drain with rollback and a resumable cursor in offlineSync/OfflineSyncManager
Reconcile the store through synchronizationEngine/persistenceLayer after each drain, and expose conflict state through useOfflineMode/useOfflineSync
Add retention/GC for acked records and a serviceWorker background-sync trigger to drain when connectivity returns
Write tests for idempotent replay under duplicate delivery, deterministic merge across clock drift, batch rollback on partial failure, and cursor resume after restart
Impacted Files:
src/services/offlineSync.ts
src/services/offlineApi.ts
OfflineSyncManager.ts
src/lib/conflict/resolver.ts
src/lib/conflict/types.ts
src/lib/queue/index.ts
src/store/synchronizationEngine.ts
src/store/persistenceLayer.ts
src/hooks/useOfflineMode.tsx
src/hooks/useOfflineSync.ts
src/components/ConflictResolver.tsx
src/serviceWorker.ts
src/constants/app.constants.ts
src/services/tests/offlineSync.test.ts (new)
Acceptance Criteria
Duplicate delivery of the same offline operation applies exactly once (idempotent replay is verified by a test)
Conflict resolution is deterministic across clock drift using version vectors, and per-entity strategies are documented and tested
A partial batch failure rolls back cleanly with no divergence between the IndexedDB queue and the store, and the drain resumes from a cursor after an app restart
The UI receives pending / conflicted / resolved states via the offline hooks, and acked records are garbage-collected per the retention policy
Tests cover idempotency, deterministic merge, batch rollback, and cursor resume, with >80% coverage on offlineSync.ts and resolver.ts
Overview
Offline writes are queued in IndexedDB by
src/services/offlineSync.tsandOfflineSyncManager.ts, replayed against the API bysrc/services/offlineApi.ts, with conflict handling delegated tosrc/lib/conflict/resolver.ts. The current design has three correctness gaps. First, replay is not idempotent — a mutation that is retried after a flaky network can apply twice on the server. Second, conflict detection is last-write-wins by wall-clock timestamp only, which is unsafe under clock drift between devices and has no per-record version. Third, a partial batch failure can leave the IndexedDB queue and the client store (src/store/synchronizationEngine.ts,src/store/persistenceLayer.ts) diverged, with no rollback and no resumable cursor across app restarts. The offline hooks (src/hooks/useOfflineMode.tsx,src/hooks/useOfflineSync.ts) also expose no deterministic conflict state to the UI, sosrc/components/ConflictResolver.tsxcannot reliably present conflicts.This issue makes offline sync deterministic and convergent: per-record versioning with a logical clock, idempotent replay with operation ids and server dedupe, transactional all-or-nothing batch drain with a resumable cursor, and a UI-facing conflict state. It is a cross-cutting change spanning ~14 files and ~950 lines including tests.
Specifications
Features:
Per-record versioning (monotonic
version+updatedBy+ a logical clock) on offline records, replacing pure timestamp LWW with a deterministic per-entity-type merge strategy that is stable regardless of device clock driftIdempotent replay using a client-generated operation id plus server ack/dedupe, with capped exponential backoff on retry and a dead-letter queue for operations that exhaust retries
Transactional batch drain that is all-or-nothing per batch with rollback, so the queue and the store never diverge, plus a resumable cursor that survives app restarts
Conflict state surfaced to the UI via
useOfflineSync(pending/conflicted/resolved) and consumed by theConflictResolvercomponent, with a retention/GC policy for successfully synced recordsTasks:
Extend
conflict/resolver.tsandconflict/types.tswith version vectors and per-type resolution strategies, and add unit tests asserting merge determinism across reordered/clock-skewed inputsAdd operation ids and a dedupe map in
offlineSync, and makeofflineApireplay idempotent with ack handling and dead-letteringImplement transactional batch drain with rollback and a resumable cursor in
offlineSync/OfflineSyncManagerReconcile the store through
synchronizationEngine/persistenceLayerafter each drain, and expose conflict state throughuseOfflineMode/useOfflineSyncAdd retention/GC for acked records and a
serviceWorkerbackground-sync trigger to drain when connectivity returnsWrite tests for idempotent replay under duplicate delivery, deterministic merge across clock drift, batch rollback on partial failure, and cursor resume after restart
Impacted Files:
src/services/offlineSync.ts
src/services/offlineApi.ts
OfflineSyncManager.ts
src/lib/conflict/resolver.ts
src/lib/conflict/types.ts
src/lib/queue/index.ts
src/store/synchronizationEngine.ts
src/store/persistenceLayer.ts
src/hooks/useOfflineMode.tsx
src/hooks/useOfflineSync.ts
src/components/ConflictResolver.tsx
src/serviceWorker.ts
src/constants/app.constants.ts
src/services/tests/offlineSync.test.ts (new)
Acceptance Criteria
Duplicate delivery of the same offline operation applies exactly once (idempotent replay is verified by a test)
Conflict resolution is deterministic across clock drift using version vectors, and per-entity strategies are documented and tested
A partial batch failure rolls back cleanly with no divergence between the IndexedDB queue and the store, and the drain resumes from a cursor after an app restart
The UI receives
pending/conflicted/resolvedstates via the offline hooks, and acked records are garbage-collected per the retention policyTests cover idempotency, deterministic merge, batch rollback, and cursor resume, with >80% coverage on
offlineSync.tsandresolver.ts