Offline Mode and Sync
Neemias adopts a backend-primary, offline-resilient architecture: the backend is the definitive source of truth, but the frontend works fully offline using IndexedDB as an operational cache.
Architecture
┌──────────────┐ online ┌──────────────┐
│ IndexedDB │ ←──────────────→│ D1 (Cloudflare) │
│ (Dexie.js) │ sync queue │ Workers API │
└──────────────┘ └──────────────┘
│ │
useLiveQuery Event Sourcing
↓ ↓
React UI Audit LogsWhen offline, all mutations are recorded locally in the syncQueue and sent to the backend when connectivity is restored.
OfflineContext
OfflineContext (app/src/app/context/OfflineContext.tsx) is the heart of offline mode. It exposes:
| Property | Type | Description |
|---|---|---|
isOnline | boolean | Current connectivity state (navigator.onLine) |
pendingCount | number | Items pending sync (PENDING + RETRYING) |
pendingEntries | SyncQueueEntry[] | Full list of pending entries |
autoSyncing | boolean | Whether automatic draining is in progress |
markAllSynced() | () => Promise<void> | Marks all as synced (used when there is no backend) |
Connectivity Detection
The provider reacts to window online and offline events:
window.addEventListener("online", () => setIsOnline(true));
window.addEventListener("offline", () => setIsOnline(false));When it detects that the device has come back online, the retry counter is reset to zero.
Sync Queue (syncQueueRepository)
The repository manages the syncQueue table in Dexie with the following operations:
listPending()— lists entries withsyncState: "PENDING"or"RETRYING"drainQueue(processor)— processes each pending entry, callingsyncQueueEntryWithBackend()markAllSynced()— marks all as synced (used whenBACKEND_URLis not configured)purgeSyncedEvents(days)— removes synced events older than N days (quota management)
Exponential Backoff
In case of sync failure, the system applies exponential backoff:
| Attempt | Delay |
|---|---|
| 0 | 5 seconds |
| 1 | 10 seconds |
| 2 | 20 seconds |
| 3 | 30 seconds |
| 4+ | 30 seconds (cap) |
After 6 attempts (MAX_RETRY_ATTEMPTS), the system stops trying automatically — the user can force a new attempt by toggling the connectivity state.
Expired Session During Sync
If the backend returns an authentication error (SyncAuthExpiredError), the context attempts to renew the token via refreshAccessToken(). If renewal fails, the session is marked as expired and sync is interrupted.
No Backend Configured
When the BACKEND_URL environment variable is empty, automatic draining simply marks all items as locally synced — useful for offline development and testing.
StatusIndicator
The StatusIndicator component displays the current sync state in the interface:
- Online + empty queue — green indicator
- Online + syncing — animated indicator with pending count
- Offline — gray/yellow indicator
Conflict Resolution
During sync, conflicts between local and remote events are resolved on the server by the EventReplay class with two strategies:
| Strategy | Description |
|---|---|
| ADMIN_PRECEDENCE | Events from roles with higher weight (ADMIN > CADASTRO > CHAMADOR > RELATORIOS) prevail |
| LAST_TIMESTAMP_WINS | In case of a role tie, the event with the most recent timestamp wins |
The losing event is marked with isConflictLoser: true and the conflictSupersededBy field points to the winning event.
Full Lifecycle
- User performs offline action →
EventWriterrecords event +syncQueueentry (atomic transaction) - UI reacts immediately via
useLiveQuery - Upon reconnection →
OfflineContextdetectsonlineand firesdrainQueue() - Each entry is sent via
POST /api/v1/sync/eventswithIdempotency-Keyheader - Backend processes, resolves conflicts, and returns confirmation
- Entry marked as
SYNCEDorRETRYING(in case of transient failure) - Every 30 days, old synced entries are purged
Source: app/CONTEXT.md + OfflineContext.tsx