7.9 KiB
Sync operator runbook
Audience: systems administrators and on-site IT staff. Scope: the offline-first bidirectional sync between the local LAN server (better-sqlite3) and the Supabase cloud instance.
This document explains what a sync conflict is, when one happens, how to
resolve it from /admin/sync-logs, when to escalate, and what the diagnostic
chart tells you.
1. The data model
Every table that participates in sync uses four sync-control columns (see
server/src/database/init.js for the schema and .harness/docs/conventions.md
for the contract):
uid TEXT UNIQUE, -- stable cross-system identifier
last_synced_at DATETIME, -- last successful push/pull timestamp
sync_status TEXT CHECK IN -- 'synced' | 'pending' | 'conflict'
('synced','pending','conflict')
DEFAULT 'pending',
is_deleted INTEGER DEFAULT 0, -- soft-delete tombstone (never hard-delete)
Tables that participate in sync are listed in
server/src/services/SyncEngine.js under tablesToSync, ordered by foreign
key dependency (parents first).
2. The sync cycle
server/src/services/SyncEngine.js runs every 30 seconds (configurable via
/admin/settings → sync_interval_seconds, floor of 5s):
- Push —
POSTrows wheresync_status='pending'to Supabase.pendingis set by every write in every controller (sync_status='pending'next to everyINSERT/UPDATE). - Pull —
GETrows from Supabase where Supabase'supdated_at > our last_synced_at. Apply locally. - Conflict detection — if the same
uidchanged on both sides between pull cycles (Supabase's version newer than ours AND ours stillsync_status='pending'), mark the rowsync_status='conflict'and stop touching it until an admin resolves.
If SUPABASE_URL or SUPABASE_KEY env vars are empty/missing, both push and
pull become no-ops — the app keeps working fully offline. The sync engine
logs online=false in audit_logs.
3. What is a sync_status='conflict' row?
A conflict happens when:
- The same row was edited on the LAN (a teacher in the school), and
- The same row was edited on the cloud (an admin with Supabase Studio access), and
- Both changes happened within the same sync window, and
- They differ.
The sync engine flags the LAN copy with sync_status='conflict' and stops
trying to push/pull until a human decides. The cloud copy stays at its last
known state.
The default policy is server-wins: when an admin clicks "Resolve
(server-wins)", the LAN row is overwritten with the cloud's version and
sync_status is cleared back to 'synced'. In offline mode (SUPABASE_KEY
empty), the resolve endpoint still clears the conflict flag locally but does
not contact Supabase — the row is marked pending so it will be picked up
later when connectivity returns.
4. The /admin/sync-logs UI
Route: /admin/sync-logs. Gated to systems_admin only (other roles get
403 from the <ProtectedRoute allowedRoles>).
Four tabs:
Tab 1 — Sync Log
The last 100 audit log entries from sync_logs: which table, which row,
which direction (push/pull), success/failed/retried, error message. Shows the
last 24h of sync activity.
Tab 2 — Sync Conflicts
Live list of every row across every tablesToSync table where
sync_status='conflict'. Columns:
| Column | Source |
|---|---|
| Table | name field from each tablesToSync entry |
| Row uid | the offending row's uid |
| Conflict at | updated_at of the LAN row |
| Status | always conflict here |
Each row has a Resolve (server-wins) button. Clicking it:
- Optimistically removes the row from the UI.
- Calls
POST /api/sync/conflicts/resolvewith{ tableName, uid }. - Server delegates to
SyncEngine.resolveConflict(table, id)which:- Clears
sync_status='conflict'and setslast_synced_at=now. - Writes an audit log entry of type
SYNC_CONFLICT_RESOLVED.
- Clears
- The UI re-fetches
/api/sync/conflictsto verify the resolution. - On error, the optimistic removal is rolled back.
Tab 3 — Latency
Polled every 5 seconds. Three KPI tiles + one line chart.
- Push duration — last
pushMsfromSyncEngine.getSyncStatus(). - Pull duration — last
pullMs. - Total pending — sum of
sync_status='pending'rows across the synced tables.
The line chart (Recharts) shows the rolling last 60 samples of
pushMs, pullMs, and totalPending (each on its own y-axis scale, sharing
time on x). The implementation lives in useSyncLogsStore.fetchStatus() —
statusHistory is capped at 60 entries to bound memory.
A healthy school typically shows push/pull in the 100–500ms range and
totalPending returning to ~0 within a minute of activity. If
totalPending climbs steadily without falling, either the network is down
or Supabase is throwing 5xx — check the green/red KPI tile on top.
Tab 4 — Pending by table
A bar chart of the top 10 synced tables by count of sync_status='pending'
rows. When everything is green, this chart is empty. If one table has a tall
bar, that is usually the source of the latency — go to that table in the
admin section and confirm that the most recent writes succeeded. If they did,
pending count should drain within one cycle.
5. Manual diagnostic procedure
When a teacher reports that "their data didn't sync":
- Log in as
systems_admin. Open/admin/sync-logs. - Look at the Latency tab. Is
totalPending > 0? IsSync Runningred? - If
Sync Runningis red and stays red, the sync engine has stalled. Checklogs/sync.logon the server (oraudit_logsrows withtypelike'SYNC'). - If
Latencyis fine, go to Pending by table — find the table the teacher was editing. - If pending drains after one cycle, the issue was transient. Otherwise:
- Switch to the Conflicts tab. Click Resolve (server-wins) on the row if any. (This is the only manual action allowed by the UI; do not edit the DB directly unless you've stopped the server.)
- If conflicts keep re-appearing on the same row, escalate — there is
probably a duplicate-write somewhere in a controller that is not
properly setting
sync_status='pending'. Open a bug against the table's controller.
6. Manual sync run
If the dashboard is online but stalled, you can trigger a single sync cycle without waiting for the 30s timer:
curl -X POST http://localhost:3001/api/sysadmin/sync/trace \
-H "Authorization: Bearer $ADMIN_JWT"
The response returns the captured stdout/stderr and a per-table count of pushed/pulled rows. The endpoint returns 409 if a cycle is already running — wait for the next cycle or kill the stalled process.
7. Escalation matrix
| Symptom | Action |
|---|---|
| One-off conflict that resolves | Self-clear via the Conflicts tab. |
| Same row keeps conflicting | Open a bug against the controller for that table. |
totalPending climbing for >1h |
Check Supabase status page; check logs/sync.log. |
| Offline mode (cloud unreachable) | The LAN app keeps working; sync_status='pending' rows accumulate. They auto-drain when cloud returns. |
| DB corruption suspected | Use bash scripts/backup/verify-restore.sh latest to confirm the latest backup boots. If that fails, see docs/BACKUP.md. |
8. Related files
server/src/services/SyncEngine.js— the engine itselfserver/src/controllers/sync.controller.js—/api/sync/conflicts,/api/sync/conflicts/resolve,/api/sync/status,/api/sync/logsclient/src/store/syncLogs.ts— Zustand slice (fetchLogs,fetchConflicts,resolveConflict,fetchStatus)client/src/pages/admin/SyncLogs.tsx— the admin UI with 4 tabsclient/src/store/syncLogs.test.ts— 8 vitest specs covering all store actionsclient/e2e/sync-logs.spec.ts— 4 Playwright specs covering tab navigation and resolve flowdocs/BACKUP.md— restore / disaster-recovery runbook