Skip to main content

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, on ConnectivityCubit=online, and on RetryPolicy backoff) dequeues in createdAt order, calls KxApi with a fresh Idempotency-Key per attempt, interprets result:
    • 2xx or idempotent replay → mark synced, update SyncMeta.storeRevision/stateEpoch.
    • 409 store.epoch_mismatch → pause queue, SnapshotCoordinator.refresh() then requeue with new epoch.
    • 429 chaos.injected / DioExceptionRetryPolicy.nextDelay(attempts) (exponential 300 ms → 8 s, jitter ±20 %), requeue as pending.
    • 422/409 invalid_transition → mark failed + surface SnackBar — 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.dart uses BroadcastChannel / localStorage leader lease.
  • Native: sync_leadership_factory_stub.dart single 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): load fixture_bundleLocalStore.applySnapshot.
  • First remote launch: GET /sync-snapshotapplySnapshot (includes streamEpoch/cursor).
  • After sync.required or 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

ConditionUX
Offline Accept/ContestImmediate optimistic banner, SnackBar: queued offline, badge on hub sync icon
Back onlineSyncService drains, banners converge when SSE/refresh applies
409 versionRetry with new expectedVersion after refetch
429 chaosRetryPolicy backoff, no user toast until N retries
Stream disconnectLiveSyncService 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.dartConnectivityCubit flip offline→online drains.
  • test/core/sync/live_sync_service_test.dartLast-Event-ID replay, heartbeat skip, sync.required fallback.
  • Backend deadline_vectors.json replay ensures now >= deadlineAt inclusive 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.