๐ด Offline & sync¶
Every own project is a local-first PWA built on the same platform packages
(@platform/events, @platform/sync, @platform/offline): the UI reads and writes the
device's store, the network is a background concern. RefApp's notes exercise all of it.
The first sign-in downloads every row of the app's collections plus their eager
images; other images and files are stored the first time they are shown, and online-only
features are never kept. A relaunch opens from the device โ the app shell from the
service worker, the account from localStorage, the rows from IndexedDB โ then syncs in
the background. A new version of the app downloads in the background when it opens,
then the page reloads itself once; new data never reloads it. Only a 401 signs the
device out: a server error (the maintenance page during a deploy), a 429 or a Wi-Fi
portal page keep it signed in with its data.
๐ถ Online / offline detection¶
@platform/events keeps one online/offline state per tab, driven by events, not polling:
- Liveness stream โ
/api/events(public SSE) sends apingevery 15 s; the server ends each stream after 30 min and the browser reconnects. - No polling while online โ the only polling is one
/api/healthcall every 15 s while the app believes it is offline. - Going offline โ a suspicion (the stream errors or never opens, the OS reports no
network, a sync round gets no answer, or 35 s pass without a ping) triggers 3
/api/healthchecks, 2 s apart; only if none answers does the app switch to offline, about 6 s later. A check unanswered after 8 s counts as no answer, so a network that accepts requests but never replies still ends offline (within about 40 s). Any HTTP answer counts as online, even a 429. - Coming back โ the first answered check switches back: within 15 s, or at once when the OS reports the network or the tab becomes visible again. The streams reopen and a sync round runs.
- On screen โ a green or red dot at the bottom-left of the account's avatar in the top
bar (
OnlineDot); no dot until the first answer. An app shows connectivity through this dot or not at all.
๐ Reconciliation¶
@platform/sync pushes and pulls every collection of the app in one POST /api/sync
round.
โฑ๏ธ When¶
Never on a timer โ only on these triggers:
| Trigger | Delay |
|---|---|
| App launch, once the account is known | immediate |
| Network comes back (detection) | โค 15 s |
A sync event on /api/events/sync: another device or tab, or the server, changed one of my collections |
immediate โ the tab that made the change is not notified |
| A local edit while online | 300 ms (edits are grouped) |
| The change stream reconnects, including its forced 30 min renewal | immediate โ an open tab catches up at least every 30 min |
| The tab becomes visible, or is restored with back/forward | immediate |
| "Sync now" in the sync popover | immediate |
Edits made offline do not try to sync; they wait for the network to come back.
๐ข What the user sees¶
The sync state is a dot at the bottom-left of the account's avatar inside the app's user
panel, or on the page the avatar opens (SyncDot):
| Dot | When |
|---|---|
| ๐ข green | everything on this device is on the server |
| ๐ orange | a round runs, a file uploads, or changes wait while online |
| ๐ด red | offline, with or without changes waiting, or a refused, failing or full-device notice |
A click on it โ a tap on a phone, Enter from the keyboard โ opens the sync popover: what
waits, when the device last synced, Sync now and the notices; a second click, Escape or
a click elsewhere closes it. Hovering opens nothing. While an account is
signed in, the page's <html> also carries data-sync-state and data-sync-label: what
the test suites read. An app shows sync state through this dot or not at all.
๐ What a sync round does¶
When the network comes back, the streams reopen and one round runs:
- Push โ one
POST /api/synccarries, per collection, the unsent local edits (โค 500) and the time of its last sync. - Server โ per collection, in one transaction: applies the edits, returns every row changed since the last sync (minus a 60 s safety overlap), notifies the user's other devices.
- Device โ drops the accepted edits from its queue, removes rejected rows with a notice, merges the returned rows (a newer unsent local edit stays), saves each collection's sync time.
- Images โ downloads the eager images of new or changed rows.
- Backlog โ more than 500 edits waiting โ another round, up to 10. A Web Lock lets only one tab sync at a time.
- Files โ files waiting on this device (files & images) upload once their row is on the server, one at a time, then one more round pulls their new state. The sync dot counts them: "Synced" means rows and files reached the server.
Conflicts
- Latest edit wins, per whole row, by the device's clock: if two devices edit different fields of the same row offline, the later edit replaces the whole row and the other change is lost. On a tie the server keeps its row.
- A delete is final and beats any later edit.
- Clocks โ an edit time in the future is capped at server time + 5 min; a phone whose clock runs behind loses conflicts.
- Stale device โ not synced for over 90 days โ that collection is reloaded from the server (unsent edits are kept and re-sent).
Failures
- No network โ nothing changes; the next trigger retries.
- Server error (5xx / 429) โ everything is kept; after 20 failures in a row the sync dot turns red and its popover shows a notice.
- Refused batch (400) โ dropped with a notice: a client bug no retry can fix.
- 401 (session expired or revoked) โ the user is signed out and the device wiped, unsent edits included. Intended: the server would refuse them anyway.
What is reconciled
Only the rows of the app's declared collections. Images and files are not reconciled:
- Eager images (downloaded after each sync) are skipped when their URL is already cached โ a changed image shows only once its URL changes.
- On-view images and files (stored the first time they are shown) are never refreshed.
- Uploads โ files attached to rows go through the platform's file queue below: RefApp's note images, CardBox's logos, Kouizine's recipe photos, and TripWise's trip and item attachments and its trip pictures. Every other upload still goes through a regular API call.
- Deletes โ a deleted row (here or on another device) takes its images and files off the device.
Rows, queue and image caches are wiped on sign-out and before another account's data loads.
๐ผ๏ธ Files & images¶
A file always belongs to a synced row under a named slot, and the slot's URL is both where its bytes upload and where the UI reads them. RefApp's note images carry three:
| Slot | What | Kept on the device |
|---|---|---|
thumb |
โค 320 px copy, made by the browser | every device, after a sync (eager) |
display |
โค 1920 px copy, made by the browser | once opened (on-view) |
original |
the dropped file, untouched โ JPEG XL included | only while waiting to upload (a download otherwise) |
- Dropping โ the browser decodes the image (JPEG XL with a WASM decoder, precached, so it works offline) and makes the two copies; the row and the three files are saved on the device in one step and shown at once, offline included.
- Uploading โ once the row is on the server, each file goes with a
PUTof its raw bytes, one at a time. The server streams it todata/files/tmp, checks its size and its real type (from its first bytes, never the name), computes its SHA-256, moves it into place and marks the row. Limits: 1 MB thumbnail, 8 MB display, 32 MB original; a refused file is dropped with a notice in the sync popover, a network or server failure retries later. - On disk โ
data/files/images/thumbnails/<uuid>.webp,โฆ/displays/<uuid>.webp(.jpgfrom a browser without a WebP encoder) andโฆ/originals/<uuid>.<ext>; the paths, the original's size and its SHA-256 are on the row. - Deleting โ an image, or the note it belongs to, removes its files from the server and
from every device. A daily task sweeps what a crash could leave behind (abandoned uploads
in
tmp/, files no row references).