Offline-first
The product requirement is one sentence: the app behaves identically at the bottom of a crag with no signal as it does on wifi. Not "has an offline mode" — offline is the normal case, and connectivity is the exception that occasionally lets data move.
The rule
Screens read from on-device SQLite. Nothing else. Network access lives in
apps/mobile/src/lib/sync.tsand nowhere else.
A screen that calls api.* directly will hang the moment someone opens it in a car park. The rule
is mechanical and easy to review, which is why it is worded that way.
Note the shape: the screen and the syncer both touch SQLite, and they never touch each other.
Two kinds of data
The protocol treats them differently because their failure modes are different.
| Server-owned | User-owned | |
|---|---|---|
| What | Crags, sectors, photos, routes, route lines | Ascents, wishlist items |
| Who writes | Us, through curation and moderation | The user, on their phone |
| Direction | Pull only | Push and pull |
| On conflict | Server always wins; there is nothing to lose | Guarded — see below |
| Volume | Large; scoped to downloaded crags | Small; all of it, always |
Guidebook content is read-mostly and authoritative. A user's logbook is the opposite: small, precious, and created offline. Pretending they are the same thing is how sync engines get complicated.
Pull
POST /v1/sync/pull
{ "cursors": { "route": "1735689600000:rte_01J…" }, "cragIds": ["crg_01J…"], "limit": 500 }
The client keeps a cursor per kind. The server returns everything after it.
Why the cursor is (updated_at, id) and not just updated_at
Two records can share a millisecond. With a plain updated_at > ? cursor, the second one is
silently skipped forever — the client asks for "after 12:00:00.123", and the row at .123 never
comes back.
So the cursor is the pair, and the predicate is the lexicographic comparison:
where updated_at > :cursorUpdatedAt
or (updated_at = :cursorUpdatedAt and id > :cursorId)
order by updated_at, id
Which is exactly what the (updated_at, id) index on every syncable table is for.
An offset-based cursor would have a different but equally bad failure: insert a row mid-page and everything after it shifts, dropping one record per concurrent write.
A malformed cursor is a 400, not a silent fall back to "send everything". Falling back would turn a client bug into a full table download on every sync.
Tombstones come through
Soft-deleted rows are returned by pull, not filtered out. That is the only way a device that was
offline when a route was removed ever learns about it. The client applies deleted_at and its
queries filter on it.
Paging
The server returns limit + 1 rows, sends limit, and reports hasMore. The client keeps
pulling until hasMore is false, up to a cap of 50 pages — a pull that never terminates means
the cursor is not advancing, which is a server bug, and hammering it is not the right response.
Push
POST /v1/sync/push
{ "mutations": [
{ "kind": "ascent", "op": "upsert", "baseUpdatedAt": null, "record": { … } }
] }
Every mutation carries baseUpdatedAt: the updated_at the client last saw for that record.
- Row does not exist → insert.
baseUpdatedAtisnull. - Row exists and
updated_atmatches → update. - Row exists and
updated_atdoes not match →conflict, with the server's copy attached. record.userIdis not the caller →rejected. Never silently, never as a 500.
This is optimistic concurrency, and it is the cheapest thing that surfaces a real conflict instead of quietly overwriting the other phone's edit.
Push before pull
Deliberate. The outbox is work the user did; stale crag data is not. If a sync is going to be cut short by a dropped connection, the half that ran should be the half that protects the user's data.
The outbox
create table outbox (
seq integer primary key autoincrement,
kind text not null,
op text not null,
record_id text not null,
base_updated_at integer,
payload text not null,
queued_at integer not null,
attempts integer not null default 0,
last_error text
);
seq is autoincrement because ordering within one device is all that matters here — this table
never syncs.
Enqueue in the same user action as the local write. Never from a background job: if the local row and the outbox row can diverge, the user sees one thing and the server gets another.
Conflicts, honestly
Right now: the server's copy wins, and the client discards the local edit silently.
The protocol reports the conflict properly and hands back the server record. The app throws it away. That is a real limitation with a real failure case — edit the same tick on a phone and an iPad while both are offline, and one edit disappears without a word.
It is acceptable today because there is one device per user and no real authentication yet. It is on the roadmap for phase 2, and it is written down here rather than discovered later.
Options when it is time, roughly in order of how much they cost:
- Last-write-wins with a notice — cheap, honest, probably enough for a logbook
- Field-level merge — ascents are mostly independent fields; a merge is plausible
- Interactive resolution — a screen showing both, user picks
- CRDTs — rejected for v1 in ADR-0004; the data model does not have the concurrent-editing shape that justifies the complexity
Being offline is not an error
sync() returns a discriminated union, not a thrown exception:
type SyncOutcome =
| { status: 'ok'; pulled: number; pushed: number; conflicts: number }
| { status: 'skipped'; reason: 'offline' }
| { status: 'error'; message: string };
skipped is a normal Tuesday. It surfaces as a quiet line on the Profile screen and nothing else
— no dialog, no red banner, no retry prompt. Every screen already has its data.
Images
The gap. Photo.storageKey points at an R2 bucket that does not exist yet, so the topo viewer —
the actual point of the app — is not built.
Whatever the design ends up being, it has to answer:
- Download size. A crag's worth of topo photos is the bulk of an offline download. Users need to see the size before they commit to it on a phone plan.
- Eviction. Phones fill up. Something has to decide what goes when, and the user has to be able to override it.
- Partial downloads. A crag that is 80% downloaded should be 80% usable, not unusable.
This needs an ADR before any code. See roadmap phase 1.
When sync runs
Today: on app launch, and when the user taps "Sync now" on the Profile screen.
Not yet: background refresh, on-network-regained, or after any write. All three are wanted; all three have battery implications that deserve more thought than "add a listener".
Testing this
The API tests in apps/api/test/api.test.ts run inside workerd against a real D1 — not a mock —
because the bugs worth catching here are pagination bugs, and a mocked database will happily
return whatever you told it to. Covered today:
- A pull replayed with the returned cursors yields nothing new
- A malformed cursor is a 400, not a full download
- A stale
baseUpdatedAtproducesconflictwith the server record attached - A mutation for another user's record is
rejected
Not covered yet, and the obvious next tests: tombstone propagation, multi-page pulls, and the same-millisecond tie that the composite cursor exists to handle.
Related
- ADR-0004 — Offline-first SQLite with delta sync
- Data model — the conventions this protocol depends on
- API — the endpoint reference