Skip to content

[P2/high] feat(web): offline-first set logging sync queue (vires-ops#48)#91

Merged
cipher813 merged 1 commit into
mainfrom
groom/offline-set-sync
Jul 9, 2026
Merged

[P2/high] feat(web): offline-first set logging sync queue (vires-ops#48)#91
cipher813 merged 1 commit into
mainfrom
groom/offline-set-sync

Conversation

@ne-groomer

@ne-groomer ne-groomer Bot commented Jul 9, 2026

Copy link
Copy Markdown

Priority: P2 · Complexity: high

Closes nousergon/vires-ops#48

Design decision (settled by the Opus groomer — not re-litigated)

Append-wins with client-generated set UUIDs. Each logged set is an immutable append keyed by a client-minted crypto.randomUUID(). While offline (or when an online POST fails) the write is parked in IndexedDB; on reconnect it's replayed and deduped server-side on the client UUID, so a replay is idempotent. For an append-only set log this is near-conflict-free by construction — the correct low-risk choice over merge / server-authoritative.

Storage-cap UX: the IndexedDB queue is capped at 500 pending writes; on overflow it drops the oldest and logs a console.warn (documented in web/README.md). Safer than an unbounded queue that silently hits the IndexedDB quota — the newest sets the user just performed stay queued.

Retry/backoff: failed replays are retained with bounded exponential backoff (1s → 5min cap) and dropped after 12 attempts so one poison write can't wedge the queue.

What changed

Frontend (web/)

  • src/lib/setQueue.ts — durable IndexedDB queue (vires-offline DB / pending-sets store, keyed by client UUID): add/list/remove/clear/count, size cap with drop-oldest + warning, exponential-backoff helpers. Backend-agnostic behind a QueueBackend interface so it unit-tests in jsdom without a real IndexedDB.
  • src/lib/setSync.ts — write path (logSetOfflineFirst: mint UUID; POST immediately when online, else enqueue + register a Background-Sync tag), drainQueue replay engine (remove on 2xx ack, backoff on failure, drop after max attempts), and installOnlineReplay fallback for browsers without Background Sync.
  • public/sync-sw.js — service worker sync (and message) handler that drains the queue; imported into the workbox-generated SW alongside push-sw.js via vite.config.ts importScripts (push notifications untouched; verified both scripts land in the built dist/sw.js).
  • src/pages/WorkoutPage.tsxaddSet routes the set-create through logSetOfflineFirst.
  • src/App.tsx — installs the online-event replay fallback on mount.
  • src/lib/setQueue.test.ts, src/lib/setSync.test.ts — Vitest suites for enqueue/cap/backoff and online/offline write path + replay/dedup/backoff/drop. Updated one existing WorkoutPage assertion for the new client_uuid in the POST body.

Backend (api/)

  • api/db/models.pySetEntry.client_uuid nullable column + UniqueConstraint(session_exercise_id, client_uuid).
  • alembic/versions/d9e0f1a2b3c4_set_entry_client_uuid.py — migration adding the column + unique index (batch_alter for SQLite; up/down verified).
  • api/routers/workouts.pylog_set dedupes on client_uuid (returns the existing row on replay; race-safe via IntegrityError rollback → return winner). _set_out echoes it.
  • api/schemas/workout.pySetIn/SetOut carry client_uuid.
  • tests/test_workouts.py — replay-dedup (one row, same id), distinct-UUID append, per-session_exercise scoping, null-UUID unchanged, echo.

Schema / migration

New nullable column set_entries.client_uuid + unique index uq_set_entries_se_client_uuid on (session_exercise_id, client_uuid). Nullable so online writes and every pre-existing row need no UUID; SQLite/Postgres treat multiple NULLs as distinct, so unlabeled rows never collide. Migration d9e0f1a2b3c4 (down_revision b3c4d5e6f7a8); alembic upgrade head and downgrade -1 both verified against SQLite.

Validation (all run, all green)

Backend (repo root, python3.12 venv, pip install -e ".[dev]" + ruff/pytest):

  • ruff check .pass (All checks passed)
  • pytestpass (full suite, ~400 tests; pytest tests/test_workouts.py 27/27 incl. 5 new dedup tests)
  • alembic upgrade head / alembic downgrade -1pass

Frontend (web/, npm ci):

  • npm run lint (oxlint) → pass (0 errors; 1 pre-existing warning in an untouched file)
  • npm run build (tsc -b && vite build) → pass (SW generated; importScripts("/push-sw.js","/sync-sw.js") confirmed in dist/sw.js)
  • npm run test (vitest) → pass (160/160; coverage above all gate thresholds: stmts 67.4% / branches 65.6% / funcs 62.4% / lines 69.6%)

Note: the groom box ships Node 18; Vite 8 / Vitest 4 require Node 20+, so build/test were run under a local Node 22.12 (the missing linux-x64-gnu native bindings for oxlint/rolldown — normally pulled by npm as optional deps — were fetched to run the toolchain). No source or lockfile change was needed for this.


Groom-Run: cbb048a29c4a4505999e48322ae6571f

Queue set-log writes locally in IndexedDB while offline and replay them on
reconnect, deduped server-side on a client-generated set UUID.

Design (groomer's settled ruling): append-wins with client-generated set
UUIDs. Each logged set is an immutable append keyed by a crypto.randomUUID(),
queued in IndexedDB while offline, replayed on reconnect, and deduped
server-side on the UUID at replay — idempotent, near-conflict-free by
construction for an append-only set log.

Frontend (web/):
- src/lib/setQueue.ts: durable IndexedDB queue (add/list/remove/clear, count),
  capped at 500 with drop-oldest + warning on overflow; bounded exponential
  backoff (1s..5min, max 12 attempts). Backend-agnostic behind a QueueBackend
  interface so the logic is unit-tested in jsdom without a real IndexedDB.
- src/lib/setSync.ts: write path (mint UUID; POST when online, else enqueue +
  register Background-Sync tag), drainQueue replay engine, online-event
  fallback for browsers without Background Sync.
- public/sync-sw.js: SW 'sync' handler that drains the queue (imported into the
  workbox SW alongside push-sw.js — push untouched).
- WorkoutPage addSet routes through logSetOfflineFirst; App.tsx installs the
  online-replay fallback.
- Vitest: setQueue + setSync suites (enqueue/cap/backoff, online/offline write
  path, replay/dedup/backoff/drop).

Backend (api/):
- set_entries.client_uuid nullable column + unique (session_exercise_id,
  client_uuid) index; migration d9e0f1a2b3c4.
- log_set dedupes on client_uuid (returns the existing row on replay; race-safe
  via IntegrityError rollback). SetIn/SetOut carry client_uuid.
- pytest: replay dedup, distinct-UUID append, per-exercise scoping, null-UUID.

Closes nousergon/vires-ops#48

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ne-groomer ne-groomer Bot added the source:groom Opened by the backlog groomer label Jul 9, 2026
@cipher813 cipher813 merged commit f48911d into main Jul 9, 2026
3 checks passed
@cipher813 cipher813 deleted the groom/offline-set-sync branch July 9, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

source:groom Opened by the backlog groomer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant