[Sync] Ride history backup - #276
Conversation
📝 WalkthroughWalkthroughAdds native-stamped ChangesIncremental sync cursor contracts and schema
Native persistence
JavaScript state and E2E persistence
Validation
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant JavaScriptStore
participant NativeRepository
participant TelemetryDatabase
JavaScriptStore->>NativeRepository: Submit BoardInput or AlertRuleInput
NativeRepository->>TelemetryDatabase: Stamp and persist updated_at
TelemetryDatabase-->>NativeRepository: Return persisted entity cursor
NativeRepository-->>JavaScriptStore: Provide entity with updatedAt
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
modules/vescape-core/ios/telemetry/AppDataRepository.swift (1)
28-60: 🩺 Stability & Availability | 🔵 TrivialSilent write/read no-op when
writeris nil.
writeandreadswallow both a nilwriterand any thrown error with no logging/diagnostics. Given this PR is specifically about not missing rider writes (#275), a hot-swap window whereTelemetryDatabase.poolis nil means board/alert-rule upserts silently vanish whilenotifyDataChangedstill fires as if the write succeeded. This mirrors an existing pattern (TuneProfileStore/BoardWarningStore), so it's not new, but worth hardening now that the sync-reliability bar is being raised: consider capturing a diagnostic event (similar toDiagnosticReporterusage on Android) when a write is dropped or throws.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@modules/vescape-core/ios/telemetry/AppDataRepository.swift` around lines 28 - 60, Harden AppDataRepository’s read and write helpers so nil writers and database errors emit diagnostic events instead of failing silently. Update read(_:_:) and write(_:) to report whether the writer is unavailable or the database operation throws, while preserving the existing fallback behavior for reads and no-op behavior for writes; use the repository’s established diagnostics mechanism.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@modules/vescape-core/src/index.ts`:
- Around line 173-178: Replace the `updatedAt` documentation at
modules/vescape-core/src/index.ts:173-178 and :272-277 to identify it as
wall-clock last-write-wins metadata, not the sync cursor. In
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt:475-505,
add the 28→29 migration with atomic strictly increasing `sync_sequences`,
per-row `sync_seq` for every syncable write, and scan indexes. Add the
equivalent GRDB migration and schema in
modules/vescape-core/ios/telemetry/TelemetryDatabase.swift:421-444; retain
`updatedAt` for last-write-wins while scans use `sync_seq`.
In `@src/modules/alerts/store/alertsStore.ts`:
- Around line 15-20: Update the optimistic mutation paths in update() and
setEnabled() to wrap the locally written alert rule with withLocalCursor before
storing it. Ensure both paths assign a fresh local updatedAt while leaving the
persisted/native write behavior unchanged.
---
Nitpick comments:
In `@modules/vescape-core/ios/telemetry/AppDataRepository.swift`:
- Around line 28-60: Harden AppDataRepository’s read and write helpers so nil
writers and database errors emit diagnostic events instead of failing silently.
Update read(_:_:) and write(_:) to report whether the writer is unavailable or
the database operation throws, while preserving the existing fallback behavior
for reads and no-op behavior for writes; use the repository’s established
diagnostics mechanism.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 3c2fa431-314d-4b15-a8b1-1a21855f995e
📒 Files selected for processing (22)
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/alerts/AlertEngine.ktmodules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.ktmodules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.ktmodules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.ktmodules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.ktmodules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.ktmodules/vescape-core/android/src/test/java/expo/modules/vescapecore/alerts/AlertEngineTest.ktmodules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.ktmodules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.ktmodules/vescape-core/ios/alerts/AlertEngine.swiftmodules/vescape-core/ios/alerts/AlertEngineTests.swiftmodules/vescape-core/ios/telemetry/AppDataRepository.swiftmodules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swiftmodules/vescape-core/ios/telemetry/TelemetryDao.swiftmodules/vescape-core/ios/telemetry/TelemetryDatabase.swiftmodules/vescape-core/src/e2eFake.tsmodules/vescape-core/src/index.tssrc/modules/alerts/lib/customAlertRules.tssrc/modules/alerts/store/alertPresetStore.test.tssrc/modules/alerts/store/alertsStore.tssrc/modules/board/store/boardStore.test.tssrc/modules/board/store/boardStore.ts
| /** | ||
| * Incremental-sync cursor: epoch ms of the last write to this board, from the same clock as | ||
| * {@link createdAt}. Native stamps it on every upsert (including partial edits), so a value sent | ||
| * from JS is ignored — read it, do not author it. | ||
| */ | ||
| updatedAt: number |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Add the required monotonic sync_seq; do not scan by updatedAt.
The PR objective requires sync_seq specifically because wall clocks can move backwards. A write stamped at 900 after a prior scan cursor of 1000 is omitted by an updated_at > 1000 scan; same-millisecond writes have the same problem. Retain updated_at for last-write-wins, but allocate and persist a strictly increasing sequence atomically for every syncable write.
modules/vescape-core/src/index.ts#L173-L178: describeupdatedAtas wall-clock LWW metadata, not the scan cursor.modules/vescape-core/src/index.ts#L272-L277: apply the same contract correction for alert rules.modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt#L475-L505: add the 28→29 migration,sync_sequences, row-levelsync_seq, and scan indexes.modules/vescape-core/ios/telemetry/TelemetryDatabase.swift#L421-L444: add the equivalent GRDB migration and schema.
📍 Affects 3 files
modules/vescape-core/src/index.ts#L173-L178(this comment)modules/vescape-core/src/index.ts#L272-L277modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt#L475-L505modules/vescape-core/ios/telemetry/TelemetryDatabase.swift#L421-L444
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@modules/vescape-core/src/index.ts` around lines 173 - 178, Replace the
`updatedAt` documentation at modules/vescape-core/src/index.ts:173-178 and
:272-277 to identify it as wall-clock last-write-wins metadata, not the sync
cursor. In
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt:475-505,
add the 28→29 migration with atomic strictly increasing `sync_sequences`,
per-row `sync_seq` for every syncable write, and scan indexes. Add the
equivalent GRDB migration and schema in
modules/vescape-core/ios/telemetry/TelemetryDatabase.swift:421-444; retain
`updatedAt` for last-write-wins while scans use `sync_seq`.
| /** | ||
| * Native owns `updatedAt` (the incremental-sync cursor) and stamps it from its own clock. Rules | ||
| * mirrored into local state before that write lands carry this optimistic value until the next | ||
| * `load()` replaces them with the persisted rows. | ||
| */ | ||
| const withLocalCursor = (rule: AlertRuleInput): AlertRule => ({ ...rule, updatedAt: Date.now() }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Stamp every optimistic rule mutation.
update() and setEnabled() still retain the prior updatedAt locally. Apply withLocalCursor in both paths so the optimistic entity reflects the pending native write.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/modules/alerts/store/alertsStore.ts` around lines 15 - 20, Update the
optimistic mutation paths in update() and setEnabled() to wrap the locally
written alert rule with withLocalCursor before storing it. Ensure both paths
assign a fresh local updatedAt while leaving the persisted/native write behavior
unchanged.
# Conflicts: # docs/agents/issue-tracker.md # modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt # modules/vescape-core/ios/telemetry/TelemetryDatabase.swift
# Conflicts: # modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt # modules/vescape-core/ios/telemetry/TelemetryDatabase.swift
…ize hook - useBackupSlot reports unavailable for every Rider until #276 lands; offering "sign in to back up" promised a capability this build does not have. - fmtCompactCount picks its suffix after rounding, so 999_999 reads 1.0M not 1000k. - New useDatabaseSize hook for surfaces that only want the number; the drawer no longer mounts backup/restore/rebuild state and its progress listener.
Wire the Settings Drawer to the real uploader: `useBackupSlot` now projects `useSyncStatusStore` instead of Clerk, `BackupSlot` covers every activity native reports (off / signedOut / idle / syncing / blocked), and the strip cell opens Sync settings. A paused backup badges the gear in error red. Conflicts: iOS `Events` list keeps both `onReplayPhoneHeading` and `onSyncStatus`; `AppDataRepository` keeps the `writer` rename with dev's failure logging; `AccountWidget` stays deleted — `AccountPill` owns identity and the drawer's backup cell replaces its `BackupStatusLine`.
* Tombstone deleted Boards, never remove the row Deleting a Board now stamps boards.deleted_at instead of removing the row, so Ride History keeps a resolvable Board identity (ADR 0027). Board-owned configuration is still hard-deleted; telemetry and Tune Profiles are not. getBoards() filters tombstones, getBoard(id) deliberately resolves them, and connect paths refuse a tombstoned Board. An ordinary upsert carries an existing tombstone forward, so deletion is terminal. Ported from #276 (#279) and the closed #435 (#428), renumbered onto schema 40 -> 41. * Key telemetry on the Board id, not the BLE identifier Every telemetry table — frames, minute buckets, markers, diagnostic events and metric exclusion ranges — now keys on board_id and drops both device_id (the mutable BLE identifier) and device_name (the Board name denormalized at capture time). Ride History resolves Board names by lookup, so a rename relabels history (ADR 0028, closes #274). Migration 41 -> 42 rebuilds all five tables, resolving each BLE identifier to a Board exactly once through a shared scratch map so no two tables can pick different claimants of a duplicated identifier. Rows that resolve to no Board mint a tombstoned Board named from their historical device_name, so orphaned history keeps a label and stays joinable. The boardId -> bleId translation used by markers, events and ranges is deleted. RideHistoryRepository (added on dev after #276 branched) is rekeyed the same way. Ported from #276 (#280) with the follow-up dedup and marker/event/range rekey. * Align the iOS schema stamp and the docs with board-id keying The iOS backup stamp still claimed schema 41, so a current backup left the board-id migration unstamped and replayed it on restore — caught by TelemetryMigrationTests. Also rekeys the native API and history docs, adds both ADRs to the docs index, and records the Board Tombstone rules in CONTEXT.md. * Record Board-id keying as a whole-schema rule, not a telemetry one Every durable Board-owned table now keys on board_id; boards.ble_id is the only BLE identifier left and is a Board Link attribute, not a join key. * Fix nested GRDB read crash in Ride History page boardNamesById() opens its own pool read; calling it inside getPage's read tripped GRDBPrecondition and killed the reader queue. Hoist it out, like every other call site already does.
#279 and #280 landed on dev as #437, so the branch's own tombstone and board-id migrations are dropped and dev's ladder is the one that ships. The sync migrations move to the tail of it: 42→43 Change Timestamps, 43→44 sync_seq, 44→45 the six remaining tables, 45→46 sync_actions, 46→47 sync_binding. Schema 47 on both platforms. The branch had taken schema 32 (Room 31→32, GRDB v32) for its first sync migration and pushed the shipped alert-repeat migration to the tail. Dev has since spent 32 through 42, so the slot goes back to alert-repeat and every sync migration is renumbered above dev's. Rebuilding the buckets on board_id now happens before the sync columns exist, so the rebuild no longer has to carry them and the assertions that it does are gone.
faultCount goes out as 0, not null. The server declares it non-nullable inside a strict schema it validates whole, so a null refused the entire Sync Batch rather than the field — backup would have wedged on the first minute bucket. faultCode stays null; that one the server declares nullable. SYNC_SEQ_TABLES_V43/V44 renamed to V44/V45 to match the convention that the constant names the schema version its migration produces. Comments that had come to describe the opposite of the code: Favorites have a server table now and the uploader does not drop the case, the Kotlin cursor test header still named schema 31 and 32, and the decode caches are maintenance because they are rebuilt on the next read, not because the Board's action covers them.
The server side of this landed in vescape-server#45; this is the half that actually puts the rows on the wire. VESC Fault Occurrences and their Captures now sync. They are the one category of Rider-visible history that is permanent on the phone — exempt from every retention sweep, kept after the Board is gone — and backup carried none of it, so a lost phone lost the entire fault record. Schema 48 gives an Occurrence the ratcheted `updated_at` it needs to be judged on and both tables a `sync_seq`; a dismissal moves the stamp, which is the whole reason the column exists. Capture samples keep their local autoincrement id at home and are identified by occurrence and capture time, as Tune History already is. Alert Rules send the four columns that say how their thresholds are read, plus the repeat cadence and beep count. A config-relative rule was restoring as a bare fixed rule at a stale number — worse than losing it, because it looks configured. Board Warnings send `updatedAt`, so the server stops judging them on a detection time that does not move when a severity is re-triaged. `faultCode` and `faultCount` are gone from the frame and bucket encoders. The server dropped both columns, and sending them now refuses the whole batch. Two wedges, both of which would have stopped backup permanently on a retained row that no retry can clear: Metric Exclusion Ranges can carry an empty `board_id` — the sanitizers write it for samples that match no Board — and unlike the frame and bucket scans this one had no filter. The server's composite foreign key rejects it and 409s the batch. Fault Capture samples are the first firmware-sourced floats ever put on the wire, and a non-finite one would have thrown a permanent protocol error. They now encode as null through a new `reading` writer: these columns are nullable precisely because a field the firmware did not send is absent, so an unusable one is absent too. `number` still refuses non-finite for values a Rider authored. Also: `syncWifiOnly` joins the settings that never travel, so a restore onto a cellular-only phone stops inheriting the other phone's data-plan answer; Motor Config Values are dropped with their Board on both platforms, where iOS had been keeping them on the mismatch path too; and the `SyncTable` parity tag pointing at a TypeScript type that never existed is removed.
This PR implements the app side of Ride History backup, paired with
KacperKozak/vescape-server#12. A signed-in Rider's data syncs to the server continuously and automatically, from native, including mid-ride with the app backgrounded and the screen off — so losing a phone no longer loses months of riding.\n\n> [!WARNING]\n> Risk: High — changes native background sync, authentication, deletion semantics, and recovery behavior across both platforms.\n> Complexity: High — coordinates ordered batches, cursors, retries, Account lifecycle, cross-platform parity, and server contracts.\n> DB: Schema + data — adds tombstones, Board-keyed telemetry, sync cursors/sequences, and deletion actions through multiple Room and GRDB migrations.\n\n## Description\n\nThe backup implementation slices land on this branch and are tested once on device at the end. Native authentication is extracted into #283 andKacperKozak/vescape-server#16so other server-backed capabilities can ship without waiting for backup.What this lands
KacperKozak/vescape-server#16; it does not own authentication. Clerk provisions the credential through JS once, then native can call Account-data endpoints without a live JS runtime.board_id, dropping the mutable BLE identifier and the denormalized Board name (ADR-0028, closes [Telemetry] Reconsider device_name denormalization on telemetry tables #274). Orphaned rows get a minted tombstoned Board so nothing becomes unuploadable.Tasks
KacperKozak/vescape-server#2; neither restates the other.board_idon frames and buckets — extracted from this branch and shipped ondevas [Board] Boards are tombstoned and telemetry keys on the Board id #437. Implemented and tested here first, then rebased ontodev, renumbered onto schema 41 and 42, and broadened to cover markers, diagnostic events, exclusion ranges andRideHistoryRepository. Merged back in, so this PR no longer carries them.sync_seqon the six remaining mutable tables, plus the minute-bucket ratchet correctionKacperKozak/vescape-server#16; backup is only its first consumer#284carries the correctness risk now that #279 and #280 have shipped ondev.#283is intentionally outside the backup sequence and depends only on its standalone server peer,KacperKozak/vescape-server#16.Already on the branch
Board tombstones and telemetry keyed on the Board id (#279, #280, ADR-0027, ADR-0028, closes #274) shipped on
devas #437 and came back in through the merge. They were written and tested on this branch;devcarries the later, broader version and this PR now consumes it rather than defining it.devversion also keystelemetry_markers,diagnostic_eventsandmetric_exclusion_rangeson the Board, so theboardId -> bleIdtranslation this branch still needed is gone entirely, along with the session-boundary bug it caused.v42_telemetry_board_id, which is below every sync migration here. On this branch the rebuild ran afterupdated_atandsync_seqlanded and had to carry them across explicitly; in the merged order those columns do not exist yet, so the rebuild is simply dev's and the carry-across is gone.devis deliberately app-scoped and defers the server half to this PR. The merge folds that half back in: the Board-owned foreign key, the Sync Batch a missing parent would refuse whole, and the explicit configuration cascade that replaces the server'sON DELETE CASCADE. Server half isKacperKozak/vescape-server#27.dev: the tombstone movesupdated_atandsync_seqthrough the same stamping path as any other edit,BoardInputisOmit<Board, 'updatedAt' | 'deletedAt'>so JS cannot fabricate either, and the tombstone emits a Board Sync Action alongside the upsert.The Sync Action log (#282). An append-only local log of semantic removals,
sync_actions, keyed on its ownAUTOINCREMENTcursor — SQLite guarantees that key monotonic and never reused, so the log needs nosync_seq. Room 45 -> 46 and GRDBv46_sync_actions, additive and no-op on re-run. The server half isKacperKozak/vescape-server#12.deleted_atas an ordinary upsert and emits one action: the row says the Board is deleted, the action says its configuration is gone. Its board settings, Board Warnings and Alert Rules are raw deletes covered by that one action, so an upsert never quietly deletes rows in three other tables. Everything else — Alert Rules, Tune Profiles, Privacy Zones, Favorites, app settings, board settings, Board Warnings — leaves no row behind, so the action is the only signal there is.legalPolicyclearing, the corrupt-setting cleanup (deliberately semantic, so a restore cannot resurrect a value this phone already rejected), deleted Board-setting keys on a Board edit, preset-rule regeneration through the ordinary Alert Rule delete, and a Board Warning cleared by a clean detector evaluation.DeleteTargethas no case for a pruned table, so a sweep is structurally incapable of naming one. Asserted on both platforms, mirroring the server'sDELETE_ACTION_TARGETStest. No database trigger writes the log — intent cannot be inferred from SQL alone.max(now, row.updated_at)(last_detected_atfor Board Warnings, its own change clock). A plainnowon a rewound clock produces an action the server reads as a no-op and cannot self-heal from, because the row it would re-send is gone. A Board tombstone and its action share the newly ratcheted Board timestamp.Change Timestamps and Sync Cursors on
boards,alertsandtelemetry_minute_buckets— the first part of #281's mechanism, landed ahead of it.Change Timestamps.
boardsandalertscarriedcreated_atonly andtelemetry_minute_bucketscarried nothing, so a board rename, an alert toggle, or a minute bucket still filling was invisible to an "everything changed since T" query. Room 42 -> 43 and GRDBv43_sync_cursors, both additive, both backfilling existing rows rather than leaving them atDEFAULT 0:boardscreated_atalertscreated_attelemetry_minute_bucketslast_sample_at_msNative stamps the timestamp from its own clock on every write, never trusting the bridge value — including
setAlertRuleEnabledon both platforms, a targetedUPDATErather than a row rewrite and the specific regression that change exists to prevent. On the TS sideBoardandAlertRulegainupdatedAt: number, andupsertBoard/upsertAlertRuletakeBoardInput/AlertRuleInput(Omit<…, 'updatedAt'>) so no call site can fabricate one.The Sync Cursor split off the Change Timestamp (#275). One wall clock cannot do both jobs, because a device clock that steps backwards breaks each in a different direction:
WHERE stored.updated_at < EXCLUDED.updated_at, so the older stored copy wins and the phone advances its cursor regardless. Single device, no conflict, edit lost.So they are now two columns.
sync_seqis a device-local counter, bumped on every write, and the scan runs on it; it never crosses the wire, so there is no schema, protocol or server change.updated_atkeeps its wall-clock meaning and is ratcheted tomax(previous + 1, now)per row, which is bounded by the size of the rewind and self-corrects once the clock passes it again. Room 43 -> 44 and GRDBv44_sync_seq. Recorded server-side asvescape-serverADR-0007.The counter lives in its own
sync_sequencestable rather than being derived asMAX(sync_seq) + 1per table: deleting the highest row would hand the same number out twice, which is the original bug reintroduced by the fix.Sync Cursors on the six remaining mutable tables (#281).
app_settings,board_settings,board_warnings,privacy_zones,tune_profilesandfavoritesnow carrysync_seqand a ratcheted Change Timestamp, stamped by the samenextSyncSeq/ratchetUpdatedAtprimitives the earlier tables use. Room 44 -> 45 and GRDBv45_sync_seq_remaining, backfilling existing rows fromrowidand seeding each counter past the highest position handed out.INTEGER PRIMARY KEY AUTOINCREMENT, which SQLite guarantees monotonic and never reused — their key already is their cursor. Asserted, so a future column add has to argue with a test.board_warningsalso gainsupdated_at, the one table of the six that never had a wall clock at all, backfilled from its newest detection. It is distinct fromlast_detected_at: a severity or payload change rewrites the row without being a fresh detection.UPDATEmoves both columns, thesetAlertRuleEnabledshape that motivated the original work: the privacy-zone toggle and the three tune-profile updates (rename, save, rollback) now ratchet and renumber in their own SQL rather than round-tripping an entity.SYNC_SEQ_TABLES_V43andSYNC_SEQ_TABLES_V44are separate, so growing the set never retroactively changes what an older migration step does.The minute-bucket ratchet correction (#281). The bucket merge clamped its Change Timestamp with
max(existing, incoming)on the stated premise that the server upserts that table unconditionally. It does not — it guards it withWHERE stored.updated_at < EXCLUDED.updated_atexactly like every other mutable table, so on a backwards clock step the row was scanned, sent, and silently dropped. Buckets now use the samemax(previous + 1, now)ratchet as boards and alerts, and the comment asserting the exception is gone.The uploader (#284). Native scans each table forward from its Sync Cursor, sends a small Sync Batch, and advances only what the server accepted. It runs inside the window the app already keeps alive — the foreground service during a ride on Android, the existing background modes on iOS — at 30 s while samples are being produced and 5 min when nothing is pending, with immediate kicks on connectivity regained, ride end and sign-in, and an immediate re-send while a
200still leaves rows pending.SyncBatchBuilderwalks the server's own table order — parents before children, Delete Actions last — and stops at 1000 rows or 1 MiB of actual compact UTF-8 bytes, measured on the encoded body rather than estimated. It never orders by backlog size, which would build a batch the server refuses whole.SyncPolicyturns ride/network/Wi-Fi-only/gate/pending/backoff state into send, wait or paused. Neither touches a database, a clock or the network.200advances only after the accepted map validates exactly — a missing table, an extra table or a mismatched count is a protocol failure, not a success.413on a single row.400/409/422/unknown4xx/malformed2xxpause permanently with no cursor movement;401pauses for sign-in;429honoursRetry-After;5xx, network and timeout back off 30 s doubling to 15 min, reset on success. A permanent pause writes one coalesced, metadata-only Diagnostic Event — failure class, table, cursor, app version, never row contents, coordinates or the token.id <= accepted cursorhold, orsync_seq <= accepted cursorfor minute buckets — so a bucket rewritten after its earlier version uploaded survives until the new position is accepted. A missing cursor is 0, which protects every row. The sweep reads the cursor and deletes in one transaction, and emits no Sync Actions.sync_bindingrow (Room 46 -> 47, GRDBv47_sync_binding). The same Account keeps everything. A different one makes native refuse to bind and reportaccountChangeRequiresResetwithout storing the credential; JS shows a destructive warning saying cloud restore does not exist in this version, and only confirmation runs the ordered transition — stop, invalidate, replace the database, clear cursors and actions, bind, install the token, start. A generation captured per request is re-read before the commit, so a response from the previous Account is a no-op. Cancelling leaves the old database and binding untouched.A cross-agent review (Codex, read-only) went over this slice; eight findings landed as fixes:
OnCreatecallsresumeIfBound, which re-binds the stored Account and starts the loop.Mutex, a Swift task chain — and the generation is captured before the scan rather than after it. Previously a reset landing mid-pass could commit the previous Account's cursors onto the fresh database.stop()now cancels in-flight kicks too, and the reset no longer starts the loop itself: the caller installs the new Device Token first.413at the smallest byte target pauses instead of resending the same bytes forever, and a shrink no longer reports itself as an upload (it returns a distinct retry outcome, and an exhausted drain yields instead of spinning at zero delay).boards.transport. It is the one platform that stores the Board Transport on the Board, and the server declares the field for exactly that; sending null lost the Board Link's transport on restore.value.length <= 128and Kotlin'sString.length. Swift'scountcounts grapheme clusters, so an emoji-heavy key could pass locally and wedge the batch server-side.Two deliberate deviations from the issue, both about rows that can never be uploaded rather than rows waiting to be:
board_id, or a bucket on the unknown-Board sentinel, can never become uploadable. Pausing on one would wedge backup permanently on a row nobody can fix. The consequence is that a later owned row carries the cursor past a skipped one, so retention prunes unowned telemetry on age alone — exactly as it did before the Account binding existed.Retry-Afterreads a response header, which meant widening the sharedApiResponseto carry lowercased headers and adding aVescapeApi.exchangethat returns the raw status. The uploader needs409,413and429kept apart, andApiResultcollapses them.Verification:
bun run test:androidandbun run test:iosboth green, including new pure-module, wire-boundary, engine-against-a-fake-transport and cursor-gated-retention suites on both platforms.bun run ts,lint,format:check,knipandbun testclean. Not run on device.The Rider-facing half (#285). One setting, one choice, one status line.
syncEnabledgates the uploader outright: switched off there is no scan, no request, no backoff, no pause notification — the loop is stopped, not left deciding to do nothing every five minutes. It is checked ahead of everything in bothdecideanddescribe, including a pause, because switched off is not a broken uploader waiting to be resumed. Phone-local and deliberately not synced: a kill switch must not travel through the mechanism it kills, or a restored snapshot could switch backup back on. Lives on its own Settings → Sync page with the status line and the Wi-Fi row; the Database page keeps only local backup/restore/rebuild.AppDataRepositorypushes every write to the uploader, and the uploader restores it before its first pass on a cold launch), so the JSsetSyncWifiOnlycall is gone: a restored backup or the one-time choice reaches the uploader the same way the settings row does.SyncPolicy.describederives the Rider-facing state from the sameSyncStatethe send/wait decision reads, so the line can never disagree with what the uploader is doing: signed out, up to date, syncing, waiting for Wi-Fi, offline, paused. Signed out outranks the pause it produces (a phone with no credential is not a broken backup), and a batch waiting on backoff still reads as syncing.onSyncStatusevent, replayed on subscribe and re-pulled on foreground, mirroringonAppStatus. It lands in the account widget on the social sheet — in its signed-in branch's identity block, not a fourth branch — and in a new Backup section on the Database settings screen next to the Wi-Fi switch. The signed-out hint now names backup as the reason to sign in.BackupStatusLinetakes an optional status prop, so every state has a live showcase preview under Settings → Components → Widgets.Which app settings are per-Account and which are per-phone — the #277 question that gated this slice, now answered. Rider identity (
riderId,riderName,riderColor), this phone's session state (selectedBoardId, the last GPS and direction-point coordinates), connection and companion behaviour (autoConnect, companion presence and its cooldown, connection sounds, auto-close and its delay) and Wear pairing (wearMirrorIntervalMs,wearAutoLaunchOnConnect) stay on the phone. Everything else syncs.The split is a native constant list (
NOT_SYNCED_SETTING_KEYS/notSyncedSettingKeys) rather than a per-key column, and it is enforced by never handing those rows a cursor position:sync_seqstays 0, which is below every Sync Cursor, so no scan can see them. The migration'srowidbackfill is undone for exactly those keys, or an uploader would ship whatever the phone happened to hold at upgrade time, once. Rider Name and Rider Color live inapp_settingsby design so Group Ride keeps working signed-out — that placement is precisely what makes them phone-local rather than Account-scoped.Schema parity with the server
A drift audit against the server's wire contract found backup was silently not restoring what it was given, in three directions at once. The server half is vescape-app/vescape-server#45; this branch is what puts the rows on the wire.
Tables the app had that backup never carried. VESC Fault Occurrences and their Captures (ADR-0037) are the one category of Rider-visible history that is permanent on the phone — exempt from every retention sweep, kept after the Board is gone — and none of it was synced, so a lost phone lost the whole fault record. Schema 48 gives an Occurrence the ratcheted
updated_atit has to be judged on and both tables async_seq. A dismissal moves the stamp, which is the entire reason the column exists. Capture samples keep their local autoincrement id at home and are identified by occurrence and capture time, the way Tune History already is.Columns the app grew that the wire never learned. Alert Rules now send
thresholdKind,configFieldIdand the two offsets, plusrepeatEverySecondsandbeepCount. A config-relative rule was restoring as a bare fixed rule at a stale number — worse than losing it, because it looks configured. Board Warnings now sendupdatedAt, so the server stops judging them on a detection time that does not move when a severity is re-triaged.Columns the app dropped that the wire still demanded.
faultCodeandfaultCountare gone from the frame and bucket encoders; the server dropped both columns and sending them now refuses the whole batch.Two wedges
Both would have paused backup permanently on a retained row that no retry can clear.
board_id— the sanitizers write it for samples that match no Board — and unlike the frame and bucket scans, this one had no filter. The server's composite foreign key rejects it and 409s the entire batch.readingwriter: these columns are nullable precisely because a field the firmware did not send is absent, so an unusable one is absent too.numberstill refuses non-finite for values a Rider authored, where a NaN is a bug worth stopping for.Also
syncWifiOnlyjoins the settings that never travel. It answers what this phone's connection costs, not what the Rider prefers, so a restore onto a cellular-only phone was inheriting the other phone's answer.SyncTable@paritytag pointed at a TypeScript type that never existed. Removed rather than invented — the wire table names do not cross the bridge.Favorite Media stays out deliberately: its manifest describes bytes that live outside SQLite (ADR-0030) and nothing carries them yet, so a restore would list media it cannot open and that ADR's own reconciliation would then delete the rows again.
The
devmergedevmoved a long way under this branch, so the merge is a rewrite of the migration ladder rather than a textual reconciliation. Schema is 47 on both platforms.This branch had taken schema 32 (Room 31 -> 32, GRDB
v32) for its first sync migration and pushed the already-shipped alert-repeat migration to the tail as 38 -> 39 /v39_alert_repeat. That was safe while nothing above 32 existed;devhas since spent 32 through 42. So the slot goes back to alert-repeat, this branch's tombstone and board-id migrations are dropped as duplicates of dev's 40 -> 41 and 41 -> 42, and every sync migration moves above dev's:v32_sync_cursorsv43_sync_cursorsv33_sync_seqv44_sync_seqsync_sequencesandsync_seqv34_board_deleted_atv35_telemetry_board_idv36_sync_seq_remainingv45_sync_seq_remainingv37_sync_actionsv46_sync_actionsv38_sync_bindingv47_sync_bindingv39_alert_repeatJudgment calls worth reviewing:
devreads them fromgetBoards(), which filters tombstones, so a Favorite on a deleted Board lost its name — the exact case ADR-0027 exists to prevent. Both platforms now usegetBoardNames()/boardNamesById().faultCodeandfaultCountgo out as explicitnull.devdropped both columns when VESC faults became Board-owned evidence in their own tables (ADR-0037), and those tables are not inSyncTableyet. The server still declares the fields, so null is the honest value rather than a stale zero.deleteBoardConfigValues,deleteMotorConfigValuesanddeleteBoardConfigChangeNoticeare absent fromSyncTable, so nothing on the server needs removing and the Board's own action already says its configuration is gone.A pre-existing
devbug fell out of the merge.upsertBucketinmodules/vescape-core/ios/telemetry/TelemetryDao.swiftbinds 28 values against 27 columns — removingfault_countleft the literal0one slot right, onmax_gps_speed_centi_mpsinstead ofgps_distance_cm. Every minute-bucket write on iOS fails withSQLite error 1: 28 values for 27 columns. Nothing ondevexercises it; this branch'sSyncCursorMigrationTestsdo. Fixed here, but it is live ondevand wants its own fix there.Docs
Decisions that are hard to reverse are recorded rather than left in the diff:
docs/adr/0027-boards-are-tombstoned-never-deleted.md, with its server peer asvescape-serverADR-0008docs/adr/0028-telemetry-is-keyed-on-board-id.md, closing [Telemetry] Reconsider device_name denormalization on telemetry tables #274docs/adr/0005-…gains a Scope section — its "no reconstruction on read" rule is about replaying Telemetry Samples, not about bounded configuration lookups, which is what ADR-0028 relies onCONTEXT.mdin both repos: Device Token, and the rule that Ride History is owned by the Vescape Account and only labelled by a Board. The server's glossary also renames Delete Action to Sync Action with a type, so the log generalises past deletion.Verification
After the
devmerge and the schema-parity work:bun run test:android: 811 tests, 0 failures. Room KSP codegen accepts the merged entities and DAO, and schema 48 is exercised for columns, indices, theupdated_atbackfill, counter seeding and re-run safety.bun run test:ios: green, 665 tests. #278 landed the runnable Swift test target, so the GRDB migrations are executed rather than mirrored on faith, and the iOS Sync Cursor tests run the real migrator against an in-memory database.bun run ts,bun test(949 pass),bun run lint,bun run format:checkandbun knipare clean. Not run on device.The server side is green independently:
bun run checkon vescape-app/vescape-server#45 is 609 pass, with the DB-backed suites running against a real Postgres with the migration chain applied from scratch.Open questions
upsertBucketarity bug is fixed here but still live ondev. It wants a standalone fix ondevrather than waiting for this branch.devmerge renumbered the sync migrations, so a phone sitting on the old schema 39 has no path forward: Android wipes silently throughfallbackToDestructiveMigration, and iOS throws on a replayed migration and will not open the database at all. Acceptable for an unreleased branch, but delete and reinstall before taking this build.schema_migrationsalready records the old versions and the edits will not re-apply on their own.Deliberately out of scope
WorkManager, noBGTaskScheduler. A ride that ends offline on a phone that is never reopened waits for the next open or the next ride.Tickets
devvia [Board] Boards are tombstoned and telemetry keys on the Board id #437