Offline & Live Sync
Bonus B-03 (offline outbox) + B-02 notification sync + P-01 mock-backend co-operation.
Outbox
lib/core/sync/outbox.dart:
- Table
OutboxRows{ id uuid PK, opKind: accept|contest|pay|taskCreate, payloadJson, stateEpoch, version, createdAt, attempts, syncState }. - Enqueue is always a Drift insert + optimistic local flip; no network throw propagates to the widget.
SyncService.drain()(called on boot, onConnectivityCubit=online, and onRetryPolicybackoff) dequeues increatedAtorder, callsKxApiwith a freshIdempotency-Keyper attempt, interprets result:2xxor idempotent replay → marksynced, updateSyncMeta.storeRevision/stateEpoch.409 store.epoch_mismatch→ pause queue,SnapshotCoordinator.refresh()then requeue with new epoch.429 chaos.injected/DioException→RetryPolicy.nextDelay(attempts)(exponential 300 ms → 8 s, jitter ±20 %), requeue aspending.422/409 invalid_transition→ markfailed+ surfaceSnackBar— no silent retry.
Outbox survives reload/restart because it lives in Drift, not memory.
Live SSE → Drift
LiveSyncService (live_sync_service.dart):
App foreground
→ KxApi.events(lastEventId: syncMeta.streamCursor)
→ dio ResponseType.stream → parse SSE frames
→ SseEntityCommitter.apply(eventData, LocalStore)
→ SnapshotCoordinator.persistCursor(streamCursor)
Frames: charge.created/updated, task.created, notification.updated, sync.required. Heartbeat comment ignored. Last-Event-ID header causes backend ring replay; if ring trimmed, frame sync.required forces full GET /sync-snapshot via SnapshotCoordinator.
SseEntityCommitter is pure mapper: given {chargeId, status, version, ...} it patches the single Charges row inside a transaction — no full snapshot.
Sync leadership
Multiple browser tabs would otherwise open duplicate SSE streams. SyncLeadership elects one leader:
- Web:
sync_leadership_factory_web.dartusesBroadcastChannel/localStorageleader lease. - Native:
sync_leadership_factory_stub.dartsingle owner.
Only the leader drains LiveSyncService; followers still drain Outbox locally when online.
Snapshot coordinator
snapshot_coordinator.dart owns SyncMeta{stateEpoch, storeRevision, streamEpoch, streamCursor} and decides:
- First launch (
fixture): loadfixture_bundle→LocalStore.applySnapshot. - First
remotelaunch:GET /sync-snapshot→applySnapshot(includesstreamEpoch/cursor). - After
sync.requiredor epoch mismatch: refetch snapshot (never partial).
persisted_demo_clock.dart anchors demo now so deadline copy stays stable.
Clocks & deadline copy
ServerAdjustedClock computes skew = serverTime(meta.serverTime) - DateTime.now() per response and adds it to now() so the intro copy "will be automatically accepted on …" uses the server's time, not a desynced local clock — verified by deadline_vectors replay.
Failure modes & UX
| Condition | UX |
|---|---|
| Offline Accept/Contest | Immediate optimistic banner, SnackBar: queued offline, badge on hub sync icon |
| Back online | SyncService drains, banners converge when SSE/refresh applies |
| 409 version | Retry with new expectedVersion after refetch |
| 429 chaos | RetryPolicy backoff, no user toast until N retries |
| Stream disconnect | LiveSyncService reconnect with previous Last-Event-ID after 1–5 s |
Testing
test/core/sync/outbox_test.dart— enqueue→pending→drain→synced survives restart (reopen DB).test/integration/outbox_reconnect_test.dart—ConnectivityCubitflip offline→online drains.test/core/sync/live_sync_service_test.dart—Last-Event-IDreplay, heartbeat skip,sync.requiredfallback.- Backend
deadline_vectors.jsonreplay ensuresnow >= deadlineAtinclusive both sides.
Not a background job
Offline queue is foreground-only (no WorkManager/Dispatch). Notification bonus B-02 uses flutter_local_notifications triggered by SSE charge.created, not FCM/APNs — documented limitation.