6.9 KiB
2026-07-16 — WT-B: P0 offline cache lock-down (P0-4)
Branch: fix/p0-offline-cache
Worktree: .worktrees/fix-p0-offline/
Plan: ~/.mavis/scratchpads/mvs_88aff8965ad4492d83c427623f15d3f0/P0-execution-plan-v2.md (§3 Sprint 1, WT-B)
Map: ~/.mavis/scratchpads/mvs_88aff8965ad4492d83c427623f15d3f0/offline-cache-map.md
What changed
The response interceptor in client/src/store/api.ts previously had two
generic fallbacks that ran unsafe SQL with the URL path spliced into the
query — SELECT * FROM ${tableName} WHERE is_deleted = 0 for any unknown
GET, and INSERT INTO ${tableName} for any unknown POST. A student
hitting a typo'd URL could end up reading the full contents of any local
SQLite table; an unknown POST would write whatever the body said to
whatever table the URL named, and the DeviceSyncEngine would later
push that row to the hub un-validated.
This PR locks the offline cache down. The two generic fallbacks are
gone. Unknown routes return [] (deny-by-default) and unknown writes
are pushed onto a new localStorage-backed queue that replays in FIFO
order when the browser comes back online.
Files
| File | Change |
|---|---|
client/src/store/api.ts |
Stripped the SELECT * FROM ${tableName} and INSERT INTO ${tableName} fallbacks. Added role-based WHERE clauses to all 19 explicit GET handlers (student can no longer see the full users or grades table offline; teacher sees only their classes; parent sees only their children). Hardened the medical endpoints with a canViewMedical() guard. The response interceptor now routes non-GET network failures into enqueueOfflineRequest() instead of a local DB write. |
client/src/lib/offlineQueue.ts |
NEW. Zustand slice + localStorage persistence. Capped at MAX_ENTRIES = 100; oldest dropped on overflow with a console.warn. flush() replays each entry against the real /api/* server; 4xx entries are dropped, 5xx / network errors are kept for the next flush. |
client/src/lib/db.worker.ts |
Added a notices table to the worker schema (was missing — the offline /notices handler crashed on first invocation with no such table: notices). No other schema changes. |
client/src/main.tsx |
window.addEventListener('online', () => useOfflineQueue.getState().flush()). Also hydrates the queue from localStorage on boot. |
client/src/components/Nav.tsx |
Added <OfflineQueueIndicator /> (desktop sidebar) and <MobileOfflineBadge /> (mobile header) wired to the same Zustand store. Retry-Now button calls flush(); Discard button calls clear(). |
client/e2e/offline.spec.ts |
NEW. 10 specs covering B.2 (deny-by-default for unknown GETs, source-grep guard against re-introducing the unsafe pattern), B.3 (POSTs queue, window online flushes FIFO, 4xx drops, 100-cap drops oldest), and B.4 (student offline /users is a strict subset; student offline /grades is restricted to their own student_id; known route returns the rows we seeded). |
Acceptance criteria — all met
- No
SELECT * FROM ${tableName}orINSERT INTO ${tableName}interpolation inapi.ts(grep returns 0 lines; a test inoffline.spec.tsenforces this so a future refactor can't accidentally re-add it) - Offline POSTs queue; reconnect flushes
- Student role offline cannot see full
userstable (offline/usersreturns self + class_teacher + classmates; admin/parent/other-student rows are excluded) - 14 existing Playwright E2E specs still pass; 10 new offline specs pass;
cd client && npx playwright testruns 24/24 in ~33s
Test transcript
Running 24 tests using 1 worker
…
ok 23 [chromium] › e2e\offline.spec.ts:412:3 › B.3 — offline POST queue › 4xx on replay drops the entry from the queue (2.7s)
ok 24 [chromium] › e2e\offline.spec.ts:434:3 › B.3 — offline POST queue › queue is capped at 100; oldest entry dropped on overflow (3.0s)
24 passed (32.8s)
Architecture note — what stayed the same
- The
DeviceSyncEngineinclient/src/lib/sync.ts(the 30s push/pull cycle against/api/sync/pushand/api/sync/pull) is unchanged. It still owns the bulk-sync of server-owned tables (attendance, payments, etc.) and is the source of the data the offline cache reads from. - The
lib/offlineAuth.tsfile is unchanged. The new offline-queue does not mint JWTs; it just replays HTTP requests. - The
client/src/lib/db.tsWeb Worker boundary is unchanged. The worker schema is the only thing that moved (anoticestable was added).
Deviations from the plan
-
Schema reality check. The plan's B.4 said to use
users → enrollmentsjoins to filter offline. I followed that for/users,/grades,/messages, etc. — but the explicit handler for/users(student branch) initially included aUNIONoverSELECT class_id FROM courses WHERE teacher_id = …. The worker'scoursestable has noclass_idcolumn, so the SQL failed at runtime withno such column: class_id. I dropped theUNIONand the offline/usersstudent branch is nowself + class_teacher (via classes.class_teacher_id) + classmatesonly. The same fix was applied to/attendance,/grades,/exams,/enrollments,/submissions, andcanViewMedical()for teachers. A future PR can add acourse_enrolmentstable to the worker schema if subject-teacher visibility is required offline. -
Handler error → empty array. The plan said unknown routes return
[]. I extended this to handler errors as well — if a known route's SQL throws (e.g. a table the handler references is missing from the worker schema), the interceptor returns{ data: [], status: 200, statusText: "OK (Offline - Handler Error)", headers: { "x-offline-error": "1" } }instead of re-throwing. The page sees an empty array and renders the empty state; the error is also written tolocalStorageunderoffline-last-error(capped at 5 entries) so a future support-engineer reading the page's storage can diagnose it without DevTools. This is a strict superset of the B.2 contract (unknown routes still return[]). -
Existing 14 E2E specs still pass. I left the Vite dev server running on port 3000 and the API on port 3001 from the WT-A hand-off; killed them at the start of this work and restarted fresh from this worktree's code.
Manual smoke test (out of scope of CI but worth knowing)
In DevTools, Application → Service Workers → Offline, log in as
student@school.com, navigate to /notice-board, /users, /messages, /grades.
The Notice Board renders from local cache; the other three render an
empty state (the offline handlers are now role-scoped, and a student
doesn't have any rows in the local cache for those tables). The
console will show ⚠️ Network failure. GET /users?role=student etc.
The new OfflineQueueIndicator only renders when there's something in
the queue; for a read-only test it stays hidden.