[P2/high] feat(web): offline-first set logging sync queue (vires-ops#48)#91
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 inweb/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-offlineDB /pending-setsstore, keyed by client UUID): add/list/remove/clear/count, size cap with drop-oldest + warning, exponential-backoff helpers. Backend-agnostic behind aQueueBackendinterface 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),drainQueuereplay engine (remove on 2xx ack, backoff on failure, drop after max attempts), andinstallOnlineReplayfallback for browsers without Background Sync.public/sync-sw.js— service workersync(andmessage) handler that drains the queue; imported into the workbox-generated SW alongsidepush-sw.jsviavite.config.tsimportScripts(push notifications untouched; verified both scripts land in the builtdist/sw.js).src/pages/WorkoutPage.tsx—addSetroutes the set-create throughlogSetOfflineFirst.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.py—SetEntry.client_uuidnullable 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.py—log_setdedupes onclient_uuid(returns the existing row on replay; race-safe viaIntegrityErrorrollback → return winner)._set_outechoes it.api/schemas/workout.py—SetIn/SetOutcarryclient_uuid.tests/test_workouts.py— replay-dedup (one row, same id), distinct-UUID append, per-session_exercisescoping, null-UUID unchanged, echo.Schema / migration
New nullable column
set_entries.client_uuid+ unique indexuq_set_entries_se_client_uuidon(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. Migrationd9e0f1a2b3c4(down_revisionb3c4d5e6f7a8);alembic upgrade headanddowngrade -1both 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)pytest→ pass (full suite, ~400 tests;pytest tests/test_workouts.py27/27 incl. 5 new dedup tests)alembic upgrade head/alembic downgrade -1→ passFrontend (
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 indist/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-gnunative 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