Add sync redesign with offline fallback (M9)

- Migration 003: adds logged_at to sync_log for TTL pruning; migrates
  settings_history to UUID TEXT PK with updated_at column
- SyncStore: Prune() deletes rows older than 30d and writes a '_pruned'
  marker at the boundary version; Pull() calls Prune lazily and returns
  ErrSyncStale (410) when the client's since_version is behind the marker
- sync_handler.go: GET /api/sync/pull?since=N; POST /api/sync/push with
  last-updated_at-wins conflict resolution for entries, balance_adjustments,
  settings_history; closed_days/closed_weeks skipped (server-only mutations)
- router.go: passes entryStore, adjustmentStore, settingsStore to SyncHandler
- settings_store.go: UUID PK, updated_at column, Upsert() for push path
- settings_service.go: generates UUID on create, sets updated_at on update
- settings_handler.go: ID params changed from int64 to string
- domain.go: Settings.ID string, Settings.UpdatedAt added
- client.ts: all mutation methods catch TypeError (offline) and fall back
  to Dexie write + outbox enqueue; crypto.randomUUID() for offline creates;
  Settings.id type changed to string
- db.ts: Dexie v3 — settings_history key path changed to string UUID;
  upgrade handler clears table for repopulation via pull
- sync.ts: real pushOutbox to POST /api/sync/push; pullChanges uses GET
  with ?since=N; 410 triggers coldStart() + retry; coldStart() wipes all
  tables and resets last_version
- 4 new Go store tests covering normal pull, stale client, empty prune,
  client-ahead-of-marker; all tests pass (store + service, 19 Vitest)
This commit is contained in:
2026-04-30 22:50:33 +02:00
parent 3214f48a6f
commit d8366f5c25
15 changed files with 864 additions and 144 deletions

44
PLAN.md
View File

@@ -77,22 +77,25 @@ CREATE TABLE closed_weeks (
);
-- Settings with effective-from semantics so past weeks aren't retroactively changed
-- Migration 003: switched to TEXT UUID primary key; added updated_at
CREATE TABLE settings_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
effective_from TEXT NOT NULL, -- 'YYYY-MM-DD'
id TEXT PRIMARY KEY, -- UUID (client-generated, sync-friendly)
effective_from TEXT NOT NULL, -- 'YYYY-MM-DD'
hours_per_week REAL NOT NULL,
workdays_mask INTEGER NOT NULL DEFAULT 31, -- bits Mon=1..Sun=64; Mon-Fri = 31
workdays_mask INTEGER NOT NULL DEFAULT 31, -- bits Mon=1..Sun=64; Mon-Fri = 31
timezone TEXT NOT NULL DEFAULT 'UTC',
created_at INTEGER NOT NULL
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Sync event log (last-write-wins per entity row)
-- Sync event log (append-only; TTL-pruned; prune marker for stale-client detection)
CREATE TABLE sync_log (
entity TEXT NOT NULL, -- 'entries' | 'closed_days' | 'closed_weeks' | 'balance_adjustments'
entity TEXT NOT NULL, -- 'entries' | 'closed_days' | ... | '_pruned'
entity_id TEXT NOT NULL,
op TEXT NOT NULL, -- 'upsert' | 'delete'
op TEXT NOT NULL, -- 'upsert' | 'delete' | 'marker'
version INTEGER NOT NULL, -- monotonic server-assigned
payload TEXT NOT NULL, -- JSON snapshot
logged_at INTEGER NOT NULL, -- unix ms; used for TTL pruning
PRIMARY KEY (entity, entity_id, version)
);
@@ -144,8 +147,8 @@ PUT /api/settings { effective_from, hours_per_week, workday
GET /api/settings/history
# Sync
POST /api/sync/pull { since_version } -> { changes[], server_version }
POST /api/sync/push { changes[] } -> { applied[], conflicts[] }
GET /api/sync/pull?since=N -> { changes[], server_version } | 410 Gone
POST /api/sync/push { changes[] } -> { applied[], skipped[] }
# Health
GET /healthz (unauthenticated)
@@ -266,7 +269,23 @@ Staged implementation:
Manual corrective entries on the History page that adjust the overall overtime balance without touching week math. Separate `balance_adjustments` table with signed `delta_ms`, optional `note`, and `effective_at` (backdatable). Balance summary combines `Σ closed_weeks.delta_ms + Σ balance_adjustments.delta_ms`.
### M9 — Future
### M9 — Sync redesign ✅
Full offline support with online-first, offline-fallback mutation strategy.
**Backend:**
- Migration 003: `logged_at` on `sync_log`; `settings_history` migrated to UUID TEXT PK with `updated_at`.
- `SyncStore.Prune(ctx, ttl)`: deletes rows older than TTL and writes a `_pruned` marker at the boundary version. Clients pulling with `since < marker_version` receive `ErrSyncStale``410 Gone`.
- `GET /api/sync/pull?since=N`: calls Prune lazily (30-day TTL), returns changes or 410.
- `POST /api/sync/push`: accepts batched outbox items; applies last-`updated_at`-wins for `entries`, `balance_adjustments`, `settings_history`. `closed_days`/`closed_weeks` are server-only and skipped. Returns `{applied, skipped}`.
**Frontend:**
- `client.ts`: all mutation methods (`entries`, `balance`, `settings`) catch `TypeError` (network error) and fall back to writing directly to Dexie + enqueuing in the outbox. IDs for offline creates are generated client-side via `crypto.randomUUID()`.
- `sync.ts`: `pushOutbox` sends outbox to `POST /api/sync/push`; on success removes applied items. `pullChanges` uses `GET /api/sync/pull?since=N`; on 410 calls `coldStart()` and retries. `coldStart()` clears all Dexie tables and resets `last_version=0`.
- `db.ts`: Dexie v3 — `settings_history` key path changed to `id` (string); upgrade handler clears the table for repopulation via pull.
- Settings page: `editingId` and ID params updated from `number` to `string`.
### M10 — Future
CSV/JSON export, monthly summary view.
## 7. Decisions & Rationale
@@ -286,4 +305,7 @@ CSV/JSON export, monthly summary view.
| Balance adjustments | Separate `balance_adjustments` table | Avoids conflating measured deltas with manual corrections; preserves week math invariant (`delta_ms = worked - expected`) |
| Balance adjustment scope | Closed weeks only for auto balance | In-progress week delta shown separately in week view; mixing would make balance jitter |
| Balance adjustment IDs | TEXT (UUIDv7, client-generated) | Consistent with `entries`; allows offline creation and sync |
| Test framework | Vitest (frontend) + Go testing (backend) | Automated coverage for capability logic and key utilities |
| Settings history PK | TEXT UUID (migration 003) | Consistent with other entities; enables offline create; `updated_at` enables last-write-wins sync |
| Sync prune strategy | Prune marker row at boundary version | No extra table; client detects stale state from the log itself; 410 triggers full re-sync |
| Sync conflict resolution | Last `updated_at` wins | Server is authoritative; simple to implement and reason about for single-user |
| Offline mutation flow | Online-first, offline-fallback | Server is primary; client writes to Dexie+outbox only on network failure; simpler than full local-first |