Skip to content

[PRD][Sync] Ride history backup — app side #277

Description

@KacperKozak

The server side of Ride History backup is specified in KacperKozak/vescape-server#2 and implemented in KacperKozak/vescape-server#12. This PRD is the app half, and deliberately does not restate either. It carries only what has no other home: the native uploader's runtime design, the Rider-facing surface, and the order the work lands in.

Decisions that are hard to reverse are already recorded as ADRs and are authoritative:

  • docs/adr/0027-boards-are-tombstoned-never-deleted.md — a deleted Board keeps its row on both sides, so Ride History outlives it
  • docs/adr/0028-telemetry-is-keyed-on-board-id.md — telemetry keys on board_id, drops the BLE identifier and the denormalized Board name (closes [Telemetry] Reconsider device_name denormalization on telemetry tables #274)
  • docs/adr/0005-ride-history-read-paths-stay-precomputed.md — Scope section added: the no-reconstruction rule is about replaying Telemetry Samples, not about bounded configuration lookups
  • docs/adr/0007-local-diagnostic-events.md — the failure surface this feature reuses
  • vescape-server ADR-0007 — the Sync Cursor is a counter, not a clock

Domain language is in CONTEXT.md (Device Token, and the rule that Ride History is owned by the Vescape Account and only labelled by a Board) and in the server's CONTEXT.md (Sync Batch, Sync Cursor, Change Timestamp, Sync Action, Delete Target).

Problem Statement

Everything a Rider owns lives on one phone. Ride History, Favorites, Boards, Board settings, Alert Rules, Tune Profiles, Privacy Zones and App Settings are held in the app's native database and nowhere else. Lose the phone and months of riding are gone.

The server can now receive all of it. The app cannot send any of it. There is no uploader, no Sync Cursor storage, no batching, no credential native can use, and no Sync Action log — a Rider who deletes a Board has no way to tell the server so.

The two halves also disagree in three places that would corrupt a backup rather than merely fail to make one: telemetry keys on a mutable BLE identifier where the server keys on a Board, a deleted Board takes its rides with it on the server but not on the phone, and seven mutable tables—including Favorites, which lands first—still have no monotonic column an upload scan can read.

Solution

Map Points are explicitly outside backup. They are global server-owned data accessed through the ordinary Map Points API; they use no Sync Cursor or Sync Action.

A signed-in Rider's data uploads continuously and automatically from native, including mid-ride with the app in the background and the screen off. Nothing to press, nothing to remember, no file to keep.

The uploader lives in vescape-core next to the data it reads, inside the process that already stays alive during a ride. It scans each table forward from its Sync Cursor, sends small Sync Batches, and advances only what the server accepted. Being offline is not a failure — it is a pause, and the next batch repairs it.

The Rider sees one line in the account widget on the social sheet: backed up, syncing, waiting for Wi-Fi, or paused. One setting decides whether backup may use cellular. Failures become Diagnostic Events and appear where every other app failure already does.

User Stories

  1. As a Rider, I want my rides to upload while I ride, so that a crash or a dead battery mid-ride does not cost me that ride.
  2. As a Rider, I want backup to keep running with the app in the background and the screen off, so that I do not have to keep the app open to be protected.
  3. As a Rider, I want backup to resume by itself after riding out of coverage, so that a tunnel is not a permanent hole in my history.
  4. As a Rider, I want to sign in once and never think about backup again, so that there is nothing to remember.
  5. As a Rider, I want my Boards, their settings and their Alert Rules backed up, so that a new phone does not mean re-linking and re-configuring everything.
  6. As a Rider, I want my Tune Profiles and their history backed up, so that tuning work spread over many rides survives a phone change.
  7. As a Rider, I want my Privacy Zones backed up, so that my privacy settings and marked places are not silently reset.
  8. As a Rider, I want my App Settings backed up, so that a restored app feels like the one I was using.
  9. As a Rider, I want deleting a Board on my phone to delete it from the backup too, so that the backup reflects what I actually keep.
  10. As a Rider, I want deleting a Tune Profile, Privacy Zone to propagate the same way, so that a restore never quietly resurrects what I removed.
  11. As a Rider, I want the rides of a Board I deleted to survive, so that removing a board I no longer own does not erase the months I rode it.
  12. As a Rider, I want the app's own storage cleanup to never remove anything from my backup, so that the backup outlives what my phone has room to keep.
  13. As a Rider, I want to decide whether backup may use cellular data, so that leaving it on is never something I regret.
  14. As a Rider, I want to be asked about that once, when I turn backup on and it can see how much there is to send, so that a long first upload is a choice I made.
  15. As a Rider, I want to see whether my data is actually backed up, so that I can trust the promise instead of hoping.
  16. As a Rider, I want to be told when backup has stopped and needs me, so that it never fails silently for weeks.
  17. As a Rider, I want signing out to stop uploads immediately, so that nothing of mine goes anywhere once I have left.
  18. As a Rider, I want signing back in to resume rather than re-upload everything, so that leaving and returning is cheap.
  19. As a Rider, I want the app to warn me before switching to a different Account erases this Account's local data, so that a shared or handed-down phone never leaks one Account's Boards, Ride History or locations into another.
  20. As a Rider, I want backup to cost little battery and little data, so that it is never the reason I turn something off.
  21. As a Rider, I want a re-installed app to upload everything again without duplicating rides, so that recovery is safe to retry.
  22. As a Rider, I want my Ride History to keep naming the Board that produced it, even for a Board I deleted, so that old rides stay readable.
  23. As a Rider, I want renaming a Board to be reflected in its history, so that the app does not show me a name I no longer use.
  24. As a Rider, I want to keep using Group Ride without signing in, so that riding with friends never requires an Account.
  25. As a Rider, I want backup to stay off until I sign in, so that nothing leaves my phone before I ask.
  26. As a Rider on a blocked app version, I want backup to stop along with every other Online Capability, so that the app behaves consistently when the server has stopped accepting it.
  27. As a developer, I want the uploader's batching and scheduling logic to be pure and testable without a database or a network, so that its edge cases are cheap to pin.
  28. As a developer, I want a bound database's local retention sweep to prune only rows already accepted by the server and to be structurally incapable of emitting a deletion, so that cleanup can neither create a hole in the backup nor delete data from it.
  29. As a developer, I want an upload failure to leave the Sync Cursor where it was, so that the failure mode is a re-send and never a silent loss.
  30. As a developer, I want native to authenticate without a live JS runtime, so that background upload does not depend on the app being open.
  31. As a developer, I want the same behaviour on Android and iOS, so that a Rider's experience does not depend on their phone.
  32. As an operator, I want backup failures to appear as Diagnostic Events, so that a Rider's report can be explained without guessing.
  33. As a Rider, I want my Favorites backed up with their ranges, names and summaries, so that the rides I deliberately kept survive losing my phone.

Implementation Decisions

Where the uploader runs

Native, in vescape-core, on both platforms under @parity. The data it reads is native, the process that survives a backgrounded ride is native, and pushing per-sample rows across the bridge to upload them is the thing ADR-0018 exists to avoid. JS supplies the Device Token and renders status.

The uploader runs in the window the app already keeps alive: Android's foreground service while a Board Session or GPS is active, iOS's existing background modes. No WorkManager and no BGTaskScheduler in this version. A ride that ends while offline, on a phone that is then never opened, waits for the next app open or the next ride. That tail case is accepted; the machinery to close it is opportunistic on iOS and so cannot be promised to a Rider anyway.

Auth prerequisite: Device Token

Native cannot refresh a Clerk session, so it holds a credential of its own. JS exchanges a live Clerk token for a Device Token once, and native stores it in the Keystore or Keychain and uses it for every request afterwards. The server issues, hashes, and can revoke it, and verifies it without reaching Clerk — so a Clerk outage no longer reads as "signed out" to a background uploader.

It is scoped by caller, not by feature: native uses it for the Account's data, and anything that changes the Account itself — identity, billing, deletion — requires a freshly signed-in JS runtime. Future native calls inherit it with no new mechanism.

Lifetime is 365 days. Native optionally includes the Device Token in its App Status handshake; when the token is inside its final 90 days and the app is not blocked, the server extends expiry to one year from that request. networkBlock still permits credential renewal, while appBlock requires an update first. The exchange also returns the stable server Account id. Sign-out revokes the token server-side and clears the credential locally. Backup separately keeps its bound Account id, local data and Sync Cursors, so signing back into the same Account resumes.

Authentication is an independent capability tracked by #283 and KacperKozak/vescape-server#16. Ride History backup consumes it as its first client; neither backup PR owns the credential mechanism.

The loop

Time-driven with an adaptive interval, plus immediate kicks on connectivity regained, ride end, and sign-in.

RIDE_INTERVAL      30s     while a Board Session is producing samples
IDLE_INTERVAL      5min    no-op when nothing is pending
BATCH_ROW_CAP      1000 total across every table
BATCH_BYTE_CAP     1 MiB of compact UTF-8 JSON
BACKOFF            30s, doubling, capped at 15min, reset on success

Thirty seconds means the worst a crash costs is thirty seconds of ride. On a 200 with rows still pending the loop sends again immediately rather than waiting for the next tick, so a long backlog drains rather than trickling.

Failures are classified, not all backed off:

200             validate exact accepted counts, then commit cursors
400/409/422     permanent protocol/data error; pause
401             dead Device Token; pause for sign-in
413             rebuild smaller; one-row failure pauses
429             wait for Retry-After
5xx/net/timeout transient; exponential backoff

Unknown 4xx and malformed/unexpected successful responses are permanent protocol failures. A timeout may follow a committed server transaction, so retrying the identical batch stays safe. Permanent failure never advances a cursor, never skips the row, records one coalesced Diagnostic Event without row contents, and stops automatic retries.

Building a batch

The server writes a batch as one transaction, table by table, in an order where parents precede children. The builder walks that same order and fills until either the total row cap or encoded byte cap is reached. It measures the actual compact UTF-8 wire JSON, not an object-size estimate. It must never order by backlog size: a table with thousands of pending Board-owned rows would crowd out the handful of Boards they depend on, and the composite foreign key refuses the whole batch.

Strongly typed wire DTO encoders validate required/unknown fields, enum cases, key lengths, integer bounds and finite numbers before transport. A single row that cannot encode or exceeds the byte cap is a permanent local protocol failure; it is retained and never skipped.

This is the deepest pure module in the feature — given per-table pending readers, current cursors, row/byte caps and deterministic encoded sizes, it returns a batch and the cursor advance set that goes with it, with no database, clock or network involved. Valid and invalid contract fixtures are mirrored against KacperKozak/vescape-server#14.

Sync Cursors

Two kinds, one scan shape:

  • append-only tables (telemetry frames, markers, diagnostic events, metric exclusion ranges, tune history entries) use their existing AUTOINCREMENT primary key, which SQLite guarantees monotonic and never reused on both platforms. No migration, and no new index on the largest table in the app.
  • mutable tables use sync_seq from the shared sequence table. Three have it; seven do not yet after Favorites lands. [History] 1 - Create and list Favorites #287 owns only stable Favorite identity and ordinary timestamps; [Sync] 3 - Add sync_seq to six tables #281 later adds/backfills its cursor with the other missing tables.

This also settles the concern the server raised about metric exclusion ranges never advancing: the merge is a delete-plus-insert, so the wider range gets a fresh id and the cursor moves.

Cursors live in a local table and are written after the response, in their own transaction — never in the same transaction as the rows. A cursor advanced past rows the server did not take is unrecoverable; a cursor lagging behind is a re-send the server upserts idempotently. The design always fails in the re-send direction.

Retention safety

Local retention is also cursor-gated once a database is bound to an Account. Age chooses candidates; the accepted Sync Cursor decides which candidates are safe to remove.

  • append-only retention tables delete only rows whose local id is at or below that table's accepted cursor
  • minute buckets delete only rows whose sync_seq is at or below the accepted bucket cursor, so an old bucket rewritten after its last upload stays protected
  • a missing cursor means zero rows have been accepted and therefore nothing in that table may be pruned
  • a never-bound database keeps today's local-only retention behaviour, because no backup promise exists yet
  • sign-out keeps the Account binding, so new offline data remains protected until that Account signs back in and uploads it
  • the confirmed different-Account reset replaces the whole database and is the one explicit destructive bypass

The retention query reads the cursor and deletes in one local transaction. If upload and cleanup race, cleanup sees either the old cursor and keeps extra rows or the committed cursor and removes only accepted rows. It can never delete a row from an in-flight batch before acceptance.

Account changes

One local database belongs to one Vescape Account once it is first claimed. The stable server Account id returned by the Device Token exchange is stored as bound_account_id and survives sign-out.

  • signing back into the same Account keeps the database and Sync Cursors, so upload resumes
  • signing into a different Account is refused until the Rider confirms a destructive reset
  • confirmation stops the uploader, invalidates in-flight work, replaces the entire local app-data database with a fresh one, clears Sync Cursors and pending Sync Actions, binds the fresh database to the new Account, then starts sync
  • the reset is local maintenance and emits no Sync Actions to either Account

This deliberately avoids per-Account rows and per-Account cursors. Local offline use after sign-out continues to belong to the bound Account. Because restore is out of scope, returning to the old Account after switching cannot download the wiped local data in this version; the warning says that plainly.

Change Timestamps

Every mutable table ratchets its Change Timestamp to max(previous + 1, now). Minute buckets currently clamp with max(existing, incoming) instead, on the stated premise that the server upserts that table unconditionally — the server in fact guards it exactly like every other mutable table. On a backwards clock step the row is scanned and sent and then silently dropped server-side. Buckets adopt the same ratchet as everything else, and the exception disappears.

Sync Actions

Deletions cannot be expressed by a row that no longer exists, so they travel on an append-only, typed log. delete is the only type today; the log is named for what it is so later intents do not need a second mechanism.

The boundary is semantic removal, not merely a bridge method or a Rider tap. Any removal that a future restore must preserve emits an action, including automatic state transitions such as a Board Warning clearing after a clean evaluation.

  • Tombstoned entities — Boards — sync their deleted_at as an ordinary upsert and emit one Sync Action. The same local transaction tombstones the Board, appends the action, and raw-deletes its configuration. Those child deletes emit no actions because the Board action performs the server cascade.
  • Removed entities — Alert Rules, Tune Profiles, Privacy Zones, Favorites, app settings, board settings, board warnings — leave no row, so each semantic removal appends an action and deletes the row in one local transaction. A Favorite action covers its Favorite Media children. Tune History deleted with its parent profile is a raw cascade covered by the profile action.
  • Maintenance removals — cursor-gated retention, migrations and the confirmed Account reset — use explicit raw-delete paths and emit no actions.

Every DELETE against a syncable table must therefore be classified as semantic removal, parent-covered cascade, retention, migration or Account reset. Public/raw DAO boundaries make the choice visible; no database triggers infer it. A DeleteTarget enum with cases only for configuration/current-state kinds makes retention unable to name a telemetry frame or bucket.

The log is transport state, not durable truth. Action insertion and the row delete/tombstone are atomic. After upload, the accepted action cursor commits first; pruning happens afterwards, so a crash can only leave an accepted action to be resent, never remove an uncheckpointed action.

Network policy

One rule, and it means what it says.

An App Setting, "Back up over Wi-Fi only", defaults to off. When on, nothing syncs until the phone is on an unmetered connection — mid-ride included. There are no row classes, no backlog thresholds and no partial exceptions: a switch that partly ignores the Rider is worse than one that does not exist. The setting itself lives in a synced table, so it restores with everything else.

The Rider is offered this choice once, in the flow where backup is turned on, with the pending volume shown, so the only expensive moment in the feature's life is a decision they made.

Rider-facing surface

The account widget on the social sheet already renders sign-in state and is the most-seen surface in the app. It gains a status line: off, up to date with a relative time, syncing with a pending count, waiting for Wi-Fi, offline, or paused. Paused carries an actionable reason: sign in again, update required, or backup error. Native owns the state and emits it; JS renders it. The signed-out hint changes from generic online-features wording to naming backup, because that is now the reason to sign in.

Every paused state also raises a notification. It never resolves through ordinary retry, and silence is the failure this feature can least afford.

Failures become Diagnostic Events, so the existing event log is the debugging surface and no new error UI is invented. Permanent sync failures are coalesced by failure class/table/cursor: one event, not one per attempted retry. Payloads contain metadata only — never the Device Token, row contents, coordinates, opaque server body or SQL.

Gating

Sync endpoints are Online Capabilities and sit behind the existing App Status gate with every other server-backed capability. Group Ride stays unauthenticated and is untouched.

Slice order

All of it lands on the existing feat/ride-history-backup branch (#276) and is tested once on device at the end. Issues are units of work, not pull requests, but the schema work is ordered first and deliberately: it carries all the migration risk, and it should be behind us before the release rather than in front of it.

already on the branch   Change Timestamps + sync_seq on boards, alerts, minute buckets
1  Board tombstoning    deleted_at, list filtering, session refusal   [ADR-0027]
2  board_id on telemetry frames and buckets, orphan Boards minted     [ADR-0028, #274]
3  sync_seq             the six remaining mutable tables, after Favorites lands
4  Sync Action log      table, DeleteTarget enum, write points
--- schema settled ---
A  Auth prerequisite    #283 + vescape-server#16; reusable Device Token capability
5  Auth integration       consume native credential states; no auth implementation
6  Sync Engine          cursors, retention guard, scan, batch build, transport, backoff, drain
7  Rider surface        Wi-Fi setting, first-run choice, status line, Diagnostic Events

Slice 1 precedes slice 2 because minting a tombstoned Board for orphaned telemetry needs deleted_at to exist. Slice 6 needs 3 and 5.

Open questions

Two, and the first can change the shape of slice 3.

Which App Settings are per-Account and which are per-phone. The table syncs wholesale today. Rider Name and Rider Color live in it by design, because Group Ride must work signed-out — restoring them onto a second phone would overwrite that phone's Rider identity. Other keys may be equally phone-shaped. This needs either a per-key sync flag or an explicit not-synced list before slice 3 fixes the table's shape. It is the only open item that can produce wrong behaviour rather than missing data.

The iOS test target does not run. bun run test:ios is red on dev independently of this work, GRDB files are excluded from the SPM target so the migration tests are not in a runnable target at all, and the generated Pods project cannot be refreshed under the installed Ruby. Five consecutive GRDB migrations are about to ship to a release with no executable test behind them. Given that this project ships both platforms together, this wants fixing before slice 1 rather than after slice 7.

Testing Decisions

A good test here drives external behaviour: what a caller observes from a module's interface, or what a pure function returns for a given input. It does not assert on private helpers, intermediate state, or the shape of a query. Prior art is the existing native pure-logic tests described by ADR-0010, and SyncCursorMigrationTest / SyncCursorMigrationTests, which drive a real database through a real migration on each platform.

Most of the interesting behaviour is deliberately pushed into two pure modules so it can be tested without a database, a clock or a network.

Batch building. That a batch fills in dependency order under the total row and exact encoded-byte caps, so children never outrun the parents their foreign key needs. Boundary tests cover exactly-at and one-over both limits, plus a single oversized/invalid row. The returned cursor advance set describes exactly the rows sent and nothing more; an empty scan produces no request. Shared valid/invalid wire fixtures pass on Android, iOS and vescape-server#14.

Scheduling and policy. That the interval follows whether a Board Session is producing samples. That backoff grows and resets on success. Wi-Fi-only blocks and releases in both positions, including mid-ride. Response classification covers exact 200 acceptance, permanent 4xx, dead-token 401, adaptive 413, 429 Retry-After, transient 5xx/network, timeout-after-server-commit resend, and malformed success. Permanent failures pause without cursor movement or automatic retry.

Retention. On a bound database, old unsynced rows survive, accepted rows prune, and a minute bucket rewritten after upload survives through its newer sync_seq. A missing cursor protects the whole table, sign-out keeps protection, a never-bound database retains today's cleanup behaviour, and the confirmed Account reset remains the explicit full-wipe path.

The Sync Action log. Every semantic removal path appends its action atomically with the delete/tombstone, including replacement, reset-to-default and automatic warning-clear paths. Parent cascades emit only the parent action. Retention, migration and Account-reset paths emit none. A structural inventory test classifies every SQL delete of a syncable table, and action pruning cannot precede accepted-cursor commit.

Migrations. Each of the five steps applies forward, backfills what it claims to backfill, and is a no-op on re-run. Extends the existing migration tests on both platforms. The orphan-Board minting in slice 2 gets its own case, since it is the only migration that creates rows the Rider never made.

The engine, against a fake transport. A batch the server refuses whole leaves every cursor untouched. A failure part-way through a drain leaves the cursors at the last accepted batch. A dead Device Token stops the loop and surfaces the paused state. A re-sent batch changes nothing. Signing back into the bound Account preserves data and cursors; confirming a different Account stops the engine, fences stale responses, wipes locally without emitting Sync Actions, and starts from a fresh database.

No test is written for the Keystore or Keychain wrapper, or for the HTTP client itself. They are platform wrappers with nothing to assert that the platform does not already guarantee, and faking the transport is what makes the engine testable in the first place.

Out of Scope

  • Restore as a Rider-facing flow. The server can serve the data back; this version does not download or apply it. Nothing here renders a restore. Consequently, an Account switch that wipes local data cannot recover it by switching back in this version.
  • Scheduled background upload outside a ride. No WorkManager, no BGTaskScheduler. Covered above and accepted.
  • Per-ride backup indicators in Ride History. Answering "is this ride safe?" per row needs a synced check on a history read path, which is what ADR-0005 is about. The single status line is the whole surface in this version.
  • Multi-device live sync and conflict resolution. Two phones on one Account is last-write-wins by Change Timestamp, with no merge UI.
  • Media History Assets. Still owned by the OS photo library, never copied.
  • Rides as an entity. Ride History stays derived from the timeline; see the server's ADR-0006.
  • Group Ride changes. It stays unauthenticated and untouched.
  • Account deletion as an automated flow. The server's schema cascades from its own user row; no webhook handler is built here.
  • The Board name label question beyond board_id. ADR-0028 settles how telemetry is keyed and where the name comes from. Nothing further about history labelling is in scope.

Further Notes

Why the schema goes first. The usual advice is a tracer bullet — prove the whole path on the least risky table, then broaden. That was rejected deliberately: a release is close, and five migrations over the app's largest tables are better behind it than in front of it. The usual objection to schema-first is that migrations get written against an uploader nobody has run, and it is unusually weak here, because the server's wire schemas already pin the exact target shape and are tested against a real database.

What #276 already contributes. The branch carries Change Timestamps and sync_seq on three tables, the shared sequence primitive the remaining seven will reuse after Favorites lands, and TS input types that make it impossible for a call site to fabricate a timestamp. None of it is invalidated by anything above; slice 3 extends it rather than replacing it. The sync_seq half is written but not yet pushed.

Cost on the wire. Frames are delta-encoded before they are stored, so what uploads is already compact — a riding hour is a few megabytes. The expensive case is the one-time historical backfill on an existing install, which is exactly what the Wi-Fi setting and its first-run prompt exist for.

Parity. Every native module here is @parity on both platforms, and this project ships them together. The red iOS test target is therefore a blocker for the schema slices, not a cleanup task to follow them.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:dbTouches database / persistent storagearea:nativeTouches native side (modules/vesc-ble, Swift/Kotlin)area:syncBackup sync — native uploader, Sync Cursors, Sync Actions, Device Tokenready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions