Part 7 of 8
LeXi: offline-first sync and usage stats
Reconnect retry, the pending audio queue, a stats endpoint and the FK bug that broke sync in production.
Milestone 7 is the offline-first sync and the usage stats. The sync core already existed (push/pull to D1, pending audio queue); my job was closing the gaps that kept it from actually working.
The gaps I found
Auditing the code turned up four problems:
setupSyncListeners()was defined but never called → no reconnect retry.sync.register('lexi-sync')was registered but had no handler in the service worker → dead code (the browser retried and gave up).ConnectionStatus.svelteonly showed online/offline, with no sync state.- Usage events were uploaded to D1 but there was no way to see them.
Design decisions
Online retry, no Background Sync. We removed the sync.register and rely on the online event + sync on app open + sync after edits.
Lesson: a
sync.registerwithout a handler is a silent bug. Building a custom SW (swSrc+injectManifest) would have changed the SW architecture for a nice-to-have feature. Theonlineevent covers 99% of the cases with much less code.
An aggregated /api/stats endpoint instead of returning raw events. Local events are cleared after the push (clearEvents), so D1 — which accumulates per-device history — is the only complete source. And reusing GET /api/sync would return thousands of taps and would not scale.
Hand-rolled SVG charts, no library. There was no chart lib in the project and adding one goes against offline-first and a small bundle.
The implementation
src/stores/index.ts—syncState(idle|syncing|ok|error) andlastSyncAt.src/lib/sync.ts— removed the dead block;syncNow()updates the stores.src/components/CardGrid.svelte— inonMount,setupSyncListeners(() => void syncNow()).src/components/ConnectionStatus.svelte— “Syncing… / Up to date HH:MM / Pending”.functions/api/stats.ts—GET /api/stats?days=14: daily activity, top cards (join withcardsandcategories), totals by verb, recorded vs TTS voice, storage.src/lib/stats.ts— local aggregation (offline fallback) +fetchStats().UsageStats.svelte— modal with charts: 14-day bars, top cards, recorded-voice vs TTS donut.- New events:
hablar(on Speak) andeditar(on saving audio).
Lesson (Svelte 5):
$derivedis writtenlet x = $derived(...), not$derived x = ...; and a{@const}cannot live outside a block. Two compile errors we fixed on the fly.
The bug that broke sync in production
Testing the end-to-end flow in production I found that POST /api/sync always returned “Error al sincronizar”, even with a real device.
- Root cause: the claim inserts into
devicesbut never creates the row inusers. Sinceevents.user_id/cards.user_id/categories.user_idhave FK tousers(id)and D1 enforces them by default, inserting an event violated the constraint:FOREIGN KEY constraint failed. - Fix: an
upsertUser()helper infunctions/lib/auth.ts, called in all three claim paths (demo, recovery, normal). - Migration
0009_claim_users.sqlbackfill for already-registered devices: creates theusersrow fromdevices.
What we learned the hard way: D1 enforces foreign keys by default, even though local SQLite does not. An FK pointing at a table that is never filled only shows up in production. The “upsert the parent row before inserting the child” pattern repeated in milestone 5 (upload) and in milestone 7 (sync).
End-to-end verification: POST /api/sync → {"ok":true} and /api/stats reflects the event.
Milestone wrap-up
Commit feat: online retry sync, sync status indicator, usage stats (charts + /api/stats), hablar/editar events and milestone 7 checked in both READMEs.
In the next part, the installable PWA and the custom domain.