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:
44
PLAN.md
44
PLAN.md
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user