# 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):

```sql
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):

1. **Push** — `POST` rows where `sync_status='pending'` to Supabase.
   `pending` is set by every write in every controller
   (`sync_status='pending'` next to every `INSERT`/`UPDATE`).
2. **Pull** — `GET` rows from Supabase where Supabase's `updated_at > our
   last_synced_at`. Apply locally.
3. **Conflict detection** — if the same `uid` changed on both sides between
   pull cycles (Supabase's version newer than ours AND ours still
   `sync_status='pending'`), mark the row `sync_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:

1. Optimistically removes the row from the UI.
2. Calls `POST /api/sync/conflicts/resolve` with `{ tableName, uid }`.
3. Server delegates to `SyncEngine.resolveConflict(table, id)` which:
   - Clears `sync_status='conflict'` and sets `last_synced_at=now`.
   - Writes an audit log entry of type `SYNC_CONFLICT_RESOLVED`.
4. The UI re-fetches `/api/sync/conflicts` to verify the resolution.
5. On error, the optimistic removal is rolled back.

### Tab 3 — Latency

Polled every 5 seconds. Three KPI tiles + one line chart.

- **Push duration** — last `pushMs` from `SyncEngine.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":

1. Log in as `systems_admin`. Open `/admin/sync-logs`.
2. Look at the **Latency** tab. Is `totalPending > 0`? Is `Sync Running` red?
3. If `Sync Running` is red and stays red, the sync engine has stalled.
   Check `logs/sync.log` on the server (or `audit_logs` rows with `type` like
   `'SYNC'`).
4. If `Latency` is fine, go to **Pending by table** — find the table the
   teacher was editing.
5. If pending drains after one cycle, the issue was transient. Otherwise:
6. 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.)
7. 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:

```bash
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 itself
- `server/src/controllers/sync.controller.js` — `/api/sync/conflicts`, `/api/sync/conflicts/resolve`, `/api/sync/status`, `/api/sync/logs`
- `client/src/store/syncLogs.ts` — Zustand slice (`fetchLogs`, `fetchConflicts`, `resolveConflict`, `fetchStatus`)
- `client/src/pages/admin/SyncLogs.tsx` — the admin UI with 4 tabs
- `client/src/store/syncLogs.test.ts` — 8 vitest specs covering all store actions
- `client/e2e/sync-logs.spec.ts` — 4 Playwright specs covering tab navigation and resolve flow
- `docs/BACKUP.md` — restore / disaster-recovery runbook
