Skip to content

ADR-0039: Online-Only Default Mode — Decoupling the Offline Stack (wayfinder map #667) ​

Status: accepted Date: 2026-08-18 Deciders: @barateza Tags: [online-only, offline, sync, pwa, dexie, d1, flag, island, lgpd, backend-primary]

Context ​

Neemias has been local-first with write-through (ADR-0030): reads come from the IndexedDB mirror (DexieAdapter), writes go to the local store + sync_queue and drain to D1. Three confirmed troublemakers keep breaking: login-hydration staleness (#654), encryption-at-rest bugs, and sync-queue retry loops (stuck PENDING/FAILED). The repo's own docs contradict each other — REASONIX.md says "backend-primary, D1 source of truth"; app/CONTEXT.md + ADR-0030 say "offline-first, local source of truth" — the code is really local-first with write-through. Wayfinder map #667 ("Decouple offline stack → online-only default (offline check-in island)") resolved the contradiction toward backend-primary: the admin/attendance/students/reports surface reads and writes straight to the Worker API (/api/v1, REST CRUD, audit trail preserved), while the offline stack is decoupled behind the StorageBackend seam and feature-flagged off — code kept, unsupported, reversible. The one exception is the offline check-in/check-out island (issue #144 flow), which keeps working as a narrow offline capability.

All decisions below were settled by grilling (#669, #670, #672, #673, #674), proven by prototype (#676 read layer; #683 island — browser e2e validated 2026-08-18 on the seeded local stack), and implemented incrementally on feature branches (#680, #682). Rollout is demo-first (app.neemias.app validation builds only), flag default OFF until this ADR + the prototype pass.

Decision ​

1. Online-only is the default direction; the legacy path stays feature-flagged, unsupported, reversible ​

The destination is backend-primary: D1 is the source of truth; the app is a thin REST client when the flag is ON. The legacy offline stack is not deleted and not rehabilitated — it stays in the repo, gated off by the flag, byte-for-byte reversible (rebuild with the flag OFF restores the legacy behavior exactly). No in-place fixes of the legacy troublemakers (hydration, encryption, sync retries) — the effort decouples away from them.

2. Read path — typed REST clients + TanStack Query (#669, #677, #680) ​

  • Typed read clients per module on the shared fetch wrapper (app/src/api/client.ts → apiUrl/authHeaders/handleResponse/getJson; readClients.ts with fetchAllPages full-table nextCursor loops) replace useSqlQuery call sites (33 total) — parity with today's full-table reads and client-side filters, no server pagination.
  • Async-state layer = TanStack Query (@tanstack/react-query): the typed clients are the queryFn layer; hierarchical queryKeys (entity-prefix invalidation); reference-ish tables staleTime: Infinity + boot prefetch; hot data ~60s; retry: false / refetchOnWindowFocus: false → fail-fast offline error banner + retry CTA; Zod-parse at the client boundary (required — PII validated before entering the cache/UI).
  • Mutation→read invalidation: useMutation.onSuccess → invalidateQueries({ queryKey: [entity] }); setQueryData insert on create (no empty-list flash); optimistic UI per-page (attendance only). No SSE ack, no local-write/sync trigger — the REST response is authoritative.
  • createStore (app/src/storage/index.ts) becomes the flag-driven composition switch; the legacy seam stays untouched and reversible.

3. Write path — per-entity REST CRUD, event-sourcing preserved (#670) ​

  • All writes reuse the existing per-entity REST CRUD routes — they already route through applyEvent (unified events row + projection row + capacity adjustment in one atomic batch; audit = unified events table per ADR-0023; audit_logs vestigial). Event-sourcing is preserved end-to-end; the client issues typed commands only, no local mirror.
  • Idempotency-Key mandatory per mutation: route-level idempotentMutation wrapper (ledger replay, 409 on payload mismatch); the frontend sends a UUID per user intent, request-lifetime only.
  • Execution items carried by this decision: nuclei CRUD routes (POST/PATCH/DELETE /api/v1/nuclei); optional client-supplied createdAt (so offline replay records the true offline time); quick-register client UUID acceptance (offline-created students).

4. Flag & config — build-time VITE_ONLINE_ONLY (#672) ​

  • Build-time env var only (VITE_ONLINE_ONLY === "true", app/src/config.ts), matching the repo's 100% build-time VITE_* convention; no runtime toggle (would ship both modes in every bundle and let a stray localStorage key re-enable the unsupported path).
  • Default OFF everywhere; rolled out 2026-08-21: app.neemias.app (demo) and production IJCP (ijcp.neemias.app) now build ON (VITE_ONLINE_ONLY=true), after the demo validation passed and the release path was hardened (#703: BUILD_MODE marker + scripts/verify-online-build.sh + pre-push/CI guards — a build without the flag fails loudly). Per-deployment = env-file selection at build.
  • Under the flag: createStore() returns a DisabledBackend (fail-fast "online-only" state on any legacy read — never a silent empty mirror); boot skips local seedDatabaseIfNeeded() and encryption init (PII never touches IndexedDB; the demo seed targets the Worker/D1); OfflineProvider/sync engine mount no-op (no drain loops, no queue); login hydration becomes a query-cache seed (#679/#682 — prefetch students+classes+roles, PermissionStore.seedFromRoles, legacy hydrate early-returns); the island is flag-independent (its own DB is the only local store under the flag); PWA behavior per §6.

5. Offline island — narrow check-in/check-out (#671, #683) ​

The only local state in online-only mode. Covenant (user): narrow and disciplined — no generic sync engine, no encryption-at-rest machinery, wiped on logout.

  • Store: dedicated Dexie/IndexedDB neemias-island (roster, classes, today's sessions, church config, daily secret, append-only replay queue), versioned, navigator.storage.persist(), wiped on logout. Separated from the legacy mirror and the TanStack cache. (Literature-backed: IndexedDB-via-Dexie is the standard for this pattern; SQLite-WASM/OPFS rejected — wrong scale, COOP/COEP header cost, re-imports the reverted stack.)
  • Daily-secret convergence: server check-in/check-out derive codes via deriveDailySecret (not the raw master checkin_secret) — the client caches the daily secret and computes the identical HMAC code offline, so offline codes match server codes at replay (proven: e2e codeMatches:1, codeMismatches:0, code X43GQ2).
  • Offline check-out is locally-validated (upgrades the old "best-effort with revalidation"): local code validation, attempt counting vs cached maxCodeAttempts, CHECKOUT_ATTEMPT events queued, EMERGENCY_CHECKOUT honoring cached emergencyAdminOnly; AUTHORIZED_LIST offline uses minimal cached match data (student_id, relationship, last-4 phone suffix) — the hard NOT_AUTHORIZED gate is preserved; if never synced, EMERGENCY_CHECKOUT-only fallback.
  • Quick visitor registration: the island generates the student UUID client-side; replay accepts client-supplied student_id + createdAt (execution item in §3).
  • Replay on reconnect: FIFO through the existing REST routes with per-event UUID Idempotency-Key + original createdAt; capacity is re-validated at replay (offline shows a stale-data warning; a full session at replay → 409 surfaces as a CONFLICT in a resolution view — admin force-with-justification or void; offline admissions are never silently dropped).
  • Offline auth (probed by #683): the island is in-session-offline capable only. There is no persisted session/token in online-only mode; a receptionist logged in before the drop keeps working until token expiry or reload; a cold device with no connectivity cannot reach the island. v1 accepts and warns (login before service; banner flags that session expiry ends the island until connectivity returns). The extension policy (session extension vs device-level unlock) is deferred.

6. PWA / service worker — shell-only, zero data caching (#674) ​

  • Precache the app shell (built JS/CSS/HTML + icons + manifest); runtime-cache fonts only (SWR, versioned cache name); explicit /api/v1/* → NetworkOnly — never cache API responses. One SW config for both modes.
  • Stale shell by design: the shell is code, not data — it lets the app boot offline (island works; everything else shows the fail-fast banner). Data is always live from /api/v1.
  • Invalidation: content-hashed assets + cleanupOutdatedCaches + versioned font cache; update flow = registerType: "prompt" + "Nova versão disponível" banner (the reception desk controls when a reload happens).
  • The SW's shell precache is load-bearing for the island — without it the island can't boot offline.

7. Migration on flip (#673) ​

  • Drain-then-flip on the first authenticated boot under the flag: one final drainQueue of PENDING/RETRYING (reusing the existing drain; token is available post-auth).
  • Leftovers are surfaced, never silently discarded: FAILED (>6 retries) + still-pending entries appear on a one-time "Local-only data" screen (counts per entity, Export JSON/CSV via the sync registry, Discard with explicit consent).
  • Then the legacy neemias-db is deleted (LGPD: no stale local copies of personal data; storage hygiene). Consequences: no persisted local session → re-login on next visit (consistent with online-only). The island DB is untouched.
  • Reversible flip: rebuild with the flag OFF → legacy path boots → fresh hydration from D1 at login (students+roles) recreates the mirror on demand; D1 remains the durable source of truth. Accepted consequence: the legacy mirror starts fresh.

Consequences ​

  • Legacy path is unsupported: the troublemakers (hydration staleness, encryption-at-rest, sync-queue retries) are decoupled away from, never rehabilitated. Code kept, flagged off, reversible.
  • The island is the only local state in online-only mode; its offline capability is in-session-only (documented in §5; policy deferred).
  • Seed-vs-Zod finding (from #683 e2e): the demo seed's class UUIDs (00000000-…-0001) are not valid v1-8 UUIDs, so the Zod-bound read clients (studentListItemSchema/classListItemSchema) reject the demo seed — the seed/classes must use valid UUIDs before the demo flips ONLINE_ONLY.
  • Bundle: the ON build may tree-shake the legacy seam once the read sweep replaces every useSqlQuery call site.
  • Rollout gates: demo-first validation builds; this ADR + the prototype are the gates for the flip; production instances flip only after validation.

References ​

  • Wayfinder map #667 and its decisions: #668 (coverage map), #669 (read path), #670 (write path), #671 (island), #672 (flag), #673 (migration), #674 (PWA), #676 (read prototype), #677 (query layer), #678 (invalidation), #679 (hydration seed), #680/#682 (execution), #683 (island prototype + e2e).
  • docs/research/rest-coverage-2026.md, docs/research/prototype-676-read-layer-evaluation-2026.md, docs/research/hydration-query-cache-seeding-2026.md, docs/research/prototype-683-offline-island-evaluation-2026.md.
  • Prior ADRs: ADR-0023 (unified events/audit), ADR-0026 (deterministic daily codes), ADR-0030 (Dexie reversion), ADR-0032 (Durable Objects fan-out), ADR-0036 (authorized pickup list).

Distribuído sob licença MIT.