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
- 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.
- 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.
- 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).
- 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.
- 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.
- 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.
- 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
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)
Difficulty: Expert
Type: feature (offline architecture)
Background
The app ships an offline-aware caching layer:
public/sw.jsimplements a service worker that cachesGETresponses 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.tstracksisOnline/isSyncing/lastUpdatedAtfor the read path. There is no equivalent handling for the write path:updatePolicy(),assignRole(),removeRole()inlib/api/live.tsare plainfetch-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 inlib/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/onlineevent infrastructure), and any resulting server-side conflict (e.g., a409from the existing optimistic-concurrency check documented indocs/POLICY_CONCURRENCY.md) surfaces through the existingPolicyConflictDialogflow rather than silently failing or silently overwriting.Suggested Implementation
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.lib/api/live.ts(or a new thin wrapper consumed by the admin pages), detect a network failure onassignRole/removeRole/updatePolicywhilenavigator.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.onlineevent handling inlib/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).updatePolicyspecifically, wire replay conflicts (409) into the existingPolicyConflictDialog— the admin should see the same reload/force-overwrite/cancel choice they'd see for a live conflict, not a silently dropped queued mutation.updatedAtthe policy had when the mutation was originally queued) to make that conflict resolution meaningful after a delay.components/ui/sync-status-banner.tsx, which already exists for the read-side sync status) showing the count of pending queued mutations.test/optimistic-updates.test.tsortest/policy-drafts.test.tspatterns) simulating: mutation while offline → queued → reconnect → replayed → conflict surfaced via the dialog.Acceptance Criteria
409conflict encountered during replay of a queuedupdatePolicysurfaces throughPolicyConflictDialogwith 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.public/sw.js's existing pass-through behavior for/v1/admin/*and non-GETmethods is unchanged).npm testpasses.npm run typecheck,npm run lintpass.Likely Affected Files/Directories
lib/offline/mutation-queue.ts(new)lib/offline/use-sync-status.tslib/api/live.tsapp/admin/members/page.tsxapp/admin/policies/page.tsxcomponents/ui/policy-conflict-dialog.tsxcomponents/ui/sync-status-banner.tsxpublic/sw.js(verify no unintended interaction)test/optimistic-updates.test.ts,test/policy-drafts.test.ts(reference patterns)