Skip to content

Implement an offline mutation queue with conflict resolution for admin actions #306

Description

@Lakes41

Difficulty: Expert
Type: feature (offline architecture)

Background

The app ships an offline-aware caching layer: public/sw.js implements a service worker that caches GET responses with a stale-while-revalidate strategy and explicit eviction (evictIfNeeded), while its own comment states its scope clearly: "Never cache admin mutation routes (/v1/admin/*, POST, PUT, DELETE)... Pass through: mutations, admin routes... untouched." lib/offline/use-sync-status.ts tracks isOnline/isSyncing/lastUpdatedAt for the read path. There is no equivalent handling for the write path: updatePolicy(), assignRole(), removeRole() in lib/api/live.ts are plain fetch-backed calls with no offline awareness.

Problem

If an admin loses connectivity mid-session (a realistic scenario this app already partially designs for, given the read-side offline infrastructure) and attempts to assign a role or update a policy, the mutation simply fails with a network error — there's no queuing, no retry-on-reconnect, and no indication to the admin that their action was lost rather than applied. This is a meaningful gap given how much of the rest of the app (service worker, useSyncStatus, optimistic updates in lib/api/optimistic.ts) is already built around graceful offline/online transitions.

Expected Outcome

When an admin performs a role-assignment or policy-update mutation while offline, the action is durably queued (surviving a page reload) rather than failing outright, is automatically replayed when connectivity returns (detected via the existing useSyncStatus/online event infrastructure), and any resulting server-side conflict (e.g., a 409 from the existing optimistic-concurrency check documented in docs/POLICY_CONCURRENCY.md) surfaces through the existing PolicyConflictDialog flow rather than silently failing or silently overwriting.

Suggested Implementation

  1. Add an IndexedDB-backed queue (lib/offline/mutation-queue.ts) storing pending mutations as { id, type: 'assignRole' | 'removeRole' | 'updatePolicy', payload, queuedAt, retryCount }. Use the native IndexedDB API or a minimal wrapper — avoid adding a heavy new dependency given the project currently has none for storage.
  2. In lib/api/live.ts (or a new thin wrapper consumed by the admin pages), detect a network failure on assignRole/removeRole/updatePolicy while navigator.onLine === false (or the request itself fails with a network error) and enqueue instead of surfacing a hard failure to the caller — return a distinct "queued" result so the UI (app/admin/members/page.tsx, app/admin/policies/page.tsx) can show an "action queued, will retry when back online" state rather than an error toast.
  3. Add a drain/replay routine triggered by the existing online event handling in lib/offline/use-sync-status.ts (or a sibling hook) that processes the queue in FIFO order, one mutation at a time, awaiting each result before starting the next (to preserve ordering guarantees, especially for policy updates against the same resource).
  4. For updatePolicy specifically, wire replay conflicts (409) into the existing PolicyConflictDialog — the admin should see the same reload/force-overwrite/cancel choice they'd see for a live conflict, not a silently dropped queued mutation.
  5. Persist enough context per queued item (e.g., the updatedAt the policy had when the mutation was originally queued) to make that conflict resolution meaningful after a delay.
  6. Add a small UI affordance (e.g., in components/ui/sync-status-banner.tsx, which already exists for the read-side sync status) showing the count of pending queued mutations.
  7. Add unit tests for the queue's persistence/replay/ordering logic and an integration test (extending test/optimistic-updates.test.ts or test/policy-drafts.test.ts patterns) simulating: mutation while offline → queued → reconnect → replayed → conflict surfaced via the dialog.

Acceptance Criteria

  • A role-assignment or policy-update attempted while offline is queued (not failed) and the queue survives a full page reload.
  • On reconnect, queued mutations are replayed in the order they were queued.
  • A 409 conflict encountered during replay of a queued updatePolicy surfaces through PolicyConflictDialog with the correct "your changes" vs. "current version" context.
  • components/ui/sync-status-banner.tsx (or an equivalent UI element) reflects the number of pending queued mutations.
  • Mutation routes remain excluded from the service worker's response cache (public/sw.js's existing pass-through behavior for /v1/admin/* and non-GET methods is unchanged).
  • New unit and integration tests cover queuing, ordering, replay, and conflict-surfacing; npm test passes.
  • npm run typecheck, npm run lint pass.

Likely Affected Files/Directories

  • lib/offline/mutation-queue.ts (new)
  • lib/offline/use-sync-status.ts
  • lib/api/live.ts
  • app/admin/members/page.tsx
  • app/admin/policies/page.tsx
  • components/ui/policy-conflict-dialog.tsx
  • components/ui/sync-status-banner.tsx
  • public/sw.js (verify no unintended interaction)
  • test/optimistic-updates.test.ts, test/policy-drafts.test.ts (reference patterns)

Metadata

Metadata

Assignees

Labels

GrantFox OSSGrantFox Open Source Sponsorship program tagMaybe RewardedIssue may qualify for a reward upon successful completion per campaign rulesOfficial Campaign | FWC26Official FWC26 campaign issue — eligible for campaign scoring and rewardsapi-layerAutomatically createdfeatureNew feature, enhancement, or functional additionhelp wantedExtra attention is neededpriority: mediumAutomatically created

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions