Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a166e07
Add updated_at sync cursors to boards, alerts, minute buckets
KacperKozak Jul 26, 2026
7836646
Document cursor clamp semantics on bucket merge
KacperKozak Jul 26, 2026
e836891
Update docs
KacperKozak Jul 27, 2026
23b7f08
sync_seq split (#275)
KacperKozak Jul 27, 2026
a8260ba
Merge remote-tracking branch 'origin/dev' into feat/ride-history-backup
KacperKozak Jul 29, 2026
43e3062
Merge remote-tracking branch 'origin/dev' into feat/ride-history-backup
KacperKozak Jul 31, 2026
47509f5
Tombstone deleted Boards #279
KacperKozak Jul 31, 2026
68144ca
Key telemetry on board_id #280
KacperKozak Aug 1, 2026
21c9ba9
Add sync_seq to six tables #281
KacperKozak Aug 1, 2026
206ee79
Log Sync Actions for semantic removals #282
KacperKozak Aug 1, 2026
32a1165
Merge remote-tracking branch 'origin/dev' into feat/ride-history-backup
KacperKozak Aug 1, 2026
aabd235
Stamp the ephemeral alert-test rule
KacperKozak Aug 1, 2026
20f0ceb
Upload Sync Batches #284
KacperKozak Aug 1, 2026
e0c36c7
Harden the Sync uploader after review #284
KacperKozak Aug 1, 2026
11603e7
Show backup status #285
KacperKozak Aug 1, 2026
752e8ea
Add a backup master switch and a Sync settings page #285
KacperKozak Aug 1, 2026
6a7b4d8
Test the uploader loses no rows
KacperKozak Aug 2, 2026
bd5b37a
Merge branch 'dev' into feat/ride-history-backup
KacperKozak Aug 2, 2026
f12dc01
Kick sync on connectivity, ride end, and Rider edits
KacperKozak Aug 3, 2026
44ea846
Resolve a duplicated BLE identifier once in the board-id migration
KacperKozak Aug 3, 2026
a427fce
Show backup progress as a bar
KacperKozak Aug 3, 2026
3719b17
Key markers, events and ranges on the Board
KacperKozak Aug 3, 2026
b43b03c
Merge dev into feat/ride-history-backup
KacperKozak Aug 7, 2026
5406e81
Merge dev
KacperKozak Aug 18, 2026
c5f33b6
Drop stray node_modules symlink
KacperKozak Aug 18, 2026
f7a988b
Use interface for ProgressBar props
KacperKozak Aug 18, 2026
dcb340d
Point schema-version guards at 39
KacperKozak Aug 18, 2026
7b1435c
Drop stray node_modules symlink
KacperKozak Aug 18, 2026
0cc0e4c
Merge dev into feat/ride-history-backup
KacperKozak Sep 2, 2026
abd104a
Fix review findings from the dev merge
KacperKozak Sep 2, 2026
c651e09
Send every durable table the backup contract now accepts
KacperKozak Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ _Avoid_: Device, controller, scooter
A deleted Board's surviving row, marked by a deletion stamp. The Board leaves every Rider-facing list but stays resolvable by id, so Ride History can still name the Board that produced it. Its configuration is hard-deleted; its telemetry and Tune Profiles are not (ADR 0027).
_Avoid_: Soft delete, archived Board

**Sync Action**:
An append-only local record that something was semantically removed, so the removal reaches the Vescape Account backup. A deleted row cannot carry a **Change Timestamp** saying it is gone, so the log is the only signal there is. Typed — `delete` is the only type today — and written from Rider-facing removal paths only, never from retention, migrations or a database trigger.
_Avoid_: Delete log, tombstone table, audit trail, change event

**Sync Cursor**:
A phone-held, device-local position saying how far one table has been accepted by the server. It never crosses the wire — the server keeps no watermark — and it runs on a counter rather than a clock, so a device clock that steps backwards cannot make the upload scan skip a write. Advanced only after a response, in its own transaction, so the failure mode is always a harmless re-send.
_Avoid_: Watermark, sync token, last-synced timestamp, offset

**Sync Batch**:
One upload: rows from one or more tables, sent in the order the server applies them so a Board-owned row never arrives before its Board. Capped by row count and by actual compact JSON bytes. Accepted whole or refused whole — nothing is half-applied, and nothing is skipped to make a batch fit.
_Avoid_: Sync payload, upload chunk, page, delta

**Backup Status**:
Native's one answer to "what is my backup doing": signed out, up to date, syncing, waiting for Wi-Fi, offline, or paused with the reason that stopped it. Derived from the same state the uploader decides on, so a status line can never disagree with the uploader. JS renders it and derives none of its own; every paused reason also raises a notification, because a pause never clears through ordinary retry.
_Avoid_: Sync state, upload progress, connection status

**Account Binding**:
The one **Vescape Account** a phone's local database belongs to, claimed by the first Account to sign in. It survives sign-out, so data recorded while signed out stays protected from retention for the same Account. A different Account cannot take over the database; it can only replace it, which the Rider has to confirm.
_Avoid_: Account link, owner id, current user

**Board Link**:
The saved, probe-confirmed reachability details for a Board, including BLE peripheral id, selected Board Transport, and capabilities or firmware facts discovered for that transport.
_Avoid_: Pairing, connection settings, device config
Expand Down Expand Up @@ -360,6 +380,10 @@ _Avoid_: User, account, member, profile, friend
An optional online identity that never gates the app's local, offline-first capabilities or ownership of local data.
_Avoid_: Rider profile, User, Profile

**Device Token**:
A long-lived server-issued credential held by one app install that lets native call the Vescape server for a **Vescape Account's** own data without a signed-in JS runtime.
_Avoid_: API key, session token, sync token, auth token, refresh token

**Rider Presence**:
A **Rider's** live shared snapshot within a **Group Ride**: location and heading from the phone **GPS Fix**, plus optional speed and **Battery SoC Estimate** when a **Board Session** is live. Ephemeral and server-relayed, never persisted on phone or server, suppressed while the Rider is inside a **Privacy Zone**. A Rider with no recent Rider Presence goes stale, then drops from the Group Ride.
_Avoid_: Position update, presence ping, location share, group telemetry
Expand Down Expand Up @@ -484,6 +508,9 @@ _Avoid_: Position update, presence ping, location share, group telemetry
- A **Group Ride** contains zero or more **Riders** and exists only while at least one **Rider** is present; it owns no durable truth and is never written to **Ride History**.
- A **Rider** may be in at most one **Group Ride** at a time and is identified independently of any **Board**.
- A **Vescape Account** is independent of a **Rider** and may enable optional online services such as backup, sync, or paid entitlements, but is not required to use local Boards, Ride Recording, Ride History, or tuning.
- **Ride History** is owned by the **Vescape Account** and only labelled by a **Board**; deleting a Board hides it and drops its configuration but never removes the rides it produced, on the phone or on the server.
- A **Device Token** belongs to exactly one **Vescape Account** and one app install; it authorizes reading and writing that Account's data, never changing the Account itself, which requires a freshly signed-in JS runtime.
- A **Device Token** is revoked on sign-out and is not a **Group Ride** credential, which stays unauthenticated.
- A **Rider Presence** belongs to one **Rider** in one **Group Ride**, derives location from a **GPS Fix** and optional speed/**Battery SoC Estimate** from a live **Board Session**, and is not produced while the Rider is inside a **Privacy Zone**.
- A **Group Ride** requires only a phone **GPS Fix** to join; a **Board Session** is optional and only enriches a **Rider Presence**, never gates it.

Expand Down Expand Up @@ -539,5 +566,6 @@ _Avoid_: Position update, presence ping, location share, group telemetry
- "force update" was used to mean both denying server compatibility and locking app UI; resolved terms: use **Online Block** for denying **Online Capabilities** and **App Block** for the exceptional update-only UI state.
- "version warning" was used for both an update prompt and denial of server features; resolved terms: use **Update Warning** for the non-blocking prompt and **Online Block** when **Online Capabilities** are denied.
- "message" may mean version compatibility or general communication; resolved: compatibility belongs to the **Release Policy**, while a **Community Message** never changes capability availability.
- "device" in **Device Token** names the calling app install, not a **Board** and not the phone BLE peripheral; resolved: a **Device Token** identifies a caller, while records the app backs up carry no device or install identity of their own.
- "posi switch" and "dual switch" refer to **Posi Sensor** mode in rider language; the firmware field name is an implementation detail.
- "move board" may mean **Remote Tilt** or motor movement while disengaged; resolved term: use **Board Move** for deliberate app-driven movement of a disengaged Board.
4 changes: 4 additions & 0 deletions docs/adr/0005-ride-history-read-paths-stay-precomputed.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ Ride History and profile screens are latency-sensitive. Normal reads must load p
- Existing Ride History may keep older derived values until an explicit maintenance path exists.
- Future recalculation of old summaries must be an intentional maintenance workflow, not part of normal reads.
- Read paths must not mutate durable Ride History as a side effect unless that behavior is documented as maintenance.

## Scope

"Reconstruct" here means replaying raw **Telemetry Samples** to recompute derived values. It does not mean any join at all. Resolving a label or attribute from a small configuration table — a **Board** name from its id, a **Tune Profile** name from its id — is a bounded lookup, not a replay, and this ADR does not forbid it. ADR-0028 relies on that reading.
14 changes: 10 additions & 4 deletions docs/adr/0027-boards-are-tombstoned-never-deleted.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
# Boards Are Tombstoned, Never Deleted

Deleting a **Board** sets `boards.deleted_at` instead of removing the row. The Board disappears from every Rider-facing list, its configuration (Board settings, Board warnings, **Alert Rules**, Last Known Board Config Values) is hard-deleted as before, and its **Ride History** is untouched — as it already was.
Deleting a **Board** sets `boards.deleted_at` instead of removing the row, on the phone and on the Vescape server alike. The Board disappears from every Rider-facing list, its configuration (Board settings, Board warnings, **Alert Rules**) and its decoded config caches (Last Known Board Config Values and any pending change notice) are hard-deleted as before, and its **Ride History** is untouched — as it already was locally, and now as it is on the server too.

The reason is that **Ride History** outlives the Board that produced it. The app has always kept telemetry after a Board delete, but the `boards` row vanishing left those rides pointing at a Board id that resolves to nothing. History could only fall back to the `device_name` snapshotted on each row: a frozen label, not an identity. A tombstone keeps the row resolvable, so a deleted Board's rides still name it and still group by it.

The server makes the same row load-bearing for a different reason: it models telemetry as Board-owned through a composite foreign key with `ON DELETE CASCADE`, so a Board **Delete Action** would have wiped exactly the rides backup exists to preserve — and the phone could never have re-uploaded them, because the missing parent row makes the foreign key refuse the whole **Sync Batch**. A tombstone keeps the parent alive, so the foreign key holds and orphaned **Tune Profiles** a phone re-uploads after a Board delete land instead of wedging the batch.

## Considered Options

- **Cascade** — deleting a Board deletes its Ride History too. Rejected outright: it deletes the thing worth keeping.
- **Cascade** — deleting a Board deletes its Ride History too. Rejected outright: it deletes the thing worth keeping, and contradicts the rule that local storage cleanup never removes anything from the backup.
- **Leave the hard delete and lean on the snapshotted `device_name`.** Rejected because a name is not an identity: renames before the delete produce rides labelled inconsistently, and nothing links a ride back to the Board it came from.
- **Move Board identity onto the history rows** (denormalize more at write time). Rejected as strictly more storage for strictly less: it still cannot answer "which rides came from this Board" after the Board is gone.
- **Drop the foreign key on the server's telemetry tables**, keeping `board_id` as unenforced text. Rejected because it also drops the "a Sync Batch naming an unknown Board is refused whole" guard, which is the server's protection against a half-applied batch.
- **Hard delete plus per-child Delete Actions.** Rejected because it makes one Rider intent into an unbounded list of actions, and still leaves telemetry without a parent.

## Consequences

- `getBoards()` filters `deleted_at IS NULL`. `getBoard(id)` deliberately does not — **Ride History** must still be able to name a deleted Board. Callers that act on a Board rather than describe one (`buildSessionConfig`, `BoardConnectConfig.resolve`) check `deletedAt` and refuse.
- An ordinary upsert never clears an existing tombstone, so deletion is terminal. Only the delete path stamps one, and deleting an already-tombstoned Board is a no-op.
- **Tune Profiles** are deliberately outside the cascade. Tuning work is expensive to recreate and survives its Board; removing one takes its own deletion.
- `boards.deleted_at` is nullable and part of the synced row, so a tombstone reaches the server as an ordinary upsert as well as through its **Sync Action**. The two say different things and are both needed: the row says the Board is deleted, the action says its configuration is gone. Keeping the cascade an explicit, replay-safe action is what stops a dumb upsert from quietly deleting rows in three other tables — the phone writes both in one transaction, stamped with the same ratcheted timestamp (#282).
- On the server the `ON DELETE CASCADE` behind the Board-owned configuration tables stops firing, because nothing is deleted anymore. The Sync Action handler deletes those children explicitly, which makes the server's cascade identical to `deleteBoardWithSettings` rather than merely similar.
- **Tune Profiles** are deliberately outside that cascade on both sides. Tuning work is expensive to recreate and survives its Board; removing one takes its own deletion.
- Telemetry can carry a stable `board_id` instead of keying on the mutable BLE identifier, because the row it points at never disappears. That unblocks the identity half of the `device_name` question (#274); the label half stays governed by ADR-0005.
- Tombstones accumulate. They are one small row per deleted Board, bounded by how many Boards a rider ever owned, so no pruning rule is warranted.
- The server half of this decision — tombstones crossing the wire, and the Board **Delete Action** that carries the configuration cascade — lands with Ride History backup (#276) and is out of scope here.
- Account deletion still removes everything, cascading from the server's own user row. A tombstone is a Board-level intent, not a retention policy.
1 change: 1 addition & 0 deletions docs/agents/issue-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Use one or more app-area labels for filtering:
| `area:legal-mode` | `[Legal Mode]` | Legal Mode UI, jurisdiction speed defaults, speed-warning alerts, and legal board constraints |
| `area:warnings` | `[Warnings]` | Board Warnings: app-authored condition detection, warning registry, rider-facing warnings |
| `area:diagnostics` | `[Diagnostics]` | Debug Recordings, replay tooling, Diagnostic Events, dev-mode debugging surfaces |
| `area:sync` | `[Sync]` | Backup sync — native uploader, Sync Cursors, Sync Actions, Device Token, backup status |
| `area:auth` | `[Auth]` | Clerk sessions, native Device Tokens, credential lifecycle, and endpoint caller policy |
| `area:release` | `[Release]` | release automation, Play tracks, versioning, release notes, and GitHub Releases |
| `area:assets` | `[Assets]` | hosted media, image normalization, upload state, and remote Asset references |
Expand Down
4 changes: 3 additions & 1 deletion modules/vescape-core/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ let expoOwnedSources: Set<String> = [
/// Test-only helpers that are not themselves `XCTestCase` files, so the `*Tests.swift` rule misses
/// them. They use `@testable import VescapeCore` and belong in the test target.
let testSupportSources: Set<String> = [
"replay/ConfigReplayHarness.swift"
"replay/ConfigReplayHarness.swift",
"sync/FakeSyncServer.swift",
"sync/FakeSyncSource.swift",
]

/// Symlinks into `shared/`. The pod bundles all of them through `resource_bundles`; SPM only needs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import expo.modules.vescapecore.alerts.AlertCoordinator
import expo.modules.vescapecore.appstatus.AppStatusCoordinator
import expo.modules.vescapecore.weather.WeatherCoordinator
import expo.modules.vescapecore.auth.NativeAuthCoordinator
import expo.modules.vescapecore.sync.SyncCoordinator
import expo.modules.vescapecore.service.BoardProbeAutoStartGate
import expo.modules.vescapecore.connection.BoardTransport
import expo.modules.vescapecore.connection.BoardTransportDetector
Expand Down Expand Up @@ -89,6 +90,9 @@ private fun Map<String, Any?>.toAlertTestRule(): AlertRuleEntity? {
repeatEverySeconds = normalizedAlertRepeatSeconds((this["repeatEverySeconds"] as? Number)?.toDouble()),
beepCount = normalizedAlertBeepCount((this["beepCount"] as? Number)?.toInt()),
source = null,
// Ephemeral: the preview rule is never persisted, so it has no last-write-wins timestamp to
// carry and never reaches the upload scan.
updatedAt = 0,
)
}

Expand Down Expand Up @@ -183,6 +187,7 @@ class VescapeCoreModule : Module() {
"onNavigation",
"onRouteProgress",
"onWeather",
"onSyncStatus",
)

// Native owns App Status truth; JS mirrors it. Push every successful refresh (late subscribers
Expand Down Expand Up @@ -300,6 +305,26 @@ class VescapeCoreModule : Module() {
CoroutineScope(Dispatchers.IO).launch { BoardWarningRegistry.get(context).emitSnapshot() }
}
OnStopObserving("onBoardWarnings") { stopObserving("onBoardWarnings") }
// Native owns backup state; JS mirrors it. Push every transition, and replay the current one on
// subscribe so a late listener never renders an empty status line.
// @parity /modules/vescape-core/ios/VescapeCoreModule.swift `sendSyncStatus`
// @parity /modules/vescape-core/src/index.ts `SyncStatusEvent`
SyncCoordinator.get(context).onStatusChanged = { status ->
if (shouldEmitToFrontend("onSyncStatus")) {
mainHandler.post {
if (shouldEmitToFrontend("onSyncStatus")) sendEvent("onSyncStatus", status)
}
}
}

OnStartObserving("onSyncStatus") {
startObserving("onSyncStatus")
CoroutineScope(Dispatchers.IO).launch {
val status = SyncCoordinator.get(context).status().toMap()
mainHandler.post { sendEvent("onSyncStatus", status) }
}
}
OnStopObserving("onSyncStatus") { stopObserving("onSyncStatus") }

OnStartObserving("onVescFaults") {
startObserving("onVescFaults")
Expand Down Expand Up @@ -364,6 +389,9 @@ class VescapeCoreModule : Module() {
// Cold start: fetch App Status before JS asks. A foreground event arriving right after is
// coalesced into this request.
AppStatusCoordinator.get(context).refresh()
// The Device Token outlives the process, so a signed-in phone has to pick the uploader back
// up here: provisioning only happens once, and nothing else would start the loop again.
SyncCoordinator.get(context).resumeIfBound()
}

OnActivityEntersForeground {
Expand Down Expand Up @@ -501,6 +529,19 @@ class VescapeCoreModule : Module() {
Function("clearDeviceCredential") {
NativeAuthCoordinator.get(context).clear()
}
// The Rider confirmed the destructive Account change; native performs the ordered transition.
// @parity /modules/vescape-core/ios/VescapeCoreModule.swift `confirmSyncAccountReset`
AsyncFunction("confirmSyncAccountReset") Coroutine {
serverUrl: String,
deviceToken: String,
accountId: String,
->
NativeAuthCoordinator.get(context).confirmAccountReset(serverUrl, deviceToken, accountId)
}
// @parity /modules/vescape-core/ios/VescapeCoreModule.swift `getSyncStatus`
AsyncFunction("getSyncStatus") Coroutine { ->
SyncCoordinator.get(context).status().toMap()
}
// Stable Vescape route keeps the app decoupled from the final store destination.
// @parity /modules/vescape-core/ios/VescapeCoreModule.swift `openAppUpdate`
// @platform-diff Android uses the stable Android download route.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ internal fun withLegalModeOverlay(
soundType = "preset:tick",
createdAt = 0L,
source = null,
// In-memory overlay: no row is ever persisted, so the sync cursor is meaningless here.
updatedAt = 0L,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,16 @@ data class ApiRequest(
/**
* @parity /modules/vescape-core/ios/api/ApiResult.swift `ApiResponse`
*/
data class ApiResponse(val status: Int, val body: String)
data class ApiResponse(
val status: Int,
val body: String,
/**
* Lowercased response headers. Only what a caller has to act on crosses this seam today: a `429`
* carries its delay in `Retry-After`, and guessing one instead would either hammer the server or
* stall a drain far longer than it asked for.
*/
val headers: Map<String, String> = emptyMap(),
)

/**
* The single blocking HTTP seam. Production wires OkHttp; tests wire a fake and never reach the
Expand Down
Loading
Loading