From a166e07d589e9f785e70a74c0f7c1f8d5ee406a3 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sun, 26 Jul 2026 17:57:05 +0200 Subject: [PATCH 01/24] Add updated_at sync cursors to boards, alerts, minute buckets --- .../modules/vescapecore/alerts/AlertEngine.kt | 2 + .../telemetry/AppDataRepository.kt | 18 +- .../telemetry/TelemetryBucketBuilder.kt | 9 +- .../vescapecore/telemetry/TelemetryDao.kt | 14 +- .../telemetry/TelemetryDatabase.kt | 35 +++- .../telemetry/TelemetryEntities.kt | 30 ++- .../vescapecore/alerts/AlertEngineTest.kt | 1 + .../telemetry/ProfileStatsRepositoryTest.kt | 1 + .../telemetry/SyncCursorMigrationTest.kt | 133 ++++++++++++ .../vescape-core/ios/alerts/AlertEngine.swift | 8 +- .../ios/alerts/AlertEngineTests.swift | 3 +- .../ios/telemetry/AppDataRepository.swift | 62 ++++-- .../telemetry/SyncCursorMigrationTests.swift | 194 ++++++++++++++++++ .../ios/telemetry/TelemetryDao.swift | 15 +- .../ios/telemetry/TelemetryDatabase.swift | 29 ++- modules/vescape-core/src/e2eFake.ts | 23 ++- modules/vescape-core/src/index.ts | 34 ++- src/modules/alerts/lib/customAlertRules.ts | 2 +- .../alerts/store/alertPresetStore.test.ts | 4 + src/modules/alerts/store/alertsStore.ts | 19 +- src/modules/board/store/boardStore.test.ts | 2 + src/modules/board/store/boardStore.ts | 6 +- 22 files changed, 594 insertions(+), 50 deletions(-) create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt create mode 100644 modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/alerts/AlertEngine.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/alerts/AlertEngine.kt index 1a12e9aa9..cc8792555 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/alerts/AlertEngine.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/alerts/AlertEngine.kt @@ -138,6 +138,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, ) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt index eb9faa946..be6c8cc12 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt @@ -207,7 +207,7 @@ class AppDataRepository private constructor(private val context: Context) { suspend fun setAlertRuleEnabled(boardId: String, id: String, enabled: Boolean): Unit = withContext(Dispatchers.IO) { - dao.setAlertRuleEnabled(boardId, id, enabled) + dao.setAlertRuleEnabled(boardId, id, enabled, System.currentTimeMillis()) } suspend fun deleteAlertRule(boardId: String, id: String): Unit = withContext(Dispatchers.IO) { @@ -678,6 +678,7 @@ fun BoardEntity.toMap(settings: List): Map { "alertPresetsOnboarded" to (values["alertPresetsOnboarded"] ?: false), "legalMode" to (values["legalMode"] ?: mapOf("enabled" to false)), "link" to link, + "updatedAt" to updatedAt, ) } @@ -749,6 +750,7 @@ fun AlertRuleEntity.toMap(): Map = mapOf( "soundType" to soundType, "createdAt" to createdAt, "source" to source, + "updatedAt" to updatedAt, ) fun TuneProfileEntity.toMap(): Map = mapOf( @@ -922,11 +924,17 @@ private fun Map.normalizedBoardLink(): Map? { ) } -internal fun Map.toBoardEntity(): BoardEntity = BoardEntity( +/** + * Native stamps [BoardEntity.updatedAt] itself rather than trusting the bridge value: it is a sync + * cursor, so it must come from the device clock that already writes `created_at` and must move on + * every upsert, including partial edits that leave `createdAt` untouched. + */ +internal fun Map.toBoardEntity(now: Long = System.currentTimeMillis()): BoardEntity = BoardEntity( id = getString("id"), name = getString("name"), bleId = normalizedBoardLink()?.get("bleId") as? String, createdAt = getLong("createdAt"), + updatedAt = now, ) internal fun Map.toBoardSettingEntities(boardId: String): Pair, List> { @@ -1073,7 +1081,10 @@ private fun parseLegacyMapString(value: String): Map? { }.toMap() } -private fun Map.toAlertRuleEntity(): AlertRuleEntity = AlertRuleEntity( +/** Native stamps [AlertRuleEntity.updatedAt]; see [toBoardEntity] for why the bridge value is ignored. */ +internal fun Map.toAlertRuleEntity( + now: Long = System.currentTimeMillis(), +): AlertRuleEntity = AlertRuleEntity( boardId = getString("boardId"), id = getString("id"), controlId = getString("controlId"), @@ -1083,6 +1094,7 @@ private fun Map.toAlertRuleEntity(): AlertRuleEntity = AlertRuleEn soundType = get("soundType") as? String ?: "default", createdAt = getLong("createdAt"), source = get("source") as? String, + updatedAt = now, ) private fun Map.getString(key: String): String = diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt index b4e5a2c4b..13edd5793 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt @@ -162,7 +162,14 @@ private class MutableBucket( } } - fun toEntity(): TelemetryMinuteBucketEntity = TelemetryMinuteBucketEntity( + /** + * [now] is the incremental-sync cursor stamped on the row, so every append or rebuild that + * reaches the database is visible to cursor sync. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDao.swift `upsertBucket` + */ + fun toEntity(now: Long = System.currentTimeMillis()): TelemetryMinuteBucketEntity = TelemetryMinuteBucketEntity( + updatedAt = now, bucketStartMs = bucketStartMs, deviceId = deviceId, deviceName = deviceName, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 957db0e6c..679d513a9 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -385,8 +385,15 @@ interface TelemetryDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAlertRule(rule: AlertRuleEntity) - @Query("UPDATE alerts SET enabled = :enabled WHERE board_id = :boardId AND id = :id") - suspend fun setAlertRuleEnabled(boardId: String, id: String, enabled: Boolean) + /** + * Targeted toggle. Unlike the `@Insert` upserts it never round-trips an entity, so `updated_at` + * has to move here explicitly — without it, toggling a rule leaves the sync cursor stale and the + * change never reaches the server. + * + * @parity /modules/vescape-core/ios/telemetry/AppDataRepository.swift `setAlertRuleEnabled` + */ + @Query("UPDATE alerts SET enabled = :enabled, updated_at = :updatedAt WHERE board_id = :boardId AND id = :id") + suspend fun setAlertRuleEnabled(boardId: String, id: String, enabled: Boolean, updatedAt: Long) @Query("DELETE FROM alerts WHERE board_id = :boardId AND id = :id") suspend fun deleteAlertRule(boardId: String, id: String) @@ -588,6 +595,9 @@ private fun TelemetryMinuteBucketEntity.merge(next: TelemetryMinuteBucketEntity) }, firstMovingAtMs = mergeNullableMin(firstMovingAtMs, next.firstMovingAtMs), lastMovingAtMs = mergeNullableMax(lastMovingAtMs, next.lastMovingAtMs), + // The merged row is being written now, so `next` carries the fresher stamp. `maxOf` keeps the + // cursor monotonic even if the device clock steps backwards between writes. + updatedAt = maxOf(updatedAt, next.updatedAt), ) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index 2cd691ef6..88877d28f 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -12,7 +12,7 @@ import java.io.File // @parity /modules/vescape-core/ios/VescapeCoreModule.swift internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" -internal const val TELEMETRY_DATABASE_VERSION = 27 +internal const val TELEMETRY_DATABASE_VERSION = 28 @Database( entities = [ @@ -472,6 +472,38 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * Incremental-sync cursor on `boards`, `alerts` and `telemetry_minute_buckets`. The first two + * carried `created_at` only, so a board rename or an alert toggle was invisible to an + * "everything changed since T" query — the shape every other mutable table already supports. + * Buckets are append-and-merge targets with no cursor at all. + * + * Existing rows backfill to the best evidence of when they last changed — `created_at` for + * boards and alerts, `last_sample_at_ms` for buckets — never 0 and never null, so a first sync + * after upgrade reports each row at its true age instead of flooding the server with + * epoch-zero rows. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v28_sync_cursors` + */ + internal val MIGRATION_27_28 = object : Migration(27, 28) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE boards ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0") + db.execSQL("UPDATE boards SET updated_at = created_at") + db.execSQL("CREATE INDEX IF NOT EXISTS index_boards_updated_at ON boards(updated_at)") + + db.execSQL("ALTER TABLE alerts ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0") + db.execSQL("UPDATE alerts SET updated_at = created_at") + db.execSQL("CREATE INDEX IF NOT EXISTS index_alerts_updated_at ON alerts(updated_at)") + + db.execSQL("ALTER TABLE telemetry_minute_buckets ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0") + db.execSQL("UPDATE telemetry_minute_buckets SET updated_at = last_sample_at_ms") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_minute_buckets_updated_at " + + "ON telemetry_minute_buckets(updated_at)", + ) + } + } + /** * One-time file rename from the pre-release "telemetry.db" name. Checkpoints the legacy WAL so * the whole database lives in the main file, then renames it in place. Idempotent: once the new @@ -526,6 +558,7 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, + MIGRATION_27_28, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index bbe809be3..e4c324527 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -109,7 +109,10 @@ data class TelemetryFrameEntity( @Entity( tableName = "telemetry_minute_buckets", primaryKeys = ["bucket_start_ms", "device_id"], - indices = [Index(value = ["bucket_start_ms"])], + indices = [ + Index(value = ["bucket_start_ms"]), + Index(value = ["updated_at"]), + ], ) data class TelemetryMinuteBucketEntity( @ColumnInfo(name = "bucket_start_ms") @@ -170,6 +173,14 @@ data class TelemetryMinuteBucketEntity( val firstMovingAtMs: Long? = null, @ColumnInfo(name = "last_moving_at_ms") val lastMovingAtMs: Long? = null, + /** + * Incremental-sync cursor: wall-clock epoch ms of the last write to this bucket. Distinct from + * [lastSampleAtMs], which tracks the newest *sample* in the bucket — a merge that folds in older + * samples, or a bucket rebuild, changes the row without moving that. Only a write-time stamp + * makes an appended bucket visible to a "everything changed since T" query. + */ + @ColumnInfo(name = "updated_at") + val updatedAt: Long, ) @Entity( @@ -228,6 +239,7 @@ data class DiagnosticEventEntity( tableName = "boards", indices = [ Index(value = ["created_at"]), + Index(value = ["updated_at"]), ], ) data class BoardEntity( @@ -238,6 +250,13 @@ data class BoardEntity( val bleId: String?, @ColumnInfo(name = "created_at") val createdAt: Long, + /** + * Incremental-sync cursor: epoch ms of the last write to this row, from the same clock as + * [createdAt]. Equal to [createdAt] on insert and bumped on every mutation, so the client can ask + * the server for "everything changed since T". Indexed because cursor sync scans on it. + */ + @ColumnInfo(name = "updated_at") + val updatedAt: Long, ) @Entity( @@ -265,6 +284,7 @@ data class BoardSettingEntity( Index(value = ["control_id"]), Index(value = ["enabled"]), Index(value = ["created_at"]), + Index(value = ["updated_at"]), ], ) data class AlertRuleEntity( @@ -286,6 +306,14 @@ data class AlertRuleEntity( * JS authors and regenerates preset rules; native only persists the string. */ val source: String?, + /** + * Incremental-sync cursor: epoch ms of the last write to this row, from the same clock as + * [createdAt]. Equal to [createdAt] on insert and bumped on every mutation — including the + * targeted enable/disable update — so a toggled rule is visible to sync. Indexed because cursor + * sync scans on it. + */ + @ColumnInfo(name = "updated_at") + val updatedAt: Long, ) @Entity( diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/alerts/AlertEngineTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/alerts/AlertEngineTest.kt index 614e15dd4..58d88485e 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/alerts/AlertEngineTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/alerts/AlertEngineTest.kt @@ -29,6 +29,7 @@ class AlertEngineTest { soundType = soundType, createdAt = 0L, source = null, + updatedAt = 0L, ) private fun telemetry( diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt index 1fcdf80ee..f87d50c91 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt @@ -136,5 +136,6 @@ class ProfileStatsRepositoryTest { maxGpsSpeedCentiMps = 9_999, firstMovingAtMs = firstMoving, lastMovingAtMs = lastMoving, + updatedAt = end, ) } diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt new file mode 100644 index 000000000..c9d0e919f --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt @@ -0,0 +1,133 @@ +package expo.modules.vescapecore.telemetry + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.lang.reflect.Proxy + +/** + * Incremental-sync cursors: schema 27→28 adds `updated_at` to `boards`, `alerts` and + * `telemetry_minute_buckets`, backfills it from each table's best evidence of last change, and + * indexes it. Every write path then has to move it. + * + * @parity /modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift + */ +class SyncCursorMigrationTest { + /** Table → the column its pre-28 rows backfill from. */ + private val backfillSource = mapOf( + "boards" to "created_at", + "alerts" to "created_at", + "telemetry_minute_buckets" to "last_sample_at_ms", + ) + + private fun migrationSql(): List { + val sql = mutableListOf() + val db = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + if (method.name == "execSQL") { + sql += args?.firstOrNull() as String + null + } else { + throw UnsupportedOperationException(method.name) + } + } as SupportSQLiteDatabase + TelemetryDatabase.MIGRATION_27_28.migrate(db) + return sql + } + + @Test + fun migrationAddsUpdatedAtColumnAndIndexToEverySyncedTable() { + val sql = migrationSql() + + for (table in backfillSource.keys) { + assertTrue( + "missing updated_at column on $table", + sql.any { it == "ALTER TABLE $table ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0" }, + ) + assertTrue( + "missing updated_at index on $table", + sql.any { + it == "CREATE INDEX IF NOT EXISTS index_${table}_updated_at ON $table(updated_at)" + }, + ) + } + } + + /** + * The backfill is the whole point of shipping this as a migration rather than a plain column add: + * a row left at the `DEFAULT 0` would report epoch zero to the server and get re-synced forever. + */ + @Test + fun migrationBackfillsExistingRowsInsteadOfLeavingThemAtZero() { + val sql = migrationSql() + + for ((table, source) in backfillSource) { + val added = sql.indexOf("ALTER TABLE $table ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0") + val backfilled = sql.indexOf("UPDATE $table SET updated_at = $source") + assertTrue("missing backfill for $table", backfilled >= 0) + // The backfill only works once the column exists. + assertTrue("backfill for $table runs before the column is added", backfilled > added) + } + } + + @Test + fun migrationTargetsTheCurrentSchemaVersion() { + assertEquals(28, TELEMETRY_DATABASE_VERSION) + assertEquals(27, TelemetryDatabase.MIGRATION_27_28.startVersion) + assertEquals(28, TelemetryDatabase.MIGRATION_27_28.endVersion) + } + + /** + * The regression this whole change exists to prevent. `setAlertRuleEnabled` is a targeted UPDATE + * rather than an entity round-trip, so it is the one write path that can silently skip the cursor + * — toggling an alert would then never reach the server. + * + * Asserted against the DAO source because Room's `@Query` has BINARY retention (invisible to + * runtime reflection) and its generated implementation keeps the SQL in a method-local string. + * A JVM unit test has no other handle on the statement Room will actually run. + */ + @Test + fun setAlertRuleEnabledQueryBumpsUpdatedAt() { + val dao = File("src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt").readText() + val query = Regex("""@Query\("(UPDATE alerts SET[^"]*)"\)""").find(dao)?.groupValues?.get(1) + + assertEquals( + "UPDATE alerts SET enabled = :enabled, updated_at = :updatedAt " + + "WHERE board_id = :boardId AND id = :id", + query, + ) + } + + @Test + fun boardAndAlertRuleBridgeShapesCarryTheCursor() { + val board = mapOf( + "id" to "board-1", + "name" to "ADV", + "createdAt" to 1_000L, + // Native ignores a bridge-supplied cursor and stamps its own. + "updatedAt" to 1L, + ).toBoardEntity(now = 2_000L) + + assertEquals(1_000L, board.createdAt) + assertEquals(2_000L, board.updatedAt) + assertEquals(2_000L, board.toMap(emptyList())["updatedAt"]) + + val rule = mapOf( + "boardId" to "board-1", + "id" to "rule-1", + "controlId" to "duty", + "threshold" to 70.0, + "enabled" to true, + "createdAt" to 1_000L, + "updatedAt" to 1L, + ).toAlertRuleEntity(now = 2_000L) + + assertEquals(1_000L, rule.createdAt) + assertEquals(2_000L, rule.updatedAt) + assertEquals(2_000L, rule.toMap()["updatedAt"]) + } +} diff --git a/modules/vescape-core/ios/alerts/AlertEngine.swift b/modules/vescape-core/ios/alerts/AlertEngine.swift index 6999104ba..adb80174d 100644 --- a/modules/vescape-core/ios/alerts/AlertEngine.swift +++ b/modules/vescape-core/ios/alerts/AlertEngine.swift @@ -14,6 +14,10 @@ internal struct AlertRule { /// Free-text provenance tag mirroring TS `AlertRule.source`: `manual` (or nil) or `preset`. /// JS authors and regenerates preset rules; native only persists the string. let source: String? + /// Incremental-sync cursor: epoch ms of the last write to this row, from the same clock as + /// `createdAt`. Equal to `createdAt` on insert and bumped on every mutation — including the + /// targeted enable/disable update — so a toggled rule is visible to sync. + let updatedAt: Int64 } /// Adds Legal Mode's per-Board speed warning to in-memory rules. No Alert Rule row is materialized. @@ -43,7 +47,9 @@ internal func withLegalModeOverlay( enabled: true, soundType: "preset:tick", createdAt: 0, - source: nil + source: nil, + // In-memory overlay: no row is ever persisted, so the sync cursor is meaningless here. + updatedAt: 0 ), ] } diff --git a/modules/vescape-core/ios/alerts/AlertEngineTests.swift b/modules/vescape-core/ios/alerts/AlertEngineTests.swift index 9d098b248..273d6737a 100644 --- a/modules/vescape-core/ios/alerts/AlertEngineTests.swift +++ b/modules/vescape-core/ios/alerts/AlertEngineTests.swift @@ -21,7 +21,8 @@ final class AlertEngineTests: XCTestCase { enabled: true, soundType: soundType, createdAt: 0, - source: nil + source: nil, + updatedAt: 0 ) } diff --git a/modules/vescape-core/ios/telemetry/AppDataRepository.swift b/modules/vescape-core/ios/telemetry/AppDataRepository.swift index ed82ab891..0f7896b2e 100644 --- a/modules/vescape-core/ios/telemetry/AppDataRepository.swift +++ b/modules/vescape-core/ios/telemetry/AppDataRepository.swift @@ -25,9 +25,20 @@ final class AppDataRepository { /// `CoreForegroundService.emitEvent` static — a module-owned emit the repo funnels through. static var onDataChanged: ((String) -> Void)? - private var pool: DatabasePool? { TelemetryDatabase.pool } + /// Test seam, mirroring `TuneProfileStore(dbWriter:)` / `BoardWarningStore(dbWriter:)`: nil in the + /// app so every access follows the shared pool (including a hot-swap after a restore). + private let dbWriter: (any DatabaseWriter)? - private init() {} + private var writer: (any DatabaseWriter)? { dbWriter ?? TelemetryDatabase.pool } + + private init(dbWriter: (any DatabaseWriter)? = nil) { + self.dbWriter = dbWriter + } + + /// In-memory instance for DB-backed tests. The app always uses `shared`. + static func forTesting(dbWriter: any DatabaseWriter) -> AppDataRepository { + AppDataRepository(dbWriter: dbWriter) + } /// Notify JS that persisted data in [scope] changed, so the matching store reloads and stays in /// sync without an app restart. Every mutating method below funnels through here — new writes get @@ -39,13 +50,13 @@ final class AppDataRepository { } private func read(_ fallback: T, _ body: (Database) throws -> T) -> T { - guard let pool else { return fallback } - return (try? pool.read(body)) ?? fallback + guard let writer else { return fallback } + return (try? writer.read(body)) ?? fallback } private func write(_ body: @escaping (Database) throws -> Void) { - guard let pool else { return } - try? pool.write(body) + guard let writer else { return } + try? writer.write(body) } private func nowMs() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) } @@ -56,7 +67,7 @@ final class AppDataRepository { read([]) { db in let boards = try Row.fetchAll( db, - sql: "SELECT id, name, ble_id, transport, created_at FROM boards ORDER BY created_at ASC" + sql: "SELECT id, name, ble_id, transport, created_at, updated_at FROM boards ORDER BY created_at ASC" ) let settings = try Row.fetchAll(db, sql: "SELECT board_id, key, value_json FROM board_settings") var byBoard: [String: [(String, String)]] = [:] @@ -72,7 +83,7 @@ final class AppDataRepository { read(nil) { db in guard let board = try Row.fetchOne( db, - sql: "SELECT id, name, ble_id, transport, created_at FROM boards WHERE id = ? LIMIT 1", + sql: "SELECT id, name, ble_id, transport, created_at, updated_at FROM boards WHERE id = ? LIMIT 1", arguments: [id] ) else { return nil } let settings = try Row.fetchAll( @@ -103,12 +114,18 @@ final class AppDataRepository { // Legal Mode changes only through the dedicated native intent. ] + linkSettings.filter { $0.0 != "transport" } let transport = linkSettings.first { $0.0 == "transport" }?.1 as? String + // One stamp for the board row's sync cursor and every board-setting row written below. Native + // stamps it rather than trusting the bridge value: it must come from the device clock that + // already writes `created_at` and must move on every upsert, including partial edits. let updatedAt = nowMs() write { db in try db.execute( - sql: "INSERT OR REPLACE INTO boards (id, name, ble_id, transport, created_at) VALUES (?, ?, ?, ?, ?)", - arguments: [id, name, bleId, transport, createdAt] + sql: """ + INSERT OR REPLACE INTO boards (id, name, ble_id, transport, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + arguments: [id, name, bleId, transport, createdAt, updatedAt] ) for (key, value) in settings { guard let value, let json = Self.encodeJson(value) else { @@ -186,6 +203,7 @@ final class AppDataRepository { "alertPresetsOnboarded": values["alertPresetsOnboarded"] ?? false, "legalMode": values["legalMode"] ?? ["enabled": false], "link": link, + "updatedAt": row["updated_at"] as Int64, ] } @@ -269,6 +287,7 @@ final class AppDataRepository { "soundType": row["sound_type"] as String, "createdAt": row["created_at"] as Int64, "source": row["source"] as String?, + "updatedAt": row["updated_at"] as Int64, ] } } @@ -294,7 +313,8 @@ final class AppDataRepository { enabled: (row["enabled"] as Int64) != 0, soundType: row["sound_type"] as String, createdAt: row["created_at"] as Int64, - source: row["source"] as String? + source: row["source"] as String?, + updatedAt: row["updated_at"] as Int64 ) } } @@ -312,22 +332,32 @@ final class AppDataRepository { let soundType = rule["soundType"] as? String ?? "default" let createdAt = Self.longValue(rule["createdAt"] ?? nil) ?? nowMs() let source = rule["source"] as? String + // Native stamps the sync cursor rather than trusting the bridge value: it must come from the + // device clock that already writes `created_at` and must move on every upsert. + let updatedAt = nowMs() write { db in try db.execute( sql: """ - INSERT OR REPLACE INTO alerts (board_id, id, control_id, threshold, threshold_max, enabled, sound_type, created_at, source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO alerts (board_id, id, control_id, threshold, threshold_max, enabled, sound_type, created_at, source, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - arguments: [boardId, id, controlId, threshold, thresholdMax, enabled ? 1 : 0, soundType, createdAt, source] + arguments: [ + boardId, id, controlId, threshold, thresholdMax, enabled ? 1 : 0, soundType, createdAt, source, updatedAt, + ] ) } } + /// Targeted toggle. Unlike `upsertAlertRule` it never rewrites the whole row, so `updated_at` has + /// to move here explicitly — without it, toggling a rule leaves the sync cursor stale and the + /// change never reaches the server. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `setAlertRuleEnabled` func setAlertRuleEnabled(_ boardId: String, _ id: String, _ enabled: Bool) { + let updatedAt = nowMs() write { db in try db.execute( - sql: "UPDATE alerts SET enabled = ? WHERE board_id = ? AND id = ?", - arguments: [enabled ? 1 : 0, boardId, id] + sql: "UPDATE alerts SET enabled = ?, updated_at = ? WHERE board_id = ? AND id = ?", + arguments: [enabled ? 1 : 0, updatedAt, boardId, id] ) } } diff --git a/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift b/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift new file mode 100644 index 000000000..7cb132a17 --- /dev/null +++ b/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift @@ -0,0 +1,194 @@ +import XCTest +import GRDB +@testable import VescapeCore + +/// Incremental-sync cursors: the `v28_sync_cursors` migration adds `updated_at` to `boards`, +/// `alerts` and `telemetry_minute_buckets`, backfills it from each table's best evidence of last +/// change, and indexes it. Every write path then has to move it. +/// +/// Runs the real migrator against an in-memory database, stopping at v27 to seed pre-migration rows. +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt +final class SyncCursorMigrationTests: XCTestCase { + private var queue: DatabaseQueue! + + override func setUpWithError() throws { + queue = try DatabaseQueue() + } + + override func tearDownWithError() throws { + queue = nil + } + + /// Migrate up to (and including) the last pre-cursor migration, so the seeded rows look exactly + /// like an installed app's rows before it upgrades. + private func migrateToV27() throws { + try TelemetryDatabase.migrator.migrate(queue, upTo: "v27_alert_board_id") + } + + private func migrateToLatest() throws { + try TelemetryDatabase.migrator.migrate(queue) + } + + private func columnNames(_ table: String) throws -> [String] { + try queue.read { db in try db.columns(in: table).map(\.name) } + } + + private func indexNames(_ table: String) throws -> [String] { + try queue.read { db in try db.indexes(on: table).map(\.name) } + } + + func testV27HasNoCursorColumns() throws { + try migrateToV27() + + for table in ["boards", "alerts", "telemetry_minute_buckets"] { + XCTAssertFalse(try columnNames(table).contains("updated_at"), "\(table) already has a cursor") + } + } + + func testMigrationAddsCursorColumnAndIndexToEverySyncedTable() throws { + try migrateToLatest() + + for table in ["boards", "alerts", "telemetry_minute_buckets"] { + XCTAssertTrue(try columnNames(table).contains("updated_at"), "\(table) is missing updated_at") + XCTAssertTrue( + try indexNames(table).contains("index_\(table)_updated_at"), + "\(table) is missing its updated_at index" + ) + } + } + + /// The backfill is the whole point of shipping this as a migration rather than a plain column add: + /// a row left at the `DEFAULT 0` would report epoch zero to the server and get re-synced forever. + func testBackfillCarriesExistingRowsInsteadOfLeavingThemAtZero() throws { + try migrateToV27() + try queue.write { db in + try db.execute( + sql: "INSERT INTO boards (id, name, ble_id, transport, created_at) VALUES (?, ?, NULL, NULL, ?)", + arguments: ["board-1", "ADV", 1_000] + ) + try db.execute( + sql: """ + INSERT INTO alerts (board_id, id, control_id, threshold, threshold_max, enabled, sound_type, created_at, source) + VALUES (?, ?, ?, ?, NULL, 1, ?, ?, NULL) + """, + arguments: ["board-1", "rule-1", "duty", 70.0, "default", 2_000] + ) + try db.execute( + sql: """ + INSERT INTO telemetry_minute_buckets ( + bucket_start_ms, device_id, device_name, sample_count, first_sample_at_ms, last_sample_at_ms, + sum_abs_speed_centi_kmh, moving_speed_sample_count, sum_moving_abs_speed_centi_kmh, + max_abs_speed_centi_kmh, min_battery_voltage_mv, max_motor_current_abs_ma, + max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, + max_duty_abs_permille, fault_count, first_odometer_cm, last_odometer_cm, + gps_point_count, precise_gps_point_count, gps_distance_cm, max_gps_speed_centi_mps + ) VALUES (?, ?, NULL, 1, ?, ?, 0, 0, 0, 0, NULL, 0, 0, 0, 0, 0, 0, NULL, NULL, 0, 0, 0, NULL) + """, + arguments: [60_000, "board-1", 60_000, 3_000] + ) + } + + try migrateToLatest() + + try queue.read { db in + XCTAssertEqual(try Int64.fetchOne(db, sql: "SELECT updated_at FROM boards"), 1_000) + XCTAssertEqual(try Int64.fetchOne(db, sql: "SELECT updated_at FROM alerts"), 2_000) + // Buckets have no `created_at`; `last_sample_at_ms` is the closest record of last change. + XCTAssertEqual( + try Int64.fetchOne(db, sql: "SELECT updated_at FROM telemetry_minute_buckets"), + 3_000 + ) + } + } + + // MARK: - Write paths + + private func makeRepository() throws -> AppDataRepository { + try migrateToLatest() + return AppDataRepository.forTesting(dbWriter: queue) + } + + private func alertCursor() throws -> Int64? { + try queue.read { db in try Int64.fetchOne(db, sql: "SELECT updated_at FROM alerts") } + } + + func testUpsertsStampTheCursor() throws { + let repo = try makeRepository() + + repo.upsertBoard(["id": "board-1", "name": "ADV", "createdAt": 1_000]) + repo.upsertAlertRule([ + "boardId": "board-1", "id": "rule-1", "controlId": "duty", "threshold": 70.0, + "enabled": true, "createdAt": 1_000, + ]) + + let boardCursor = try queue.read { db in + try Int64.fetchOne(db, sql: "SELECT updated_at FROM boards") + } + // Stamped from the device clock, not from the bridge-supplied `createdAt`. + XCTAssertGreaterThan(boardCursor ?? 0, 1_000) + XCTAssertGreaterThan(try alertCursor() ?? 0, 1_000) + } + + /// The regression this whole change exists to prevent. `setAlertRuleEnabled` is a targeted UPDATE + /// rather than a whole-row rewrite, so it is the one write path that can silently skip the cursor + /// — toggling an alert would then never reach the server. + func testSetAlertRuleEnabledBumpsTheCursor() throws { + let repo = try makeRepository() + repo.upsertAlertRule([ + "boardId": "board-1", "id": "rule-1", "controlId": "duty", "threshold": 70.0, + "enabled": true, "createdAt": 1_000, + ]) + let before = try XCTUnwrap(alertCursor()) + + // The cursor is millisecond-resolution wall clock, so force a tick we can observe. + Thread.sleep(forTimeInterval: 0.005) + repo.setAlertRuleEnabled("board-1", "rule-1", false) + + let after = try XCTUnwrap(alertCursor()) + XCTAssertGreaterThan(after, before) + XCTAssertEqual( + try queue.read { db in try Int64.fetchOne(db, sql: "SELECT enabled FROM alerts") }, + 0 + ) + } + + /// Buckets are append-and-merge targets: a later append has to move the cursor even though it + /// leaves most aggregate columns folded into the existing row. + func testBucketUpsertAdvancesTheCursorOnMerge() throws { + try migrateToLatest() + var bucket = TelemetryBucket(bucketStartMs: 60_000, deviceId: "board-1") + bucket.firstSampleAtMs = 60_000 + bucket.lastSampleAtMs = 60_500 + bucket.sampleCount = 1 + + try queue.write { db in try upsertBucket(db, bucket, now: 1_000) } + try queue.write { db in try upsertBucket(db, bucket, now: 5_000) } + + let (cursor, samples) = try queue.read { db -> (Int64?, Int64?) in + ( + try Int64.fetchOne(db, sql: "SELECT updated_at FROM telemetry_minute_buckets"), + try Int64.fetchOne(db, sql: "SELECT sample_count FROM telemetry_minute_buckets") + ) + } + XCTAssertEqual(cursor, 5_000) + // Sanity: the second write merged into the same row rather than inserting a new one. + XCTAssertEqual(samples, 2) + } + + /// A device clock that steps backwards must never walk the cursor back, or the server would stop + /// seeing later writes. + func testBucketCursorIsMonotonicAcrossClockSteps() throws { + try migrateToLatest() + var bucket = TelemetryBucket(bucketStartMs: 60_000, deviceId: "board-1") + bucket.firstSampleAtMs = 60_000 + bucket.lastSampleAtMs = 60_500 + + try queue.write { db in try upsertBucket(db, bucket, now: 5_000) } + try queue.write { db in try upsertBucket(db, bucket, now: 1_000) } + + XCTAssertEqual( + try queue.read { db in try Int64.fetchOne(db, sql: "SELECT updated_at FROM telemetry_minute_buckets") }, + 5_000 + ) + } +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryDao.swift b/modules/vescape-core/ios/telemetry/TelemetryDao.swift index f0408ee52..c20a555ae 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDao.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDao.swift @@ -83,7 +83,11 @@ internal func insertFrame(_ db: Database, _ state: FullTelemetryState) throws { ) } -internal func upsertBucket(_ db: Database, _ b: TelemetryBucket) throws { +/// [now] is the incremental-sync cursor stamped on the row, so every append or rebuild that reaches +/// the database is visible to cursor sync. `MAX` on conflict keeps it monotonic even if the device +/// clock steps backwards between writes. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt `toEntity` +internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = telemetryNowMs()) throws { try db.execute( sql: """ INSERT INTO telemetry_minute_buckets ( @@ -93,8 +97,8 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket) throws { max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, max_duty_abs_permille, fault_count, first_odometer_cm, last_odometer_cm, gps_point_count, precise_gps_point_count, gps_distance_cm, max_gps_speed_centi_mps, max_temp_mosfet_deci_c, max_temp_motor_deci_c, - first_latitude_e7, first_longitude_e7, first_moving_at_ms, last_moving_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?) + first_latitude_e7, first_longitude_e7, first_moving_at_ms, last_moving_at_ms, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(bucket_start_ms, device_id) DO UPDATE SET device_name=excluded.device_name, sample_count=telemetry_minute_buckets.sample_count + excluded.sample_count, @@ -117,7 +121,8 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket) throws { max_temp_mosfet_deci_c=MAX(telemetry_minute_buckets.max_temp_mosfet_deci_c, excluded.max_temp_mosfet_deci_c), max_temp_motor_deci_c=MAX(telemetry_minute_buckets.max_temp_motor_deci_c, excluded.max_temp_motor_deci_c), first_moving_at_ms=MIN(telemetry_minute_buckets.first_moving_at_ms, excluded.first_moving_at_ms), - last_moving_at_ms=MAX(telemetry_minute_buckets.last_moving_at_ms, excluded.last_moving_at_ms) + last_moving_at_ms=MAX(telemetry_minute_buckets.last_moving_at_ms, excluded.last_moving_at_ms), + updated_at=MAX(telemetry_minute_buckets.updated_at, excluded.updated_at) """, arguments: [ b.bucketStartMs, b.deviceId, b.deviceName, b.sampleCount, b.firstSampleAtMs, b.lastSampleAtMs, @@ -125,7 +130,7 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket) throws { b.minBatteryVoltageMv, b.maxMotorCurrentAbsMa, b.maxBatteryCurrentAbsMa, b.batteryUsedWhMilli, b.batteryRegenWhMilli, b.maxDutyAbsPermille, b.faultCount, b.firstOdometerCm, b.lastOdometerCm, b.gpsPointCount, b.preciseGpsPointCount, b.maxGpsSpeedCentiMps, b.maxTempMosfetDeciC, - b.maxTempMotorDeciC, b.firstLatitudeE7, b.firstLongitudeE7, b.firstMovingAtMs, b.lastMovingAtMs, + b.maxTempMotorDeciC, b.firstLatitudeE7, b.firstLongitudeE7, b.firstMovingAtMs, b.lastMovingAtMs, now, ] ) } diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 0f4d6dae2..8569cf3ad 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -115,7 +115,9 @@ enum TelemetryDatabase { } } - private static var migrator: DatabaseMigrator { + /// Internal rather than private so migration tests can replay the same migrator against an + /// in-memory database instead of re-declaring the schema. + static var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() migrator.registerMigration("v1") { db in @@ -416,6 +418,31 @@ enum TelemetryDatabase { """) } + // MARK: Incremental-sync cursors + // `boards` and `alerts` carried `created_at` only, so a board rename or an alert toggle was + // invisible to an "everything changed since T" query — the shape every other mutable table + // already supports. `telemetry_minute_buckets` is an append-and-merge target with no cursor at all. + // + // Existing rows backfill to the best evidence of when they last changed — `created_at` for boards + // and alerts, `last_sample_at_ms` for buckets — never 0 and never null, so a first sync after + // upgrade reports each row at its true age instead of flooding the server with epoch-zero rows. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_27_28` + migrator.registerMigration("v28_sync_cursors") { db in + let backfillSource = [ + "boards": "created_at", + "alerts": "created_at", + "telemetry_minute_buckets": "last_sample_at_ms", + ] + for (table, source) in backfillSource.sorted(by: { $0.key < $1.key }) { + let hasUpdatedAt = try db.columns(in: table).contains { $0.name == "updated_at" } + if !hasUpdatedAt { + try db.execute(sql: "ALTER TABLE \(table) ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0") + try db.execute(sql: "UPDATE \(table) SET updated_at = \(source)") + } + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_\(table)_updated_at ON \(table)(updated_at)") + } + } + return migrator } } diff --git a/modules/vescape-core/src/e2eFake.ts b/modules/vescape-core/src/e2eFake.ts index 22b34e85f..bc74b66e1 100644 --- a/modules/vescape-core/src/e2eFake.ts +++ b/modules/vescape-core/src/e2eFake.ts @@ -3,6 +3,7 @@ import type { EventSubscription } from 'expo-modules-core' import type { AppSettings, Board, + BoardInput, BoardProbeProgressEvent, BoardProbeResult, DeviceFoundEvent, @@ -679,12 +680,14 @@ export const e2eFake = { return [...e2eBoards] }, - upsertBoard(board: Board): void { - const index = e2eBoards.findIndex((b) => b.id === board.id) + upsertBoard(board: BoardInput): void { + // Stand in for native: the sync cursor is stamped by the store on every write, never by the caller. + const stored: Board = { ...board, updatedAt: Date.now() } + const index = e2eBoards.findIndex((b) => b.id === stored.id) if (index >= 0) { - e2eBoards[index] = board + e2eBoards[index] = stored } else { - e2eBoards.push(board) + e2eBoards.push(stored) } }, @@ -702,13 +705,17 @@ export const e2eFake = { }, seedE2EData(flow: string): void { + // Seeded rows stand in for freshly inserted ones, where the sync cursor equals `createdAt`. + const seededAt = Date.now() + if (flow === 'connect-board') { const boardId = 'e2e-board-1' const board: Board = { id: boardId, name: 'E2E Board', description: 'Seeded by Maestro', - createdAt: Date.now(), + createdAt: seededAt, + updatedAt: seededAt, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', @@ -729,7 +736,8 @@ export const e2eFake = { id: boardId, name: 'E2E History Board', description: 'Seeded by Maestro', - createdAt: Date.now(), + createdAt: seededAt, + updatedAt: seededAt, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', @@ -751,7 +759,8 @@ export const e2eFake = { id: boardId, name: 'E2E Privacy Board', description: 'Seeded by Maestro', - createdAt: Date.now(), + createdAt: seededAt, + updatedAt: seededAt, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 3380e7fb3..78a56c412 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -163,11 +163,19 @@ export interface BoardLink { refloatBaseVersion?: string } +// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `BoardEntity` +// @parity /modules/vescape-core/ios/telemetry/AppDataRepository.swift `composeBoard` export interface Board { id: string name: string description: string | null createdAt: number + /** + * 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 batteryConfig: BatteryConfig | null /** Last Battery SoC Estimate persisted natively; survives full app kill. `undefined` before first session. */ lastBattery?: LastBattery | null @@ -207,6 +215,12 @@ export interface Board { link: BoardLink | null } +/** + * Write shape for {@link upsertBoard}. Native stamps `updatedAt` from its own clock on every write, + * so callers never author it — a board that has never been persisted has no cursor yet. + */ +export type BoardInput = Omit + export interface LastBattery { percent: number voltage: number | null @@ -255,6 +269,12 @@ export interface AlertRule { enabled: boolean soundType: AlertSoundType createdAt: number + /** + * Incremental-sync cursor: epoch ms of the last write to this rule, from the same clock as + * {@link createdAt}. Native stamps it on every upsert and on the enable/disable toggle, so a + * value sent from JS is ignored — read it, do not author it. + */ + updatedAt: number /** * Provenance tag. `manual` (or absent) = rider-authored. `preset` rules are generated + owned * by JS orchestration and regenerated wholesale; native persists the string opaquely. @@ -262,6 +282,12 @@ export interface AlertRule { source?: 'manual' | 'preset' } +/** + * Write shape for {@link upsertAlertRule}. Native stamps `updatedAt` from its own clock on every + * write, so callers never author it — a rule that has never been persisted has no cursor yet. + */ +export type AlertRuleInput = Omit + export type PrivacyZonePreset = 'home' | 'work' | 'custom' export interface PrivacyZone { @@ -1407,10 +1433,10 @@ type VescapeCoreNativeModule = NativeEventEmitter & { deleteTelemetryRange(options: TelemetryDeleteRangeOptions): Promise clearTelemetryHistory(): Promise getBoards(): Promise - upsertBoard(board: Board): Promise + upsertBoard(board: BoardInput): Promise deleteBoard(id: string): Promise getAlertRules(boardId: string): Promise - upsertAlertRule(rule: AlertRule): Promise + upsertAlertRule(rule: AlertRuleInput): Promise setAlertRuleEnabled(boardId: string, id: string, enabled: boolean): Promise deleteAlertRule(boardId: string, id: string): Promise getPrivacyZones(): Promise @@ -2012,7 +2038,7 @@ export async function getBoards(): Promise { return native.getBoards() } -export async function upsertBoard(board: Board): Promise { +export async function upsertBoard(board: BoardInput): Promise { if (E2E_ENABLED) { e2eFake.upsertBoard(board) return @@ -2028,7 +2054,7 @@ export async function getAlertRules(boardId: string): Promise { return native.getAlertRules(boardId) } -export async function upsertAlertRule(rule: AlertRule): Promise { +export async function upsertAlertRule(rule: AlertRuleInput): Promise { return native.upsertAlertRule(rule) } diff --git a/src/modules/alerts/lib/customAlertRules.ts b/src/modules/alerts/lib/customAlertRules.ts index d33808fbd..b9d5232d7 100644 --- a/src/modules/alerts/lib/customAlertRules.ts +++ b/src/modules/alerts/lib/customAlertRules.ts @@ -17,7 +17,7 @@ import { * {@link DraftAlertRule} is that shape; the live adapter maps its store rules down to it * and the wizard holds them in memory until `save()` stamps the new Board's id on. */ -export type DraftAlertRule = Omit +export type DraftAlertRule = Omit /** * Take ownership of a level: expand it exactly as the preset generator would, then hand the diff --git a/src/modules/alerts/store/alertPresetStore.test.ts b/src/modules/alerts/store/alertPresetStore.test.ts index 402c381f3..7a18b646b 100644 --- a/src/modules/alerts/store/alertPresetStore.test.ts +++ b/src/modules/alerts/store/alertPresetStore.test.ts @@ -28,6 +28,7 @@ function makeBoard(overrides?: { name: 'Board', description: null, createdAt: 1, + updatedAt: 1, // Honor an explicit `null` (invalid config) — `??` would swallow it back to the valid default. batteryConfig: overrides && 'batteryConfig' in overrides ? (overrides.batteryConfig ?? null) : VALID_BATTERY, @@ -126,6 +127,7 @@ test('manual rules and other metrics survive a preset regeneration', async () => enabled: true, soundType: 'preset:beep', createdAt: 1, + updatedAt: 1, source: 'manual', } const otherPreset: AlertRule = { @@ -137,6 +139,7 @@ test('manual rules and other metrics survive a preset regeneration', async () => enabled: true, soundType: 'preset:tick', createdAt: 1, + updatedAt: 1, source: 'preset', } const { useAlertsStore, useAlertPresetStore } = await setup({ seedRules: [manual, otherPreset] }) @@ -185,6 +188,7 @@ test('editing an inactive board regenerates only that board rules', async () => enabled: true, soundType: 'preset:tick', createdAt: 1, + updatedAt: 1, source: 'preset', } getAlertRules.mockImplementation(async (boardId: string) => diff --git a/src/modules/alerts/store/alertsStore.ts b/src/modules/alerts/store/alertsStore.ts index 0f5325b44..613b128fb 100644 --- a/src/modules/alerts/store/alertsStore.ts +++ b/src/modules/alerts/store/alertsStore.ts @@ -4,12 +4,20 @@ import { getAlertRules, setAlertRuleEnabled, type AlertRule, + type AlertRuleInput, type AlertSoundType, upsertAlertRule, } from 'vescape-core' import { generateId } from '@/helpers/id' -export type { AlertRule, AlertSoundType } from 'vescape-core' +export type { AlertRule, AlertRuleInput, AlertSoundType } from 'vescape-core' + +/** + * 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() }) interface AlertsState { /** @@ -36,7 +44,7 @@ interface AlertsActions { thresholdMax: number | null, soundType: AlertSoundType, ): void - upsert(rule: AlertRule): Promise + upsert(rule: AlertRuleInput): Promise setEnabled(id: string, enabled: boolean): Promise toggle(id: string): Promise remove(id: string): Promise @@ -67,7 +75,7 @@ export const useAlertsStore = create((set, get) => add(controlId, threshold, thresholdMax = null, soundType = 'preset:beep') { const boardId = get().boardId if (!boardId) return - const rule: AlertRule = { + const rule = withLocalCursor({ boardId, id: generateId(), controlId, @@ -76,7 +84,7 @@ export const useAlertsStore = create((set, get) => enabled: true, soundType, createdAt: Date.now(), - } + }) set((s) => ({ rules: [...s.rules, rule] })) void upsertAlertRule(rule) }, @@ -92,10 +100,11 @@ export const useAlertsStore = create((set, get) => async upsert(rule) { // Only reflect the rule locally when it belongs to the bound Board; always persist natively. if (rule.boardId === get().boardId) { + const local = withLocalCursor(rule) set((s) => { const exists = s.rules.some((r) => r.id === rule.id) return { - rules: exists ? s.rules.map((r) => (r.id === rule.id ? rule : r)) : [...s.rules, rule], + rules: exists ? s.rules.map((r) => (r.id === rule.id ? local : r)) : [...s.rules, local], } }) } diff --git a/src/modules/board/store/boardStore.test.ts b/src/modules/board/store/boardStore.test.ts index 2e741390f..c9704f03a 100644 --- a/src/modules/board/store/boardStore.test.ts +++ b/src/modules/board/store/boardStore.test.ts @@ -101,6 +101,7 @@ test('stored Board Link survives a store reload from native boards', async () => name: 'ADV', description: null, createdAt: 1, + updatedAt: 1, batteryConfig: null, link: null, } @@ -130,6 +131,7 @@ test('updated battery config survives a store reload from native boards', async name: 'ADV', description: null, createdAt: 1, + updatedAt: 1, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', diff --git a/src/modules/board/store/boardStore.ts b/src/modules/board/store/boardStore.ts index 8142d3432..546271b86 100644 --- a/src/modules/board/store/boardStore.ts +++ b/src/modules/board/store/boardStore.ts @@ -86,11 +86,15 @@ export const useBoardStore = create((set, get) => ({ alertPreset, alertPresetsOnboarded, }) { + const now = Date.now() const board: Board = { id: generateId(), name, description: description ?? null, - createdAt: Date.now(), + createdAt: now, + // Native owns `updatedAt` (the incremental-sync cursor) and stamps it from its own clock on + // the upsert below; this optimistic value only holds until the next load() replaces the row. + updatedAt: now, batteryConfig: batteryConfig ?? DEFAULT_BATTERY_CONFIG, topSpeedKmh, alertPreset: alertPreset ?? null, From 78366463cf5d5120a386ecbae01e44a235141536 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sun, 26 Jul 2026 18:20:32 +0200 Subject: [PATCH 02/24] Document cursor clamp semantics on bucket merge --- .../expo/modules/vescapecore/telemetry/TelemetryDao.kt | 10 ++++++++-- modules/vescape-core/ios/telemetry/TelemetryDao.swift | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 679d513a9..dc9973a70 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -595,8 +595,14 @@ private fun TelemetryMinuteBucketEntity.merge(next: TelemetryMinuteBucketEntity) }, firstMovingAtMs = mergeNullableMin(firstMovingAtMs, next.firstMovingAtMs), lastMovingAtMs = mergeNullableMax(lastMovingAtMs, next.lastMovingAtMs), - // The merged row is being written now, so `next` carries the fresher stamp. `maxOf` keeps the - // cursor monotonic even if the device clock steps backwards between writes. + // The merged row is being written now, so `next` normally carries the fresher stamp. `maxOf` + // clamps a backwards device-clock step: the value stays at the last real write time instead of + // regressing below a cursor already synced. Bounded and self-correcting — once the clock passes + // the old value again the stamp is truthful, unlike a ratcheting `existing + 1` counter, which + // would permanently inflate this row against other devices under last-write-wins. + // + // Not a completeness guarantee: a frozen stamp is only picked up because the sync query is + // `updated_at >= watermark`. Clock-rewind completeness is tracked separately (see #275). updatedAt = maxOf(updatedAt, next.updatedAt), ) } diff --git a/modules/vescape-core/ios/telemetry/TelemetryDao.swift b/modules/vescape-core/ios/telemetry/TelemetryDao.swift index c20a555ae..e2fc3181f 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDao.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDao.swift @@ -84,8 +84,14 @@ internal func insertFrame(_ db: Database, _ state: FullTelemetryState) throws { } /// [now] is the incremental-sync cursor stamped on the row, so every append or rebuild that reaches -/// the database is visible to cursor sync. `MAX` on conflict keeps it monotonic even if the device -/// clock steps backwards between writes. +/// the database is visible to cursor sync. `MAX` on conflict clamps a backwards device-clock step: +/// the value stays at the last real write time instead of regressing below a cursor already synced. +/// Bounded and self-correcting — once the clock passes the old value again the stamp is truthful, +/// unlike a ratcheting `existing + 1` counter, which would permanently inflate this row against +/// other devices under last-write-wins. +/// +/// Not a completeness guarantee: a frozen stamp is only picked up because the sync query is +/// `updated_at >= watermark`. Clock-rewind completeness is tracked separately (see #275). /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt `toEntity` internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = telemetryNowMs()) throws { try db.execute( From e83689164513f32373cf4de8df0bbbc1f71cb76f Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Mon, 27 Jul 2026 05:00:18 +0200 Subject: [PATCH 03/24] Update docs --- CONTEXT.md | 8 +++++++ ...ide-history-read-paths-stay-precomputed.md | 4 ++++ ...027-boards-are-tombstoned-never-deleted.md | 22 +++++++++++++++++++ .../0028-telemetry-is-keyed-on-board-id.md | 17 ++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 docs/adr/0027-boards-are-tombstoned-never-deleted.md create mode 100644 docs/adr/0028-telemetry-is-keyed-on-board-id.md diff --git a/CONTEXT.md b/CONTEXT.md index ae52bff6a..192fdd830 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -264,6 +264,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 @@ -353,6 +357,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. @@ -404,5 +411,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. diff --git a/docs/adr/0005-ride-history-read-paths-stay-precomputed.md b/docs/adr/0005-ride-history-read-paths-stay-precomputed.md index d467b2ab5..6382da9ce 100644 --- a/docs/adr/0005-ride-history-read-paths-stay-precomputed.md +++ b/docs/adr/0005-ride-history-read-paths-stay-precomputed.md @@ -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. diff --git a/docs/adr/0027-boards-are-tombstoned-never-deleted.md b/docs/adr/0027-boards-are-tombstoned-never-deleted.md new file mode 100644 index 000000000..e8f68ca7f --- /dev/null +++ b/docs/adr/0027-boards-are-tombstoned-never-deleted.md @@ -0,0 +1,22 @@ +# Boards Are Tombstoned, Never Deleted + +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**) is hard-deleted as before, and its **Ride History** is untouched — which is what it already was locally, and now what 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 `deleteBoardWithSettings`, but the server 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 row alive, so the foreign key holds, history survives on both sides, and orphaned **Tune Profiles** a phone re-uploads after a Board delete land instead of wedging the batch. + +## Considered Options + +- **Drop the foreign key on the 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. +- **Cascade on the app side too** — deleting a Board deletes its Ride History locally. Rejected outright: it deletes the thing the feature exists to protect, and contradicts the rule that local storage cleanup never removes anything from the backup. +- **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 + +- `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 **Delete Action**. +- `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`) check `deletedAt` and refuse. +- On the server the `ON DELETE CASCADE` behind the Board-owned configuration tables stops firing, because nothing is deleted anymore. The Delete 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 Delete Action. +- Telemetry can now 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 — whether a Board name is still denormalized onto telemetry rows — is unchanged here and stays governed by ADR-0005. +- Account deletion still removes everything, cascading from the server's own user row. A tombstone is a Board-level intent, not a retention policy. diff --git a/docs/adr/0028-telemetry-is-keyed-on-board-id.md b/docs/adr/0028-telemetry-is-keyed-on-board-id.md new file mode 100644 index 000000000..6ca12b0e7 --- /dev/null +++ b/docs/adr/0028-telemetry-is-keyed-on-board-id.md @@ -0,0 +1,17 @@ +# Telemetry Is Keyed on Board Id, Not on the BLE Identifier + +`telemetry_frames` and `telemetry_minute_buckets` key on `board_id` and no longer carry `device_id` (the BLE identifier) or `device_name` (the **Board** name denormalized at capture time). The Board id is already known at capture — `SessionConfig` carries `appBoardId` alongside `deviceId` — it simply was not written down. Board names on **Ride History** are resolved by looking the Board up by id. Resolves issue #274. + +The BLE identifier was never an identity. It is nullable, it moves when a Board is re-linked to a different peripheral, and two different peripherals over a Board's lifetime produced two unjoinable halves of one Board's history. The denormalized name existed to survive that, and to survive Board deletion — but ADR-0027 makes Boards tombstones that never disappear, so the lookup always resolves and the reason for the copy is gone. + +The decisive argument came from backup. The server stores frames and buckets keyed on `boardId` and does not accept `deviceId` or `deviceName` for them, so the denormalized name is data that is never backed up. Keeping it would mean a restored app resolves history labels by lookup while the app that made the backup reads a column — two label sources, where the one that must work is the one the column does not feed. + +`telemetry_markers`, `diagnostic_events` and `metric_exclusion_ranges` are unchanged: they keep `device_id` and `device_name`, because that is what crosses the wire for them and they are low-cardinality display rows, not a per-sample cost. + +## Consequences + +- Migration adds `board_id`, backfilled by matching `boards.ble_id` to `device_id`, then drops both columns. Minute buckets move their primary key from `(bucket_start_ms, device_id)` to `(bucket_start_ms, board_id)`, which is what the server already uses. +- Rows that backfill to no Board — telemetry from Boards hard-deleted before ADR-0027, or whose BLE identifier moved on a re-link — would otherwise lose both their identity and their label. The migration mints one tombstoned Board per unresolved `device_id`, named from the historical `device_name`, so the history keeps a label, stays joinable, and can be backed up. A tombstoned Board never appears in the Rider's Board list. +- Renaming a Board now retroactively relabels its **Ride History**. Previously history kept the name the Board carried at ride time. This is the intended reading: it is the same Board. +- Read paths resolve the Board name by lookup rather than reading it off the sample row. Permitted by ADR-0005, whose "no reconstruction on read" rule is about replaying raw **Telemetry Samples**, not about bounded configuration lookups. +- Query keys that meant "this Board" while saying `device_id` now say `board_id` — the bucket key, and the frame and bucket range reads. From 23b7f0844083a3009f78341362e61407f112d761 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Mon, 27 Jul 2026 05:02:30 +0200 Subject: [PATCH 04/24] sync_seq split (#275) --- docs/agents/issue-tracker.md | 1 + .../vescapecore/telemetry/TelemetryDao.kt | 115 ++++++++++++--- .../telemetry/TelemetryDatabase.kt | 38 ++++- .../telemetry/TelemetryEntities.kt | 69 +++++++-- .../telemetry/SyncCursorMigrationTest.kt | 130 +++++++++++++++-- .../ios/telemetry/AppDataRepository.swift | 51 ++++--- .../telemetry/SyncCursorMigrationTests.swift | 135 +++++++++++++++++- .../ios/telemetry/TelemetryDao.swift | 67 +++++++-- .../ios/telemetry/TelemetryDatabase.swift | 36 +++++ 9 files changed, 575 insertions(+), 67 deletions(-) diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index efbe2af2a..d9aa79310 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -82,6 +82,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 — native fault-code detection, warning registry, rider-facing warning surface | | `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 | When a PRD or issue-planning skill creates or starts using a new app-area label, update this table in the same turn. Add the label, title prefix, and a short "Use for" description so future PRDs and implementation issues can reuse the prefix consistently. diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index dc9973a70..eccc3c9e3 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -110,15 +110,41 @@ interface TelemetryDao { @Transaction suspend fun upsertBuckets(buckets: Collection) { for (bucket in buckets) { - val existing = getBucket(bucket.bucketStartMs, bucket.deviceId) + // A merge rewrites a row the scan may already have passed, so the seq moves on both branches. + val next = bucket.copy(syncSeq = nextSyncSeq(SYNC_SEQ_MINUTE_BUCKETS)) + val existing = getBucket(next.bucketStartMs, next.deviceId) if (existing == null) { - insertBucket(bucket) + insertBucket(next) } else { - updateBucket(existing.merge(bucket)) + updateBucket(existing.merge(next)) } } } + @Query("INSERT OR IGNORE INTO sync_sequences (name, last_value) VALUES (:name, 0)") + suspend fun seedSyncSequence(name: String) + + @Query("UPDATE sync_sequences SET last_value = last_value + 1 WHERE name = :name") + suspend fun bumpSyncSequence(name: String) + + @Query("SELECT last_value FROM sync_sequences WHERE name = :name") + suspend fun getSyncSequence(name: String): Long? + + /** + * Hands out the next Sync Cursor position for [name]. Bump-then-read rather than read-then-bump so + * two writes racing inside the same database can never be handed the same number; both statements + * run in the caller's transaction. + * + * Seeds the row first because a fresh install builds the schema from the entities and never runs + * the migration that inserts it. + */ + @Transaction + suspend fun nextSyncSeq(name: String): Long { + seedSyncSequence(name) + bumpSyncSequence(name) + return getSyncSequence(name) ?: 0L + } + @Transaction suspend fun insertBatch( frames: List, @@ -340,7 +366,25 @@ interface TelemetryDao { suspend fun getBoard(id: String): BoardEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBoard(board: BoardEntity) + suspend fun insertBoardRow(board: BoardEntity) + + @Query("SELECT updated_at FROM boards WHERE id = :id") + suspend fun getBoardUpdatedAt(id: String): Long? + + /** + * Stamps both sync columns before the row lands: a fresh `sync_seq` so the upload scan sees this + * write, and a ratcheted `updated_at` so the server keeps it. Caller-supplied values for either + * are overwritten — see [SyncSequenceEntity] and [BoardEntity.updatedAt]. + */ + @Transaction + suspend fun upsertBoard(board: BoardEntity) { + insertBoardRow( + board.copy( + updatedAt = ratchetUpdatedAt(getBoardUpdatedAt(board.id), board.updatedAt), + syncSeq = nextSyncSeq(SYNC_SEQ_BOARDS), + ), + ) + } @Query("SELECT * FROM board_settings WHERE board_id = :boardId") suspend fun getBoardSettings(boardId: String): List @@ -383,17 +427,47 @@ interface TelemetryDao { suspend fun getEnabledAlertRules(boardId: String): List @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertAlertRule(rule: AlertRuleEntity) + suspend fun insertAlertRuleRow(rule: AlertRuleEntity) + + @Query("SELECT updated_at FROM alerts WHERE board_id = :boardId AND id = :id") + suspend fun getAlertRuleUpdatedAt(boardId: String, id: String): Long? + + /** Stamps both sync columns; see [upsertBoard]. */ + @Transaction + suspend fun upsertAlertRule(rule: AlertRuleEntity) { + insertAlertRuleRow( + rule.copy( + updatedAt = ratchetUpdatedAt(getAlertRuleUpdatedAt(rule.boardId, rule.id), rule.updatedAt), + syncSeq = nextSyncSeq(SYNC_SEQ_ALERTS), + ), + ) + } /** - * Targeted toggle. Unlike the `@Insert` upserts it never round-trips an entity, so `updated_at` - * has to move here explicitly — without it, toggling a rule leaves the sync cursor stale and the - * change never reaches the server. + * Targeted toggle. Unlike the `@Insert` upserts it never round-trips an entity, so both sync + * columns have to move here explicitly — without them, toggling a rule leaves it invisible to the + * upload scan and the change never reaches the server. * - * @parity /modules/vescape-core/ios/telemetry/AppDataRepository.swift `setAlertRuleEnabled` + * The `MAX(updated_at + 1, :updatedAt)` fold is the same ratchet [upsertBoard] applies, expressed + * in SQL because the row is already being read by the `WHERE`. */ - @Query("UPDATE alerts SET enabled = :enabled, updated_at = :updatedAt WHERE board_id = :boardId AND id = :id") - suspend fun setAlertRuleEnabled(boardId: String, id: String, enabled: Boolean, updatedAt: Long) + @Query( + "UPDATE alerts SET enabled = :enabled, updated_at = MAX(updated_at + 1, :updatedAt), " + + "sync_seq = :syncSeq WHERE board_id = :boardId AND id = :id", + ) + suspend fun setAlertRuleEnabledRow( + boardId: String, + id: String, + enabled: Boolean, + updatedAt: Long, + syncSeq: Long, + ) + + /** @parity /modules/vescape-core/ios/telemetry/AppDataRepository.swift `setAlertRuleEnabled` */ + @Transaction + suspend fun setAlertRuleEnabled(boardId: String, id: String, enabled: Boolean, updatedAt: Long) { + setAlertRuleEnabledRow(boardId, id, enabled, updatedAt, nextSyncSeq(SYNC_SEQ_ALERTS)) + } @Query("DELETE FROM alerts WHERE board_id = :boardId AND id = :id") suspend fun deleteAlertRule(boardId: String, id: String) @@ -597,16 +671,25 @@ private fun TelemetryMinuteBucketEntity.merge(next: TelemetryMinuteBucketEntity) lastMovingAtMs = mergeNullableMax(lastMovingAtMs, next.lastMovingAtMs), // The merged row is being written now, so `next` normally carries the fresher stamp. `maxOf` // clamps a backwards device-clock step: the value stays at the last real write time instead of - // regressing below a cursor already synced. Bounded and self-correcting — once the clock passes - // the old value again the stamp is truthful, unlike a ratcheting `existing + 1` counter, which - // would permanently inflate this row against other devices under last-write-wins. + // regressing. No `+ 1` ratchet here, unlike boards and alerts — the server writes this table + // with an unconditional upsert, so a stale stamp is never grounds for rejecting the row. // - // Not a completeness guarantee: a frozen stamp is only picked up because the sync query is - // `updated_at >= watermark`. Clock-rewind completeness is tracked separately (see #275). + // Completeness is [syncSeq]'s job, not this column's. updatedAt = maxOf(updatedAt, next.updatedAt), + syncSeq = next.syncSeq, ) } +/** + * The write-time fold behind [BoardEntity.updatedAt]: never below the value already stored, and + * strictly above it whenever the clock fails to be. + * + * `+ 1` rather than a plain `maxOf` because the server keeps the stored row unless the incoming + * stamp is strictly newer. Freezing at the old value would satisfy the scan and still lose the edit. + */ +internal fun ratchetUpdatedAt(previous: Long?, now: Long): Long = + if (previous == null) now else maxOf(previous + 1, now) + private fun mergeNullableSums(a: Int?, b: Int?): Int? { if (a == null && b == null) return null return (a ?: 0) + (b ?: 0) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index 88877d28f..9fd6bbab1 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -12,7 +12,7 @@ import java.io.File // @parity /modules/vescape-core/ios/VescapeCoreModule.swift internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" -internal const val TELEMETRY_DATABASE_VERSION = 28 +internal const val TELEMETRY_DATABASE_VERSION = 29 @Database( entities = [ @@ -30,6 +30,7 @@ internal const val TELEMETRY_DATABASE_VERSION = 28 PrivacyZoneEntity::class, MapPointEntity::class, BoardWarningEntity::class, + SyncSequenceEntity::class, ], version = TELEMETRY_DATABASE_VERSION, exportSchema = false, @@ -504,6 +505,40 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * Splits the Sync Cursor off the last-write-wins timestamp (#275). `sync_seq` is a device-local + * counter the upload scan runs on; `updated_at` keeps its wall-clock meaning and stays the value + * the server compares. Scanning a counter is what makes the scan complete under a device clock + * that steps backwards, which an `updated_at >= watermark` scan is not. + * + * Existing rows backfill from `rowid`: nothing has ever been uploaded, so any strictly + * increasing assignment works, and `rowid` gives one for free without an O(n²) self-join over + * tables that hold a row per ridden minute. Each counter then starts above every assigned value. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v29_sync_seq` + */ + internal val MIGRATION_28_29 = object : Migration(28, 29) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS sync_sequences ( + name TEXT NOT NULL PRIMARY KEY, + last_value INTEGER NOT NULL + ) + """.trimIndent(), + ) + for (table in SYNC_SEQ_TABLES) { + db.execSQL("ALTER TABLE $table ADD COLUMN sync_seq INTEGER NOT NULL DEFAULT 0") + db.execSQL("UPDATE $table SET sync_seq = rowid") + db.execSQL("CREATE INDEX IF NOT EXISTS index_${table}_sync_seq ON $table(sync_seq)") + db.execSQL( + "INSERT OR REPLACE INTO sync_sequences (name, last_value) " + + "VALUES ('$table', (SELECT COALESCE(MAX(sync_seq), 0) FROM $table))", + ) + } + } + } + /** * One-time file rename from the pre-release "telemetry.db" name. Checkpoints the legacy WAL so * the whole database lives in the main file, then renames it in place. Idempotent: once the new @@ -559,6 +594,7 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, + MIGRATION_28_29, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index e4c324527..fffe80ff4 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -112,6 +112,7 @@ data class TelemetryFrameEntity( indices = [ Index(value = ["bucket_start_ms"]), Index(value = ["updated_at"]), + Index(value = ["sync_seq"]), ], ) data class TelemetryMinuteBucketEntity( @@ -174,13 +175,18 @@ data class TelemetryMinuteBucketEntity( @ColumnInfo(name = "last_moving_at_ms") val lastMovingAtMs: Long? = null, /** - * Incremental-sync cursor: wall-clock epoch ms of the last write to this bucket. Distinct from + * Last-write-wins timestamp: wall-clock epoch ms of the last write to this bucket. Distinct from * [lastSampleAtMs], which tracks the newest *sample* in the bucket — a merge that folds in older - * samples, or a bucket rebuild, changes the row without moving that. Only a write-time stamp - * makes an appended bucket visible to a "everything changed since T" query. + * samples, or a bucket rebuild, changes the row without moving that. + * + * Not the Sync Cursor column; [syncSeq] is. This one crosses the wire and decides which of two + * writes to the same row the server keeps, so it stays a truthful wall clock. */ @ColumnInfo(name = "updated_at") val updatedAt: Long, + /** Device-local Sync Cursor position; see [SyncSequenceEntity]. */ + @ColumnInfo(name = "sync_seq") + val syncSeq: Long = 0, ) @Entity( @@ -240,6 +246,7 @@ data class DiagnosticEventEntity( indices = [ Index(value = ["created_at"]), Index(value = ["updated_at"]), + Index(value = ["sync_seq"]), ], ) data class BoardEntity( @@ -251,12 +258,21 @@ data class BoardEntity( @ColumnInfo(name = "created_at") val createdAt: Long, /** - * Incremental-sync cursor: epoch ms of the last write to this row, from the same clock as - * [createdAt]. Equal to [createdAt] on insert and bumped on every mutation, so the client can ask - * the server for "everything changed since T". Indexed because cursor sync scans on it. + * Last-write-wins timestamp: epoch ms of the last write to this row, from the same clock as + * [createdAt]. Equal to [createdAt] on insert and bumped on every mutation. It crosses the wire + * and is what the server compares to decide which of two writes to this row it keeps, so it stays + * a truthful wall clock rather than a counter. + * + * Ratcheted to `max(previous + 1, now)` on write. A device clock that steps backwards would + * otherwise stamp an edit below the copy the server already holds, and the server's + * last-write-wins guard would silently drop it. Per row, so the inflation is bounded by the + * rewind and disappears once the wall clock passes it again. */ @ColumnInfo(name = "updated_at") val updatedAt: Long, + /** Device-local Sync Cursor position; see [SyncSequenceEntity]. */ + @ColumnInfo(name = "sync_seq") + val syncSeq: Long = 0, ) @Entity( @@ -285,6 +301,7 @@ data class BoardSettingEntity( Index(value = ["enabled"]), Index(value = ["created_at"]), Index(value = ["updated_at"]), + Index(value = ["sync_seq"]), ], ) data class AlertRuleEntity( @@ -307,15 +324,47 @@ data class AlertRuleEntity( */ val source: String?, /** - * Incremental-sync cursor: epoch ms of the last write to this row, from the same clock as - * [createdAt]. Equal to [createdAt] on insert and bumped on every mutation — including the - * targeted enable/disable update — so a toggled rule is visible to sync. Indexed because cursor - * sync scans on it. + * Last-write-wins timestamp, ratcheted on write exactly as [BoardEntity.updatedAt] is, and moved + * by every mutation including the targeted enable/disable update. */ @ColumnInfo(name = "updated_at") val updatedAt: Long, + /** Device-local Sync Cursor position; see [SyncSequenceEntity]. */ + @ColumnInfo(name = "sync_seq") + val syncSeq: Long = 0, ) +/** + * One counter per syncable table, handing out the strictly increasing `sync_seq` those tables stamp + * on every write. + * + * The Sync Cursor is the phone's own record of how far it has uploaded, and it never crosses the + * wire — the server stores no watermark and has no opinion about one. That is what lets the scan + * run on a counter instead of a clock: a device clock that steps backwards makes an + * `updated_at >= watermark` scan skip the write entirely, because the row lands below a cursor the + * phone already passed. A counter cannot regress, so the scan stays complete however the clock + * behaves. + * + * The counter lives in its own table rather than being derived as `MAX(sync_seq) + 1` per table: + * deleting the highest row would hand the same number out twice, and the second row would fall on + * the wrong side of a cursor already advanced past it. + */ +@Entity(tableName = "sync_sequences") +data class SyncSequenceEntity( + @PrimaryKey + val name: String, + @ColumnInfo(name = "last_value") + val lastValue: Long, +) + +/** Table names used as [SyncSequenceEntity] keys. */ +internal const val SYNC_SEQ_BOARDS = "boards" +internal const val SYNC_SEQ_ALERTS = "alerts" +internal const val SYNC_SEQ_MINUTE_BUCKETS = "telemetry_minute_buckets" + +/** Every table carrying a `sync_seq`, in the order the migration adds it. */ +internal val SYNC_SEQ_TABLES = listOf(SYNC_SEQ_BOARDS, SYNC_SEQ_ALERTS, SYNC_SEQ_MINUTE_BUCKETS) + @Entity( tableName = "metric_exclusion_ranges", indices = [ diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt index c9d0e919f..6d6bbaf92 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt @@ -1,5 +1,6 @@ package expo.modules.vescapecore.telemetry +import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -10,7 +11,8 @@ import java.lang.reflect.Proxy /** * Incremental-sync cursors: schema 27→28 adds `updated_at` to `boards`, `alerts` and * `telemetry_minute_buckets`, backfills it from each table's best evidence of last change, and - * indexes it. Every write path then has to move it. + * indexes it. Schema 28→29 then splits the two jobs that column was doing — `sync_seq` carries the + * Sync Cursor, `updated_at` stays the last-write-wins timestamp. Every write path has to move both. * * @parity /modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift */ @@ -22,7 +24,7 @@ class SyncCursorMigrationTest { "telemetry_minute_buckets" to "last_sample_at_ms", ) - private fun migrationSql(): List { + private fun migrationSql(migration: Migration): List { val sql = mutableListOf() val db = Proxy.newProxyInstance( SupportSQLiteDatabase::class.java.classLoader, @@ -35,10 +37,15 @@ class SyncCursorMigrationTest { throw UnsupportedOperationException(method.name) } } as SupportSQLiteDatabase - TelemetryDatabase.MIGRATION_27_28.migrate(db) + migration.migrate(db) return sql } + private fun migrationSql(): List = migrationSql(TelemetryDatabase.MIGRATION_27_28) + + private fun daoSource(): String = + File("src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt").readText() + @Test fun migrationAddsUpdatedAtColumnAndIndexToEverySyncedTable() { val sql = migrationSql() @@ -75,33 +82,132 @@ class SyncCursorMigrationTest { } @Test - fun migrationTargetsTheCurrentSchemaVersion() { - assertEquals(28, TELEMETRY_DATABASE_VERSION) + fun migrationsTargetTheCurrentSchemaVersion() { + assertEquals(29, TELEMETRY_DATABASE_VERSION) assertEquals(27, TelemetryDatabase.MIGRATION_27_28.startVersion) assertEquals(28, TelemetryDatabase.MIGRATION_27_28.endVersion) + assertEquals(28, TelemetryDatabase.MIGRATION_28_29.startVersion) + assertEquals(29, TelemetryDatabase.MIGRATION_28_29.endVersion) } /** * The regression this whole change exists to prevent. `setAlertRuleEnabled` is a targeted UPDATE - * rather than an entity round-trip, so it is the one write path that can silently skip the cursor - * — toggling an alert would then never reach the server. + * rather than an entity round-trip, so it is the one write path that can silently skip both sync + * columns — toggling an alert would then never reach the server. * * Asserted against the DAO source because Room's `@Query` has BINARY retention (invisible to * runtime reflection) and its generated implementation keeps the SQL in a method-local string. * A JVM unit test has no other handle on the statement Room will actually run. */ @Test - fun setAlertRuleEnabledQueryBumpsUpdatedAt() { - val dao = File("src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt").readText() - val query = Regex("""@Query\("(UPDATE alerts SET[^"]*)"\)""").find(dao)?.groupValues?.get(1) + fun setAlertRuleEnabledQueryMovesBothSyncColumns() { + // The statement is written as a concatenation to stay inside the line limit; join it back up + // before matching so the test sees the string Room will compile. + val dao = daoSource().replace(Regex("""\"\s*\+\s*\""""), "") + val query = Regex("""\"(UPDATE alerts SET[^"]*)\"""").find(dao)?.groupValues?.get(1) assertEquals( - "UPDATE alerts SET enabled = :enabled, updated_at = :updatedAt " + - "WHERE board_id = :boardId AND id = :id", + "UPDATE alerts SET enabled = :enabled, updated_at = MAX(updated_at + 1, :updatedAt), " + + "sync_seq = :syncSeq WHERE board_id = :boardId AND id = :id", query, ) } + // MARK: Sync Cursor sequence (#275) + + /** + * The Sync Cursor scan runs on `sync_seq`, not on `updated_at`. A wall clock that steps backwards + * lands a write below a cursor the phone has already passed, and the scan never picks it up; a + * counter cannot regress. + */ + @Test + fun syncSeqMigrationAddsColumnIndexAndCounterToEverySyncedTable() { + val sql = migrationSql(TelemetryDatabase.MIGRATION_28_29) + + assertTrue( + "missing sync_sequences table", + sql.any { it.contains("CREATE TABLE IF NOT EXISTS sync_sequences") }, + ) + for (table in SYNC_SEQ_TABLES) { + assertTrue( + "missing sync_seq column on $table", + sql.any { it == "ALTER TABLE $table ADD COLUMN sync_seq INTEGER NOT NULL DEFAULT 0" }, + ) + assertTrue( + "missing sync_seq index on $table", + sql.any { it == "CREATE INDEX IF NOT EXISTS index_${table}_sync_seq ON $table(sync_seq)" }, + ) + assertTrue( + "missing counter seed for $table", + sql.any { it.contains("INSERT OR REPLACE INTO sync_sequences") && it.contains("'$table'") }, + ) + } + } + + /** + * Existing rows need distinct, increasing positions and the counter has to resume above all of + * them, or the first writes after upgrade reuse numbers the scan would order wrongly. + */ + @Test + fun syncSeqMigrationBackfillsExistingRowsBeforeSeedingTheCounter() { + val sql = migrationSql(TelemetryDatabase.MIGRATION_28_29) + + for (table in SYNC_SEQ_TABLES) { + val backfilled = sql.indexOf("UPDATE $table SET sync_seq = rowid") + val seeded = sql.indexOfFirst { + it.contains("INSERT OR REPLACE INTO sync_sequences") && it.contains("'$table'") + } + assertTrue("missing sync_seq backfill for $table", backfilled >= 0) + assertTrue("counter for $table is seeded before its rows are numbered", seeded > backfilled) + } + } + + /** Every entity write path stamps a fresh position, including the merge branch for buckets. */ + @Test + fun everyEntityWritePathAllocatesASyncSeq() { + val dao = daoSource() + + for (marker in listOf( + "syncSeq = nextSyncSeq(SYNC_SEQ_BOARDS)", + "syncSeq = nextSyncSeq(SYNC_SEQ_ALERTS)", + "syncSeq = nextSyncSeq(SYNC_SEQ_MINUTE_BUCKETS)", + )) { + assertTrue("no write path allocates via `$marker`", dao.contains(marker)) + } + // The bucket merge folds into the stored row, so the fresh position has to survive the fold. + assertTrue("bucket merge drops the new sync_seq", dao.contains("syncSeq = next.syncSeq")) + } + + // MARK: Last-write-wins ratchet (#275) + + /** + * The server keeps its stored row unless the incoming stamp is strictly newer, so a rewound clock + * that stamps at or below it is a silently dropped edit — freezing the value is not enough. + */ + @Test + fun ratchetStepsPastAStampTheClockCannotBeat() { + assertEquals(1_000L, ratchetUpdatedAt(null, 1_000L)) + // Clock ahead of the stored row: truthful wall clock, no inflation. + assertEquals(5_000L, ratchetUpdatedAt(1_000L, 5_000L)) + // Clock rewound below it, or stalled on it: strictly above. + assertEquals(5_001L, ratchetUpdatedAt(5_000L, 1_000L)) + assertEquals(5_001L, ratchetUpdatedAt(5_000L, 5_000L)) + } + + @Test + fun boardAndAlertUpsertsRatchetAgainstTheStoredStamp() { + val dao = daoSource() + + assertTrue( + "board upsert does not ratchet", + dao.contains("ratchetUpdatedAt(getBoardUpdatedAt(board.id), board.updatedAt)"), + ) + assertTrue( + "alert upsert does not ratchet", + dao.contains("ratchetUpdatedAt(getAlertRuleUpdatedAt(rule.boardId, rule.id), rule.updatedAt)"), + ) + } + @Test fun boardAndAlertRuleBridgeShapesCarryTheCursor() { val board = mapOf( diff --git a/modules/vescape-core/ios/telemetry/AppDataRepository.swift b/modules/vescape-core/ios/telemetry/AppDataRepository.swift index 0f7896b2e..4b3b0f1f0 100644 --- a/modules/vescape-core/ios/telemetry/AppDataRepository.swift +++ b/modules/vescape-core/ios/telemetry/AppDataRepository.swift @@ -114,18 +114,24 @@ final class AppDataRepository { // Legal Mode changes only through the dedicated native intent. ] + linkSettings.filter { $0.0 != "transport" } let transport = linkSettings.first { $0.0 == "transport" }?.1 as? String - // One stamp for the board row's sync cursor and every board-setting row written below. Native - // stamps it rather than trusting the bridge value: it must come from the device clock that - // already writes `created_at` and must move on every upsert, including partial edits. + // One stamp for the board row's last-write-wins timestamp and every board-setting row written + // below. Native stamps it rather than trusting the bridge value: it must come from the device + // clock that already writes `created_at` and must move on every upsert, including partial edits. let updatedAt = nowMs() write { db in + // Read-modify-write rather than an `ON CONFLICT` fold: `INSERT OR REPLACE` deletes the old row + // before inserting, so the ratchet has no `excluded`-style handle on the value it replaces. + let previous = try Int64.fetchOne(db, sql: "SELECT updated_at FROM boards WHERE id = ?", arguments: [id]) try db.execute( sql: """ - INSERT OR REPLACE INTO boards (id, name, ble_id, transport, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO boards (id, name, ble_id, transport, created_at, updated_at, sync_seq) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - arguments: [id, name, bleId, transport, createdAt, updatedAt] + arguments: [ + id, name, bleId, transport, createdAt, + ratchetUpdatedAt(previous, updatedAt), try nextSyncSeq(db, syncSeqBoards), + ] ) for (key, value) in settings { guard let value, let json = Self.encodeJson(value) else { @@ -332,32 +338,45 @@ final class AppDataRepository { let soundType = rule["soundType"] as? String ?? "default" let createdAt = Self.longValue(rule["createdAt"] ?? nil) ?? nowMs() let source = rule["source"] as? String - // Native stamps the sync cursor rather than trusting the bridge value: it must come from the - // device clock that already writes `created_at` and must move on every upsert. + // Native stamps the last-write-wins timestamp rather than trusting the bridge value: it must come + // from the device clock that already writes `created_at` and must move on every upsert. let updatedAt = nowMs() write { db in + // See `upsertBoard` for why the ratchet reads the old value instead of folding it on conflict. + let previous = try Int64.fetchOne( + db, + sql: "SELECT updated_at FROM alerts WHERE board_id = ? AND id = ?", + arguments: [boardId, id] + ) try db.execute( sql: """ - INSERT OR REPLACE INTO alerts (board_id, id, control_id, threshold, threshold_max, enabled, sound_type, created_at, source, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO alerts (board_id, id, control_id, threshold, threshold_max, enabled, sound_type, created_at, source, updated_at, sync_seq) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ - boardId, id, controlId, threshold, thresholdMax, enabled ? 1 : 0, soundType, createdAt, source, updatedAt, + boardId, id, controlId, threshold, thresholdMax, enabled ? 1 : 0, soundType, createdAt, source, + ratchetUpdatedAt(previous, updatedAt), try nextSyncSeq(db, syncSeqAlerts), ] ) } } - /// Targeted toggle. Unlike `upsertAlertRule` it never rewrites the whole row, so `updated_at` has - /// to move here explicitly — without it, toggling a rule leaves the sync cursor stale and the - /// change never reaches the server. + /// Targeted toggle. Unlike `upsertAlertRule` it never rewrites the whole row, so both sync columns + /// have to move here explicitly — without them, toggling a rule leaves it invisible to the upload + /// scan and the change never reaches the server. + /// + /// The `MAX(updated_at + 1, ?)` fold is the same ratchet `ratchetUpdatedAt` applies, expressed in + /// SQL because the row is already being read by the `WHERE`. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `setAlertRuleEnabled` func setAlertRuleEnabled(_ boardId: String, _ id: String, _ enabled: Bool) { let updatedAt = nowMs() write { db in try db.execute( - sql: "UPDATE alerts SET enabled = ?, updated_at = ? WHERE board_id = ? AND id = ?", - arguments: [enabled ? 1 : 0, updatedAt, boardId, id] + sql: """ + UPDATE alerts SET enabled = ?, updated_at = MAX(updated_at + 1, ?), sync_seq = ? + WHERE board_id = ? AND id = ? + """, + arguments: [enabled ? 1 : 0, updatedAt, try nextSyncSeq(db, syncSeqAlerts), boardId, id] ) } } diff --git a/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift b/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift index 7cb132a17..a17d0b6e0 100644 --- a/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift +++ b/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift @@ -4,7 +4,9 @@ import GRDB /// Incremental-sync cursors: the `v28_sync_cursors` migration adds `updated_at` to `boards`, /// `alerts` and `telemetry_minute_buckets`, backfills it from each table's best evidence of last -/// change, and indexes it. Every write path then has to move it. +/// change, and indexes it. `v29_sync_seq` then splits the two jobs that column was doing — `sync_seq` +/// carries the Sync Cursor, `updated_at` stays the last-write-wins timestamp. Every write path has to +/// move both. /// /// Runs the real migrator against an in-memory database, stopping at v27 to seed pre-migration rows. /// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt @@ -191,4 +193,135 @@ final class SyncCursorMigrationTests: XCTestCase { 5_000 ) } + + // MARK: - Sync Cursor sequence (#275) + + private func syncSeq(_ table: String) throws -> Int64? { + try queue.read { db in try Int64.fetchOne(db, sql: "SELECT sync_seq FROM \(table)") } + } + + private func counter(_ name: String) throws -> Int64? { + try queue.read { db in + try Int64.fetchOne(db, sql: "SELECT last_value FROM sync_sequences WHERE name = ?", arguments: [name]) + } + } + + func testMigrationAddsSyncSeqColumnAndIndexToEverySyncedTable() throws { + try migrateToLatest() + + for table in syncSeqTables { + XCTAssertTrue(try columnNames(table).contains("sync_seq"), "\(table) is missing sync_seq") + XCTAssertTrue( + try indexNames(table).contains("index_\(table)_sync_seq"), + "\(table) is missing its sync_seq index" + ) + } + } + + /// Pre-29 rows need distinct, increasing positions, and the counter has to resume above all of + /// them — otherwise the first writes after upgrade reuse numbers the scan would order wrongly. + func testMigrationBackfillsSyncSeqAndResumesTheCounterAboveIt() throws { + try migrateToV27() + try queue.write { db in + for (index, id) in ["board-1", "board-2", "board-3"].enumerated() { + try db.execute( + sql: "INSERT INTO boards (id, name, ble_id, transport, created_at) VALUES (?, ?, NULL, NULL, ?)", + arguments: [id, "ADV", 1_000 + index] + ) + } + } + + try migrateToLatest() + + let seqs = try queue.read { db in + try Int64.fetchAll(db, sql: "SELECT sync_seq FROM boards ORDER BY sync_seq") + } + XCTAssertEqual(seqs.count, 3) + XCTAssertEqual(Set(seqs).count, 3, "backfilled positions collide") + XCTAssertEqual(try counter(syncSeqBoards), seqs.max()) + } + + func testUpsertsAdvanceTheSyncSeq() throws { + let repo = try makeRepository() + + repo.upsertBoard(["id": "board-1", "name": "ADV", "createdAt": 1_000]) + let first = try XCTUnwrap(syncSeq(syncSeqBoards)) + repo.upsertBoard(["id": "board-1", "name": "Renamed", "createdAt": 1_000]) + + XCTAssertGreaterThan(try XCTUnwrap(syncSeq(syncSeqBoards)), first) + } + + /// The reason the counter lives in its own table instead of being derived as `MAX(sync_seq) + 1`: + /// deleting the highest row would hand its number out again, and the reused row would land below a + /// cursor the phone had already advanced past. + func testSyncSeqIsNotReusedAfterTheHighestRowIsDeleted() throws { + let repo = try makeRepository() + repo.upsertBoard(["id": "board-1", "name": "ADV", "createdAt": 1_000]) + let deleted = try XCTUnwrap(syncSeq(syncSeqBoards)) + + repo.deleteBoard("board-1") + repo.upsertBoard(["id": "board-2", "name": "GT", "createdAt": 1_000]) + + XCTAssertGreaterThan(try XCTUnwrap(syncSeq(syncSeqBoards)), deleted) + } + + func testBucketMergeAdvancesTheSyncSeq() throws { + try migrateToLatest() + var bucket = TelemetryBucket(bucketStartMs: 60_000, deviceId: "board-1") + bucket.firstSampleAtMs = 60_000 + bucket.lastSampleAtMs = 60_500 + + try queue.write { db in try upsertBucket(db, bucket, now: 1_000) } + let first = try XCTUnwrap(syncSeq(syncSeqMinuteBuckets)) + // A merge rewrites a row the scan may already have passed, so it needs a fresh position too. + try queue.write { db in try upsertBucket(db, bucket, now: 2_000) } + + XCTAssertGreaterThan(try XCTUnwrap(syncSeq(syncSeqMinuteBuckets)), first) + } + + // MARK: - Last-write-wins ratchet (#275) + + func testRatchetStepsPastAStampTheClockCannotBeat() throws { + XCTAssertEqual(ratchetUpdatedAt(nil, 1_000), 1_000) + // Clock ahead of the stored row: truthful wall clock, no inflation. + XCTAssertEqual(ratchetUpdatedAt(1_000, 5_000), 5_000) + // Clock rewound below it: strictly above, so the server's `stored < incoming` guard accepts it. + XCTAssertEqual(ratchetUpdatedAt(5_000, 1_000), 5_001) + XCTAssertEqual(ratchetUpdatedAt(5_000, 5_000), 5_001) + } + + /// A rewound clock must not leave the row stamped at or below the copy the server already holds — + /// the upsert guard there keeps the stored row unless the incoming stamp is strictly newer, so a + /// frozen stamp is a silently dropped edit. + func testBoardUpsertNeverStampsAtOrBelowTheStoredValue() throws { + let repo = try makeRepository() + repo.upsertBoard(["id": "board-1", "name": "ADV", "createdAt": 1_000]) + // Stand in for a rewind by putting the stored row far beyond any clock the write can read. + let ahead = Int64(Date().timeIntervalSince1970 * 1000) + 3_600_000 + try queue.write { db in + try db.execute(sql: "UPDATE boards SET updated_at = ?", arguments: [ahead]) + } + + repo.upsertBoard(["id": "board-1", "name": "Renamed", "createdAt": 1_000]) + + XCTAssertEqual(try queue.read { db in try Int64.fetchOne(db, sql: "SELECT updated_at FROM boards") }, ahead + 1) + } + + func testSetAlertRuleEnabledNeverStampsAtOrBelowTheStoredValue() throws { + let repo = try makeRepository() + repo.upsertAlertRule([ + "boardId": "board-1", "id": "rule-1", "controlId": "duty", "threshold": 70.0, + "enabled": true, "createdAt": 1_000, + ]) + let ahead = Int64(Date().timeIntervalSince1970 * 1000) + 3_600_000 + try queue.write { db in + try db.execute(sql: "UPDATE alerts SET updated_at = ?", arguments: [ahead]) + } + let seqBefore = try XCTUnwrap(syncSeq(syncSeqAlerts)) + + repo.setAlertRuleEnabled("board-1", "rule-1", false) + + XCTAssertEqual(try alertCursor(), ahead + 1) + XCTAssertGreaterThan(try XCTUnwrap(syncSeq(syncSeqAlerts)), seqBefore) + } } diff --git a/modules/vescape-core/ios/telemetry/TelemetryDao.swift b/modules/vescape-core/ios/telemetry/TelemetryDao.swift index e2fc3181f..4d257617c 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDao.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDao.swift @@ -83,17 +83,59 @@ internal func insertFrame(_ db: Database, _ state: FullTelemetryState) throws { ) } -/// [now] is the incremental-sync cursor stamped on the row, so every append or rebuild that reaches -/// the database is visible to cursor sync. `MAX` on conflict clamps a backwards device-clock step: -/// the value stays at the last real write time instead of regressing below a cursor already synced. -/// Bounded and self-correcting — once the clock passes the old value again the stamp is truthful, -/// unlike a ratcheting `existing + 1` counter, which would permanently inflate this row against -/// other devices under last-write-wins. +/// Table names carrying a `sync_seq`, and the keys their counters use in `sync_sequences`. +internal let syncSeqBoards = "boards" +internal let syncSeqAlerts = "alerts" +internal let syncSeqMinuteBuckets = "telemetry_minute_buckets" +internal let syncSeqTables = [syncSeqBoards, syncSeqAlerts, syncSeqMinuteBuckets] + +/// Hands out the next Sync Cursor position for [name]. +/// +/// The Sync Cursor is the phone's own record of how far it has uploaded and never crosses the wire, +/// which is what lets the upload scan run on a counter instead of a clock: a device clock that steps +/// backwards makes an `updated_at >= watermark` scan skip the write entirely, because the row lands +/// below a cursor the phone already passed. A counter cannot regress. +/// +/// Bump-then-read rather than read-then-bump so two writes racing inside the same database can never +/// be handed the same number; both statements run in the caller's transaction. The counter lives in +/// its own table rather than being derived as `MAX(sync_seq) + 1`, which would hand the same number +/// out twice after the highest row is deleted. Seeded on demand because a database created fresh +/// never runs the migration that inserts the row. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `nextSyncSeq` +internal func nextSyncSeq(_ db: Database, _ name: String) throws -> Int64 { + try db.execute( + sql: "INSERT OR IGNORE INTO sync_sequences (name, last_value) VALUES (?, 0)", + arguments: [name] + ) + try db.execute( + sql: "UPDATE sync_sequences SET last_value = last_value + 1 WHERE name = ?", + arguments: [name] + ) + return try Int64.fetchOne(db, sql: "SELECT last_value FROM sync_sequences WHERE name = ?", arguments: [name]) ?? 0 +} + +/// The write-time fold behind `updated_at` on `boards` and `alerts`: never below the value already +/// stored, and strictly above it whenever the clock fails to be. +/// +/// `+ 1` rather than a plain `max` because the server keeps the stored row unless the incoming stamp +/// is strictly newer — freezing at the old value would satisfy the scan and still lose the edit. Per +/// row, so the inflation is bounded by the rewind and disappears once the wall clock passes it. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `ratchetUpdatedAt` +internal func ratchetUpdatedAt(_ previous: Int64?, _ now: Int64) -> Int64 { + guard let previous else { return now } + return max(previous + 1, now) +} + +/// [now] is the last-write-wins timestamp stamped on the row. `MAX` on conflict clamps a backwards +/// device-clock step so the value stays at the last real write time instead of regressing. No `+ 1` +/// ratchet here, unlike boards and alerts — the server writes this table with an unconditional +/// upsert, so a stale stamp is never grounds for rejecting the row. /// -/// Not a completeness guarantee: a frozen stamp is only picked up because the sync query is -/// `updated_at >= watermark`. Clock-rewind completeness is tracked separately (see #275). +/// Completeness is `sync_seq`'s job, and it moves on every write including a merge into a row the +/// scan may already have passed. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt `toEntity` internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = telemetryNowMs()) throws { + let syncSeq = try nextSyncSeq(db, syncSeqMinuteBuckets) try db.execute( sql: """ INSERT INTO telemetry_minute_buckets ( @@ -103,8 +145,9 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = te max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, max_duty_abs_permille, fault_count, first_odometer_cm, last_odometer_cm, gps_point_count, precise_gps_point_count, gps_distance_cm, max_gps_speed_centi_mps, max_temp_mosfet_deci_c, max_temp_motor_deci_c, - first_latitude_e7, first_longitude_e7, first_moving_at_ms, last_moving_at_ms, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?) + first_latitude_e7, first_longitude_e7, first_moving_at_ms, last_moving_at_ms, updated_at, + sync_seq + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(bucket_start_ms, device_id) DO UPDATE SET device_name=excluded.device_name, sample_count=telemetry_minute_buckets.sample_count + excluded.sample_count, @@ -128,7 +171,8 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = te max_temp_motor_deci_c=MAX(telemetry_minute_buckets.max_temp_motor_deci_c, excluded.max_temp_motor_deci_c), first_moving_at_ms=MIN(telemetry_minute_buckets.first_moving_at_ms, excluded.first_moving_at_ms), last_moving_at_ms=MAX(telemetry_minute_buckets.last_moving_at_ms, excluded.last_moving_at_ms), - updated_at=MAX(telemetry_minute_buckets.updated_at, excluded.updated_at) + updated_at=MAX(telemetry_minute_buckets.updated_at, excluded.updated_at), + sync_seq=excluded.sync_seq """, arguments: [ b.bucketStartMs, b.deviceId, b.deviceName, b.sampleCount, b.firstSampleAtMs, b.lastSampleAtMs, @@ -137,6 +181,7 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = te b.batteryRegenWhMilli, b.maxDutyAbsPermille, b.faultCount, b.firstOdometerCm, b.lastOdometerCm, b.gpsPointCount, b.preciseGpsPointCount, b.maxGpsSpeedCentiMps, b.maxTempMosfetDeciC, b.maxTempMotorDeciC, b.firstLatitudeE7, b.firstLongitudeE7, b.firstMovingAtMs, b.lastMovingAtMs, now, + syncSeq, ] ) } diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 8569cf3ad..f421089e9 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -443,6 +443,42 @@ enum TelemetryDatabase { } } + // MARK: Sync Cursor split off the last-write-wins timestamp + // `sync_seq` is a device-local counter the upload scan runs on; `updated_at` keeps its wall-clock + // meaning and stays the value the server compares. Scanning a counter is what makes the scan + // complete under a device clock that steps backwards, which an `updated_at >= watermark` scan is + // not: a rewound clock lands the write below a cursor the phone has already passed. + // + // Existing rows backfill from `rowid`. Nothing has ever been uploaded, so any strictly increasing + // assignment works, and `rowid` gives one for free without an O(n²) self-join over tables that + // hold a row per ridden minute. Each counter then starts above every assigned value. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_28_29` + migrator.registerMigration("v29_sync_seq") { db in + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS sync_sequences ( + name TEXT NOT NULL PRIMARY KEY, + last_value INTEGER NOT NULL + ) + """ + ) + for table in syncSeqTables { + let hasSyncSeq = try db.columns(in: table).contains { $0.name == "sync_seq" } + if !hasSyncSeq { + try db.execute(sql: "ALTER TABLE \(table) ADD COLUMN sync_seq INTEGER NOT NULL DEFAULT 0") + try db.execute(sql: "UPDATE \(table) SET sync_seq = rowid") + } + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_\(table)_sync_seq ON \(table)(sync_seq)") + try db.execute( + sql: """ + INSERT OR REPLACE INTO sync_sequences (name, last_value) + VALUES (?, (SELECT COALESCE(MAX(sync_seq), 0) FROM \(table))) + """, + arguments: [table] + ) + } + } + return migrator } } From 47509f5ede6c91b28b34e97724508ae37ecd0ae2 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sat, 1 Aug 2026 01:15:40 +0200 Subject: [PATCH 05/24] Tombstone deleted Boards #279 --- CONTEXT.md | 4 + .../connection/SessionConfigBuilder.kt | 4 + .../telemetry/AppDataRepository.kt | 5 +- .../vescapecore/telemetry/TelemetryDao.kt | 29 +++- .../telemetry/TelemetryDatabase.kt | 18 +- .../telemetry/TelemetryEntities.kt | 9 + .../telemetry/BoardTombstoneTest.kt | 128 ++++++++++++++ .../telemetry/SyncCursorMigrationTest.kt | 2 +- .../vescape-core/ios/VescapeCoreModule.swift | 2 + .../ios/telemetry/AppDataRepository.swift | 46 ++++- .../ios/telemetry/BoardTombstoneTests.swift | 158 ++++++++++++++++++ .../ios/telemetry/TelemetryDatabase.swift | 11 ++ modules/vescape-core/src/e2eFake.ts | 12 +- modules/vescape-core/src/index.ts | 13 +- .../alerts/store/alertPresetStore.test.ts | 1 + src/modules/board/store/boardStore.test.ts | 2 + src/modules/board/store/boardStore.ts | 1 + 17 files changed, 426 insertions(+), 19 deletions(-) create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt create mode 100644 modules/vescape-core/ios/telemetry/BoardTombstoneTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index 4e9c2b147..ca66f5b9d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,10 @@ This context defines the shared language for the VESC-based board app. The app c A saved rideable device that can be connected over BLE and may expose one motor controller through CAN. _Avoid_: Device, controller, scooter +**Board Tombstone**: +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 + **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 diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/SessionConfigBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/SessionConfigBuilder.kt index 0c8f07e0c..1417bb024 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/SessionConfigBuilder.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/connection/SessionConfigBuilder.kt @@ -24,6 +24,10 @@ internal suspend fun buildSessionConfig( val repo = AppDataRepository.get(context.applicationContext) val board = repo.getBoard(boardId) ?: throw IllegalArgumentException("Board not found: $boardId") + // Reads resolve tombstones so history can name them (ADR 0027); connecting to one is refused. + if (board["deletedAt"] != null) { + throw IllegalArgumentException("Board is deleted: $boardId") + } @Suppress("UNCHECKED_CAST") val link = board["link"] as? Map val bleId = link?.get("bleId") as? String diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt index 2e2de8339..136fbda63 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt @@ -187,8 +187,9 @@ class AppDataRepository private constructor(private val context: Context) { notifyDataChanged(AppDataScope.BOARDS) } + /** Tombstones the Board and hard-deletes its configuration; see [TelemetryDao.deleteBoardWithSettings]. */ suspend fun deleteBoard(id: String): Unit = withContext(Dispatchers.IO) { - dao.deleteBoardWithSettings(id) + dao.deleteBoardWithSettings(id, System.currentTimeMillis()) notifyDataChanged(AppDataScope.BOARDS) } @@ -622,6 +623,7 @@ class AppDataRepository private constructor(private val context: Context) { val settings = getTypedSettings() settings.selectedBoardId ?.let { dao.getBoard(it) } + ?.takeIf { it.deletedAt == null } ?.let { it.toMap(dao.getBoardSettings(it.id)) } ?: dao.getBoards().firstOrNull()?.let { it.toMap(dao.getBoardSettings(it.id)) } } @@ -687,6 +689,7 @@ fun BoardEntity.toMap(settings: List): Map { "legalMode" to (values["legalMode"] ?: mapOf("enabled" to false)), "link" to link, "updatedAt" to updatedAt, + "deletedAt" to deletedAt, ) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index e1bc6c903..4dae92cac 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -394,9 +394,14 @@ interface TelemetryDao { clearExclusions() } - @Query("SELECT * FROM boards ORDER BY created_at ASC") + /** Live Boards only — a tombstoned Board is gone from every Rider-facing list (ADR 0027). */ + @Query("SELECT * FROM boards WHERE deleted_at IS NULL ORDER BY created_at ASC") suspend fun getBoards(): List + /** + * Resolves tombstones too, deliberately: Ride History still has to name a deleted Board. Callers + * that act on a Board rather than describe one check [BoardEntity.deletedAt] and refuse. + */ @Query("SELECT * FROM boards WHERE id = :id LIMIT 1") suspend fun getBoard(id: String): BoardEntity? @@ -406,10 +411,16 @@ interface TelemetryDao { @Query("SELECT updated_at FROM boards WHERE id = :id") suspend fun getBoardUpdatedAt(id: String): Long? + @Query("SELECT deleted_at FROM boards WHERE id = :id") + suspend fun getBoardDeletedAt(id: String): Long? + /** * Stamps both sync columns before the row lands: a fresh `sync_seq` so the upload scan sees this * write, and a ratcheted `updated_at` so the server keeps it. Caller-supplied values for either * are overwritten — see [SyncSequenceEntity] and [BoardEntity.updatedAt]. + * + * An existing tombstone survives the write, so an ordinary upsert can never resurrect a deleted + * Board — deletion is terminal (ADR 0027). Only [deleteBoardWithSettings] stamps a new one. */ @Transaction suspend fun upsertBoard(board: BoardEntity) { @@ -417,6 +428,7 @@ interface TelemetryDao { board.copy( updatedAt = ratchetUpdatedAt(getBoardUpdatedAt(board.id), board.updatedAt), syncSeq = nextSyncSeq(SYNC_SEQ_BOARDS), + deletedAt = board.deletedAt ?: getBoardDeletedAt(board.id), ), ) } @@ -443,16 +455,21 @@ interface TelemetryDao { @Query("DELETE FROM board_settings WHERE board_id = :boardId") suspend fun deleteBoardSettings(boardId: String) - @Query("DELETE FROM boards WHERE id = :id") - suspend fun deleteBoard(id: String) - + /** + * The Rider-facing delete: configuration goes, the Board row stays as a tombstone (ADR 0027). + * Telemetry and Tune Profiles are untouched — both outlive the Board. + * + * The tombstone is an ordinary write, so it runs through [upsertBoard] and moves both sync + * columns like any other edit. An unknown or already-tombstoned id is a no-op. + */ @Transaction - suspend fun deleteBoardWithSettings(id: String) { + suspend fun deleteBoardWithSettings(id: String, deletedAt: Long) { + val board = getBoard(id)?.takeIf { it.deletedAt == null } ?: return deleteBoardSettings(id) deleteBoardWarnings(id) // Alert Rules are Board-owned (#254) — drop them with the Board so no orphan rows survive. deleteAlertRules(id) - deleteBoard(id) + upsertBoard(board.copy(deletedAt = deletedAt, updatedAt = deletedAt)) } @Query("SELECT * FROM alerts WHERE board_id = :boardId ORDER BY created_at ASC") diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index c58ddebf4..f989f509a 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -12,7 +12,7 @@ import java.io.File // @parity /modules/vescape-core/ios/VescapeCoreModule.swift internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" -internal const val TELEMETRY_DATABASE_VERSION = 33 +internal const val TELEMETRY_DATABASE_VERSION = 34 @Database( entities = [ @@ -570,6 +570,21 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * Board tombstones (#279). Deleting a Board stops removing its row and stamps `deleted_at` + * instead, so Ride History outlives the Board that produced it (ADR 0027). Additive: existing + * rows stay null, i.e. alive. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v34_board_deleted_at` + */ + internal val MIGRATION_33_34 = object : Migration(33, 34) { + override fun migrate(db: SupportSQLiteDatabase) { + if (!hasColumn(db, "boards", "deleted_at")) { + db.execSQL("ALTER TABLE boards ADD COLUMN deleted_at INTEGER") + } + } + } + private fun dropMapPointTables(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS map_point_reactions") db.execSQL("DROP TABLE IF EXISTS map_points") @@ -710,6 +725,7 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, + MIGRATION_33_34, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index 84703f5f2..e0d05cd05 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -274,6 +274,15 @@ data class BoardEntity( /** Device-local Sync Cursor position; see [SyncSequenceEntity]. */ @ColumnInfo(name = "sync_seq") val syncSeq: Long = 0, + /** + * Tombstone stamp: epoch ms of the rider's delete, null while the Board is alive. A deleted Board + * keeps its row so Ride History can still name it and the server's Board-owned foreign keys hold; + * only the Board's configuration is hard-deleted (ADR-0027). + * + * Written by the delete path only — an upsert from the bridge never authors it, like [updatedAt]. + */ + @ColumnInfo(name = "deleted_at") + val deletedAt: Long? = null, ) @Entity( diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt new file mode 100644 index 000000000..eab0f92b8 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt @@ -0,0 +1,128 @@ +package expo.modules.vescapecore.telemetry + +import android.database.Cursor +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.lang.reflect.Proxy + +/** + * Board tombstones (ADR 0027): deleting a Board stamps `boards.deleted_at` instead of removing the + * row, so Ride History outlives the Board that produced it. Configuration still goes; telemetry and + * Tune Profiles never did and still do not. + * + * Room's `@Query` has BINARY retention and its generated implementation keeps the SQL in a + * method-local string, so a JVM unit test has no runtime handle on the statements Room will run — + * the read/delete contracts are asserted against the DAO source, as in [SyncCursorMigrationTest]. + * + * @parity /modules/vescape-core/ios/telemetry/BoardTombstoneTests.swift + */ +class BoardTombstoneTest { + private fun migrationSql(migration: Migration): List { + val sql = mutableListOf() + val db = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + sql += args?.firstOrNull() as String + null + } + "query" -> emptyCursor() + else -> throw UnsupportedOperationException(method.name) + } + } as SupportSQLiteDatabase + migration.migrate(db) + return sql + } + + private fun emptyCursor(): Cursor = Proxy.newProxyInstance( + Cursor::class.java.classLoader, + arrayOf(Cursor::class.java), + ) { _, method, _ -> + when (method.name) { + "getColumnIndex" -> 0 + "moveToNext" -> false + "close" -> null + else -> throw UnsupportedOperationException(method.name) + } + } as Cursor + + private fun daoSource(): String = + File("src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt").readText() + + /** + * Additive and nullable: existing rows stay null, which is what "alive" means. A `NOT NULL DEFAULT` + * would tombstone every Board on the device the moment it upgraded. + */ + @Test + fun migrationAddsNullableDeletedAtColumn() { + val sql = migrationSql(TelemetryDatabase.MIGRATION_33_34) + + assertEquals(listOf("ALTER TABLE boards ADD COLUMN deleted_at INTEGER"), sql) + } + + @Test + fun migrationTargetsTheCurrentSchemaVersion() { + assertEquals(34, TELEMETRY_DATABASE_VERSION) + assertEquals(33, TelemetryDatabase.MIGRATION_33_34.startVersion) + assertEquals(34, TelemetryDatabase.MIGRATION_33_34.endVersion) + } + + /** The Rider-facing list drops tombstones; lookup by id keeps them so history can name them. */ + @Test + fun listReadFiltersTombstonesAndLookupByIdDoesNot() { + val dao = daoSource() + + assertTrue( + "getBoards() does not filter tombstones", + dao.contains("SELECT * FROM boards WHERE deleted_at IS NULL ORDER BY created_at ASC"), + ) + assertTrue( + "getBoard(id) stopped resolving tombstones", + dao.contains("SELECT * FROM boards WHERE id = :id LIMIT 1"), + ) + } + + /** + * The regression this change exists to prevent: a Board delete that still removes the row takes + * Ride History with it on the server, and the phone can never re-upload it. + */ + @Test + fun deleteTombstonesTheBoardInsteadOfRemovingTheRow() { + val dao = daoSource() + + assertFalse("a DELETE on boards survives", dao.contains("DELETE FROM boards")) + assertTrue( + "the delete path does not stamp a tombstone", + dao.contains("upsertBoard(board.copy(deletedAt = deletedAt, updatedAt = deletedAt))"), + ) + } + + /** Configuration is still hard-deleted — only the Board row survives. */ + @Test + fun deleteStillRemovesBoardConfiguration() { + val dao = daoSource() + val body = dao.substringAfter("suspend fun deleteBoardWithSettings").substringBefore("\n }") + + for (call in listOf("deleteBoardSettings(id)", "deleteBoardWarnings(id)", "deleteAlertRules(id)")) { + assertTrue("the delete path dropped `$call`", body.contains(call)) + } + } + + /** Deletion is terminal: an ordinary upsert must not clear a tombstone already on the row. */ + @Test + fun upsertPreservesAnExistingTombstone() { + val dao = daoSource() + + assertTrue( + "upsertBoard can resurrect a deleted Board", + dao.contains("deletedAt = board.deletedAt ?: getBoardDeletedAt(board.id)"), + ) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt index 39227ac17..32a18df04 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt @@ -98,7 +98,7 @@ class SyncCursorMigrationTest { @Test fun migrationsTargetTheCurrentSchemaVersion() { - assertEquals(33, TELEMETRY_DATABASE_VERSION) + assertEquals(34, TELEMETRY_DATABASE_VERSION) assertEquals(31, TelemetryDatabase.MIGRATION_31_32.startVersion) assertEquals(32, TelemetryDatabase.MIGRATION_31_32.endVersion) assertEquals(32, TelemetryDatabase.MIGRATION_32_33.startVersion) diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index fc59a65d3..15cb124d8 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -994,6 +994,8 @@ public class VescapeCoreModule: Module { /// transport is read straight from the link, never rediscovered. private func connectConfig(boardId: String) -> BoardConnectConfig? { guard let board = appData.getBoard(boardId) else { return nil } + // Reads resolve tombstones so history can name them (ADR 0027); connecting to one is refused. + guard board["deletedAt"] as? Int64 == nil else { return nil } guard let link = board["link"] as? [String: Any?] else { return nil } guard let bleId = link["bleId"] as? String, !bleId.isEmpty else { return nil } let transport = BoardTransport.fromBridge(link["transport"] ?? nil) ?? .direct diff --git a/modules/vescape-core/ios/telemetry/AppDataRepository.swift b/modules/vescape-core/ios/telemetry/AppDataRepository.swift index e2240af69..568303e29 100644 --- a/modules/vescape-core/ios/telemetry/AppDataRepository.swift +++ b/modules/vescape-core/ios/telemetry/AppDataRepository.swift @@ -64,11 +64,16 @@ final class AppDataRepository { // MARK: - Boards + /// Live Boards only — a tombstoned Board is gone from every Rider-facing list (ADR 0027). + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `getBoards` func getBoards() -> [[String: Any?]] { read([]) { db in let boards = try Row.fetchAll( db, - sql: "SELECT id, name, ble_id, transport, created_at, updated_at FROM boards ORDER BY created_at ASC" + sql: """ + SELECT id, name, ble_id, transport, created_at, updated_at, deleted_at FROM boards + WHERE deleted_at IS NULL ORDER BY created_at ASC + """ ) let settings = try Row.fetchAll(db, sql: "SELECT board_id, key, value_json FROM board_settings") var byBoard: [String: [(String, String)]] = [:] @@ -80,11 +85,17 @@ final class AppDataRepository { } } + /// Resolves tombstones too, deliberately: Ride History still has to name a deleted Board. Callers + /// that act on a Board rather than describe one check `deletedAt` and refuse. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `getBoard` func getBoard(_ id: String) -> [String: Any?]? { read(nil) { db in guard let board = try Row.fetchOne( db, - sql: "SELECT id, name, ble_id, transport, created_at, updated_at FROM boards WHERE id = ? LIMIT 1", + sql: """ + SELECT id, name, ble_id, transport, created_at, updated_at, deleted_at FROM boards + WHERE id = ? LIMIT 1 + """, arguments: [id] ) else { return nil } let settings = try Row.fetchAll( @@ -124,14 +135,17 @@ final class AppDataRepository { // Read-modify-write rather than an `ON CONFLICT` fold: `INSERT OR REPLACE` deletes the old row // before inserting, so the ratchet has no `excluded`-style handle on the value it replaces. let previous = try Int64.fetchOne(db, sql: "SELECT updated_at FROM boards WHERE id = ?", arguments: [id]) + // An existing tombstone survives the write, so an ordinary upsert can never resurrect a + // deleted Board — deletion is terminal (ADR 0027). Only `deleteBoard` stamps a new one. + let deletedAt = try Int64.fetchOne(db, sql: "SELECT deleted_at FROM boards WHERE id = ?", arguments: [id]) try db.execute( sql: """ - INSERT OR REPLACE INTO boards (id, name, ble_id, transport, created_at, updated_at, sync_seq) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO boards (id, name, ble_id, transport, created_at, updated_at, sync_seq, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ id, name, bleId, transport, createdAt, - ratchetUpdatedAt(previous, updatedAt), try nextSyncSeq(db, syncSeqBoards), + ratchetUpdatedAt(previous, updatedAt), try nextSyncSeq(db, syncSeqBoards), deletedAt, ] ) for (key, value) in settings { @@ -148,12 +162,31 @@ final class AppDataRepository { notifyDataChanged(.boards) } + /// The Rider-facing delete: configuration goes, the Board row stays as a tombstone (ADR 0027). + /// Telemetry and Tune Profiles are untouched — both outlive the Board. + /// + /// The tombstone is an ordinary write, so it moves both sync columns like any other edit. A Board + /// that is not there (or already deleted) is left alone. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `deleteBoardWithSettings` func deleteBoard(_ id: String) { + let deletedAt = nowMs() write { db in try db.execute(sql: "DELETE FROM board_settings WHERE board_id = ?", arguments: [id]) + try db.execute(sql: "DELETE FROM board_warnings WHERE board_id = ?", arguments: [id]) // Alert Rules are Board-owned (#254) — drop them with the Board so no orphan rows survive. try db.execute(sql: "DELETE FROM alerts WHERE board_id = ?", arguments: [id]) - try db.execute(sql: "DELETE FROM boards WHERE id = ?", arguments: [id]) + guard let row = try Row.fetchOne( + db, + sql: "SELECT updated_at, deleted_at FROM boards WHERE id = ?", + arguments: [id] + ), row["deleted_at"] as Int64? == nil else { return } + try db.execute( + sql: "UPDATE boards SET deleted_at = ?, updated_at = ?, sync_seq = ? WHERE id = ?", + arguments: [ + deletedAt, ratchetUpdatedAt(row["updated_at"] as Int64?, deletedAt), + try nextSyncSeq(db, syncSeqBoards), id, + ] + ) } notifyDataChanged(.boards) } @@ -211,6 +244,7 @@ final class AppDataRepository { "legalMode": values["legalMode"] ?? ["enabled": false], "link": link, "updatedAt": row["updated_at"] as Int64, + "deletedAt": row["deleted_at"] as Int64?, ] } diff --git a/modules/vescape-core/ios/telemetry/BoardTombstoneTests.swift b/modules/vescape-core/ios/telemetry/BoardTombstoneTests.swift new file mode 100644 index 000000000..9443a9793 --- /dev/null +++ b/modules/vescape-core/ios/telemetry/BoardTombstoneTests.swift @@ -0,0 +1,158 @@ +import XCTest +import GRDB +@testable import VescapeCore + +/// Board tombstones (ADR 0027): deleting a Board stamps `boards.deleted_at` instead of removing the +/// row, so Ride History outlives the Board that produced it. Configuration still goes; telemetry and +/// Tune Profiles never did and still do not. +/// +/// Runs the real migrator and the real repository against an in-memory database. +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt +final class BoardTombstoneTests: XCTestCase { + private var queue: DatabaseQueue! + private var repo: AppDataRepository! + + override func setUpWithError() throws { + queue = try DatabaseQueue() + try TelemetryDatabase.migrator.migrate(queue) + repo = AppDataRepository.forTesting(dbWriter: queue) + } + + override func tearDownWithError() throws { + repo = nil + queue = nil + } + + private func seedBoard(_ id: String = "board-1") { + repo.upsertBoard([ + "id": id, + "name": "ADV", + "createdAt": Int64(1000), + "link": ["bleId": "AA:BB", "transport": "direct"] as [String: Any?], + ]) + } + + private func deletedAt(_ id: String) throws -> Int64? { + try queue.read { db in + try Int64.fetchOne(db, sql: "SELECT deleted_at FROM boards WHERE id = ?", arguments: [id]) + } + } + + private func rowCount(_ table: String, boardId: String) throws -> Int { + try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(table) WHERE board_id = ?", arguments: [boardId]) ?? 0 + } + } + + // MARK: Migration + + func testMigrationAddsNullableDeletedAtLeavingExistingRowsAlive() throws { + let columns = try queue.read { db in try db.columns(in: "boards") } + let deletedAt = columns.first { $0.name == "deleted_at" } + + XCTAssertNotNil(deletedAt, "boards is missing deleted_at") + XCTAssertFalse(deletedAt?.isNotNull ?? true, "deleted_at must be nullable — null means alive") + + seedBoard() + XCTAssertNil(try self.deletedAt("board-1"), "a fresh Board must start alive") + } + + /// Re-running the whole migrator over a migrated database has to be a no-op, not a duplicate + /// column error. + func testMigrationIsANoOpOnReRun() throws { + XCTAssertNoThrow(try TelemetryDatabase.migrator.migrate(queue)) + } + + // MARK: Delete + + func testDeleteKeepsTheRowAndStampsDeletedAt() throws { + seedBoard() + + repo.deleteBoard("board-1") + + XCTAssertNotNil(try deletedAt("board-1"), "delete removed the row instead of tombstoning it") + } + + func testDeleteStillRemovesBoardConfiguration() throws { + seedBoard() + repo.upsertAlertRule([ + "boardId": "board-1", + "id": "rule-1", + "controlId": "speed", + "threshold": 40.0, + "enabled": true, + "soundType": "beep", + "createdAt": Int64(1000), + ]) + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO board_warnings (board_id, kind, severity, first_detected_at, last_detected_at, payload_json) + VALUES ('board-1', 'test-kind', 'warn', 1, 1, '{}') + """ + ) + } + + repo.deleteBoard("board-1") + + XCTAssertEqual(try rowCount("board_settings", boardId: "board-1"), 0, "board settings survived") + XCTAssertEqual(try rowCount("board_warnings", boardId: "board-1"), 0, "board warnings survived") + XCTAssertEqual(try rowCount("alerts", boardId: "board-1"), 0, "alert rules survived") + } + + /// The reason the tombstone exists: history is what the delete must not take with it. + func testDeleteLeavesTelemetryAndTuneProfilesUntouched() throws { + seedBoard() + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO tune_profiles (id, board_id, name, fields_json, created_at, updated_at) + VALUES ('tune-1', 'board-1', 'Stiff', '{}', 1, 1) + """ + ) + } + + repo.deleteBoard("board-1") + + XCTAssertEqual(try rowCount("tune_profiles", boardId: "board-1"), 1, "tune profiles were deleted") + } + + // MARK: Reads + + func testTombstonedBoardLeavesTheRiderFacingListButStaysResolvableById() throws { + seedBoard() + seedBoard("board-2") + + repo.deleteBoard("board-1") + + XCTAssertEqual(repo.getBoards().compactMap { $0["id"] as? String }, ["board-2"]) + XCTAssertNotNil(repo.getBoard("board-1"), "history can no longer name the deleted Board") + } + + func testUpsertNeverResurrectsATombstonedBoard() throws { + seedBoard() + repo.deleteBoard("board-1") + + seedBoard() + + XCTAssertNotNil(try deletedAt("board-1"), "an upsert cleared the tombstone") + XCTAssertTrue(repo.getBoards().isEmpty, "a resurrected Board came back to the list") + } + + /// A tombstone is an ordinary write: the server only keeps it if it arrives with a newer stamp + /// and the upload scan only sees it if its Sync Cursor moved. + func testDeleteMovesBothSyncColumns() throws { + seedBoard() + let before = try queue.read { db in + try Row.fetchOne(db, sql: "SELECT updated_at, sync_seq FROM boards WHERE id = 'board-1'")! + } + + repo.deleteBoard("board-1") + + let after = try queue.read { db in + try Row.fetchOne(db, sql: "SELECT updated_at, sync_seq FROM boards WHERE id = 'board-1'")! + } + XCTAssertGreaterThan(after["updated_at"] as Int64, before["updated_at"] as Int64) + XCTAssertGreaterThan(after["sync_seq"] as Int64, before["sync_seq"] as Int64) + } +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 5fa8c1cba..8fa6efa76 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -491,6 +491,17 @@ enum TelemetryDatabase { } } + // Board tombstones (#279). Deleting a Board stops removing its row and stamps `deleted_at` + // instead, so Ride History outlives the Board that produced it (ADR 0027). Additive: existing + // rows stay null, i.e. alive. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_33_34` + migrator.registerMigration("v34_board_deleted_at") { db in + let hasDeletedAt = try db.columns(in: "boards").contains { $0.name == "deleted_at" } + if !hasDeletedAt { + try db.execute(sql: "ALTER TABLE boards ADD COLUMN deleted_at INTEGER") + } + } + return migrator } } diff --git a/modules/vescape-core/src/e2eFake.ts b/modules/vescape-core/src/e2eFake.ts index d437a0c3e..e6d197aa8 100644 --- a/modules/vescape-core/src/e2eFake.ts +++ b/modules/vescape-core/src/e2eFake.ts @@ -684,8 +684,13 @@ export const e2eFake = { upsertBoard(board: BoardInput): void { // Stand in for native: the sync cursor is stamped by the store on every write, never by the caller. - const stored: Board = { ...board, updatedAt: Date.now() } - const index = e2eBoards.findIndex((b) => b.id === stored.id) + const index = e2eBoards.findIndex((b) => b.id === board.id) + // A tombstone survives an upsert, like native — only a delete stamps one. + const stored: Board = { + ...board, + updatedAt: Date.now(), + deletedAt: index >= 0 ? e2eBoards[index].deletedAt : null, + } if (index >= 0) { e2eBoards[index] = stored } else { @@ -718,6 +723,7 @@ export const e2eFake = { description: 'Seeded by Maestro', createdAt: seededAt, updatedAt: seededAt, + deletedAt: null, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', @@ -740,6 +746,7 @@ export const e2eFake = { description: 'Seeded by Maestro', createdAt: seededAt, updatedAt: seededAt, + deletedAt: null, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', @@ -763,6 +770,7 @@ export const e2eFake = { description: 'Seeded by Maestro', createdAt: seededAt, updatedAt: seededAt, + deletedAt: null, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index f7aad152a..1788df867 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -176,6 +176,13 @@ export interface Board { * from JS is ignored — read it, do not author it. */ updatedAt: number + /** + * Tombstone stamp: epoch ms of the rider's delete, `null` while the Board is alive. A deleted + * Board keeps its row so Ride History can still name it (ADR 0027) — {@link getBoards} filters + * tombstones, {@link getBoard} deliberately does not. Native-owned like {@link updatedAt}: + * deletion goes through {@link deleteBoard}, never through an upsert. + */ + deletedAt: number | null batteryConfig: BatteryConfig | null /** Last Battery SoC Estimate persisted natively; survives full app kill. `undefined` before first session. */ lastBattery?: LastBattery | null @@ -217,9 +224,11 @@ export interface Board { /** * Write shape for {@link upsertBoard}. Native stamps `updatedAt` from its own clock on every write, - * so callers never author it — a board that has never been persisted has no cursor yet. + * so callers never author it — a board that has never been persisted has no cursor yet. `deletedAt` + * is out for the same reason: a tombstone is stamped by {@link deleteBoard} alone, and an upsert + * never clears the one already on the row. */ -export type BoardInput = Omit +export type BoardInput = Omit export interface LastBattery { percent: number diff --git a/src/modules/alerts/store/alertPresetStore.test.ts b/src/modules/alerts/store/alertPresetStore.test.ts index 7a18b646b..c715270ef 100644 --- a/src/modules/alerts/store/alertPresetStore.test.ts +++ b/src/modules/alerts/store/alertPresetStore.test.ts @@ -29,6 +29,7 @@ function makeBoard(overrides?: { description: null, createdAt: 1, updatedAt: 1, + deletedAt: null, // Honor an explicit `null` (invalid config) — `??` would swallow it back to the valid default. batteryConfig: overrides && 'batteryConfig' in overrides ? (overrides.batteryConfig ?? null) : VALID_BATTERY, diff --git a/src/modules/board/store/boardStore.test.ts b/src/modules/board/store/boardStore.test.ts index c9704f03a..39afe35e8 100644 --- a/src/modules/board/store/boardStore.test.ts +++ b/src/modules/board/store/boardStore.test.ts @@ -102,6 +102,7 @@ test('stored Board Link survives a store reload from native boards', async () => description: null, createdAt: 1, updatedAt: 1, + deletedAt: null, batteryConfig: null, link: null, } @@ -132,6 +133,7 @@ test('updated battery config survives a store reload from native boards', async description: null, createdAt: 1, updatedAt: 1, + deletedAt: null, batteryConfig: { mode: 'preset', cellPresetId: 'molicel:21700:p50b', diff --git a/src/modules/board/store/boardStore.ts b/src/modules/board/store/boardStore.ts index 546271b86..1876f57d2 100644 --- a/src/modules/board/store/boardStore.ts +++ b/src/modules/board/store/boardStore.ts @@ -95,6 +95,7 @@ export const useBoardStore = create((set, get) => ({ // Native owns `updatedAt` (the incremental-sync cursor) and stamps it from its own clock on // the upsert below; this optimistic value only holds until the next load() replaces the row. updatedAt: now, + deletedAt: null, batteryConfig: batteryConfig ?? DEFAULT_BATTERY_CONFIG, topSpeedKmh, alertPreset: alertPreset ?? null, From 68144ca0c0ea9d7980072d90faa6b5bd0bb6b6af Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sat, 1 Aug 2026 02:00:59 +0200 Subject: [PATCH 06/24] Key telemetry on board_id #280 --- .../protocol/VescTelemetryMapper.kt | 1 + .../telemetry/HistoryGpsProjection.kt | 15 +- .../telemetry/ProfileStatsRepository.kt | 43 ++- .../telemetry/TelemetryBucketBuilder.kt | 41 +-- .../vescapecore/telemetry/TelemetryDao.kt | 63 ++-- .../telemetry/TelemetryDatabase.kt | 288 ++++++++++++++++- .../telemetry/TelemetryEntities.kt | 32 +- .../telemetry/TelemetryRepository.kt | 173 ++++++----- .../telemetry/BoardTombstoneTest.kt | 2 +- .../telemetry/FavoriteSummaryBuilderTest.kt | 5 +- .../telemetry/HistoryGpsProjectionTest.kt | 4 +- .../telemetry/MetricSanitizerTest.kt | 4 +- .../telemetry/ProfileStatsRepositoryTest.kt | 3 +- .../telemetry/SyncCursorMigrationTest.kt | 2 +- .../TelemetryBoardIdMigrationTest.kt | 254 +++++++++++++++ .../telemetry/TelemetryBucketBuilderTest.kt | 42 ++- .../telemetry/TelemetryPipelineTest.kt | 1 + .../sanitizers/FreeSpinMetricSanitizerTest.kt | 4 +- .../LowSpeedAverageSpeedSanitizerTest.kt | 2 +- .../connection/BoardSessionController.swift | 1 + .../ios/telemetry/FavoriteStoreTests.swift | 2 +- .../telemetry/ProfileStatsRepository.swift | 71 +++-- .../telemetry/SyncCursorMigrationTests.swift | 6 +- .../telemetry/TelemetryBucketBuilder.swift | 12 +- .../ios/telemetry/TelemetryDao.swift | 54 ++-- .../ios/telemetry/TelemetryDatabase.swift | 289 ++++++++++++++++++ .../telemetry/TelemetryMigrationTests.swift | 184 +++++++++++ .../ios/telemetry/TelemetryPipeline.swift | 10 +- .../ios/telemetry/TelemetryRangePayload.swift | 54 ++-- .../ios/telemetry/TelemetryRepository.swift | 114 +++---- modules/vescape-core/src/e2eFake.ts | 76 ++--- modules/vescape-core/src/index.ts | 43 +-- .../history/components/HistoryPanelNav.tsx | 6 +- .../components/HistorySessionSheet.tsx | 2 +- src/modules/history/lib/favoriteRoute.test.ts | 4 +- src/modules/history/lib/favorites.test.ts | 10 +- src/modules/history/lib/favorites.ts | 4 +- src/modules/history/lib/markerOverlap.test.ts | 4 +- src/modules/history/lib/mediaHistory.test.ts | 4 +- src/modules/history/lib/rideFormat.ts | 10 +- src/modules/history/lib/sessions.test.ts | 6 +- src/modules/history/lib/sessions.ts | 20 +- .../history/store/historyStore.test.ts | 22 +- src/modules/history/store/historyStore.ts | 12 +- .../main/history/HistoryRideDetail.tsx | 2 +- .../main/history/HistoryTelemetryPanel.tsx | 6 +- .../main/history/useHistoryFavorites.ts | 4 +- src/screens/main/mainState.test.ts | 4 +- src/screens/main/mainState.ts | 2 +- src/screens/showcase/mapShowcaseFixtures.ts | 8 +- src/test-utils/factories.ts | 8 +- 51 files changed, 1589 insertions(+), 444 deletions(-) create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryMapper.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryMapper.kt index 3d9c6ad94..26746f957 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryMapper.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/protocol/VescTelemetryMapper.kt @@ -10,6 +10,7 @@ internal fun RefloatTelemetry.toCapture(session: SessionConfig, canId: Int?): Te TelemetryCapture( capturedAtMs = lastPacketAt, elapsedRealtimeMs = SystemClock.elapsedRealtime(), + boardId = session.appBoardId, deviceId = session.deviceId, deviceName = session.deviceName, canId = canId, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/HistoryGpsProjection.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/HistoryGpsProjection.kt index fbae2558e..e0122540c 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/HistoryGpsProjection.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/HistoryGpsProjection.kt @@ -11,13 +11,14 @@ internal data class HistoryGpsPoint( val location: ScaledLocation, val distanceFromPreviousCm: Long?, ) { - fun toSampleMap(): Map { + /** [boardNames] resolves `boards.id` -> name on read; the row never carried one (ADR 0028). */ + fun toSampleMap(boardNames: Map): Map { val telemetry = sample.state return mapOf( "id" to sample.id, "capturedAtMs" to telemetry.capturedAtMs, - "deviceId" to telemetry.deviceId, - "deviceName" to (telemetry.deviceName ?: UNKNOWN_TELEMETRY_DEVICE_NAME), + "boardId" to telemetry.boardId, + "boardName" to (telemetry.boardId?.let { boardNames[it] } ?: UNKNOWN_TELEMETRY_BOARD_NAME), "latitude" to location.latitudeE7 / 10_000_000.0, "longitude" to location.longitudeE7 / 10_000_000.0, "speedMps" to location.gpsSpeedCentiMps?.let { it / 100.0 }, @@ -34,8 +35,7 @@ internal data class HistoryGpsPoint( val telemetry = sample.state return BucketLocationPoint( capturedAtMs = telemetry.capturedAtMs, - deviceId = telemetry.deviceId, - deviceName = telemetry.deviceName, + boardId = telemetry.boardId, precise = true, distanceFromPreviousCm = distanceFromPreviousCm, gpsSpeedCentiMps = location.gpsSpeedCentiMps, @@ -73,8 +73,9 @@ internal fun List.toHistoryGpsPoints(): List.toGpsSampleMaps(): List> = - toHistoryGpsPoints().map { it.toSampleMap() } +internal fun List.toGpsSampleMaps( + boardNames: Map, +): List> = toHistoryGpsPoints().map { it.toSampleMap(boardNames) } internal fun List.toBucketLocationPoints(): List = toHistoryGpsPoints().map { it.toBucketPoint() } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt index 70136c82c..39b764463 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt @@ -16,7 +16,12 @@ class ProfileStatsRepository private constructor(context: Context) { suspend fun getTotalProfileStats(): Map { val buckets = dao.getAllHistoryBucketsAsc() val markers = markersForBuckets(buckets) - return computeProfileStatsForBuckets(buckets, markers, month = null) + return computeProfileStatsForBuckets( + buckets = buckets, + markers = markers, + month = null, + bleIdByBoardId = bleIdByBoardId(), + ) } suspend fun getMonthlyProfileStats(options: Map): Map { @@ -32,13 +37,14 @@ class ProfileStatsRepository private constructor(context: Context) { buckets = buckets, markers = markers, month = ProfileStatsMonth(year = year, month = month), + bleIdByBoardId = bleIdByBoardId(), ) } suspend fun getProfileStatMonths(): List> { val buckets = dao.getAllHistoryBucketsAsc() val markers = markersForBuckets(buckets) - return computeProfileStatMonthsForBuckets(buckets, markers).map { month -> + return computeProfileStatMonthsForBuckets(buckets, markers, bleIdByBoardId = bleIdByBoardId()).map { month -> mapOf("year" to month.year, "month" to month.month) } } @@ -52,6 +58,13 @@ class ProfileStatsRepository private constructor(context: Context) { return dao.getMarkers(fromMs = fromMs, toMs = toMs, deviceId = null) } + /** + * Buckets key on the Board (ADR 0028); markers still key on the BLE identifier. Session boundary + * detection compares the two, so it needs the translation. + */ + private suspend fun bleIdByBoardId(): Map = + dao.getBoards().mapNotNull { board -> board.bleId?.let { board.id to it } }.toMap() + companion object { @Volatile private var instance: ProfileStatsRepository? = null @@ -71,7 +84,7 @@ class ProfileStatsRepository private constructor(context: Context) { } private data class ProfileSessionAggregate( - val deviceId: String, + val boardId: String, var startAtMs: Long, var endAtMs: Long, var sampleCount: Int, @@ -90,8 +103,9 @@ internal fun computeProfileStatsForBuckets( markers: List, month: ProfileStatsMonth?, zoneId: ZoneId = ZoneId.systemDefault(), + bleIdByBoardId: Map = emptyMap(), ): Map { - val sessions = groupProfileSessions(buckets, markers).filter { it.avgSpeedSampleCount > 0 } + val sessions = groupProfileSessions(buckets, markers, bleIdByBoardId).filter { it.avgSpeedSampleCount > 0 } val included = if (month == null) { sessions } else { @@ -140,8 +154,9 @@ internal fun computeProfileStatMonthsForBuckets( buckets: List, markers: List, zoneId: ZoneId = ZoneId.systemDefault(), + bleIdByBoardId: Map = emptyMap(), ): List { - return groupProfileSessions(buckets, markers) + return groupProfileSessions(buckets, markers, bleIdByBoardId) .filter { it.avgSpeedSampleCount > 0 } .map { profileMonth(it.startAtMs, zoneId) } .distinct() @@ -151,6 +166,7 @@ internal fun computeProfileStatMonthsForBuckets( private fun groupProfileSessions( buckets: List, markers: List, + bleIdByBoardId: Map, ): List { if (buckets.isEmpty()) return emptyList() val sorted = buckets.sortedBy { it.firstSampleAtMs } @@ -160,15 +176,15 @@ private fun groupProfileSessions( for (bucket in sorted) { if (bucket.sampleCount <= 0) continue - val boundaryBefore = markerBoundaryForBucket(bucket, markers) - val breakByDevice = current == null || current.deviceId != bucket.deviceId + val boundaryBefore = markerBoundaryForBucket(bucket, markers, bleIdByBoardId) + val breakByBoard = current == null || current.boardId != bucket.boardId val breakByGap = previous != null && bucket.firstSampleAtMs - previous.lastSampleAtMs > PROFILE_SESSION_GAP_MS val breakByBoundary = boundaryBefore != null && PROFILE_BREAK_BOUNDARIES.contains(boundaryBefore) - if (breakByDevice || breakByGap || breakByBoundary) { + if (breakByBoard || breakByGap || breakByBoundary) { current?.let { sessions.add(it) } current = ProfileSessionAggregate( - deviceId = bucket.deviceId, + boardId = bucket.boardId, startAtMs = bucket.firstSampleAtMs, endAtMs = bucket.lastSampleAtMs, sampleCount = 0, @@ -194,20 +210,17 @@ private fun groupProfileSessions( private fun markerBoundaryForBucket( bucket: TelemetryMinuteBucketEntity, markers: List, + bleIdByBoardId: Map, ): String? { + val bucketDeviceId = bleIdByBoardId[bucket.boardId] ?: UNKNOWN_TELEMETRY_DEVICE_ID val marker = markers.lastOrNull { marker -> marker.occurredAtMs >= bucket.firstSampleAtMs - 5_000L && marker.occurredAtMs <= bucket.firstSampleAtMs + 1_000L && - sameMarkerDeviceAsBucket(marker.deviceId, bucket.deviceId) + (marker.deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID) == bucketDeviceId } return marker?.type } -private fun sameMarkerDeviceAsBucket(markerDeviceId: String?, bucketDeviceId: String): Boolean { - val normalizedMarker = markerDeviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID - return normalizedMarker == bucketDeviceId -} - private fun mergeBucketIntoSession( session: ProfileSessionAggregate, bucket: TelemetryMinuteBucketEntity, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt index 13edd5793..42f0c12d2 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt @@ -6,13 +6,27 @@ import kotlin.math.roundToLong // @parity /modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift internal const val TELEMETRY_BUCKET_SIZE_MS = 60_000L internal const val UNKNOWN_TELEMETRY_DEVICE_ID = "" -internal const val UNKNOWN_TELEMETRY_DEVICE_NAME = "VESC Board" + +/** + * Stand-in Board id for buckets whose samples match no saved Board. `board_id` is part of the + * bucket primary key, so unattributed rows need a value rather than null. + */ +internal const val UNKNOWN_TELEMETRY_BOARD_ID = "" +internal const val UNKNOWN_TELEMETRY_BOARD_NAME = "VESC Board" + +/** + * Id prefix for the tombstoned Boards migration 34→35 mints for telemetry whose BLE identifier + * resolves to nothing. Derived from the identifier rather than random so the mint is idempotent. + */ +internal const val ORPHAN_BOARD_ID_PREFIX = "orphan-" private const val MAX_ENERGY_SAMPLE_GAP_MS = 5_000L internal data class BucketTelemetryPoint( val capturedAtMs: Long, + /** Owning Board (`boards.id`); the durable identity telemetry is keyed on (ADR 0028). */ + val boardId: String?, + /** BLE identifier. Not stored on frames or buckets — only Metric Exclusion Ranges still key on it. */ val deviceId: String?, - val deviceName: String?, val speedCentiKmh: Int, val batteryVoltageMv: Int, val motorCurrentMa: Int, @@ -32,8 +46,7 @@ internal data class BucketTelemetryPoint( internal data class BucketLocationPoint( val capturedAtMs: Long, - val deviceId: String?, - val deviceName: String?, + val boardId: String?, val precise: Boolean, val distanceFromPreviousCm: Long?, val gpsSpeedCentiMps: Int?, @@ -49,17 +62,15 @@ internal fun buildTelemetryBuckets( val buckets = linkedMapOf, MutableBucket>() for (point in telemetryPoints) { val bucketStart = point.capturedAtMs - (point.capturedAtMs % TELEMETRY_BUCKET_SIZE_MS) - val deviceId = point.deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID - val key = bucketStart to deviceId - val bucket = buckets.getOrPut(key) { - MutableBucket(bucketStart, deviceId, point.deviceName) - } + val boardId = point.boardId ?: UNKNOWN_TELEMETRY_BOARD_ID + val key = bucketStart to boardId + val bucket = buckets.getOrPut(key) { MutableBucket(bucketStart, boardId) } bucket.add(point) } for (point in locationPoints) { val bucketStart = point.capturedAtMs - (point.capturedAtMs % TELEMETRY_BUCKET_SIZE_MS) - val deviceId = point.deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID - val key = bucketStart to deviceId + val boardId = point.boardId ?: UNKNOWN_TELEMETRY_BOARD_ID + val key = bucketStart to boardId val bucket = buckets[key] ?: continue bucket.addLocation(point) } @@ -68,8 +79,7 @@ internal fun buildTelemetryBuckets( private class MutableBucket( private val bucketStartMs: Long, - private val deviceId: String, - private var deviceName: String?, + private val boardId: String, ) { private var sampleCount = 0 private var firstSampleAtMs = Long.MAX_VALUE @@ -101,7 +111,6 @@ private class MutableBucket( fun add(point: BucketTelemetryPoint) { sampleCount++ - if (point.deviceName != null) deviceName = point.deviceName firstSampleAtMs = minOf(firstSampleAtMs, point.capturedAtMs) lastSampleAtMs = maxOf(lastSampleAtMs, point.capturedAtMs) val absSpeed = abs(point.speedCentiKmh) @@ -147,7 +156,6 @@ private class MutableBucket( fun addLocation(point: BucketLocationPoint) { gpsPointCount++ if (point.precise) preciseGpsPointCount++ - if (point.deviceName != null) deviceName = point.deviceName firstSampleAtMs = minOf(firstSampleAtMs, point.capturedAtMs) lastSampleAtMs = maxOf(lastSampleAtMs, point.capturedAtMs) if (firstLatitudeE7 == null && point.latitudeE7 != null) { @@ -171,8 +179,7 @@ private class MutableBucket( fun toEntity(now: Long = System.currentTimeMillis()): TelemetryMinuteBucketEntity = TelemetryMinuteBucketEntity( updatedAt = now, bucketStartMs = bucketStartMs, - deviceId = deviceId, - deviceName = deviceName, + boardId = boardId, sampleCount = sampleCount, firstSampleAtMs = firstSampleAtMs, lastSampleAtMs = lastSampleAtMs, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 4dae92cac..92cc270ec 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -88,15 +88,15 @@ interface TelemetryDao { @Update suspend fun updateBucket(bucket: TelemetryMinuteBucketEntity) - @Query("SELECT * FROM telemetry_minute_buckets WHERE bucket_start_ms = :bucketStartMs AND device_id = :deviceId LIMIT 1") - suspend fun getBucket(bucketStartMs: Long, deviceId: String): TelemetryMinuteBucketEntity? + @Query("SELECT * FROM telemetry_minute_buckets WHERE bucket_start_ms = :bucketStartMs AND board_id = :boardId LIMIT 1") + suspend fun getBucket(bucketStartMs: Long, boardId: String): TelemetryMinuteBucketEntity? @Transaction suspend fun upsertBuckets(buckets: Collection) { for (bucket in buckets) { // A merge rewrites a row the scan may already have passed, so the seq moves on both branches. val next = bucket.copy(syncSeq = nextSyncSeq(SYNC_SEQ_MINUTE_BUCKETS)) - val existing = getBucket(next.bucketStartMs, next.deviceId) + val existing = getBucket(next.bucketStartMs, next.boardId) if (existing == null) { insertBucket(next) } else { @@ -167,7 +167,7 @@ interface TelemetryDao { @Query( """ SELECT * FROM telemetry_minute_buckets - WHERE (:deviceId IS NULL OR device_id = :deviceId) + WHERE (:boardId IS NULL OR board_id = :boardId) AND bucket_start_ms <= :beforeMs AND bucket_start_ms >= :fromMs AND bucket_start_ms <= :toMs @@ -180,7 +180,7 @@ interface TelemetryDao { fromMs: Long, toMs: Long, beforeMs: Long, - deviceId: String?, + boardId: String?, limit: Int, ): List @@ -219,7 +219,7 @@ interface TelemetryDao { """ SELECT * FROM telemetry_frames WHERE captured_at_ms <= :fromMs - AND (:deviceId IS NULL OR device_id = :deviceId) + AND (:boardId IS NULL OR board_id = :boardId) AND (flags & :keyframeFlag) != 0 ORDER BY captured_at_ms DESC LIMIT 1 @@ -227,7 +227,7 @@ interface TelemetryDao { ) suspend fun getLatestKeyframeBefore( fromMs: Long, - deviceId: String?, + boardId: String?, keyframeFlag: Int = TELEMETRY_FLAG_KEYFRAME, ): TelemetryFrameEntity? @@ -236,30 +236,30 @@ interface TelemetryDao { SELECT * FROM telemetry_frames WHERE captured_at_ms >= :fromMs AND captured_at_ms <= :toMs - AND (:deviceId IS NULL OR device_id = :deviceId) + AND (:boardId IS NULL OR board_id = :boardId) ORDER BY captured_at_ms ASC LIMIT :limit """, ) - suspend fun getFrames(fromMs: Long, toMs: Long, deviceId: String?, limit: Int): List + suspend fun getFrames(fromMs: Long, toMs: Long, boardId: String?, limit: Int): List @Query( """ - SELECT DISTINCT device_id FROM telemetry_frames + SELECT DISTINCT board_id FROM telemetry_frames WHERE captured_at_ms >= :fromMs AND captured_at_ms <= :toMs - AND device_id IS NOT NULL - ORDER BY device_id ASC + AND board_id IS NOT NULL + ORDER BY board_id ASC """, ) - suspend fun getDeviceIdsInRange(fromMs: Long, toMs: Long): List + suspend fun getBoardIdsInRange(fromMs: Long, toMs: Long): List @Query( """ SELECT * FROM telemetry_frames WHERE captured_at_ms >= :fromMs AND captured_at_ms <= :toMs - AND device_id = :deviceId + AND board_id = :boardId ORDER BY captured_at_ms ASC LIMIT 1 """, @@ -267,7 +267,7 @@ interface TelemetryDao { suspend fun getFirstFrameInRange( fromMs: Long, toMs: Long, - deviceId: String, + boardId: String, ): TelemetryFrameEntity? @Query("SELECT COUNT(*) FROM telemetry_frames") @@ -310,12 +310,12 @@ interface TelemetryDao { WHERE captured_at_ms >= :fromMs AND captured_at_ms <= :toMs AND ( - (:deviceId IS NOT NULL AND device_id = :deviceId) - OR (:deviceId IS NULL AND device_id IS NULL) + (:boardId IS NOT NULL AND board_id = :boardId) + OR (:boardId IS NULL AND board_id IS NULL) ) """, ) - suspend fun deleteFramesRange(fromMs: Long, toMs: Long, deviceId: String?): Int + suspend fun deleteFramesRange(fromMs: Long, toMs: Long, boardId: String?): Int @Query( """ @@ -335,16 +335,20 @@ interface TelemetryDao { DELETE FROM telemetry_minute_buckets WHERE last_sample_at_ms >= :fromMs AND first_sample_at_ms <= :toMs - AND device_id = :bucketDeviceId + AND board_id = :bucketBoardId """, ) - suspend fun deleteBucketsRange(fromMs: Long, toMs: Long, bucketDeviceId: String): Int + suspend fun deleteBucketsRange(fromMs: Long, toMs: Long, bucketBoardId: String): Int + /** + * [deviceId] is the BLE identifier the Board carried; markers still key on it (ADR 0028), while + * frames and buckets key on [boardId]. Null on either side means "every device". + */ @Transaction - suspend fun deleteRange(fromMs: Long, toMs: Long, deviceId: String?): Int { - val frames = deleteFramesRange(fromMs, toMs, deviceId) + suspend fun deleteRange(fromMs: Long, toMs: Long, boardId: String?, deviceId: String?): Int { + val frames = deleteFramesRange(fromMs, toMs, boardId) deleteMarkersRange(fromMs, toMs, deviceId) - deleteBucketsRange(fromMs, toMs, deviceId ?: UNKNOWN_TELEMETRY_DEVICE_ID) + deleteBucketsRange(fromMs, toMs, boardId ?: UNKNOWN_TELEMETRY_BOARD_ID) deleteExclusionsRange(fromMs, toMs) return frames } @@ -433,6 +437,18 @@ interface TelemetryDao { ) } + /** + * Every Board including tombstones, for Ride History name resolution. Names are looked up on read + * rather than denormalized onto telemetry rows (ADR 0028), so a rename retroactively relabels the + * history and a deleted Board is still nameable. + */ + @Query("SELECT id, name FROM boards") + suspend fun getBoardNames(): List + + /** The BLE identifier a Board currently claims, for the tables still keyed on it. */ + @Query("SELECT ble_id FROM boards WHERE id = :id LIMIT 1") + suspend fun getBoardBleId(id: String): String? + @Query("SELECT * FROM board_settings WHERE board_id = :boardId") suspend fun getBoardSettings(boardId: String): List @@ -708,7 +724,6 @@ interface TelemetryDao { private fun TelemetryMinuteBucketEntity.merge(next: TelemetryMinuteBucketEntity): TelemetryMinuteBucketEntity { return copy( - deviceName = next.deviceName ?: deviceName, sampleCount = sampleCount + next.sampleCount, firstSampleAtMs = minOf(firstSampleAtMs, next.firstSampleAtMs), lastSampleAtMs = maxOf(lastSampleAtMs, next.lastSampleAtMs), diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index f989f509a..a7dd4f335 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -12,7 +12,7 @@ import java.io.File // @parity /modules/vescape-core/ios/VescapeCoreModule.swift internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" -internal const val TELEMETRY_DATABASE_VERSION = 34 +internal const val TELEMETRY_DATABASE_VERSION = 35 @Database( entities = [ @@ -585,6 +585,291 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * Telemetry keys on the Board id (#280, ADR 0028). `telemetry_frames` and + * `telemetry_minute_buckets` gain `board_id` and lose `device_id` (the BLE identifier) and + * `device_name` (the Board name denormalized at capture time); Ride History resolves the name + * by looking the Board up instead. Markers, diagnostic events and metric exclusion ranges are + * deliberately untouched — that is what crosses the wire for them. + * + * Both tables are rebuilt rather than altered: the bucket primary key moves to + * `(bucket_start_ms, board_id)`, and dropping a column in place needs a SQLite newer than the + * oldest supported device ships. The rebuild is a full copy, so it is the expensive step of + * this upgrade on a phone with a long Ride History. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v35_telemetry_board_id` + */ + internal val MIGRATION_34_35 = object : Migration(34, 35) { + override fun migrate(db: SupportSQLiteDatabase) { + mintOrphanBoards(db) + rebuildFramesOnBoardId(db) + rebuildBucketsOnBoardId(db) + } + } + + /** + * Telemetry whose `device_id` matches no Board would lose both its identity and its label: + * either the Board was hard-deleted before tombstones existed (ADR 0027), or it was re-linked + * to a different peripheral and the old identifier no longer resolves. One tombstoned Board is + * minted per unresolved identifier, named from that telemetry's own historical `device_name`, + * so the history stays joinable, keeps a label, and can be backed up. + * + * The minted row is a tombstone with no Board Link: `deleted_at` keeps it out of every + * Rider-facing list, and a null `ble_id` stops it from ever capturing a future re-link. The id + * is derived from the identifier rather than random so re-running the migration is a no-op. + */ + private fun mintOrphanBoards(db: SupportSQLiteDatabase) { + val now = System.currentTimeMillis() + for (table in listOf("telemetry_frames" to "captured_at_ms", "telemetry_minute_buckets" to "bucket_start_ms")) { + val (name, timeColumn) = table + db.execSQL( + """ + INSERT OR IGNORE INTO boards (id, name, ble_id, created_at, updated_at, sync_seq, deleted_at) + SELECT + '$ORPHAN_BOARD_ID_PREFIX' || t.device_id, + COALESCE( + ( + SELECT n.device_name FROM $name n + WHERE n.device_id = t.device_id AND n.device_name IS NOT NULL + ORDER BY n.$timeColumn DESC LIMIT 1 + ), + '$UNKNOWN_TELEMETRY_BOARD_NAME' + ), + NULL, + MIN(t.$timeColumn), + $now, + 0, + $now + FROM $name t + WHERE t.device_id IS NOT NULL + AND t.device_id != '' + AND NOT EXISTS (SELECT 1 FROM boards b WHERE b.ble_id = t.device_id) + GROUP BY t.device_id + """.trimIndent(), + ) + } + // A minted Board is an ordinary write and has to upload like one. Every row that survived + // migration 32→33 carries a positive `sync_seq`, so zero marks exactly the rows just minted. + db.execSQL( + """ + UPDATE boards + SET sync_seq = (SELECT COALESCE(last_value, 0) FROM sync_sequences WHERE name = 'boards') + rowid + WHERE sync_seq = 0 + """.trimIndent(), + ) + db.execSQL( + "INSERT OR REPLACE INTO sync_sequences (name, last_value) " + + "VALUES ('boards', (SELECT COALESCE(MAX(sync_seq), 0) FROM boards))", + ) + } + + /** + * Resolves a telemetry row's `device_id` to a Board id: the linked Board when one still claims + * the identifier, otherwise the tombstone minted for it above. A row that never carried an + * identifier stays unattributed. + */ + private fun boardIdFromDeviceId(alias: String): String = + """ + CASE + WHEN $alias.device_id IS NULL OR $alias.device_id = '' THEN %s + ELSE COALESCE( + (SELECT b.id FROM boards b WHERE b.ble_id = $alias.device_id LIMIT 1), + '$ORPHAN_BOARD_ID_PREFIX' || $alias.device_id + ) + END + """.trimIndent() + + private fun rebuildFramesOnBoardId(db: SupportSQLiteDatabase) { + val columns = + "captured_at_ms, elapsed_realtime_ms, can_id, flags, changed_mask_1, changed_mask_2, " + + "speed_centi_kmh, battery_voltage_mv, motor_current_ma, battery_current_ma, duty_permille, " + + "pitch_centi_deg, roll_centi_deg, balance_pitch_centi_deg, balance_current_ma, erpm, state, " + + "switch_state, adc1_milli, adc2_milli, odometer_cm, temp_mosfet_deci_c, temp_motor_deci_c, " + + "fault_code, latitude_e7, longitude_e7, gps_speed_centi_mps, bearing_centi_deg, accuracy_cm, " + + "altitude_cm, location_timestamp_ms" + db.execSQL("DROP INDEX IF EXISTS index_telemetry_frames_captured_at_ms") + db.execSQL("DROP INDEX IF EXISTS index_telemetry_frames_device_id_captured_at_ms") + db.execSQL("DROP INDEX IF EXISTS index_telemetry_frames_fault") + db.execSQL( + """ + CREATE TABLE telemetry_frames_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + captured_at_ms INTEGER NOT NULL, + elapsed_realtime_ms INTEGER NOT NULL, + board_id TEXT, + can_id INTEGER, + flags INTEGER NOT NULL, + changed_mask_1 INTEGER NOT NULL, + changed_mask_2 INTEGER NOT NULL, + speed_centi_kmh INTEGER, + battery_voltage_mv INTEGER, + motor_current_ma INTEGER, + battery_current_ma INTEGER, + duty_permille INTEGER, + pitch_centi_deg INTEGER, + roll_centi_deg INTEGER, + balance_pitch_centi_deg INTEGER, + balance_current_ma INTEGER, + erpm INTEGER, + state INTEGER, + switch_state INTEGER, + adc1_milli INTEGER, + adc2_milli INTEGER, + odometer_cm INTEGER, + temp_mosfet_deci_c INTEGER, + temp_motor_deci_c INTEGER, + fault_code INTEGER, + latitude_e7 INTEGER, + longitude_e7 INTEGER, + gps_speed_centi_mps INTEGER, + bearing_centi_deg INTEGER, + accuracy_cm INTEGER, + altitude_cm INTEGER, + location_timestamp_ms INTEGER + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO telemetry_frames_new (id, board_id, $columns) + SELECT f.id, ${boardIdFromDeviceId("f").format("NULL")}, $columns + FROM telemetry_frames f + """.trimIndent(), + ) + db.execSQL("DROP TABLE telemetry_frames") + db.execSQL("ALTER TABLE telemetry_frames_new RENAME TO telemetry_frames") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_frames_captured_at_ms " + + "ON telemetry_frames(captured_at_ms)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_frames_board_id_captured_at_ms " + + "ON telemetry_frames(board_id, captured_at_ms)", + ) + db.execSQL( + """ + CREATE INDEX IF NOT EXISTS index_telemetry_frames_fault + ON telemetry_frames(captured_at_ms) + WHERE fault_code IS NOT NULL AND fault_code != 0 + """.trimIndent(), + ) + } + + /** + * The primary key move from `(bucket_start_ms, device_id)` to `(bucket_start_ms, board_id)` is + * a table rebuild, not an `ALTER`. `updated_at` and `sync_seq` were added to this table earlier + * in the same release, so the copy has to carry them across explicitly or every bucket silently + * resets its Sync Cursor position. + */ + private fun rebuildBucketsOnBoardId(db: SupportSQLiteDatabase) { + val columns = + "bucket_start_ms, sample_count, first_sample_at_ms, last_sample_at_ms, " + + "sum_abs_speed_centi_kmh, moving_speed_sample_count, sum_moving_abs_speed_centi_kmh, " + + "max_abs_speed_centi_kmh, min_battery_voltage_mv, max_motor_current_abs_ma, " + + "max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, " + + "max_duty_abs_permille, fault_count, first_odometer_cm, last_odometer_cm, gps_point_count, " + + "precise_gps_point_count, gps_distance_cm, max_gps_speed_centi_mps, max_temp_mosfet_deci_c, " + + "max_temp_motor_deci_c, first_latitude_e7, first_longitude_e7, first_moving_at_ms, " + + "last_moving_at_ms, updated_at, sync_seq" + db.execSQL("DROP INDEX IF EXISTS index_telemetry_minute_buckets_bucket_start_ms") + db.execSQL("DROP INDEX IF EXISTS index_telemetry_minute_buckets_updated_at") + db.execSQL("DROP INDEX IF EXISTS index_telemetry_minute_buckets_sync_seq") + db.execSQL( + """ + CREATE TABLE telemetry_minute_buckets_new ( + bucket_start_ms INTEGER NOT NULL, + board_id TEXT NOT NULL, + sample_count INTEGER NOT NULL, + first_sample_at_ms INTEGER NOT NULL, + last_sample_at_ms INTEGER NOT NULL, + sum_abs_speed_centi_kmh INTEGER NOT NULL, + moving_speed_sample_count INTEGER, + sum_moving_abs_speed_centi_kmh INTEGER, + max_abs_speed_centi_kmh INTEGER NOT NULL, + min_battery_voltage_mv INTEGER, + max_motor_current_abs_ma INTEGER NOT NULL, + max_battery_current_abs_ma INTEGER NOT NULL, + battery_used_wh_milli INTEGER NOT NULL, + battery_regen_wh_milli INTEGER NOT NULL, + max_duty_abs_permille INTEGER NOT NULL, + fault_count INTEGER NOT NULL, + first_odometer_cm INTEGER, + last_odometer_cm INTEGER, + gps_point_count INTEGER NOT NULL, + precise_gps_point_count INTEGER NOT NULL, + gps_distance_cm INTEGER NOT NULL, + max_gps_speed_centi_mps INTEGER, + max_temp_mosfet_deci_c INTEGER, + max_temp_motor_deci_c INTEGER, + first_latitude_e7 INTEGER, + first_longitude_e7 INTEGER, + first_moving_at_ms INTEGER, + last_moving_at_ms INTEGER, + updated_at INTEGER NOT NULL, + sync_seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_start_ms, board_id) + ) + """.trimIndent(), + ) + // Grouped rather than copied row-for-row so the rebuild is total. A `board_id` collision on + // the new key needs two identifiers resolving to one Board inside one minute, which the + // resolver below cannot currently produce — but an ungrouped copy would abort the whole + // migration on a constraint error if it ever did, stranding the database mid-upgrade. The + // fold sums the additive lanes and takes the extreme of the peaks, as an upsert merge would. + db.execSQL( + """ + INSERT INTO telemetry_minute_buckets_new (board_id, $columns) + SELECT + ${boardIdFromDeviceId("b").format("''")} AS board_id, + b.bucket_start_ms, + SUM(b.sample_count), + MIN(b.first_sample_at_ms), + MAX(b.last_sample_at_ms), + SUM(b.sum_abs_speed_centi_kmh), + SUM(b.moving_speed_sample_count), + SUM(b.sum_moving_abs_speed_centi_kmh), + MAX(b.max_abs_speed_centi_kmh), + MIN(b.min_battery_voltage_mv), + MAX(b.max_motor_current_abs_ma), + MAX(b.max_battery_current_abs_ma), + SUM(b.battery_used_wh_milli), + SUM(b.battery_regen_wh_milli), + MAX(b.max_duty_abs_permille), + SUM(b.fault_count), + MIN(b.first_odometer_cm), + MAX(b.last_odometer_cm), + SUM(b.gps_point_count), + SUM(b.precise_gps_point_count), + SUM(b.gps_distance_cm), + MAX(b.max_gps_speed_centi_mps), + MAX(b.max_temp_mosfet_deci_c), + MAX(b.max_temp_motor_deci_c), + MIN(b.first_latitude_e7), + MIN(b.first_longitude_e7), + MIN(b.first_moving_at_ms), + MAX(b.last_moving_at_ms), + MAX(b.updated_at), + MAX(b.sync_seq) + FROM telemetry_minute_buckets b + GROUP BY b.bucket_start_ms, board_id + """.trimIndent(), + ) + db.execSQL("DROP TABLE telemetry_minute_buckets") + db.execSQL("ALTER TABLE telemetry_minute_buckets_new RENAME TO telemetry_minute_buckets") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_minute_buckets_bucket_start_ms " + + "ON telemetry_minute_buckets(bucket_start_ms)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_minute_buckets_updated_at " + + "ON telemetry_minute_buckets(updated_at)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_telemetry_minute_buckets_sync_seq " + + "ON telemetry_minute_buckets(sync_seq)", + ) + } + private fun dropMapPointTables(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS map_point_reactions") db.execSQL("DROP TABLE IF EXISTS map_points") @@ -726,6 +1011,7 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, + MIGRATION_34_35, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index e0d05cd05..bf5a0f818 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -36,7 +36,7 @@ const val TELEMETRY_MASK2_LOCATION = 1 tableName = "telemetry_frames", indices = [ Index(value = ["captured_at_ms"]), - Index(value = ["device_id", "captured_at_ms"]), + Index(value = ["board_id", "captured_at_ms"]), ], ) data class TelemetryFrameEntity( @@ -46,10 +46,13 @@ data class TelemetryFrameEntity( val capturedAtMs: Long, @ColumnInfo(name = "elapsed_realtime_ms") val elapsedRealtimeMs: Long, - @ColumnInfo(name = "device_id") - val deviceId: String?, - @ColumnInfo(name = "device_name") - val deviceName: String?, + /** + * Owning Board (`boards.id`), or null when the samples match no saved Board. Never the BLE + * identifier: it is nullable, it moves when a Board is re-linked, and it is not an identity + * (ADR 0028). The Board name is resolved from `boards` on read, never denormalized here. + */ + @ColumnInfo(name = "board_id") + val boardId: String?, @ColumnInfo(name = "can_id") val canId: Int?, val flags: Int, @@ -109,7 +112,7 @@ data class TelemetryFrameEntity( @Entity( tableName = "telemetry_minute_buckets", - primaryKeys = ["bucket_start_ms", "device_id"], + primaryKeys = ["bucket_start_ms", "board_id"], indices = [ Index(value = ["bucket_start_ms"]), Index(value = ["updated_at"]), @@ -119,10 +122,13 @@ data class TelemetryFrameEntity( data class TelemetryMinuteBucketEntity( @ColumnInfo(name = "bucket_start_ms") val bucketStartMs: Long, - @ColumnInfo(name = "device_id") - val deviceId: String, - @ColumnInfo(name = "device_name") - val deviceName: String?, + /** + * Owning Board (`boards.id`), or [UNKNOWN_TELEMETRY_BOARD_ID] when the samples match no saved + * Board — the column is part of the primary key, so it cannot be null. Keyed on the Board rather + * than the BLE identifier (ADR 0028), which is also what the server keys this table on. + */ + @ColumnInfo(name = "board_id") + val boardId: String, @ColumnInfo(name = "sample_count") val sampleCount: Int, @ColumnInfo(name = "first_sample_at_ms") @@ -285,6 +291,12 @@ data class BoardEntity( val deletedAt: Long? = null, ) +/** Projection for Ride History name resolution — see `TelemetryDao.getBoardNames`. */ +data class BoardNameRow( + val id: String, + val name: String, +) + @Entity( tableName = "board_settings", primaryKeys = ["board_id", "key"], diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index d898e75d9..732c155dc 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -60,6 +60,9 @@ data class TelemetryLocationCapture( data class TelemetryCapture( val capturedAtMs: Long, val elapsedRealtimeMs: Long, + /** Owning Board (`boards.id`) — what frames and buckets are keyed on (ADR 0028). */ + val boardId: String?, + /** BLE identifier; still stamped on markers and diagnostic events, never on frames or buckets. */ val deviceId: String?, val deviceName: String, val canId: Int?, @@ -282,13 +285,14 @@ class TelemetryRepository private constructor(context: Context) { query.fromMs, query.toMs, query.beforeMs, - query.deviceId, + query.boardId, query.limit, ) if (buckets.isEmpty()) return@withContext emptyList() val markerFrom = buckets.minOf { it.bucketStartMs } - GAP_BOUNDARY_MS val markerTo = buckets.maxOf { it.bucketStartMs } + TELEMETRY_BUCKET_SIZE_MS - val markers = dao.getMarkers(markerFrom, markerTo, query.deviceId) + val markers = dao.getMarkers(markerFrom, markerTo, bleIdForBoard(query.boardId)) + val boardNames = boardNamesById() buckets.map { bucket -> val marker = markers.lastOrNull { it.occurredAtMs >= bucket.firstSampleAtMs - 5_000L && @@ -312,12 +316,12 @@ class TelemetryRepository private constructor(context: Context) { val maxGpsSpeedKmh = bucket.maxGpsSpeedCentiMps?.let { it / 100.0 * 3.6 } val distanceM = distanceDeltaM(bucket) ?: bucket.gpsDistanceCm.takeIf { it > 0L }?.let { it / 100.0 } mapOf( - "id" to "${bucket.deviceId}:${bucket.bucketStartMs}", + "id" to "${bucket.boardId}:${bucket.bucketStartMs}", "startAtMs" to bucket.firstSampleAtMs, "endAtMs" to bucket.lastSampleAtMs, "bucketStartMs" to bucket.bucketStartMs, - "deviceId" to bucket.deviceId.ifBlank { null }, - "deviceName" to (bucket.deviceName ?: UNKNOWN_TELEMETRY_DEVICE_NAME), + "boardId" to bucket.boardId.ifBlank { null }, + "boardName" to (boardNames[bucket.boardId] ?: UNKNOWN_TELEMETRY_BOARD_NAME), "sampleCount" to bucket.sampleCount, "gpsPointCount" to bucket.gpsPointCount, "preciseGpsPointCount" to bucket.preciseGpsPointCount, @@ -350,8 +354,8 @@ class TelemetryRepository private constructor(context: Context) { suspend fun getSamples(options: Map): List> = withContext(Dispatchers.IO) { val query = SampleQueryOptions.from(options) smoothedSampleMaps( - getSampleStates(query.fromMs, query.toMs, query.deviceId, query.limit), - batteryConfigByDevice(), + getSampleStates(query.fromMs, query.toMs, query.boardId, query.limit), + batteryConfigByBoard(), ) } @@ -366,12 +370,17 @@ class TelemetryRepository private constructor(context: Context) { ): List> { val windowMs = AppDataRepository.get(appContext).getTypedSettings().socEstimateWindowSeconds * 1000L val windows = HashMap() + val boardNames = boardNamesById() return samples.map { sample -> val estimate = deriveBatteryPercent(sample.state, configs)?.let { - windows.getOrPut(sample.state.deviceId) { SocMedianWindow(windowMs) } + windows.getOrPut(sample.state.boardId) { SocMedianWindow(windowMs) } .median(it, sample.state.capturedAtMs) } - sample.state.toSampleMap(sample.id, estimate) + sample.state.toSampleMap( + sample.id, + boardNames[sample.state.boardId] ?: UNKNOWN_TELEMETRY_BOARD_NAME, + estimate, + ) } } @@ -380,7 +389,7 @@ class TelemetryRepository private constructor(context: Context) { * little-endian Float64 lanes packed row-major into one direct ByteBuffer, returned as a JSI * ArrayBuffer. This replaces ~25 per-field JSI conversions × N samples (the dominant history-load * cost) with a single buffer transfer; JS rebuilds TelemetrySample objects locally. Nullable - * numeric lanes use NaN as the null sentinel; deviceId/deviceName are dictionary-encoded. + * numeric lanes use NaN as the null sentinel; the Board id and name are dictionary-encoded. * * @parity /modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift `sampleColumns` */ @@ -390,21 +399,22 @@ class TelemetryRepository private constructor(context: Context) { ): Map { val windowMs = AppDataRepository.get(appContext).getTypedSettings().socEstimateWindowSeconds * 1000L val windows = HashMap() - val deviceIds = ArrayList() - val deviceNames = ArrayList() - val deviceIndex = HashMap() + val boardNames = boardNamesById() + val boardIds = ArrayList() + val names = ArrayList() + val boardIndex = HashMap() val buffer = ByteBuffer .allocateDirect(samples.size * SAMPLE_COLUMN_COUNT * 8) .order(ByteOrder.LITTLE_ENDIAN) for (sample in samples) { val s = sample.state val estimate = deriveBatteryPercent(s, configs)?.let { - windows.getOrPut(s.deviceId) { SocMedianWindow(windowMs) }.median(it, s.capturedAtMs) + windows.getOrPut(s.boardId) { SocMedianWindow(windowMs) }.median(it, s.capturedAtMs) } - val di = deviceIndex.getOrPut(s.deviceId) { - deviceIds.add(s.deviceId) - deviceNames.add(s.deviceName ?: UNKNOWN_TELEMETRY_DEVICE_NAME) - deviceIds.size - 1 + val di = boardIndex.getOrPut(s.boardId) { + boardIds.add(s.boardId) + names.add(boardNames[s.boardId] ?: UNKNOWN_TELEMETRY_BOARD_NAME) + boardIds.size - 1 } buffer .putDouble(sample.id.toDouble()) @@ -436,32 +446,50 @@ class TelemetryRepository private constructor(context: Context) { return mapOf( "boardColumns" to NativeArrayBuffer.wrap(buffer), "boardCount" to samples.size, - "boardDevices" to deviceIds, - "boardDeviceNames" to deviceNames, + "boardIds" to boardIds, + "boardNames" to names, ) } - /** bleId (telemetry deviceId) -> the board's normalized battery config. */ - private suspend fun batteryConfigByDevice(): Map> { + /** + * `boards.id` -> the Board's normalized battery config. Keyed on the Board rather than its BLE + * identifier now that samples carry the Board id (ADR 0028), so a re-linked Board keeps its + * config across its whole history. + */ + private suspend fun batteryConfigByBoard(): Map> { BatterySocEstimator.ensureInitialized(appContext) val result = mutableMapOf>() for (board in AppDataRepository.get(appContext).getBoards()) { - @Suppress("UNCHECKED_CAST") - val link = board["link"] as? Map ?: continue - val bleId = link["bleId"] as? String ?: continue + val id = board["id"] as? String ?: continue @Suppress("UNCHECKED_CAST") val config = board["batteryConfig"] as? Map ?: continue - result[bleId] = config + result[id] = config } return result } + /** + * `boards.id` -> Board name, tombstones included: Ride History still has to name a Board the + * Rider deleted (ADR 0027), and resolving on read is what makes a rename retroactive. + */ + private suspend fun boardNamesById(): Map = + dao.getBoardNames().associate { it.id to it.name } + + /** + * The BLE identifier a Board currently claims. Markers, diagnostic events and Metric Exclusion + * Ranges still key on it, so a Board-scoped query has to translate. A Board re-linked since the + * ride no longer resolves its older markers — accepted: they are low-cardinality display rows, + * not the sample stream (ADR 0028). + */ + private suspend fun bleIdForBoard(boardId: String?): String? = + boardId?.let { dao.getBoardBleId(it) } + /** Derive IR-compensated battery % on read, mirroring the live native path. */ private fun deriveBatteryPercent( state: FullTelemetryState, configs: Map>, ): Double? { - val config = state.deviceId?.let { configs[it] } ?: return null + val config = state.boardId?.let { configs[it] } ?: return null return BatterySocEstimator.estimateBatteryPercent( state.batteryVoltageMv / 1000.0, config, @@ -472,12 +500,12 @@ class TelemetryRepository private constructor(context: Context) { private suspend fun getSampleStates( fromMs: Long, toMs: Long, - deviceId: String?, + boardId: String?, limit: Int, ): List { - val keyframe = dao.getLatestKeyframeBefore(fromMs, deviceId) + val keyframe = dao.getLatestKeyframeBefore(fromMs, boardId) val start = keyframe?.capturedAtMs ?: fromMs - val frames = dao.getFrames(start, toMs, deviceId, limit + 1) + val frames = dao.getFrames(start, toMs, boardId, limit + 1) var state: FullTelemetryState? = null val samples = mutableListOf() for (frame in frames) { @@ -492,12 +520,13 @@ class TelemetryRepository private constructor(context: Context) { suspend fun getRange(options: Map): Map = withContext(Dispatchers.IO) { val query = SampleQueryOptions.from(options) - val samples = getSampleStates(query.fromMs, query.toMs, query.deviceId, query.limit) - val configs = batteryConfigByDevice() + val samples = getSampleStates(query.fromMs, query.toMs, query.boardId, query.limit) + val configs = batteryConfigByBoard() + val deviceId = bleIdForBoard(query.boardId) smoothedSampleColumns(samples, configs) + mapOf( - "gpsSamples" to samples.toGpsSampleMaps(), - "markers" to dao.getMarkers(query.fromMs, query.toMs, query.deviceId).map { it.toMap() }, - "exclusions" to dao.getExclusions(query.fromMs, query.toMs, query.deviceId).map { it.toMap() }, + "gpsSamples" to samples.toGpsSampleMaps(boardNamesById()), + "markers" to dao.getMarkers(query.fromMs, query.toMs, deviceId).map { it.toMap() }, + "exclusions" to dao.getExclusions(query.fromMs, query.toMs, deviceId).map { it.toMap() }, ) } @@ -529,9 +558,10 @@ class TelemetryRepository private constructor(context: Context) { flushNow() val requested = TelemetryTimeRange(query.fromMs, query.toMs) val protected = favoriteTelemetryRanges() - promoteProtectedRangeStarts(protected, query.deviceId) + val deviceId = bleIdForBoard(query.boardId) + promoteProtectedRangeStarts(protected, query.boardId) val deleted = subtractProtectedTelemetryRanges(requested, protected).sumOf { range -> - dao.deleteRange(range.startMs, range.endMs, query.deviceId) + dao.deleteRange(range.startMs, range.endMs, query.boardId, deviceId) } deleted } @@ -546,7 +576,7 @@ class TelemetryRepository private constructor(context: Context) { */ suspend fun getFavorites(): List> = withContext(Dispatchers.IO) { favoriteMediaStore.reconcileAll() - val boardNames = dao.getBoards().associate { it.id to it.name } + val boardNames = boardNamesById() dao.getFavorites().map { it.toMap(boardNames[it.boardId]) } } @@ -561,17 +591,12 @@ class TelemetryRepository private constructor(context: Context) { val range = favoriteRange(options) ?: return@withContext null val startMs = range.startMs val endMs = range.endMs - val deviceId = options["deviceId"] as? String + val boardId = options["boardId"] as? String val name = (options["name"] as? String)?.trim()?.ifEmpty { null } flushNow() - val states = getSampleStates(startMs, endMs, deviceId, Int.MAX_VALUE) + val states = getSampleStates(startMs, endMs, boardId, Int.MAX_VALUE) val summary = favoriteSummary(states) - val boards = dao.getBoards() - // The ble id is a transport key — it changes on re-link and differs per install — so the - // Favorite keeps the durable `boards.id` instead. - // @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `boardId` - val boardId = deviceId?.let { ble -> boards.firstOrNull { it.bleId == ble }?.id } val nowMs = System.currentTimeMillis() val favorite = FavoriteEntity( id = UUID.randomUUID().toString(), @@ -590,7 +615,7 @@ class TelemetryRepository private constructor(context: Context) { batteryUsedWhMilli = summary.batteryUsedWhMilli, ) dao.insertFavorite(favorite) - favorite.toMap(boards.firstOrNull { it.id == boardId }?.name) + favorite.toMap(boardId?.let { boardNamesById()[it] }) } /** @@ -607,11 +632,11 @@ class TelemetryRepository private constructor(context: Context) { val range = favoriteRange(options) ?: return@withContext null val startMs = range.startMs val endMs = range.endMs - val deviceId = options["deviceId"] as? String + val boardId = options["boardId"] as? String val name = (options["name"] as? String)?.trim()?.ifEmpty { null } flushNow() - val summary = favoriteSummary(getSampleStates(startMs, endMs, deviceId, Int.MAX_VALUE)) + val summary = favoriteSummary(getSampleStates(startMs, endMs, boardId, Int.MAX_VALUE)) val updated = existing.copy( name = name, startMs = startMs, @@ -626,7 +651,7 @@ class TelemetryRepository private constructor(context: Context) { batteryUsedWhMilli = summary.batteryUsedWhMilli, ) if (dao.updateFavorite(updated) == 0) return@withContext null - updated.toMap(dao.getBoards().firstOrNull { it.id == updated.boardId }?.name) + updated.toMap(updated.boardId?.let { boardNamesById()[it] }) } /** @@ -736,7 +761,7 @@ class TelemetryRepository private constructor(context: Context) { if (protected.isEmpty()) { dao.clearAll() } else { - promoteProtectedRangeStarts(protected, deviceId = null) + promoteProtectedRangeStarts(protected, boardId = null) val requested = TelemetryTimeRange(Long.MIN_VALUE, Long.MAX_VALUE) for (range in subtractProtectedTelemetryRanges(requested, protected)) { dao.deleteRangeAllDevices(range.startMs, range.endMs) @@ -773,24 +798,24 @@ class TelemetryRepository private constructor(context: Context) { */ private suspend fun promoteProtectedRangeStarts( protected: Collection, - deviceId: String?, + boardId: String?, ) { for (range in protected) { - val devices = if (deviceId != null) { - listOf(deviceId) + val boards = if (boardId != null) { + listOf(boardId) } else { - dao.getDeviceIdsInRange(range.startMs, range.endMs) + dao.getBoardIdsInRange(range.startMs, range.endMs) } - for (protectedDeviceId in devices) { + for (protectedBoardId in boards) { val firstFrame = dao.getFirstFrameInRange( range.startMs, range.endMs, - protectedDeviceId, + protectedBoardId, ) ?: continue val first = getSampleStates( range.startMs, firstFrame.capturedAtMs, - protectedDeviceId, + protectedBoardId, Int.MAX_VALUE, ).firstOrNull { it.id == firstFrame.id } ?: continue dao.updateFrame(first.state.toFrame(previous = null, keyframe = true).copy(id = first.id)) @@ -890,7 +915,7 @@ private data class HistoryQueryOptions( val fromMs: Long, val toMs: Long, val beforeMs: Long, - val deviceId: String?, + val boardId: String?, val limit: Int, ) { companion object { @@ -900,7 +925,7 @@ private data class HistoryQueryOptions( fromMs = options.long("fromMs") ?: 0L, toMs = toMs, beforeMs = options.long("cursorBeforeMs") ?: toMs, - deviceId = options["deviceId"] as? String, + boardId = options["boardId"] as? String, limit = (options.int("limit") ?: DEFAULT_HISTORY_LIMIT).coerceIn(1, 500), ) } @@ -929,7 +954,7 @@ private data class DiagnosticQueryOptions( private data class SampleQueryOptions( val fromMs: Long, val toMs: Long, - val deviceId: String?, + val boardId: String?, val limit: Int, ) { companion object { @@ -937,7 +962,7 @@ private data class SampleQueryOptions( SampleQueryOptions( fromMs = options.requiredLong("fromMs"), toMs = options.requiredLong("toMs"), - deviceId = options["deviceId"] as? String, + boardId = options["boardId"] as? String, limit = (options.int("limit") ?: DEFAULT_SAMPLE_LIMIT).coerceIn(1, MAX_SAMPLE_LIMIT), ) } @@ -946,7 +971,7 @@ private data class SampleQueryOptions( private data class RangeMutationOptions( val fromMs: Long, val toMs: Long, - val deviceId: String?, + val boardId: String?, ) { companion object { fun from(options: Map): RangeMutationOptions { @@ -956,7 +981,7 @@ private data class RangeMutationOptions( return RangeMutationOptions( fromMs = fromMs, toMs = toMs, - deviceId = options["deviceId"] as? String, + boardId = options["boardId"] as? String, ) } } @@ -970,8 +995,9 @@ internal data class HistoryTelemetryState( internal data class FullTelemetryState( val capturedAtMs: Long, val elapsedRealtimeMs: Long, + val boardId: String?, + /** Kept off the persisted frame; only the live capture path needs it, for markers. */ val deviceId: String?, - val deviceName: String?, val canId: Int?, val hasFault: Boolean, val faultCode: Int, @@ -1013,8 +1039,7 @@ internal data class FullTelemetryState( return TelemetryFrameEntity( capturedAtMs = capturedAtMs, elapsedRealtimeMs = elapsedRealtimeMs, - deviceId = deviceId, - deviceName = deviceName, + boardId = boardId, canId = canId, flags = flags, changedMask1 = 0, @@ -1047,11 +1072,12 @@ internal data class FullTelemetryState( ).copy(changedMask1 = mask1, changedMask2 = mask2) } - fun toSampleMap(id: Long, batteryPercent: Double? = null): Map = mapOf( + /** Board name is resolved by the caller from `boards`, never read off the row (ADR 0028). */ + fun toSampleMap(id: Long, boardName: String?, batteryPercent: Double? = null): Map = mapOf( "id" to id, "capturedAtMs" to capturedAtMs, - "deviceId" to deviceId, - "deviceName" to (deviceName ?: UNKNOWN_TELEMETRY_DEVICE_NAME), + "boardId" to boardId, + "boardName" to boardName, "speedKmh" to speedCentiKmh / 100.0, "batteryVoltage" to batteryVoltageMv / 1000.0, "batteryPercent" to batteryPercent, @@ -1078,8 +1104,8 @@ internal data class FullTelemetryState( fun toBucketPoint(): BucketTelemetryPoint = BucketTelemetryPoint( capturedAtMs = capturedAtMs, + boardId = boardId, deviceId = deviceId, - deviceName = deviceName, speedCentiKmh = speedCentiKmh, batteryVoltageMv = batteryVoltageMv, motorCurrentMa = motorCurrentMa, @@ -1098,8 +1124,8 @@ internal data class FullTelemetryState( fun from(capture: TelemetryCapture): FullTelemetryState = FullTelemetryState( capturedAtMs = capture.capturedAtMs, elapsedRealtimeMs = capture.elapsedRealtimeMs, + boardId = capture.boardId, deviceId = capture.deviceId, - deviceName = capture.deviceName, canId = capture.canId, hasFault = capture.hasFault, faultCode = capture.faultCode, @@ -1149,8 +1175,9 @@ internal data class FullTelemetryState( return FullTelemetryState( capturedAtMs = frame.capturedAtMs, elapsedRealtimeMs = frame.elapsedRealtimeMs, - deviceId = frame.deviceId ?: base?.deviceId, - deviceName = frame.deviceName ?: base?.deviceName, + boardId = frame.boardId ?: base?.boardId, + // Frames never carried the BLE identifier, so a replayed state has none. + deviceId = base?.deviceId, canId = frame.canId ?: base?.canId, hasFault = (frame.flags and TELEMETRY_FLAG_HAS_FAULT) != 0, faultCode = faultCode, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt index eab0f92b8..1e9b593ea 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt @@ -69,7 +69,7 @@ class BoardTombstoneTest { @Test fun migrationTargetsTheCurrentSchemaVersion() { - assertEquals(34, TELEMETRY_DATABASE_VERSION) + assertEquals(35, TELEMETRY_DATABASE_VERSION) assertEquals(33, TelemetryDatabase.MIGRATION_33_34.startVersion) assertEquals(34, TelemetryDatabase.MIGRATION_33_34.endVersion) } diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt index 524b1379f..860b64cd4 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt @@ -216,8 +216,8 @@ class FavoriteSummaryBuilderTest { val capturedAtMs = startMs + index * intervalMs BucketTelemetryPoint( capturedAtMs = capturedAtMs, + boardId = "board-1", deviceId = "board-1", - deviceName = "VESC Board", speedCentiKmh = speedCentiKmh, batteryVoltageMv = 50_000, motorCurrentMa = 10_000, @@ -235,8 +235,7 @@ class FavoriteSummaryBuilderTest { lastOdometerCm: Long? = 1_000L, ) = TelemetryMinuteBucketEntity( bucketStartMs = bucketStartMs, - deviceId = "board-1", - deviceName = "VESC Board", + boardId = "board-1", sampleCount = 10, firstSampleAtMs = bucketStartMs, lastSampleAtMs = bucketStartMs + 9_000, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/HistoryGpsProjectionTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/HistoryGpsProjectionTest.kt index e7b18c07b..19bf29393 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/HistoryGpsProjectionTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/HistoryGpsProjectionTest.kt @@ -35,7 +35,7 @@ class HistoryGpsProjectionTest { ), ) - val gpsSample = samples.toGpsSampleMaps().single() + val gpsSample = samples.toGpsSampleMaps(mapOf("board-1" to "ADV2")).single() val bucketPoint = samples.toBucketLocationPoints().single() assertEquals(7L, gpsSample["id"]) @@ -51,8 +51,8 @@ class HistoryGpsProjectionTest { state = FullTelemetryState( capturedAtMs = capturedAtMs, elapsedRealtimeMs = capturedAtMs, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", canId = null, hasFault = false, faultCode = 0, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/MetricSanitizerTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/MetricSanitizerTest.kt index 0d290b199..9fe14145a 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/MetricSanitizerTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/MetricSanitizerTest.kt @@ -356,8 +356,8 @@ class MetricSanitizerTest { dutyPermille: Int = 0, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, + boardId = deviceId, deviceId = deviceId, - deviceName = "Test", speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -377,8 +377,8 @@ class MetricSanitizerTest { gpsAccuracyCm: Int = 500, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, + boardId = deviceId, deviceId = deviceId, - deviceName = "Test", speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt index f87d50c91..56461655e 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt @@ -112,8 +112,7 @@ class ProfileStatsRepositoryTest { lastMoving: Long? = null, ) = TelemetryMinuteBucketEntity( bucketStartMs = start - (start % TELEMETRY_BUCKET_SIZE_MS), - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", sampleCount = 1, firstSampleAtMs = start, lastSampleAtMs = end, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt index 32a18df04..f91cb08ac 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt @@ -98,7 +98,7 @@ class SyncCursorMigrationTest { @Test fun migrationsTargetTheCurrentSchemaVersion() { - assertEquals(34, TELEMETRY_DATABASE_VERSION) + assertEquals(35, TELEMETRY_DATABASE_VERSION) assertEquals(31, TelemetryDatabase.MIGRATION_31_32.startVersion) assertEquals(32, TelemetryDatabase.MIGRATION_31_32.endVersion) assertEquals(32, TelemetryDatabase.MIGRATION_32_33.startVersion) diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt new file mode 100644 index 000000000..76101e8f1 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt @@ -0,0 +1,254 @@ +package expo.modules.vescapecore.telemetry + +import android.database.Cursor +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.lang.reflect.Proxy + +/** + * Telemetry keys on the Board id (#280, ADR 0028). Schema 34→35 adds `board_id` to + * `telemetry_frames` and `telemetry_minute_buckets`, backfills it by matching `boards.ble_id`, + * mints a tombstoned Board for every identifier that resolves to nothing, moves the bucket primary + * key onto the new column, and drops `device_id` and `device_name` from both tables. + * + * Asserted against the emitted SQL rather than a live database: Room's `@Query` has BINARY + * retention and this module's JVM test source set has no SQLite, the same constraint + * [SyncCursorMigrationTest] works under. The behavioural half — actual rows after an actual + * migration — runs on the GRDB peer, which does have an in-memory database. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift + */ +class TelemetryBoardIdMigrationTest { + private fun migrationSql(): List { + val sql = mutableListOf() + val db = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + sql += args?.firstOrNull() as String + null + } + "query" -> emptyCursor() + else -> throw UnsupportedOperationException(method.name) + } + } as SupportSQLiteDatabase + TelemetryDatabase.MIGRATION_34_35.migrate(db) + return sql + } + + private fun emptyCursor(): Cursor = Proxy.newProxyInstance( + Cursor::class.java.classLoader, + arrayOf(Cursor::class.java), + ) { _, method, _ -> + when (method.name) { + "getColumnIndex" -> 0 + "moveToNext" -> false + "close" -> null + else -> throw UnsupportedOperationException(method.name) + } + } as Cursor + + private fun daoSource(): String = + File("src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt").readText() + + private fun statement(match: String): String = + migrationSql().firstOrNull { it.contains(match) } + ?: throw AssertionError("no migration statement contains `$match`") + + @Test + fun migrationTargetsTheCurrentSchemaVersion() { + assertEquals(35, TELEMETRY_DATABASE_VERSION) + assertEquals(34, TelemetryDatabase.MIGRATION_34_35.startVersion) + assertEquals(35, TelemetryDatabase.MIGRATION_34_35.endVersion) + } + + // MARK: Backfill + + /** + * The point of shipping this as a migration rather than a column add: a row left without a + * `board_id` is telemetry with no owner, unjoinable and unbackupable. + */ + @Test + fun bothTablesBackfillBoardIdByMatchingTheBleIdentifier() { + for (match in listOf("INSERT INTO telemetry_frames_new", "INSERT INTO telemetry_minute_buckets_new")) { + val sql = statement(match) + assertTrue( + "$match does not resolve device_id through boards.ble_id", + sql.contains("SELECT b.id FROM boards b WHERE b.ble_id ="), + ) + } + } + + /** A row that never carried an identifier stays unattributed rather than joining a random Board. */ + @Test + fun framesWithNoIdentifierBackfillToNullAndBucketsToTheUnknownSentinel() { + assertTrue( + "frames without a device_id do not backfill to NULL", + statement("INSERT INTO telemetry_frames_new").contains("device_id = '' THEN NULL"), + ) + assertTrue( + "buckets without a device_id do not backfill to the unknown sentinel", + statement("INSERT INTO telemetry_minute_buckets_new").contains("device_id = '' THEN ''"), + ) + } + + // MARK: Orphan minting + + /** + * Telemetry from a Board hard-deleted before tombstones existed, or from a peripheral the Board + * was re-linked away from, resolves to nothing. Without a minted Board it loses both its identity + * and its label — the one case in this migration sequence that creates rows the Rider never made. + */ + @Test + fun unresolvedIdentifiersMintATombstonedBoardNamedFromTheHistoricalDeviceName() { + for (table in listOf("telemetry_frames", "telemetry_minute_buckets")) { + val sql = migrationSql().firstOrNull { + it.startsWith("INSERT OR IGNORE INTO boards") && it.contains("FROM $table t") + } ?: throw AssertionError("no orphan mint sourced from $table") + + assertTrue( + "the mint does not skip identifiers a Board still claims", + sql.contains("NOT EXISTS (SELECT 1 FROM boards b WHERE b.ble_id = t.device_id)"), + ) + assertTrue( + "the minted Board is not named from the telemetry's own device_name", + sql.contains("SELECT n.device_name FROM $table n"), + ) + assertTrue( + "the minted Board id is not derived from the identifier, so re-running duplicates it", + sql.contains("'$ORPHAN_BOARD_ID_PREFIX' || t.device_id"), + ) + } + } + + /** + * A minted Board must never reach the Rider's Board list, and must never capture a future + * re-link: the tombstone stamp keeps it out of `getBoards()`, the null `ble_id` keeps it out of + * every identifier match — including this migration's own backfill on a later upgrade. + */ + @Test + fun aMintedBoardIsTombstonedAndCarriesNoBoardLink() { + val sql = statement("FROM telemetry_frames t") + val columns = sql.substringAfter("(").substringBefore(")").split(",").map { it.trim() } + // Tail of the SELECT list, in column order: ble_id, created_at, updated_at, sync_seq, + // deleted_at. A literal NULL for the link, a stamped epoch for the tombstone. + val selected = sql.substringBefore("FROM telemetry_frames t").lines() + .map { it.trim().trimEnd(',') } + .filter { it.isNotEmpty() } + .takeLast(5) + + assertEquals( + listOf("id", "name", "ble_id", "created_at", "updated_at", "sync_seq", "deleted_at"), + columns, + ) + assertEquals("a minted Board carries a Board Link", "NULL", selected.first()) + assertTrue("a minted Board is not tombstoned", selected.last().toLongOrNull() != null) + assertTrue( + "the Rider's Board list would show minted Boards", + daoSource().contains("SELECT * FROM boards WHERE deleted_at IS NULL ORDER BY created_at ASC"), + ) + } + + /** A minted Board is an ordinary write and has to upload like one. */ + @Test + fun mintedBoardsGetASyncCursorPositionAboveEveryExistingRow() { + val sql = migrationSql() + val numbered = sql.indexOfFirst { it.contains("UPDATE boards") && it.contains("SET sync_seq =") } + val reseeded = sql.indexOfFirst { + it.contains("INSERT OR REPLACE INTO sync_sequences") && it.contains("'boards'") + } + + assertTrue("minted Boards keep sync_seq 0 and never upload", numbered >= 0) + assertTrue("the boards counter is reseeded before the new rows are numbered", reseeded > numbered) + } + + // MARK: Table rebuild + + /** + * The primary key move is a rebuild, not an `ALTER`. `updated_at` and `sync_seq` landed on this + * table earlier in the same release, so the copy has to name them explicitly — a bucket that + * silently resets its cursor position stops uploading. + */ + @Test + fun theBucketRebuildMovesThePrimaryKeyAndCarriesTheSyncColumns() { + val create = statement("CREATE TABLE telemetry_minute_buckets_new") + val copy = statement("INSERT INTO telemetry_minute_buckets_new") + + assertTrue( + "the bucket primary key is not (bucket_start_ms, board_id)", + create.contains("PRIMARY KEY (bucket_start_ms, board_id)"), + ) + for (column in listOf("updated_at", "sync_seq")) { + assertTrue("the rebuilt bucket table drops $column", create.contains(column)) + assertTrue("the bucket rebuild does not carry $column across", copy.contains(column)) + } + assertTrue( + "the rebuilt table is not swapped in", + migrationSql().contains("ALTER TABLE telemetry_minute_buckets_new RENAME TO telemetry_minute_buckets"), + ) + } + + /** + * The copy is grouped so the rebuild is total: an ungrouped copy would abort the whole migration + * on a `board_id` collision, stranding the database mid-upgrade. + */ + @Test + fun theBucketCopyIsGroupedSoAKeyCollisionCannotAbortTheRebuild() { + val copy = statement("INSERT INTO telemetry_minute_buckets_new") + + assertTrue("colliding buckets are not folded", copy.contains("GROUP BY b.bucket_start_ms, board_id")) + assertTrue("sample counts are not summed on a fold", copy.contains("SUM(b.sample_count)")) + assertTrue("peak speed is not kept on a fold", copy.contains("MAX(b.max_abs_speed_centi_kmh)")) + } + + /** Neither table may keep the columns ADR 0028 retires, on either the schema or the copy. */ + @Test + fun bothRebuiltTablesDropTheBleIdentifierAndTheDenormalizedName() { + for (table in listOf("telemetry_frames", "telemetry_minute_buckets")) { + val create = statement("CREATE TABLE ${table}_new") + assertFalse("$table keeps device_id", create.contains("device_id")) + assertFalse("$table keeps device_name", create.contains("device_name")) + assertTrue("$table has no board_id", create.contains("board_id")) + assertTrue( + "the rebuilt $table is not swapped in", + migrationSql().contains("ALTER TABLE ${table}_new RENAME TO $table"), + ) + } + } + + /** The frame index that meant "this Board" while saying `device_id` follows the column. */ + @Test + fun theFrameLookupIndexMovesOntoBoardId() { + val sql = migrationSql() + + assertTrue( + "the old device_id index survives the rebuild", + sql.contains("DROP INDEX IF EXISTS index_telemetry_frames_device_id_captured_at_ms"), + ) + assertTrue( + "frames have no board_id lookup index", + sql.any { it.contains("index_telemetry_frames_board_id_captured_at_ms") }, + ) + } + + // MARK: Untouched tables + + /** + * Markers, diagnostic events and Metric Exclusion Ranges keep both columns: that is what crosses + * the wire for them, and they are low-cardinality display rows rather than a per-sample cost. + */ + @Test + fun markersDiagnosticEventsAndExclusionRangesAreNotTouched() { + val sql = migrationSql().joinToString("\n") + + for (table in listOf("telemetry_markers", "diagnostic_events", "metric_exclusion_ranges")) { + assertFalse("the migration rewrites $table", sql.contains(table)) + } + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilderTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilderTest.kt index 14405d951..f920943b0 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilderTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilderTest.kt @@ -11,8 +11,8 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 125_000L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = -1_200, batteryVoltageMv = 77_500, motorCurrentMa = -2_500, @@ -23,8 +23,8 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 130_000L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = 1_600, batteryVoltageMv = 77_100, motorCurrentMa = 3_500, @@ -37,16 +37,14 @@ class TelemetryBucketBuilderTest { locationPoints = listOf( BucketLocationPoint( capturedAtMs = 131_000L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", precise = true, distanceFromPreviousCm = 230L, gpsSpeedCentiMps = 1_250, ), BucketLocationPoint( capturedAtMs = 132_000L, - deviceId = "board-1", - deviceName = "ADV2", + boardId = "board-1", precise = false, distanceFromPreviousCm = null, gpsSpeedCentiMps = 900, @@ -55,8 +53,7 @@ class TelemetryBucketBuilderTest { ).single() assertEquals(120_000L, buckets.bucketStartMs) - assertEquals("board-1", buckets.deviceId) - assertEquals("ADV2", buckets.deviceName) + assertEquals("board-1", buckets.boardId) assertEquals(2, buckets.sampleCount) assertEquals(2, buckets.gpsPointCount) assertEquals(1, buckets.preciseGpsPointCount) @@ -84,8 +81,7 @@ class TelemetryBucketBuilderTest { locationPoints = listOf( BucketLocationPoint( capturedAtMs = 65_000L, - deviceId = null, - deviceName = null, + boardId = null, precise = true, distanceFromPreviousCm = null, gpsSpeedCentiMps = null, @@ -102,8 +98,8 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 10_000L, + boardId = "a", deviceId = "a", - deviceName = "A", speedCentiKmh = 100, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -114,8 +110,8 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 70_000L, + boardId = "a", deviceId = "a", - deviceName = "A", speedCentiKmh = 200, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -126,8 +122,8 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 10_000L, + boardId = "b", deviceId = "b", - deviceName = "B", speedCentiKmh = 300, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -141,7 +137,7 @@ class TelemetryBucketBuilderTest { ) assertEquals(setOf(0L to "a", 60_000L to "a", 0L to "b"), buckets.map { - it.bucketStartMs to it.deviceId + it.bucketStartMs to it.boardId }.toSet()) } @@ -151,8 +147,8 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 0L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = 499, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -164,8 +160,8 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 1_000L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = -500, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -177,8 +173,8 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 2_000L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = 1_200, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -205,8 +201,8 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 0L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = 100, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -231,8 +227,8 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 0L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = 0, batteryVoltageMv = 50_000, motorCurrentMa = 0, @@ -243,8 +239,8 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 3_600L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = 0, batteryVoltageMv = 50_000, motorCurrentMa = 0, @@ -255,8 +251,8 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 7_200L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = 0, batteryVoltageMv = 50_000, motorCurrentMa = 0, @@ -279,8 +275,8 @@ class TelemetryBucketBuilderTest { telemetryPoints = listOf( BucketTelemetryPoint( capturedAtMs = 0L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = 5000, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -293,8 +289,8 @@ class TelemetryBucketBuilderTest { ), BucketTelemetryPoint( capturedAtMs = 1_000L, + boardId = "board-1", deviceId = "board-1", - deviceName = "ADV2", speedCentiKmh = 2000, batteryVoltageMv = 70_000, motorCurrentMa = 0, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryPipelineTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryPipelineTest.kt index 0e6c21415..6861773e0 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryPipelineTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryPipelineTest.kt @@ -266,6 +266,7 @@ class TelemetryPipelineTest { ): TelemetryCapture = TelemetryCapture( capturedAtMs = parsed.lastPacketAt, elapsedRealtimeMs = parsed.lastPacketAt, + boardId = cfg.deviceId, deviceId = cfg.deviceId, deviceName = cfg.deviceName, canId = canId, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizerTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizerTest.kt index c209ac485..462b1a80c 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizerTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/FreeSpinMetricSanitizerTest.kt @@ -98,8 +98,8 @@ class FreeSpinMetricSanitizerTest { dutyPermille: Int = 0, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, + boardId = deviceId, deviceId = deviceId, - deviceName = "Test", speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, @@ -119,8 +119,8 @@ class FreeSpinMetricSanitizerTest { gpsAccuracyCm: Int = 500, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, + boardId = deviceId, deviceId = deviceId, - deviceName = "Test", speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizerTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizerTest.kt index 4fe2e94a0..1e1a80242 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizerTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/sanitizers/LowSpeedAverageSpeedSanitizerTest.kt @@ -67,8 +67,8 @@ class LowSpeedAverageSpeedSanitizerTest { speedCentiKmh: Int = 0, ) = BucketTelemetryPoint( capturedAtMs = capturedAtMs, + boardId = deviceId, deviceId = deviceId, - deviceName = "Test", speedCentiKmh = speedCentiKmh, batteryVoltageMv = 70_000, motorCurrentMa = 0, diff --git a/modules/vescape-core/ios/connection/BoardSessionController.swift b/modules/vescape-core/ios/connection/BoardSessionController.swift index bde21627b..08369823e 100644 --- a/modules/vescape-core/ios/connection/BoardSessionController.swift +++ b/modules/vescape-core/ios/connection/BoardSessionController.swift @@ -1390,6 +1390,7 @@ internal final class BoardSessionController: VescGattListener { return TelemetryCapture( capturedAtMs: telemetry.lastPacketAt, elapsedRealtimeMs: elapsedMs(), + boardId: config.appBoardId, deviceId: config.bleId, deviceName: config.name, canId: canId, diff --git a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift index c06a1249f..ee9072fe0 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift @@ -301,8 +301,8 @@ final class FavoriteStoreTests: XCTestCase { let offset = Int64(index) * intervalMs return BucketTelemetryPoint( capturedAtMs: startMs + offset, + boardId: "board-1", deviceId: "board-1", - deviceName: "VESC Board", speedCentiKmh: speedCentiKmh, batteryVoltageMv: 50_000, motorCurrentMa: 10_000, diff --git a/modules/vescape-core/ios/telemetry/ProfileStatsRepository.swift b/modules/vescape-core/ios/telemetry/ProfileStatsRepository.swift index 9d94b9705..cb6e1b921 100644 --- a/modules/vescape-core/ios/telemetry/ProfileStatsRepository.swift +++ b/modules/vescape-core/ios/telemetry/ProfileStatsRepository.swift @@ -10,7 +10,7 @@ internal struct ProfileStatsMonth: Equatable, Hashable { } internal struct ProfileSessionAggregate { - let deviceId: String + let boardId: String var startAtMs: Int64 var endAtMs: Int64 var sampleCount: Int @@ -35,7 +35,12 @@ internal final class ProfileStatsRepository { func getTotalProfileStats() -> [String: Any?] { let buckets = allBuckets() - return computeProfileStatsForBuckets(buckets: buckets, markers: markersForBuckets(buckets), month: nil) + return computeProfileStatsForBuckets( + buckets: buckets, + markers: markersForBuckets(buckets), + month: nil, + bleIdByBoardId: bleIdByBoardId() + ) } func getMonthlyProfileStats(_ options: [String: Any]) -> [String: Any?] { @@ -46,16 +51,32 @@ internal final class ProfileStatsRepository { return computeProfileStatsForBuckets( buckets: buckets, markers: markersForBuckets(buckets), - month: ProfileStatsMonth(year: year, month: month) + month: ProfileStatsMonth(year: year, month: month), + bleIdByBoardId: bleIdByBoardId() ) } func getProfileStatMonths() -> [[String: Any?]] { let buckets = allBuckets() - return computeProfileStatMonthsForBuckets(buckets: buckets, markers: markersForBuckets(buckets)) + return computeProfileStatMonthsForBuckets( + buckets: buckets, + markers: markersForBuckets(buckets), + bleIdByBoardId: bleIdByBoardId() + ) .map { ["year": $0.year, "month": $0.month] } } + /// Buckets key on the Board (ADR 0028); markers still key on the BLE identifier. Session + /// boundary detection compares the two, so it needs the translation. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt `bleIdByBoardId` + private func bleIdByBoardId() -> [String: String] { + guard let pool else { return [:] } + return (try? pool.read { db in + try Row.fetchAll(db, sql: "SELECT id, ble_id FROM boards WHERE ble_id IS NOT NULL") + .reduce(into: [String: String]()) { $0[$1["id"] as String] = $1["ble_id"] as String } + }) ?? [:] + } + private func allBuckets() -> [Row] { guard let pool else { return [] } return (try? pool.read { db in @@ -81,9 +102,10 @@ internal func computeProfileStatsForBuckets( buckets: [Row], markers: [Row], month: ProfileStatsMonth?, - calendar: Calendar = .current + calendar: Calendar = .current, + bleIdByBoardId: [String: String] = [:] ) -> [String: Any?] { - let sessions = groupProfileSessions(buckets: buckets, markers: markers) + let sessions = groupProfileSessions(buckets: buckets, markers: markers, bleIdByBoardId: bleIdByBoardId) .filter { $0.avgSpeedSampleCount > 0 } let included = month.map { target in sessions.filter { profileMonth($0.startAtMs, calendar: calendar) == target } @@ -120,9 +142,10 @@ internal func computeProfileStatsForBuckets( internal func computeProfileStatMonthsForBuckets( buckets: [Row], markers: [Row], - calendar: Calendar = .current + calendar: Calendar = .current, + bleIdByBoardId: [String: String] = [:] ) -> [ProfileStatsMonth] { - Array(Set(groupProfileSessions(buckets: buckets, markers: markers) + Array(Set(groupProfileSessions(buckets: buckets, markers: markers, bleIdByBoardId: bleIdByBoardId) .filter { $0.avgSpeedSampleCount > 0 } .map { profileMonth($0.startAtMs, calendar: calendar) })) .sorted { @@ -130,7 +153,14 @@ internal func computeProfileStatMonthsForBuckets( } } -internal func groupProfileSessions(buckets: [Row], markers: [Row]) -> [ProfileSessionAggregate] { +/// [bleIdByBoardId] translates the Board a bucket is keyed on into the BLE identifier its markers +/// are keyed on (ADR 0028), which is what session boundary detection compares. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/ProfileStatsRepository.kt `groupProfileSessions` +internal func groupProfileSessions( + buckets: [Row], + markers: [Row], + bleIdByBoardId: [String: String] = [:] +) -> [ProfileSessionAggregate] { guard !buckets.isEmpty else { return [] } var sessions: [ProfileSessionAggregate] = [] var current: ProfileSessionAggregate? @@ -138,16 +168,16 @@ internal func groupProfileSessions(buckets: [Row], markers: [Row]) -> [ProfileSe for bucket in buckets.sorted(by: { ($0["first_sample_at_ms"] as Int64) < ($1["first_sample_at_ms"] as Int64) }) { if (bucket["sample_count"] as Int) <= 0 { continue } - let boundary = markerBoundaryForProfileBucket(bucket, markers: markers) - let deviceId = bucket["device_id"] as String - let breakByDevice = current == nil || current?.deviceId != deviceId + let boundary = markerBoundaryForProfileBucket(bucket, markers: markers, bleIdByBoardId: bleIdByBoardId) + let boardId = bucket["board_id"] as String + let breakByBoard = current == nil || current?.boardId != boardId let breakByGap = previous.map { (bucket["first_sample_at_ms"] as Int64) - ($0["last_sample_at_ms"] as Int64) > PROFILE_SESSION_GAP_MS } ?? false let breakByBoundary = boundary.map { PROFILE_BREAK_BOUNDARIES.contains($0) } ?? false - if breakByDevice || breakByGap || breakByBoundary { + if breakByBoard || breakByGap || breakByBoundary { if let current { sessions.append(current) } current = ProfileSessionAggregate( - deviceId: deviceId, + boardId: boardId, startAtMs: bucket["first_sample_at_ms"] as Int64, endAtMs: bucket["last_sample_at_ms"] as Int64, sampleCount: 0, @@ -173,14 +203,17 @@ internal func groupProfileSessions(buckets: [Row], markers: [Row]) -> [ProfileSe return sessions } -internal func markerBoundaryForProfileBucket(_ bucket: Row, markers: [Row]) -> String? { - markers.last { marker in +internal func markerBoundaryForProfileBucket( + _ bucket: Row, + markers: [Row], + bleIdByBoardId: [String: String] = [:] +) -> String? { + let bucketDevice = bleIdByBoardId[bucket["board_id"] as String] ?? "" + return markers.last { marker in let occurred = marker["occurred_at_ms"] as Int64 - let markerDevice = marker["device_id"] as String? ?? "" - let bucketDevice = bucket["device_id"] as String return occurred >= (bucket["first_sample_at_ms"] as Int64) - 5_000 && occurred <= (bucket["first_sample_at_ms"] as Int64) + 1_000 && - markerDevice == bucketDevice + (marker["device_id"] as String? ?? "") == bucketDevice }.map { $0["type"] as String } } diff --git a/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift b/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift index bb3c21607..5b8a1cefc 100644 --- a/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift +++ b/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift @@ -158,7 +158,7 @@ final class SyncCursorMigrationTests: XCTestCase { /// leaves most aggregate columns folded into the existing row. func testBucketUpsertAdvancesTheCursorOnMerge() throws { try migrateToLatest() - var bucket = TelemetryBucket(bucketStartMs: 60_000, deviceId: "board-1") + var bucket = TelemetryBucket(bucketStartMs: 60_000, boardId: "board-1") bucket.firstSampleAtMs = 60_000 bucket.lastSampleAtMs = 60_500 bucket.sampleCount = 1 @@ -181,7 +181,7 @@ final class SyncCursorMigrationTests: XCTestCase { /// seeing later writes. func testBucketCursorIsMonotonicAcrossClockSteps() throws { try migrateToLatest() - var bucket = TelemetryBucket(bucketStartMs: 60_000, deviceId: "board-1") + var bucket = TelemetryBucket(bucketStartMs: 60_000, boardId: "board-1") bucket.firstSampleAtMs = 60_000 bucket.lastSampleAtMs = 60_500 @@ -267,7 +267,7 @@ final class SyncCursorMigrationTests: XCTestCase { func testBucketMergeAdvancesTheSyncSeq() throws { try migrateToLatest() - var bucket = TelemetryBucket(bucketStartMs: 60_000, deviceId: "board-1") + var bucket = TelemetryBucket(bucketStartMs: 60_000, boardId: "board-1") bucket.firstSampleAtMs = 60_000 bucket.lastSampleAtMs = 60_500 diff --git a/modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift b/modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift index 5a74d5aec..e0d78f8c8 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryBucketBuilder.swift @@ -3,8 +3,9 @@ import Foundation /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt internal struct TelemetryBucket { let bucketStartMs: Int64 - let deviceId: String - var deviceName: String? + /// Owning Board (`boards.id`), or `""` when the samples match no saved Board — the column is part + /// of the bucket primary key, so unattributed rows need a value rather than null (ADR 0028). + let boardId: String var sampleCount = 0 var firstSampleAtMs = Int64.max var lastSampleAtMs = Int64.min @@ -34,7 +35,6 @@ internal struct TelemetryBucket { mutating func add(_ point: BucketTelemetryPoint) { sampleCount += 1 - if point.deviceName != nil { deviceName = point.deviceName } firstSampleAtMs = min(firstSampleAtMs, point.capturedAtMs) lastSampleAtMs = max(lastSampleAtMs, point.capturedAtMs) let absSpeed = abs(point.speedCentiKmh) @@ -82,9 +82,9 @@ internal func buildTelemetryBuckets(_ points: [BucketTelemetryPoint]) -> [Teleme var buckets: [String: TelemetryBucket] = [:] for point in points.sorted(by: { $0.capturedAtMs < $1.capturedAtMs }) { let bucketStart = point.capturedAtMs - (point.capturedAtMs % TELEMETRY_BUCKET_SIZE_MS) - let deviceId = point.deviceId ?? "" - let key = "\(deviceId):\(bucketStart)" - var bucket = buckets[key] ?? TelemetryBucket(bucketStartMs: bucketStart, deviceId: deviceId, deviceName: point.deviceName) + let boardId = point.boardId ?? UNKNOWN_TELEMETRY_BOARD_ID + let key = "\(boardId):\(bucketStart)" + var bucket = buckets[key] ?? TelemetryBucket(bucketStartMs: bucketStart, boardId: boardId) bucket.add(point) buckets[key] = bucket } diff --git a/modules/vescape-core/ios/telemetry/TelemetryDao.swift b/modules/vescape-core/ios/telemetry/TelemetryDao.swift index 76a7ecc1d..d21cdd1c6 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDao.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDao.swift @@ -8,16 +8,16 @@ internal func insertFrame(_ db: Database, _ state: FullTelemetryState) throws { try db.execute( sql: """ INSERT INTO telemetry_frames ( - captured_at_ms, elapsed_realtime_ms, device_id, device_name, can_id, flags, changed_mask_1, changed_mask_2, + captured_at_ms, elapsed_realtime_ms, board_id, can_id, flags, changed_mask_1, changed_mask_2, speed_centi_kmh, battery_voltage_mv, motor_current_ma, battery_current_ma, duty_permille, pitch_centi_deg, roll_centi_deg, balance_pitch_centi_deg, balance_current_ma, erpm, state, switch_state, adc1_milli, adc2_milli, odometer_cm, temp_mosfet_deci_c, temp_motor_deci_c, fault_code, latitude_e7, longitude_e7, gps_speed_centi_mps, bearing_centi_deg, accuracy_cm, altitude_cm, location_timestamp_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ - state.capturedAtMs, state.elapsedRealtimeMs, state.deviceId, state.deviceName, state.capture.canId, + state.capturedAtMs, state.elapsedRealtimeMs, state.boardId, state.capture.canId, TELEMETRY_FLAG_KEYFRAME | (t.hasFault ? TELEMETRY_FLAG_HAS_FAULT : 0) | (loc == nil ? 0 : TELEMETRY_FLAG_HAS_LOCATION), Int.max, 1, telemetryCenti(t.speed), telemetryMilli(t.batteryVoltage), telemetryMilli(t.motorCurrent), telemetryMilli(t.batteryCurrent), telemetryMilli(t.dutyCycle), @@ -88,7 +88,7 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = te try db.execute( sql: """ INSERT INTO telemetry_minute_buckets ( - bucket_start_ms, device_id, device_name, sample_count, first_sample_at_ms, last_sample_at_ms, + bucket_start_ms, board_id, sample_count, first_sample_at_ms, last_sample_at_ms, sum_abs_speed_centi_kmh, moving_speed_sample_count, sum_moving_abs_speed_centi_kmh, max_abs_speed_centi_kmh, min_battery_voltage_mv, max_motor_current_abs_ma, max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, max_duty_abs_permille, @@ -96,9 +96,8 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = te gps_distance_cm, max_gps_speed_centi_mps, max_temp_mosfet_deci_c, max_temp_motor_deci_c, first_latitude_e7, first_longitude_e7, first_moving_at_ms, last_moving_at_ms, updated_at, sync_seq - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(bucket_start_ms, device_id) DO UPDATE SET - device_name=excluded.device_name, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(bucket_start_ms, board_id) DO UPDATE SET sample_count=telemetry_minute_buckets.sample_count + excluded.sample_count, last_sample_at_ms=MAX(telemetry_minute_buckets.last_sample_at_ms, excluded.last_sample_at_ms), sum_abs_speed_centi_kmh=telemetry_minute_buckets.sum_abs_speed_centi_kmh + excluded.sum_abs_speed_centi_kmh, @@ -124,7 +123,7 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = te sync_seq=excluded.sync_seq """, arguments: [ - b.bucketStartMs, b.deviceId, b.deviceName, b.sampleCount, b.firstSampleAtMs, b.lastSampleAtMs, + b.bucketStartMs, b.boardId, b.sampleCount, b.firstSampleAtMs, b.lastSampleAtMs, b.sumAbsSpeedCentiKmh, b.movingSpeedSampleCount, b.sumMovingAbsSpeedCentiKmh, b.maxAbsSpeedCentiKmh, b.minBatteryVoltageMv, b.maxMotorCurrentAbsMa, b.maxBatteryCurrentAbsMa, b.batteryUsedWhMilli, b.batteryRegenWhMilli, b.maxDutyAbsPermille, b.faultCount, b.firstOdometerCm, b.lastOdometerCm, @@ -156,7 +155,8 @@ internal func insertExclusion(_ db: Database, _ range: MetricExclusionRange) thr ) } -internal func historyMap(_ row: Row, markers: [Row]) -> [String: Any?] { +/// [boardNames] resolves `boards.id` -> name on read; the row never carried one (ADR 0028). +internal func historyMap(_ row: Row, markers: [Row], boardNames: [String: String]) -> [String: Any?] { let sampleCount: Int = row["sample_count"] let movingCount: Int? = row["moving_speed_sample_count"] let sumMoving: Int64? = row["sum_moving_abs_speed_centi_kmh"] @@ -164,23 +164,20 @@ internal func historyMap(_ row: Row, markers: [Row]) -> [String: Any?] { ?? (sampleCount > 0 ? Double(row["sum_abs_speed_centi_kmh"] as Int64) / Double(sampleCount) / 100.0 : 0.0) let marker = markers.last { marker in let occurredAtMs = marker["occurred_at_ms"] as Int64 - let markerDevice = marker["device_id"] as String? ?? "" - let bucketDevice = row["device_id"] as String return occurredAtMs >= (row["first_sample_at_ms"] as Int64) - 5_000 && - occurredAtMs <= (row["first_sample_at_ms"] as Int64) + 1_000 && - markerDevice == bucketDevice + occurredAtMs <= (row["first_sample_at_ms"] as Int64) + 1_000 } let distanceDeltaM: Double? = { guard let first = row["first_odometer_cm"] as Int64?, let last = row["last_odometer_cm"] as Int64? else { return nil } return Double(max(0, last - first)) / 100.0 }() return [ - "id": "\(row["device_id"] as String):\(row["bucket_start_ms"] as Int64)", + "id": "\(row["board_id"] as String):\(row["bucket_start_ms"] as Int64)", "startAtMs": row["first_sample_at_ms"] as Int64, "endAtMs": row["last_sample_at_ms"] as Int64, "bucketStartMs": row["bucket_start_ms"] as Int64, - "deviceId": (row["device_id"] as String).isEmpty ? nil : row["device_id"] as String, - "deviceName": row["device_name"] as String? ?? "VESC Board", + "boardId": (row["board_id"] as String).isEmpty ? nil : row["board_id"] as String, + "boardName": boardNames[row["board_id"] as String] ?? UNKNOWN_TELEMETRY_BOARD_NAME, "sampleCount": sampleCount, "gpsPointCount": row["gps_point_count"] as Int, "preciseGpsPointCount": row["precise_gps_point_count"] as Int, @@ -209,12 +206,12 @@ internal func historyMap(_ row: Row, markers: [Row]) -> [String: Any?] { ] } -internal func sampleMap(_ row: Row, batteryPercent: Double?) -> [String: Any?] { +internal func sampleMap(_ row: Row, batteryPercent: Double?, boardNames: [String: String]) -> [String: Any?] { [ "id": row["id"] as Int64, "capturedAtMs": row["captured_at_ms"] as Int64, - "deviceId": row["device_id"] as String?, - "deviceName": row["device_name"] as String? ?? "VESC Board", + "boardId": row["board_id"] as String?, + "boardName": (row["board_id"] as String?).flatMap { boardNames[$0] } ?? UNKNOWN_TELEMETRY_BOARD_NAME, "speedKmh": Double(row["speed_centi_kmh"] as Int? ?? 0) / 100.0, "batteryVoltage": Double(row["battery_voltage_mv"] as Int? ?? 0) / 1000.0, "batteryPercent": batteryPercent, @@ -271,22 +268,22 @@ internal func exclusionMap(_ row: Row) -> [String: Any?] { ] } -internal func gpsMaps(_ rows: [Row]) -> [[String: Any?]] { - var previousByDevice: [String: (lat: Double, lon: Double)] = [:] +internal func gpsMaps(_ rows: [Row], boardNames: [String: String]) -> [[String: Any?]] { + var previousByBoard: [String: (lat: Double, lon: Double)] = [:] return rows.compactMap { row in guard let latitudeE7 = row["latitude_e7"] as Int64?, let longitudeE7 = row["longitude_e7"] as Int64? else { return nil } let latitude = Double(latitudeE7) / 10_000_000.0 let longitude = Double(longitudeE7) / 10_000_000.0 - let deviceId = row["device_id"] as String? ?? "" - let previous = previousByDevice[deviceId] - previousByDevice[deviceId] = (latitude, longitude) + let boardId = row["board_id"] as String? ?? "" + let previous = previousByBoard[boardId] + previousByBoard[boardId] = (latitude, longitude) return [ "id": row["id"] as Int64, "capturedAtMs": row["captured_at_ms"] as Int64, - "deviceId": (row["device_id"] as String?) ?? nil, - "deviceName": row["device_name"] as String? ?? "VESC Board", + "boardId": (row["board_id"] as String?) ?? nil, + "boardName": boardNames[boardId] ?? UNKNOWN_TELEMETRY_BOARD_NAME, "latitude": latitude, "longitude": longitude, "speedMps": (row["gps_speed_centi_mps"] as Int?).map { Double($0) / 100.0 }, @@ -303,8 +300,9 @@ internal func gpsMaps(_ rows: [Row]) -> [[String: Any?]] { internal func bucketPoint(_ row: Row) -> BucketTelemetryPoint? { BucketTelemetryPoint( capturedAtMs: row["captured_at_ms"] as Int64, - deviceId: row["device_id"] as String?, - deviceName: row["device_name"] as String?, + boardId: row["board_id"] as String?, + // Frames never carried the BLE identifier; only live capture has one, for markers. + deviceId: nil, speedCentiKmh: row["speed_centi_kmh"] as Int? ?? 0, batteryVoltageMv: row["battery_voltage_mv"] as Int? ?? 0, motorCurrentMa: row["motor_current_ma"] as Int? ?? 0, diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 8fa6efa76..cf94523a5 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -502,6 +502,295 @@ enum TelemetryDatabase { } } + // Telemetry keys on the Board id (#280, ADR 0028). `telemetry_frames` and + // `telemetry_minute_buckets` gain `board_id` and lose `device_id` (the BLE identifier) and + // `device_name` (the Board name denormalized at capture time); Ride History resolves the name + // by looking the Board up instead. Markers, diagnostic events and metric exclusion ranges are + // deliberately untouched — that is what crosses the wire for them. + // + // Both tables are rebuilt rather than altered: the bucket primary key moves to + // `(bucket_start_ms, board_id)`, and the rebuild is what drops the two retired columns. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_34_35` + migrator.registerMigration("v35_telemetry_board_id") { db in + try mintOrphanBoards(db) + try rebuildFramesOnBoardId(db) + try rebuildBucketsOnBoardId(db) + } + return migrator } } + +/// Stand-in Board id for buckets whose samples match no saved Board. `board_id` is part of the +/// bucket primary key, so unattributed rows need a value rather than null. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt `UNKNOWN_TELEMETRY_BOARD_ID` +internal let UNKNOWN_TELEMETRY_BOARD_ID = "" +internal let UNKNOWN_TELEMETRY_BOARD_NAME = "VESC Board" + +/// Id prefix for the tombstoned Boards the board-id migration mints for telemetry whose BLE +/// identifier resolves to nothing. Derived from the identifier rather than random so the mint is +/// idempotent. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt `ORPHAN_BOARD_ID_PREFIX` +internal let ORPHAN_BOARD_ID_PREFIX = "orphan-" + +/// Telemetry whose `device_id` matches no Board would lose both its identity and its label: either +/// the Board was hard-deleted before tombstones existed (ADR 0027), or it was re-linked to a +/// different peripheral and the old identifier no longer resolves. One tombstoned Board is minted +/// per unresolved identifier, named from that telemetry's own historical `device_name`, so the +/// history stays joinable, keeps a label, and can be backed up. +/// +/// The minted row is a tombstone with no Board Link: `deleted_at` keeps it out of every +/// Rider-facing list, and a null `ble_id` stops it from ever capturing a future re-link. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `mintOrphanBoards` +internal func mintOrphanBoards(_ db: Database) throws { + let now = telemetryNowMs() + for (table, timeColumn) in [ + ("telemetry_frames", "captured_at_ms"), + ("telemetry_minute_buckets", "bucket_start_ms"), + ] { + try db.execute( + sql: """ + INSERT OR IGNORE INTO boards (id, name, ble_id, created_at, updated_at, sync_seq, deleted_at) + SELECT + ? || t.device_id, + COALESCE( + ( + SELECT n.device_name FROM \(table) n + WHERE n.device_id = t.device_id AND n.device_name IS NOT NULL + ORDER BY n.\(timeColumn) DESC LIMIT 1 + ), + ? + ), + NULL, + MIN(t.\(timeColumn)), + ?, + 0, + ? + FROM \(table) t + WHERE t.device_id IS NOT NULL + AND t.device_id != '' + AND NOT EXISTS (SELECT 1 FROM boards b WHERE b.ble_id = t.device_id) + GROUP BY t.device_id + """, + arguments: [ORPHAN_BOARD_ID_PREFIX, UNKNOWN_TELEMETRY_BOARD_NAME, now, now] + ) + } + // A minted Board is an ordinary write and has to upload like one. Every row that survived the + // `v33_sync_seq` migration carries a positive `sync_seq`, so zero marks exactly the rows just + // minted. + try db.execute( + sql: """ + UPDATE boards + SET sync_seq = (SELECT COALESCE(last_value, 0) FROM sync_sequences WHERE name = ?) + rowid + WHERE sync_seq = 0 + """, + arguments: [syncSeqBoards] + ) + try db.execute( + sql: """ + INSERT OR REPLACE INTO sync_sequences (name, last_value) + VALUES (?, (SELECT COALESCE(MAX(sync_seq), 0) FROM boards)) + """, + arguments: [syncSeqBoards] + ) +} + +/// Resolves a telemetry row's `device_id` to a Board id: the linked Board when one still claims the +/// identifier, otherwise the tombstone minted for it. A row that never carried an identifier stays +/// unattributed — `unattributed` is NULL for frames and the sentinel for buckets, whose column is +/// part of the primary key. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `boardIdFromDeviceId` +private func boardIdFromDeviceId(_ alias: String, unattributed: String) -> String { + """ + CASE + WHEN \(alias).device_id IS NULL OR \(alias).device_id = '' THEN \(unattributed) + ELSE COALESCE( + (SELECT b.id FROM boards b WHERE b.ble_id = \(alias).device_id LIMIT 1), + '\(ORPHAN_BOARD_ID_PREFIX)' || \(alias).device_id + ) + END + """ +} + +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `rebuildFramesOnBoardId` +private func rebuildFramesOnBoardId(_ db: Database) throws { + let columns = """ + captured_at_ms, elapsed_realtime_ms, can_id, flags, changed_mask_1, changed_mask_2, \ + speed_centi_kmh, battery_voltage_mv, motor_current_ma, battery_current_ma, duty_permille, \ + pitch_centi_deg, roll_centi_deg, balance_pitch_centi_deg, balance_current_ma, erpm, state, \ + switch_state, adc1_milli, adc2_milli, odometer_cm, temp_mosfet_deci_c, temp_motor_deci_c, \ + fault_code, latitude_e7, longitude_e7, gps_speed_centi_mps, bearing_centi_deg, accuracy_cm, \ + altitude_cm, location_timestamp_ms + """ + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_frames_captured_at_ms") + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_frames_device_id_captured_at_ms") + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_frames_fault") + try db.execute(sql: """ + CREATE TABLE telemetry_frames_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + captured_at_ms INTEGER NOT NULL, + elapsed_realtime_ms INTEGER NOT NULL, + board_id TEXT, + can_id INTEGER, + flags INTEGER NOT NULL, + changed_mask_1 INTEGER NOT NULL, + changed_mask_2 INTEGER NOT NULL, + speed_centi_kmh INTEGER, + battery_voltage_mv INTEGER, + motor_current_ma INTEGER, + battery_current_ma INTEGER, + duty_permille INTEGER, + pitch_centi_deg INTEGER, + roll_centi_deg INTEGER, + balance_pitch_centi_deg INTEGER, + balance_current_ma INTEGER, + erpm INTEGER, + state INTEGER, + switch_state INTEGER, + adc1_milli INTEGER, + adc2_milli INTEGER, + odometer_cm INTEGER, + temp_mosfet_deci_c INTEGER, + temp_motor_deci_c INTEGER, + fault_code INTEGER, + latitude_e7 INTEGER, + longitude_e7 INTEGER, + gps_speed_centi_mps INTEGER, + bearing_centi_deg INTEGER, + accuracy_cm INTEGER, + altitude_cm INTEGER, + location_timestamp_ms INTEGER + ) + """) + try db.execute(sql: """ + INSERT INTO telemetry_frames_new (id, board_id, \(columns)) + SELECT f.id, \(boardIdFromDeviceId("f", unattributed: "NULL")), \(columns) + FROM telemetry_frames f + """) + try db.execute(sql: "DROP TABLE telemetry_frames") + try db.execute(sql: "ALTER TABLE telemetry_frames_new RENAME TO telemetry_frames") + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS index_telemetry_frames_captured_at_ms + ON telemetry_frames(captured_at_ms) + """) + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS index_telemetry_frames_board_id_captured_at_ms + ON telemetry_frames(board_id, captured_at_ms) + """) + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS index_telemetry_frames_fault + ON telemetry_frames(captured_at_ms) + WHERE fault_code IS NOT NULL AND fault_code != 0 + """) +} + +/// The primary key move from `(bucket_start_ms, device_id)` to `(bucket_start_ms, board_id)` is a +/// table rebuild, not an `ALTER`. `updated_at` and `sync_seq` were added to this table earlier in +/// the same release, so the copy has to carry them across explicitly or every bucket silently +/// resets its Sync Cursor position. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `rebuildBucketsOnBoardId` +private func rebuildBucketsOnBoardId(_ db: Database) throws { + let columns = """ + bucket_start_ms, sample_count, first_sample_at_ms, last_sample_at_ms, \ + sum_abs_speed_centi_kmh, moving_speed_sample_count, sum_moving_abs_speed_centi_kmh, \ + max_abs_speed_centi_kmh, min_battery_voltage_mv, max_motor_current_abs_ma, \ + max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, \ + max_duty_abs_permille, fault_count, first_odometer_cm, last_odometer_cm, gps_point_count, \ + precise_gps_point_count, gps_distance_cm, max_gps_speed_centi_mps, max_temp_mosfet_deci_c, \ + max_temp_motor_deci_c, first_latitude_e7, first_longitude_e7, first_moving_at_ms, \ + last_moving_at_ms, updated_at, sync_seq + """ + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_minute_buckets_bucket_start_ms") + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_minute_buckets_updated_at") + try db.execute(sql: "DROP INDEX IF EXISTS index_telemetry_minute_buckets_sync_seq") + try db.execute(sql: """ + CREATE TABLE telemetry_minute_buckets_new ( + bucket_start_ms INTEGER NOT NULL, + board_id TEXT NOT NULL, + sample_count INTEGER NOT NULL, + first_sample_at_ms INTEGER NOT NULL, + last_sample_at_ms INTEGER NOT NULL, + sum_abs_speed_centi_kmh INTEGER NOT NULL, + moving_speed_sample_count INTEGER, + sum_moving_abs_speed_centi_kmh INTEGER, + max_abs_speed_centi_kmh INTEGER NOT NULL, + min_battery_voltage_mv INTEGER, + max_motor_current_abs_ma INTEGER NOT NULL, + max_battery_current_abs_ma INTEGER NOT NULL, + battery_used_wh_milli INTEGER NOT NULL, + battery_regen_wh_milli INTEGER NOT NULL, + max_duty_abs_permille INTEGER NOT NULL, + fault_count INTEGER NOT NULL, + first_odometer_cm INTEGER, + last_odometer_cm INTEGER, + gps_point_count INTEGER NOT NULL, + precise_gps_point_count INTEGER NOT NULL, + gps_distance_cm INTEGER NOT NULL, + max_gps_speed_centi_mps INTEGER, + max_temp_mosfet_deci_c INTEGER, + max_temp_motor_deci_c INTEGER, + first_latitude_e7 INTEGER, + first_longitude_e7 INTEGER, + first_moving_at_ms INTEGER, + last_moving_at_ms INTEGER, + updated_at INTEGER NOT NULL, + sync_seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_start_ms, board_id) + ) + """) + // Grouped rather than copied row-for-row so the rebuild is total. A `board_id` collision on the + // new key needs two identifiers resolving to one Board inside one minute, which the resolver + // cannot currently produce — but an ungrouped copy would abort the whole migration on a + // constraint error if it ever did, stranding the database mid-upgrade. The fold sums the additive + // lanes and takes the extreme of the peaks, as an upsert merge would. + try db.execute(sql: """ + INSERT INTO telemetry_minute_buckets_new (board_id, \(columns)) + SELECT + \(boardIdFromDeviceId("b", unattributed: "''")) AS board_id, + b.bucket_start_ms, + SUM(b.sample_count), + MIN(b.first_sample_at_ms), + MAX(b.last_sample_at_ms), + SUM(b.sum_abs_speed_centi_kmh), + SUM(b.moving_speed_sample_count), + SUM(b.sum_moving_abs_speed_centi_kmh), + MAX(b.max_abs_speed_centi_kmh), + MIN(b.min_battery_voltage_mv), + MAX(b.max_motor_current_abs_ma), + MAX(b.max_battery_current_abs_ma), + SUM(b.battery_used_wh_milli), + SUM(b.battery_regen_wh_milli), + MAX(b.max_duty_abs_permille), + SUM(b.fault_count), + MIN(b.first_odometer_cm), + MAX(b.last_odometer_cm), + SUM(b.gps_point_count), + SUM(b.precise_gps_point_count), + SUM(b.gps_distance_cm), + MAX(b.max_gps_speed_centi_mps), + MAX(b.max_temp_mosfet_deci_c), + MAX(b.max_temp_motor_deci_c), + MIN(b.first_latitude_e7), + MIN(b.first_longitude_e7), + MIN(b.first_moving_at_ms), + MAX(b.last_moving_at_ms), + MAX(b.updated_at), + MAX(b.sync_seq) + FROM telemetry_minute_buckets b + GROUP BY b.bucket_start_ms, board_id + """) + try db.execute(sql: "DROP TABLE telemetry_minute_buckets") + try db.execute(sql: "ALTER TABLE telemetry_minute_buckets_new RENAME TO telemetry_minute_buckets") + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS index_telemetry_minute_buckets_bucket_start_ms + ON telemetry_minute_buckets(bucket_start_ms) + """) + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS index_telemetry_minute_buckets_updated_at + ON telemetry_minute_buckets(updated_at) + """) + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS index_telemetry_minute_buckets_sync_seq + ON telemetry_minute_buckets(sync_seq) + """) +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift b/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift index a60285919..4cbfef753 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift @@ -147,4 +147,188 @@ final class TelemetryMigrationTests: XCTestCase { XCTAssertEqual(try alertCount(), 1) } + + // MARK: - Telemetry keys on the Board id (#280, ADR 0028) + + /// The last migration before `v35_telemetry_board_id`. Stopping here leaves both telemetry tables + /// in their `device_id` shape, with `updated_at` and `sync_seq` already on the buckets. + private static let beforeBoardId = "v34_board_deleted_at" + + private func insertBoard(id: String, name: String, bleId: String?) throws { + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO boards (id, name, ble_id, created_at, updated_at, sync_seq) + VALUES (?, ?, ?, 1000, 1000, 1) + """, + arguments: [id, name, bleId] + ) + } + } + + private func insertLegacyFrame(deviceId: String?, deviceName: String?, capturedAtMs: Int64) throws { + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO telemetry_frames + (captured_at_ms, elapsed_realtime_ms, device_id, device_name, flags, changed_mask_1, changed_mask_2) + VALUES (?, 0, ?, ?, 1, 0, 0) + """, + arguments: [capturedAtMs, deviceId, deviceName] + ) + } + } + + private func insertLegacyBucket( + deviceId: String, + deviceName: String?, + bucketStartMs: Int64, + sampleCount: Int = 1, + updatedAt: Int64 = 7_000, + syncSeq: Int64 = 42 + ) throws { + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO telemetry_minute_buckets ( + bucket_start_ms, device_id, device_name, sample_count, first_sample_at_ms, + last_sample_at_ms, sum_abs_speed_centi_kmh, max_abs_speed_centi_kmh, + max_motor_current_abs_ma, max_battery_current_abs_ma, battery_used_wh_milli, + battery_regen_wh_milli, max_duty_abs_permille, fault_count, gps_point_count, + precise_gps_point_count, gps_distance_cm, updated_at, sync_seq + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?, ?) + """, + arguments: [ + bucketStartMs, deviceId, deviceName, sampleCount, bucketStartMs, bucketStartMs + 500, + updatedAt, syncSeq, + ] + ) + } + } + + private func boardIds(fromFrames: Bool = true) throws -> [String?] { + let table = fromFrames ? "telemetry_frames" : "telemetry_minute_buckets" + return try queue.read { db in + try Row.fetchAll(db, sql: "SELECT board_id FROM \(table) ORDER BY rowid") + .map { $0["board_id"] as String? } + } + } + + private func board(_ id: String) throws -> Row? { + try queue.read { db in + try Row.fetchOne(db, sql: "SELECT * FROM boards WHERE id = ?", arguments: [id]) + } + } + + /// Telemetry that still resolves keeps its Board; the retired columns go with the rebuild. + func testTelemetryBackfillsBoardIdFromTheLinkedBoardAndDropsTheOldColumns() throws { + try migrate(upTo: Self.beforeBoardId) + try insertBoard(id: "board-1", name: "ADV", bleId: "ble-a") + try insertLegacyFrame(deviceId: "ble-a", deviceName: "ADV", capturedAtMs: 60_000) + try insertLegacyBucket(deviceId: "ble-a", deviceName: "ADV", bucketStartMs: 60_000) + + try migrate() + + XCTAssertEqual(try boardIds(), ["board-1"]) + XCTAssertEqual(try boardIds(fromFrames: false), ["board-1"]) + for table in ["telemetry_frames", "telemetry_minute_buckets"] { + let columns = try columnNames(table) + XCTAssertFalse(columns.contains("device_id"), "\(table) kept device_id") + XCTAssertFalse(columns.contains("device_name"), "\(table) kept device_name") + } + } + + /// A frame that never carried an identifier stays unattributed rather than joining a random + /// Board; the bucket column is part of the primary key, so it takes the sentinel instead. + func testTelemetryWithNoIdentifierStaysUnattributed() throws { + try migrate(upTo: Self.beforeBoardId) + try insertLegacyFrame(deviceId: nil, deviceName: nil, capturedAtMs: 60_000) + try insertLegacyBucket(deviceId: "", deviceName: nil, bucketStartMs: 60_000) + + try migrate() + + XCTAssertEqual(try boardIds(), [nil]) + XCTAssertEqual(try boardIds(fromFrames: false), [""]) + XCTAssertEqual(try queue.read { db in try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM boards") }, 0) + } + + // MARK: Orphan minting + + /// The regression this exists to prevent: telemetry from a Board hard-deleted before tombstones + /// existed resolves to nothing, and without a minted Board it loses both its identity and its + /// label. It must end up pointing at a Board that exists, is tombstoned, and keeps the name the + /// history itself recorded. + func testUnresolvedIdentifierMintsATombstonedBoardCarryingTheHistoricalName() throws { + try migrate(upTo: Self.beforeBoardId) + try insertLegacyFrame(deviceId: "ble-gone", deviceName: "Old Board", capturedAtMs: 60_000) + + try migrate() + + let mintedId = try XCTUnwrap(try boardIds().first ?? nil) + XCTAssertEqual(mintedId, "\(ORPHAN_BOARD_ID_PREFIX)ble-gone") + + let minted = try XCTUnwrap(try board(mintedId)) + XCTAssertEqual(minted["name"] as String, "Old Board") + XCTAssertNotNil(minted["deleted_at"] as Int64?, "a minted Board is not tombstoned") + XCTAssertNil(minted["ble_id"] as String?, "a minted Board carries a Board Link") + // Every write has to upload, and every existing row is already above zero. + XCTAssertGreaterThan(minted["sync_seq"] as Int64, 0) + } + + /// A minted Board is invisible to the Rider: `getBoards()` filters tombstones (ADR 0027), so the + /// only place it surfaces is the history label it exists to provide. + func testAMintedBoardNeverAppearsInTheRidersBoardList() throws { + try migrate(upTo: Self.beforeBoardId) + try insertBoard(id: "board-1", name: "ADV", bleId: "ble-a") + try insertLegacyFrame(deviceId: "ble-gone", deviceName: "Old Board", capturedAtMs: 60_000) + + try migrate() + + let live = try queue.read { db in + try String.fetchAll(db, sql: "SELECT id FROM boards WHERE deleted_at IS NULL ORDER BY id") + } + XCTAssertEqual(live, ["board-1"]) + } + + /// Minting is derived from the identifier, not random, so a database that somehow reaches the + /// migration twice does not accumulate a second Board per ride. + func testMintingTheSameIdentifierTwiceIsANoOp() throws { + try migrate(upTo: Self.beforeBoardId) + try insertLegacyFrame(deviceId: "ble-gone", deviceName: "Old Board", capturedAtMs: 60_000) + try insertLegacyBucket(deviceId: "ble-gone", deviceName: "Old Board", bucketStartMs: 60_000) + + try migrate() + + XCTAssertEqual(try queue.read { db in try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM boards") }, 1) + } + + // MARK: Bucket rebuild + + /// The primary key move is a table rebuild. `updated_at` and `sync_seq` landed on this table + /// earlier in the same release, so a copy that forgets them silently resets every bucket's Sync + /// Cursor position and the rows stop uploading. + func testTheBucketRebuildMovesThePrimaryKeyAndPreservesUpdatedAtAndSyncSeq() throws { + try migrate(upTo: Self.beforeBoardId) + try insertBoard(id: "board-1", name: "ADV", bleId: "ble-a") + try insertLegacyBucket( + deviceId: "ble-a", + deviceName: "ADV", + bucketStartMs: 60_000, + updatedAt: 7_777, + syncSeq: 99 + ) + + try migrate() + + XCTAssertEqual( + try queue.read { db in try db.primaryKey("telemetry_minute_buckets").columns }, + ["bucket_start_ms", "board_id"] + ) + let row = try XCTUnwrap(try queue.read { db in + try Row.fetchOne(db, sql: "SELECT * FROM telemetry_minute_buckets") + }) + XCTAssertEqual(row["updated_at"] as Int64, 7_777) + XCTAssertEqual(row["sync_seq"] as Int64, 99) + } + } diff --git a/modules/vescape-core/ios/telemetry/TelemetryPipeline.swift b/modules/vescape-core/ios/telemetry/TelemetryPipeline.swift index 554957c3f..05c312eeb 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryPipeline.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryPipeline.swift @@ -27,6 +27,9 @@ internal struct TelemetryLocationCapture { internal struct TelemetryCapture { let capturedAtMs: Int64 let elapsedRealtimeMs: Int64 + /// Owning Board (`boards.id`) — what frames and buckets are keyed on (ADR 0028). + let boardId: String? + /// BLE identifier; still stamped on markers and diagnostic events, never on frames or buckets. let deviceId: String? let deviceName: String? let canId: Int? @@ -36,8 +39,10 @@ internal struct TelemetryCapture { internal struct BucketTelemetryPoint { let capturedAtMs: Int64 + /// Owning Board (`boards.id`); the durable identity telemetry is keyed on (ADR 0028). + let boardId: String? + /// BLE identifier. Not stored on frames or buckets — only Metric Exclusion Ranges still key on it. let deviceId: String? - let deviceName: String? let speedCentiKmh: Int let batteryVoltageMv: Int let motorCurrentMa: Int @@ -66,6 +71,7 @@ internal struct FullTelemetryState { var t: RefloatTelemetry { capture.telemetry } var capturedAtMs: Int64 { capture.capturedAtMs } var elapsedRealtimeMs: Int64 { capture.elapsedRealtimeMs } + var boardId: String? { capture.boardId } var deviceId: String? { capture.deviceId } var deviceName: String? { capture.deviceName } var location: TelemetryLocationCapture? { capture.location } @@ -73,8 +79,8 @@ internal struct FullTelemetryState { func toBucketPoint() -> BucketTelemetryPoint { BucketTelemetryPoint( capturedAtMs: capturedAtMs, + boardId: boardId, deviceId: deviceId, - deviceName: deviceName, speedCentiKmh: telemetryCenti(t.speed), batteryVoltageMv: telemetryMilli(t.batteryVoltage), motorCurrentMa: telemetryMilli(t.motorCurrent), diff --git a/modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift b/modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift index 1b679aaab..a25aed09d 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRangePayload.swift @@ -12,22 +12,26 @@ extension TelemetryRepository { let fromMs = telemetryLong(options["fromMs"]) ?? 0 let toMs = telemetryLong(options["toMs"]) ?? telemetryNowMs() let limit = min(MAX_SAMPLE_LIMIT, max(1, telemetryInt(options["limit"]) ?? DEFAULT_SAMPLE_LIMIT)) - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String guard let pool else { return emptyRangePayload() } - // Battery configs and the smoothing window are read up front (each opens its own DB read) so - // the estimate stays a pure computation inside the range read below. - let configs = batteryConfigByDevice() + // Markers and Metric Exclusion Ranges still key on the BLE identifier (ADR 0028), so a + // Board-scoped range read translates before it can filter them. + let deviceId = Self.bleId(forBoardId: boardId) + // Battery configs, board names and the smoothing window are read up front (each opens its own + // DB read) so the estimate stays a pure computation inside the range read below. + let configs = batteryConfigByBoard() + let boardNames = Self.boardNamesById() let windowMs = socWindowMs() return (try? pool.read { db -> [String: Any?] in let sampleRows = try Row.fetchAll( db, sql: """ SELECT * FROM telemetry_frames - WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY captured_at_ms ASC LIMIT ? """, - arguments: [fromMs, toMs, deviceId, deviceId, limit] + arguments: [fromMs, toMs, boardId, boardId, limit] ) let markers = try Row.fetchAll( db, @@ -40,8 +44,8 @@ extension TelemetryRepository { arguments: [fromMs, toMs, deviceId, deviceId] ).map(exclusionMap) let percents = self.batteryPercents(sampleRows, configs: configs, windowMs: windowMs) - return mergeTelemetryPayload(sampleColumns(sampleRows, batteryPercents: percents), [ - "gpsSamples": gpsMaps(sampleRows), + return mergeTelemetryPayload(sampleColumns(sampleRows, batteryPercents: percents, boardNames: boardNames), [ + "gpsSamples": gpsMaps(sampleRows, boardNames: boardNames), "markers": markers.map(markerMap), "exclusions": exclusions, ]) @@ -54,20 +58,24 @@ extension TelemetryRepository { /// that answers JS calls rather than in the DAO. /// /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `smoothedSampleColumns` -internal func sampleColumns(_ rows: [Row], batteryPercents: [Double?]) -> [String: Any?] { +internal func sampleColumns( + _ rows: [Row], + batteryPercents: [Double?], + boardNames: [String: String] +) -> [String: Any?] { var data = Data(capacity: rows.count * SAMPLE_COLUMN_COUNT * MemoryLayout.size) - var deviceIds: [String?] = [] - var deviceNames: [String] = [] - var deviceIndex: [String: Int] = [:] + var boardIds: [String?] = [] + var names: [String] = [] + var boardIndex: [String: Int] = [:] for (i, row) in rows.enumerated() { let id: Int64 = row["id"] - let rawDeviceId = row["device_id"] as String? - let key = rawDeviceId ?? "" - let index = deviceIndex[key] ?? { - deviceIds.append(rawDeviceId) - deviceNames.append(row["device_name"] as String? ?? "VESC Board") - let newIndex = deviceIds.count - 1 - deviceIndex[key] = newIndex + let rawBoardId = row["board_id"] as String? + let key = rawBoardId ?? "" + let index = boardIndex[key] ?? { + boardIds.append(rawBoardId) + names.append(rawBoardId.flatMap { boardNames[$0] } ?? UNKNOWN_TELEMETRY_BOARD_NAME) + let newIndex = boardIds.count - 1 + boardIndex[key] = newIndex return newIndex }() appendDouble(&data, Double(id)) @@ -99,8 +107,8 @@ internal func sampleColumns(_ rows: [Row], batteryPercents: [Double?]) -> [Strin return [ "boardColumns": (try? NativeArrayBuffer.copy(data: data)) ?? NativeArrayBuffer.allocate(size: 0), "boardCount": rows.count, - "boardDevices": deviceIds, - "boardDeviceNames": deviceNames, + "boardIds": boardIds, + "boardNames": names, ] } @@ -108,8 +116,8 @@ internal func emptyRangePayload() -> [String: Any?] { [ "boardColumns": NativeArrayBuffer.allocate(size: 0), "boardCount": 0, - "boardDevices": [] as [String?], - "boardDeviceNames": [] as [String], + "boardIds": [] as [String?], + "boardNames": [] as [String], "gpsSamples": [] as [[String: Any?]], "markers": [] as [[String: Any?]], "exclusions": [] as [[String: Any?]], diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index 70a160a40..0ed0a1f6f 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -126,19 +126,21 @@ internal final class TelemetryRepository { let fromMs = telemetryLong(options["fromMs"]) ?? 0 let beforeMs = telemetryLong(options["cursorBeforeMs"]) ?? toMs let limit = min(500, max(1, telemetryInt(options["limit"]) ?? DEFAULT_HISTORY_LIMIT)) - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String guard let pool else { return [] } + let deviceId = Self.bleId(forBoardId: boardId) + let boardNames = Self.boardNamesById() return (try? pool.read { db in let rows = try Row.fetchAll( db, sql: """ SELECT * FROM telemetry_minute_buckets WHERE bucket_start_ms >= ? AND bucket_start_ms <= ? AND bucket_start_ms < ? - AND (? IS NULL OR device_id = ?) + AND (? IS NULL OR board_id = ?) ORDER BY bucket_start_ms DESC LIMIT ? """, - arguments: [fromMs, toMs, beforeMs, deviceId, deviceId, limit] + arguments: [fromMs, toMs, beforeMs, boardId, boardId, limit] ) let markerFrom = (rows.map { $0["bucket_start_ms"] as Int64 }.min() ?? fromMs) - GAP_BOUNDARY_MS let markerTo = (rows.map { $0["bucket_start_ms"] as Int64 }.max() ?? toMs) + TELEMETRY_BUCKET_SIZE_MS @@ -147,7 +149,7 @@ internal final class TelemetryRepository { sql: "SELECT * FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND (? IS NULL OR device_id = ?) ORDER BY occurred_at_ms ASC", arguments: [markerFrom, markerTo, deviceId, deviceId] ) - return rows.map { historyMap($0, markers: markers) } + return rows.map { historyMap($0, markers: markers, boardNames: boardNames) } }) ?? [] } @@ -156,47 +158,48 @@ internal final class TelemetryRepository { let fromMs = telemetryLong(options["fromMs"]) ?? 0 let toMs = telemetryLong(options["toMs"]) ?? telemetryNowMs() let limit = min(MAX_SAMPLE_LIMIT, max(1, telemetryInt(options["limit"]) ?? DEFAULT_SAMPLE_LIMIT)) - let deviceId = options["deviceId"] as? String - // Battery configs and the smoothing window are read up front (each opens its own DB read) so - // the estimate stays a pure computation inside the frames read below. - let configs = batteryConfigByDevice() + let boardId = options["boardId"] as? String + // Battery configs, board names and the smoothing window are read up front (each opens its own + // DB read) so the estimate stays a pure computation inside the frames read below. + let configs = batteryConfigByBoard() + let boardNames = Self.boardNamesById() let windowMs = socWindowMs() return (try? pool.read { db in let rows = try Row.fetchAll( db, sql: """ SELECT * FROM telemetry_frames - WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY captured_at_ms ASC LIMIT ? """, - arguments: [fromMs, toMs, deviceId, deviceId, limit] + arguments: [fromMs, toMs, boardId, boardId, limit] ) let percents = self.batteryPercents(rows, configs: configs, windowMs: windowMs) - return zip(rows, percents).map { sampleMap($0.0, batteryPercent: $0.1) } + return zip(rows, percents).map { sampleMap($0.0, batteryPercent: $0.1, boardNames: boardNames) } }) ?? [] } // MARK: - Battery SoC on read (ADR-0016) /// Per-sample Battery SoC Estimate for a run of frames (ordered by captured_at_ms): the - /// IR-compensated % from the board's stored battery config, smoothed by a per-device - /// `SocMedianWindow`. Returns one entry per row (nil where no config is known for the device). + /// IR-compensated % from the Board's stored battery config, smoothed by a per-Board + /// `SocMedianWindow`. Returns one entry per row (nil where no config is known for the Board). /// Mirrors how the live path derives % per frame; approximate on read only because Android stores /// delta-encoded frames. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `smoothedSampleMaps` internal func batteryPercents(_ rows: [Row], configs: [String: [String: Any]], windowMs: Int64) -> [Double?] { var windows: [String: SocMedianWindow] = [:] return rows.map { row in - let deviceId = row["device_id"] as String? + let boardId = row["board_id"] as String? let voltageV = Double(row["battery_voltage_mv"] as Int? ?? 0) / 1000.0 let batteryCurrentA = Double(row["battery_current_ma"] as Int? ?? 0) / 1000.0 - guard let deviceId, let raw = deriveBatteryPercent(deviceId: deviceId, voltageV: voltageV, batteryCurrentA: batteryCurrentA, configs: configs) else { + guard let boardId, let raw = deriveBatteryPercent(boardId: boardId, voltageV: voltageV, batteryCurrentA: batteryCurrentA, configs: configs) else { return nil } - let window = windows[deviceId] ?? { + let window = windows[boardId] ?? { let w = SocMedianWindow(windowMs: windowMs) - windows[deviceId] = w + windows[boardId] = w return w }() return window.median(percent: raw, nowMs: row["captured_at_ms"] as Int64) @@ -205,23 +208,24 @@ internal final class TelemetryRepository { /// Derive IR-compensated battery % for one sample, mirroring the live native path. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `deriveBatteryPercent` - private func deriveBatteryPercent(deviceId: String, voltageV: Double, batteryCurrentA: Double, configs: [String: [String: Any]]) -> Double? { - guard let config = configs[deviceId] else { return nil } + private func deriveBatteryPercent(boardId: String, voltageV: Double, batteryCurrentA: Double, configs: [String: [String: Any]]) -> Double? { + guard let config = configs[boardId] else { return nil } return batteryEstimator.estimateBatteryPercent(voltageV: voltageV, config: config, batteryCurrentA: batteryCurrentA) } - /// bleId (telemetry deviceId) -> the board's normalized battery config. - /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `batteryConfigByDevice` - internal func batteryConfigByDevice() -> [String: [String: Any]] { + /// `boards.id` -> the Board's normalized battery config. Keyed on the Board rather than its BLE + /// identifier now that samples carry the Board id (ADR 0028), so a re-linked Board keeps its + /// config across its whole history. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `batteryConfigByBoard` + internal func batteryConfigByBoard() -> [String: [String: Any]] { batteryEstimator.ensureLoaded() var result: [String: [String: Any]] = [:] for board in AppDataRepository.shared.getBoards() { guard - let link = board["link"] as? [String: Any?], - let bleId = link["bleId"] as? String, + let id = board["id"] as? String, let config = board["batteryConfig"] as? [String: Any] else { continue } - result[bleId] = config + result[id] = config } return result } @@ -254,7 +258,7 @@ internal final class TelemetryRepository { guard let range = Self.favoriteRange(options) else { return nil } let startMs = range.startMs let endMs = range.endMs - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String let trimmedName = (options["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let config = queue.sync { metricConfig } let points = (try? pool.read { db in @@ -262,17 +266,17 @@ internal final class TelemetryRepository { db, sql: """ SELECT * FROM telemetry_frames - WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY captured_at_ms ASC """, - arguments: [startMs, endMs, deviceId, deviceId] + arguments: [startMs, endMs, boardId, boardId] ).compactMap(bucketPoint) }) ?? [] let summary = Self.favoriteSummary(points, config: config) let nowMs = telemetryNowMs() let favorite = Favorite( id: UUID().uuidString, - boardId: deviceId.flatMap { Self.boardId(forBleId: $0) }, + boardId: boardId, name: (trimmedName?.isEmpty ?? true) ? nil : trimmedName, startMs: startMs, endMs: endMs, @@ -284,23 +288,28 @@ internal final class TelemetryRepository { return favorite.toMap(boardName: favorite.boardId.flatMap { Self.boardNamesById()[$0] }) } - /// The Board that recorded under this BLE peripheral id, resolved once at creation. The ble id is - /// a transport key — it changes on re-link and differs per install — so the durable `boards.id` is - /// what the Favorite keeps. - /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `boardId` - private static func boardId(forBleId bleId: String) -> String? { - AppDataRepository.shared.getBoards().first { board in - (board["link"] as? [String: Any?])?["bleId"] as? String == bleId - }?["id"] as? String + /// The BLE identifier a Board currently claims. Markers, diagnostic events and Metric Exclusion + /// Ranges still key on it, so a Board-scoped query has to translate. A Board re-linked since the + /// ride no longer resolves its older markers — accepted: they are low-cardinality display rows, + /// not the sample stream (ADR 0028). + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `bleIdForBoard` + internal static func bleId(forBoardId boardId: String?) -> String? { + guard let boardId, let pool = TelemetryDatabase.pool else { return nil } + return try? pool.read { db in + try String.fetchOne(db, sql: "SELECT ble_id FROM boards WHERE id = ? LIMIT 1", arguments: [boardId]) + } } - private static func boardNamesById() -> [String: String] { - var names: [String: String] = [:] - for board in AppDataRepository.shared.getBoards() { - guard let id = board["id"] as? String, let name = board["name"] as? String else { continue } - names[id] = name - } - return names + /// `boards.id` -> Board name, tombstones included: Ride History still has to name a Board the + /// Rider deleted (ADR 0027), and resolving on read is what makes a rename retroactive. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `boardNamesById` + internal static func boardNamesById() -> [String: String] { + guard let pool = TelemetryDatabase.pool else { return [:] } + return (try? pool.read { db in + try Row.fetchAll(db, sql: "SELECT id, name FROM boards").reduce(into: [String: String]()) { + $0[$1["id"] as String] = $1["name"] as String + } + }) ?? [:] } /// Favorite ranges are required bridge input. Missing or inverted bounds must fail instead of @@ -325,7 +334,7 @@ internal final class TelemetryRepository { guard let range = Self.favoriteRange(options) else { return nil } let startMs = range.startMs let endMs = range.endMs - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String let trimmedName = (options["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let config = queue.sync { metricConfig } let points = (try? pool.read { db in @@ -333,10 +342,10 @@ internal final class TelemetryRepository { db, sql: """ SELECT * FROM telemetry_frames - WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR board_id = ?) ORDER BY captured_at_ms ASC """, - arguments: [startMs, endMs, deviceId, deviceId] + arguments: [startMs, endMs, boardId, boardId] ).compactMap(bucketPoint) }) ?? [] let updated = Favorite( @@ -428,7 +437,8 @@ internal final class TelemetryRepository { guard let pool else { return 0 } let fromMs = telemetryLong(options["fromMs"]) ?? 0 let toMs = telemetryLong(options["toMs"]) ?? 0 - let deviceId = options["deviceId"] as? String + let boardId = options["boardId"] as? String + let deviceId = Self.bleId(forBoardId: boardId) guard toMs >= fromMs else { return 0 } let deletable = subtractProtectedTelemetryRanges( deleteRange: TelemetryTimeRange(startMs: fromMs, endMs: toMs), @@ -439,11 +449,11 @@ internal final class TelemetryRepository { for range in deletable { count += try Int.fetchOne( db, - sql: "SELECT COUNT(*) FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", - arguments: [range.startMs, range.endMs, deviceId, deviceId, deviceId] + sql: "SELECT COUNT(*) FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND board_id = ?) OR (? IS NULL AND board_id IS NULL))", + arguments: [range.startMs, range.endMs, boardId, boardId, boardId] ) ?? 0 - try db.execute(sql: "DELETE FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", arguments: [range.startMs, range.endMs, deviceId, deviceId, deviceId]) - try db.execute(sql: "DELETE FROM telemetry_minute_buckets WHERE last_sample_at_ms >= ? AND first_sample_at_ms <= ? AND device_id = ?", arguments: [range.startMs, range.endMs, deviceId ?? ""]) + try db.execute(sql: "DELETE FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND board_id = ?) OR (? IS NULL AND board_id IS NULL))", arguments: [range.startMs, range.endMs, boardId, boardId, boardId]) + try db.execute(sql: "DELETE FROM telemetry_minute_buckets WHERE last_sample_at_ms >= ? AND first_sample_at_ms <= ? AND board_id = ?", arguments: [range.startMs, range.endMs, boardId ?? UNKNOWN_TELEMETRY_BOARD_ID]) try db.execute(sql: "DELETE FROM metric_exclusion_ranges WHERE end_ms >= ? AND start_ms <= ?", arguments: [range.startMs, range.endMs]) try db.execute(sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", arguments: [range.startMs, range.endMs, deviceId, deviceId, deviceId]) } diff --git a/modules/vescape-core/src/e2eFake.ts b/modules/vescape-core/src/e2eFake.ts index e6d197aa8..b0fa8efef 100644 --- a/modules/vescape-core/src/e2eFake.ts +++ b/modules/vescape-core/src/e2eFake.ts @@ -296,8 +296,8 @@ function getTelemetryHistory(options: TelemetryHistoryOptions): TelemetryMinuteB if (options.toMs != null) { buckets = buckets.filter((b) => b.startAtMs <= options.toMs!) } - if (options.deviceId != null) { - buckets = buckets.filter((b) => b.deviceId === options.deviceId) + if (options.boardId != null) { + buckets = buckets.filter((b) => b.boardId === options.boardId) } if (options.cursorBeforeMs != null) { buckets = buckets.filter((b) => b.bucketStartMs < options.cursorBeforeMs!) @@ -311,20 +311,20 @@ function getTelemetryHistory(options: TelemetryHistoryOptions): TelemetryMinuteB function encodeBoardSamples(samples: TelemetrySample[]): { boardColumns: ArrayBuffer boardCount: number - boardDevices: (string | null)[] - boardDeviceNames: string[] + boardIds: (string | null)[] + boardNames: string[] } { const lanes = new Float64Array(samples.length * SAMPLE_COLUMN_COUNT) - const boardDevices: (string | null)[] = [] - const boardDeviceNames: string[] = [] + const boardIds: (string | null)[] = [] + const boardNames: string[] = [] const deviceIndexMap = new Map() - function deviceIndex(deviceId: string | null, deviceName: string): number { - const key = `${deviceId ?? ''}:${deviceName}` + function boardIndex(boardId: string | null, boardName: string): number { + const key = `${boardId ?? ''}:${boardName}` let index = deviceIndexMap.get(key) if (index == null) { - index = boardDevices.length - boardDevices.push(deviceId) - boardDeviceNames.push(deviceName) + index = boardIds.length + boardIds.push(boardId) + boardNames.push(boardName) deviceIndexMap.set(key, index) } return index @@ -335,7 +335,7 @@ function encodeBoardSamples(samples: TelemetrySample[]): { const o = i * SAMPLE_COLUMN_COUNT lanes[o + 0] = s.id lanes[o + 1] = s.capturedAtMs - lanes[o + 2] = deviceIndex(s.deviceId, s.deviceName) + lanes[o + 2] = boardIndex(s.boardId, s.boardName) lanes[o + 3] = s.speedKmh lanes[o + 4] = s.batteryVoltage lanes[o + 5] = s.batteryPercent ?? NaN @@ -363,21 +363,21 @@ function encodeBoardSamples(samples: TelemetrySample[]): { return { boardColumns: lanes.buffer, boardCount: samples.length, - boardDevices, - boardDeviceNames, + boardIds, + boardNames, } } function getHistoryRange(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): { boardColumns: ArrayBuffer boardCount: number - boardDevices: (string | null)[] - boardDeviceNames: string[] + boardIds: (string | null)[] + boardNames: string[] gpsSamples: HistoryGpsSample[] markers: HistoryMarker[] exclusions: MetricExclusion[] @@ -385,8 +385,8 @@ function getHistoryRange(options: { let samples = historySamples.filter( (s) => s.capturedAtMs >= options.fromMs && s.capturedAtMs <= options.toMs, ) - if (options.deviceId != null) { - samples = samples.filter((s) => s.deviceId === options.deviceId) + if (options.boardId != null) { + samples = samples.filter((s) => s.boardId === options.boardId) } if (options.limit != null && options.limit > 0) { samples = samples.slice(0, options.limit) @@ -395,15 +395,17 @@ function getHistoryRange(options: { let gps = historyGps.filter( (g) => g.capturedAtMs >= options.fromMs && g.capturedAtMs <= options.toMs, ) - if (options.deviceId != null) { - gps = gps.filter((g) => g.deviceId === options.deviceId) + if (options.boardId != null) { + gps = gps.filter((g) => g.boardId === options.boardId) } let markers = historyMarkers.filter( (m) => m.occurredAtMs >= options.fromMs && m.occurredAtMs <= options.toMs, ) - if (options.deviceId != null) { - markers = markers.filter((m) => m.deviceId === options.deviceId) + // Markers still key on the BLE identifier (ADR 0028); the fake models one Board per install, so + // the Board-scoped filter maps straight onto it. + if (options.boardId != null) { + markers = markers.filter((m) => m.deviceId === options.boardId) } const encoded = encodeBoardSamples(samples) @@ -439,7 +441,7 @@ interface RideSeed { startLongitude: number } -function seedHistoryData(deviceId: string, deviceName: string): void { +function seedHistoryData(boardId: string, boardName: string): void { clearTelemetryHistory() const now = Date.now() @@ -475,7 +477,7 @@ function seedHistoryData(deviceId: string, deviceName: string): void { ] for (const ride of rides) { - addHistoryRide(now + ride.startOffsetMs, ride.durationMs, ride, deviceId, deviceName) + addHistoryRide(now + ride.startOffsetMs, ride.durationMs, ride, boardId, boardName) } } @@ -483,8 +485,8 @@ function addHistoryRide( rideStartMs: number, durationMs: number, ride: RideSeed, - deviceId: string, - deviceName: string, + boardId: string, + boardName: string, ): void { const rideEndMs = rideStartMs + durationMs const sampleCount = 60 @@ -495,8 +497,8 @@ function addHistoryRide( startAtMs: rideStartMs, endAtMs: rideEndMs, bucketStartMs: rideStartMs, - deviceId, - deviceName, + boardId, + boardName, sampleCount, gpsPointCount, preciseGpsPointCount: gpsPointCount, @@ -529,8 +531,8 @@ function addHistoryRide( historySamples.push({ id: nextHistorySampleId++, capturedAtMs: t, - deviceId, - deviceName, + boardId, + boardName, speedKmh: ride.avgSpeedKmh * 0.6 + progress * (ride.maxSpeedKmh - ride.avgSpeedKmh * 0.6), batteryVoltage: 75.6 - progress * 1.6, batteryPercent: 75 - progress * 2, @@ -561,8 +563,8 @@ function addHistoryRide( historyGps.push({ id: nextHistoryGpsId++, capturedAtMs: rideStartMs + progress * durationMs, - deviceId, - deviceName, + boardId, + boardName, latitude: ride.startLatitude + progress * 0.01, longitude: ride.startLongitude + progress * 0.01, speedMps: 5 + progress * 5, @@ -579,8 +581,8 @@ function addHistoryRide( id: nextHistoryMarkerId++, occurredAtMs: rideStartMs, type: 'connected', - deviceId, - deviceName, + deviceId: boardId, + deviceName: boardName, message: null, gapMs: null, }) @@ -588,8 +590,8 @@ function addHistoryRide( id: nextHistoryMarkerId++, occurredAtMs: rideEndMs, type: 'disconnected', - deviceId, - deviceName, + deviceId: boardId, + deviceName: boardName, message: null, gapMs: null, }) diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 1788df867..d7d825213 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -533,7 +533,8 @@ export interface LiveStateEvent { export interface TelemetryHistoryOptions { fromMs?: number toMs?: number - deviceId?: string + /** Scope to one Board (`boards.id`). Telemetry is keyed on the Board, not the BLE id (ADR 0028). */ + boardId?: string limit?: number cursorBeforeMs?: number } @@ -548,7 +549,7 @@ export interface DiagnosticEventOptions { export interface TelemetryDeleteRangeOptions { fromMs: number toMs: number - deviceId?: string | null + boardId?: string | null } export interface TelemetryMinuteBucket { @@ -556,8 +557,10 @@ export interface TelemetryMinuteBucket { startAtMs: number endAtMs: number bucketStartMs: number - deviceId: string | null - deviceName: string + /** Owning Board (`boards.id`), or null when the samples match no saved Board. */ + boardId: string | null + /** Resolved from `boards` on read, never stored on the row — a rename relabels history. */ + boardName: string sampleCount: number gpsPointCount: number preciseGpsPointCount: number @@ -595,8 +598,8 @@ export interface TelemetryMinuteBucket { export interface TelemetrySample { id: number capturedAtMs: number - deviceId: string | null - deviceName: string + boardId: string | null + boardName: string speedKmh: number batteryVoltage: number /** IR-compensated battery %, derived on read from the board's battery config. Null if no config. */ @@ -625,8 +628,8 @@ export interface TelemetrySample { export interface HistoryGpsSample { id: number capturedAtMs: number - deviceId: string | null - deviceName: string + boardId: string | null + boardName: string latitude: number longitude: number speedMps: number | null @@ -700,8 +703,8 @@ const BMS_SERIES_BALANCE_LANE_BITS = 30 interface NativeHistoryRange { boardColumns: ArrayBuffer boardCount: number - boardDevices: (string | null)[] - boardDeviceNames: string[] + boardIds: (string | null)[] + boardNames: string[] gpsSamples: HistoryGpsSample[] markers: HistoryMarker[] exclusions: MetricExclusion[] @@ -719,18 +722,18 @@ const nullableLane = (value: number): number | null => (Number.isNaN(value) ? nu * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt */ function decodeBoardSamples(range: NativeHistoryRange): TelemetrySample[] { - const { boardCount, boardDevices, boardDeviceNames } = range + const { boardCount, boardIds, boardNames } = range if (!boardCount || !range.boardColumns) return [] const lanes = new Float64Array(range.boardColumns) const samples = new Array(boardCount) for (let i = 0; i < boardCount; i++) { const o = i * SAMPLE_COLUMN_COUNT - const deviceIndex = lanes[o + 2] + const boardIndex = lanes[o + 2] samples[i] = { id: lanes[o], capturedAtMs: lanes[o + 1], - deviceId: boardDevices[deviceIndex] ?? null, - deviceName: boardDeviceNames[deviceIndex], + boardId: boardIds[boardIndex] ?? null, + boardName: boardNames[boardIndex], speedKmh: lanes[o + 3], batteryVoltage: lanes[o + 4], batteryPercent: nullableLane(lanes[o + 5]), @@ -850,7 +853,7 @@ export interface Favorite { export interface CreateFavoriteOptions { startMs: number endMs: number - deviceId?: string + boardId?: string name?: string } @@ -861,7 +864,7 @@ export interface CreateFavoriteOptions { export interface UpdateFavoriteOptions { startMs: number endMs: number - deviceId?: string + boardId?: string name: string | null } @@ -1530,13 +1533,13 @@ type VescapeCoreNativeModule = NativeEventEmitter & { getTelemetrySamples(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): Promise getHistoryRange(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): Promise getTelemetrySummary(): Promise @@ -2001,7 +2004,7 @@ export async function getTelemetryHistory( export async function getTelemetrySamples(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): Promise { if (E2E_ENABLED) { @@ -2014,7 +2017,7 @@ export async function getTelemetrySamples(options: { export async function getHistoryRange(options: { fromMs: number toMs: number - deviceId?: string + boardId?: string limit?: number }): Promise { const range = E2E_ENABLED diff --git a/src/modules/history/components/HistoryPanelNav.tsx b/src/modules/history/components/HistoryPanelNav.tsx index f58709742..26041a852 100644 --- a/src/modules/history/components/HistoryPanelNav.tsx +++ b/src/modules/history/components/HistoryPanelNav.tsx @@ -12,7 +12,7 @@ import { formatRideMeta, formatRideTime } from '@/modules/history/lib/rideFormat interface HistoryPanelNavProps { titleStartMs: number titleEndMs: number - deviceName: string + boardName: string title?: string subtitle?: string canPrevious: boolean @@ -35,7 +35,7 @@ interface HistoryPanelNavProps { export function HistoryPanelNav({ titleStartMs, titleEndMs, - deviceName, + boardName, title, subtitle, canPrevious, @@ -55,7 +55,7 @@ export function HistoryPanelNav({ onOpenShareInfo, }: HistoryPanelNavProps) { const primaryLabel = title ?? formatRideTime(titleStartMs, titleEndMs) - const secondaryLabel = subtitle ?? formatRideMeta(titleStartMs, titleEndMs, deviceName) + const secondaryLabel = subtitle ?? formatRideMeta(titleStartMs, titleEndMs, boardName) return ( diff --git a/src/modules/history/components/HistorySessionSheet.tsx b/src/modules/history/components/HistorySessionSheet.tsx index 56bfe339b..da82812e2 100644 --- a/src/modules/history/components/HistorySessionSheet.tsx +++ b/src/modules/history/components/HistorySessionSheet.tsx @@ -80,7 +80,7 @@ export function HistorySessionSheet({ const details = formatRideListDetails( rideWindow.endMs - rideWindow.startMs, session.distanceM, - favorite?.boardName ?? session.deviceName, + favorite?.boardName ?? session.boardName, ) return ( ): TelemetryMinuteBucke startAtMs: 1_100_000, endAtMs: 1_160_000, bucketStartMs: 1_100_000, - deviceId: 'ble-1', - deviceName: 'VESC Board', + boardId: 'ble-1', + boardName: 'VESC Board', sampleCount: 60, gpsPointCount: 10, preciseGpsPointCount: 8, @@ -122,14 +122,14 @@ test('a favorite-backed session reports the pinned range and the pinned summary' expect(detail.blockIds).toEqual(['inside', 'tail']) expect(detail.minLatitude).toBe(52) expect(detail.maxLatitude).toBe(53) - expect(detail.deviceId).toBe('ble-1') + expect(detail.boardId).toBe('ble-1') }) test('a favorite-backed session keeps board identity separate from its name', () => { - expect(favoriteToSession(favorite({ name: 'Dolina single track' }), []).deviceName).toBe( + expect(favoriteToSession(favorite({ name: 'Dolina single track' }), []).boardName).toBe( 'Onewheel', ) - expect(favoriteToSession(favorite({}), []).deviceName).toBe('Onewheel') + expect(favoriteToSession(favorite({}), []).boardName).toBe('Onewheel') }) test('a favorite whose buckets are not loaded still yields a detail session', () => { diff --git a/src/modules/history/lib/favorites.ts b/src/modules/history/lib/favorites.ts index 84ba45a30..9a3d297a8 100644 --- a/src/modules/history/lib/favorites.ts +++ b/src/modules/history/lib/favorites.ts @@ -56,8 +56,8 @@ export function favoriteToSession( const longitudes = spanned.map((block) => block.firstLongitude).filter(isFinitePoint) return { id: favoriteSessionId(favorite.id), - deviceId: spanned.find((block) => block.deviceId != null)?.deviceId ?? null, - deviceName: favorite.boardName ?? spanned[0]?.deviceName ?? '', + boardId: spanned.find((block) => block.boardId != null)?.boardId ?? null, + boardName: favorite.boardName ?? spanned[0]?.boardName ?? '', startAtMs: favorite.startMs, endAtMs: favorite.endMs, // A Favorite is already a trimmed span: it is its own Moving Window, so the chart and the title diff --git a/src/modules/history/lib/markerOverlap.test.ts b/src/modules/history/lib/markerOverlap.test.ts index 83324b773..96ff92227 100644 --- a/src/modules/history/lib/markerOverlap.test.ts +++ b/src/modules/history/lib/markerOverlap.test.ts @@ -6,8 +6,8 @@ function makeGps(id: number, capturedAtMs: number, lat: number, lng: number): Hi return { id, capturedAtMs, - deviceId: null, - deviceName: 'test', + boardId: null, + boardName: 'test', latitude: lat, longitude: lng, speedMps: null, diff --git a/src/modules/history/lib/mediaHistory.test.ts b/src/modules/history/lib/mediaHistory.test.ts index f96a37660..d3af586c8 100644 --- a/src/modules/history/lib/mediaHistory.test.ts +++ b/src/modules/history/lib/mediaHistory.test.ts @@ -15,8 +15,8 @@ function gps(id: number, capturedAtMs: number, latitude = 52, longitude = 21): H return { id, capturedAtMs, - deviceId: 'board', - deviceName: 'Board', + boardId: 'board', + boardName: 'Board', latitude, longitude, speedMps: null, diff --git a/src/modules/history/lib/rideFormat.ts b/src/modules/history/lib/rideFormat.ts index 6a49d2b61..1a21643d8 100644 --- a/src/modules/history/lib/rideFormat.ts +++ b/src/modules/history/lib/rideFormat.ts @@ -24,9 +24,9 @@ export function formatRideDate(startMs: number, endMs: number): string { return `${s.getDate()} ${MONTHS[s.getMonth()]} – ${e.getDate()} ${MONTHS[e.getMonth()]} ${e.getFullYear()}` } -export function formatRideMeta(startAtMs: number, endAtMs: number, deviceName: string): string { - return deviceName - ? `${formatRideDate(startAtMs, endAtMs)} · ${deviceName}` +export function formatRideMeta(startAtMs: number, endAtMs: number, boardName: string): string { + return boardName + ? `${formatRideDate(startAtMs, endAtMs)} · ${boardName}` : formatRideDate(startAtMs, endAtMs) } @@ -37,12 +37,12 @@ export function formatRideListDateTime(startAtMs: number, endAtMs: number): stri export function formatRideListDetails( durationMs: number, distanceM: number | null, - deviceName: string | null, + boardName: string | null, ): string { return [ formatRideListDuration(durationMs), distanceM == null ? null : `${(distanceM / 1000).toFixed(2)} km`, - deviceName?.trim() || null, + boardName?.trim() || null, ] .filter((part): part is string => part != null) .join(' · ') diff --git a/src/modules/history/lib/sessions.test.ts b/src/modules/history/lib/sessions.test.ts index c212c4051..837eb72bb 100644 --- a/src/modules/history/lib/sessions.test.ts +++ b/src/modules/history/lib/sessions.test.ts @@ -62,12 +62,12 @@ test('splits different devices even when adjacent', () => { const sessions = groupHistorySessions([ block({ id: 'new', - deviceId: 'dev-b', - deviceName: 'Board B', + boardId: 'dev-b', + boardName: 'Board B', startAtMs: 240_000, endAtMs: 300_000, }), - block({ id: 'old', deviceId: 'dev-a', startAtMs: 120_000, endAtMs: 180_000 }), + block({ id: 'old', boardId: 'dev-a', startAtMs: 120_000, endAtMs: 180_000 }), ]) expect(sessions).toHaveLength(2) }) diff --git a/src/modules/history/lib/sessions.ts b/src/modules/history/lib/sessions.ts index 1027981d2..b3ecbc241 100644 --- a/src/modules/history/lib/sessions.ts +++ b/src/modules/history/lib/sessions.ts @@ -8,8 +8,8 @@ export const RIDE_TRIM_PADDING_MS = 5_000 export interface HistorySession { id: string - deviceId: string | null - deviceName: string + boardId: string | null + boardName: string startAtMs: number endAtMs: number /** First/last moving Telemetry Sample across the session — the Moving Window. Null on legacy data. */ @@ -41,8 +41,8 @@ export interface HistorySession { } interface MutableSessionAggregate { - deviceId: string | null - deviceName: string + boardId: string | null + boardName: string boundaryBefore: TelemetryMinuteBucket['boundaryBefore'] startAtMs: number endAtMs: number @@ -89,7 +89,7 @@ export function groupHistorySessions( let previousBlock: TelemetryMinuteBucket | null = null for (const block of oldestFirst) { - const breakByDevice = !current || current.deviceId !== block.deviceId + const breakByDevice = !current || current.boardId !== block.boardId const breakByGap = !!previousBlock && block.startAtMs - previousBlock.endAtMs > gapMs const breakByBoundary = SESSION_BREAK_BOUNDARIES.has(block.boundaryBefore) @@ -133,8 +133,8 @@ export function rideDurationMs( function createAggregate(block: TelemetryMinuteBucket): MutableSessionAggregate { const aggregate: MutableSessionAggregate = { - deviceId: block.deviceId, - deviceName: block.deviceName, + boardId: block.boardId, + boardName: block.boardName, boundaryBefore: block.boundaryBefore, startAtMs: block.startAtMs, endAtMs: block.endAtMs, @@ -270,9 +270,9 @@ function finalizeSession(session: MutableSessionAggregate): HistorySession { session.coordinateCount > 0 ? session.longitudeSum / session.coordinateCount : null return { - id: `${session.deviceId ?? 'unknown'}:${session.startAtMs}:${session.endAtMs}`, - deviceId: session.deviceId, - deviceName: session.deviceName, + id: `${session.boardId ?? 'unknown'}:${session.startAtMs}:${session.endAtMs}`, + boardId: session.boardId, + boardName: session.boardName, startAtMs: session.startAtMs, endAtMs: session.endAtMs, movingStartAtMs: session.movingStartAtMs, diff --git a/src/modules/history/store/historyStore.test.ts b/src/modules/history/store/historyStore.test.ts index 77e32ec9a..e0e410fff 100644 --- a/src/modules/history/store/historyStore.test.ts +++ b/src/modules/history/store/historyStore.test.ts @@ -137,7 +137,7 @@ test('removes selected session from history and selects next ride', async () => expect(deleteTelemetryRange).toHaveBeenCalledWith({ fromMs: selected.startAtMs, toMs: selected.endAtMs, - deviceId: selected.deviceId, + boardId: selected.boardId, }) expect(useHistoryStore.getState().blocks.map((b) => b.id)).toEqual(['newest', 'oldest']) expect(useHistoryStore.getState().sessions.map((s) => s.id)).toHaveLength(2) @@ -194,7 +194,7 @@ test('selects ride immediately while loading its full route', async () => { expect(useHistoryStore.getState().sessionSamples).toEqual([ expect.objectContaining({ capturedAtMs: next.startAtMs, - deviceId: next.deviceId, + boardId: next.boardId, latitude: next.firstLatitude, longitude: next.firstLongitude, }), @@ -203,7 +203,7 @@ test('selects ride immediately while loading its full route', async () => { expect(getHistoryRange).toHaveBeenLastCalledWith({ fromMs: next.startAtMs, toMs: next.endAtMs, - deviceId: next.deviceId, + boardId: next.boardId, limit: next.sampleCount + 1, }) @@ -214,8 +214,8 @@ test('selects ride immediately while loading its full route', async () => { gpsSamples: Array.from({ length: next.gpsPointCount }, (_, index) => ({ id: index + 1, capturedAtMs: next.startAtMs + index, - deviceId: next.deviceId, - deviceName: next.deviceName, + boardId: next.boardId, + boardName: next.boardName, latitude: 51 + index * 0.001, longitude: 17 + index * 0.001, speedMps: null, @@ -292,8 +292,8 @@ test('loads a small GPS preview when selected ride has no bucket coordinate', as const previewGps: HistoryGpsSample = { id: 1, capturedAtMs: ride.startAtMs, - deviceId: ride.deviceId, - deviceName: ride.deviceName, + boardId: ride.boardId, + boardName: ride.boardName, latitude: 51, longitude: 17, speedMps: null, @@ -322,8 +322,8 @@ test('loads a small GPS preview when selected ride has no bucket coordinate', as const { useHistoryStore } = await import('@/modules/history/store/historyStore') const select = useHistoryStore.getState().selectSession({ - deviceId: ride.deviceId, - deviceName: ride.deviceName, + boardId: ride.boardId, + boardName: ride.boardName, boundaryBefore: ride.boundaryBefore, startAtMs: ride.startAtMs, endAtMs: ride.endAtMs, @@ -351,14 +351,14 @@ test('loads a small GPS preview when selected ride has no bucket coordinate', as minLongitude: null, maxLongitude: null, faultCount: ride.faultCount, - id: `${ride.deviceId}:${ride.startAtMs}:${ride.endAtMs}`, + id: `${ride.boardId}:${ride.startAtMs}:${ride.endAtMs}`, }) await Promise.resolve() expect(getHistoryRange).toHaveBeenNthCalledWith(1, { fromMs: ride.startAtMs, toMs: ride.endAtMs, - deviceId: ride.deviceId, + boardId: ride.boardId, limit: 240, }) expect(getHistoryRange).toHaveBeenCalledTimes(1) diff --git a/src/modules/history/store/historyStore.ts b/src/modules/history/store/historyStore.ts index ccb82a6c7..2ee022b97 100644 --- a/src/modules/history/store/historyStore.ts +++ b/src/modules/history/store/historyStore.ts @@ -63,8 +63,8 @@ function bucketToPreviewSample(bucket: TelemetryMinuteBucket): TelemetrySample { return { id: 0, capturedAtMs: bucket.bucketStartMs, - deviceId: bucket.deviceId, - deviceName: bucket.deviceName, + boardId: bucket.boardId, + boardName: bucket.boardName, speedKmh: bucket.avgSpeedKmh, batteryVoltage: bucket.minBatteryVoltage ?? 0, batteryPercent: null, @@ -105,7 +105,7 @@ function getSessionRangeOptions(session: HistorySession) { return { fromMs: session.startAtMs, toMs: session.endAtMs, - ...(session.deviceId ? { deviceId: session.deviceId } : {}), + ...(session.boardId ? { boardId: session.boardId } : {}), } } @@ -225,7 +225,7 @@ export const useHistoryStore = create((set, get) ? sessions.find( (session) => session.id === selectedSession.id || - (session.deviceId === selectedSession.deviceId && + (session.boardId === selectedSession.boardId && session.startAtMs <= selectedSession.endAtMs && session.endAtMs >= selectedSession.startAtMs), ) @@ -266,7 +266,7 @@ export const useHistoryStore = create((set, get) const range = await getHistoryRange({ fromMs: block.startAtMs, toMs: block.endAtMs, - ...(block.deviceId ? { deviceId: block.deviceId } : {}), + ...(block.boardId ? { boardId: block.boardId } : {}), limit: 500, }) set({ @@ -364,7 +364,7 @@ export const useHistoryStore = create((set, get) await deleteTelemetryRange({ fromMs: selectedSession.startAtMs, toMs: selectedSession.endAtMs, - deviceId: selectedSession.deviceId, + boardId: selectedSession.boardId, }) const selectedIndex = sessions.findIndex((session) => session.id === selectedSession.id) const blocks = await getTelemetryHistory({ limit: reloadLimit }) diff --git a/src/screens/main/history/HistoryRideDetail.tsx b/src/screens/main/history/HistoryRideDetail.tsx index 83dbb754f..1c9400188 100644 --- a/src/screens/main/history/HistoryRideDetail.tsx +++ b/src/screens/main/history/HistoryRideDetail.tsx @@ -53,7 +53,7 @@ export function HistoryRideDetail({ endAtMs={session.endAtMs} movingStartAtMs={session.movingStartAtMs} movingEndAtMs={session.movingEndAtMs} - deviceName={session.deviceName} + boardName={session.boardName} navigationTitle={ openFavorite ? formatFavoriteName(openFavorite.name, openFavorite.startMs, openFavorite.endMs) diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index 84844e3af..5f6c61c95 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -35,7 +35,7 @@ interface HistoryTelemetryPanelProps { endAtMs: number movingStartAtMs: number | null movingEndAtMs: number | null - deviceName: string + boardName: string navigationTitle?: string navigationSubtitle?: string samples: TelemetrySample[] @@ -70,7 +70,7 @@ export function HistoryTelemetryPanel({ endAtMs, movingStartAtMs, movingEndAtMs, - deviceName, + boardName, navigationTitle, navigationSubtitle, samples, @@ -178,7 +178,7 @@ export function HistoryTelemetryPanel({ - session.deviceId === selected.deviceId && + session.boardId === selected.boardId && session.startAtMs <= selected.endAtMs && session.endAtMs >= selected.startAtMs, ) diff --git a/src/screens/showcase/mapShowcaseFixtures.ts b/src/screens/showcase/mapShowcaseFixtures.ts index ea253e8ca..3179fa48e 100644 --- a/src/screens/showcase/mapShowcaseFixtures.ts +++ b/src/screens/showcase/mapShowcaseFixtures.ts @@ -72,8 +72,8 @@ export const FIXTURE_RIDE_GPS_SAMPLES: HistoryGpsSample[] = rideRouteCoordinates return { id: index + 1, capturedAtMs: NOW - (ROUTE_POINT_COUNT - 1 - index) * 5_000, - deviceId: 'fixture-board', - deviceName: 'Fixture Board', + boardId: 'fixture-board', + boardName: 'Fixture Board', latitude, longitude, speedMps: 3 + Math.sin(t * Math.PI * 2) * 2.5, @@ -101,8 +101,8 @@ export const FIXTURE_RIDE_TELEMETRY_SAMPLES: TelemetrySample[] = FIXTURE_RIDE_GP return { id: index + 1, capturedAtMs: gps.capturedAtMs, - deviceId: 'fixture-board', - deviceName: 'Fixture Board', + boardId: 'fixture-board', + boardName: 'Fixture Board', speedKmh, batteryVoltage: 58 - t * 4, batteryPercent: 80 - t * 30, diff --git a/src/test-utils/factories.ts b/src/test-utils/factories.ts index b684ba047..980b7baec 100644 --- a/src/test-utils/factories.ts +++ b/src/test-utils/factories.ts @@ -5,8 +5,8 @@ const BLOCK_DEFAULTS: TelemetryMinuteBucket = { startAtMs: 0, endAtMs: 60_000, bucketStartMs: 0, - deviceId: 'dev-a', - deviceName: 'Board A', + boardId: 'dev-a', + boardName: 'Board A', sampleCount: 10, gpsPointCount: 5, preciseGpsPointCount: 4, @@ -52,8 +52,8 @@ export function makeBlock(overrides: Partial = {}): Telem const SAMPLE_DEFAULTS: TelemetrySample = { id: 1, capturedAtMs: 0, - deviceId: 'dev-a', - deviceName: 'Board A', + boardId: 'dev-a', + boardName: 'Board A', speedKmh: 0, batteryVoltage: 50, batteryPercent: null, From 21c9ba9f78f86e88c53f1e934416565b381a7272 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sat, 1 Aug 2026 02:30:03 +0200 Subject: [PATCH 07/24] Add sync_seq to six tables #281 --- .../vescapecore/telemetry/TelemetryDao.kt | 202 ++++++++++++++++-- .../telemetry/TelemetryDatabase.kt | 43 +++- .../telemetry/TelemetryEntities.kt | 115 +++++++++- .../telemetry/BoardTombstoneTest.kt | 2 +- .../telemetry/SyncCursorMigrationTest.kt | 130 ++++++++++- .../TelemetryBoardIdMigrationTest.kt | 2 +- .../ios/telemetry/AppDataRepository.swift | 125 ++++++++--- .../ios/telemetry/FavoriteStore.swift | 13 +- .../telemetry/SyncCursorMigrationTests.swift | 131 +++++++++++- .../ios/telemetry/TelemetryDao.swift | 73 ++++++- .../ios/telemetry/TelemetryDatabase.swift | 43 +++- .../ios/telemetry/TuneProfileStore.swift | 44 ++-- .../ios/warnings/BoardWarningStore.swift | 14 +- 13 files changed, 849 insertions(+), 88 deletions(-) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 92cc270ec..5d45c866a 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -61,10 +61,34 @@ interface TelemetryDao { suspend fun getEnabledPrivacyZones(): List @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertPrivacyZone(zone: PrivacyZoneEntity) + suspend fun insertPrivacyZoneRow(zone: PrivacyZoneEntity) - @Query("UPDATE privacy_zones SET enabled = :enabled, updated_at = :updatedAt WHERE id = :id") - suspend fun setPrivacyZoneEnabled(id: String, enabled: Boolean, updatedAt: Long) + @Query("SELECT updated_at FROM privacy_zones WHERE id = :id") + suspend fun getPrivacyZoneUpdatedAt(id: String): Long? + + /** Stamps both sync columns; see [upsertBoard]. */ + @Transaction + suspend fun upsertPrivacyZone(zone: PrivacyZoneEntity) { + insertPrivacyZoneRow( + zone.copy( + updatedAt = ratchetUpdatedAt(getPrivacyZoneUpdatedAt(zone.id), zone.updatedAt), + syncSeq = nextSyncSeq(SYNC_SEQ_PRIVACY_ZONES), + ), + ) + } + + /** Targeted toggle that bypasses the upsert, so it moves both columns itself; see + * [setAlertRuleEnabledRow]. */ + @Query( + "UPDATE privacy_zones SET enabled = :enabled, updated_at = MAX(updated_at + 1, :updatedAt), " + + "sync_seq = :syncSeq WHERE id = :id", + ) + suspend fun setPrivacyZoneEnabledRow(id: String, enabled: Boolean, updatedAt: Long, syncSeq: Long) + + @Transaction + suspend fun setPrivacyZoneEnabled(id: String, enabled: Boolean, updatedAt: Long) { + setPrivacyZoneEnabledRow(id, enabled, updatedAt, nextSyncSeq(SYNC_SEQ_PRIVACY_ZONES)) + } @Query("DELETE FROM privacy_zones WHERE id = :id") suspend fun deletePrivacyZone(id: String) @@ -456,7 +480,24 @@ interface TelemetryDao { suspend fun getBoardSettings(boardIds: List): List @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBoardSetting(setting: BoardSettingEntity) + suspend fun insertBoardSettingRow(setting: BoardSettingEntity) + + @Query("SELECT updated_at FROM board_settings WHERE board_id = :boardId AND key = :key") + suspend fun getBoardSettingUpdatedAt(boardId: String, key: String): Long? + + /** Stamps both sync columns; see [upsertBoard]. */ + @Transaction + suspend fun upsertBoardSetting(setting: BoardSettingEntity) { + insertBoardSettingRow( + setting.copy( + updatedAt = ratchetUpdatedAt( + getBoardSettingUpdatedAt(setting.boardId, setting.key), + setting.updatedAt, + ), + syncSeq = nextSyncSeq(SYNC_SEQ_BOARD_SETTINGS), + ), + ) + } @Query("DELETE FROM board_settings WHERE board_id = :boardId AND key = :key") suspend fun deleteBoardSetting(boardId: String, key: String) @@ -550,7 +591,26 @@ interface TelemetryDao { suspend fun getAppSetting(key: String): AppSettingEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertAppSetting(setting: AppSettingEntity) + suspend fun insertAppSettingRow(setting: AppSettingEntity) + + @Query("SELECT updated_at FROM app_settings WHERE key = :key") + suspend fun getAppSettingUpdatedAt(key: String): Long? + + /** + * Stamps both sync columns like [upsertBoard], except for the phone-local keys in + * [NOT_SYNCED_SETTING_KEYS]: those keep `sync_seq` at 0, which sits below every Sync Cursor, so + * the upload scan never picks the row up and the key stays on this phone (#277). + */ + @Transaction + suspend fun upsertAppSetting(setting: AppSettingEntity) { + val phoneLocal = setting.key in NOT_SYNCED_SETTING_KEYS + insertAppSettingRow( + setting.copy( + updatedAt = ratchetUpdatedAt(getAppSettingUpdatedAt(setting.key), setting.updatedAt), + syncSeq = if (phoneLocal) 0L else nextSyncSeq(SYNC_SEQ_APP_SETTINGS), + ), + ) + } @Query("DELETE FROM app_settings WHERE key = :key") suspend fun deleteAppSetting(key: String) @@ -569,23 +629,69 @@ interface TelemetryDao { @Query("DELETE FROM tune_history_entries WHERE profile_id = :profileId") suspend fun deleteTuneHistoryForProfile(profileId: String) - @Query("UPDATE tune_profiles SET name = :name, icon = :icon, color = :color, updated_at = :updatedAt WHERE id = :profileId") - suspend fun updateProfileMetadata( + /** Targeted rename that bypasses the upsert, so it moves both columns itself; see + * [setAlertRuleEnabledRow]. */ + @Query( + "UPDATE tune_profiles SET name = :name, icon = :icon, color = :color, " + + "updated_at = MAX(updated_at + 1, :updatedAt), sync_seq = :syncSeq WHERE id = :profileId", + ) + suspend fun updateProfileMetadataRow( profileId: String, name: String, icon: String, color: String, updatedAt: Long, + syncSeq: Long, ): Int + @Transaction + suspend fun updateProfileMetadata( + profileId: String, + name: String, + icon: String, + color: String, + updatedAt: Long, + ): Int = updateProfileMetadataRow( + profileId, + name, + icon, + color, + updatedAt, + nextSyncSeq(SYNC_SEQ_TUNE_PROFILES), + ) + @Query("SELECT * FROM tune_history_entries WHERE id = :id LIMIT 1") suspend fun getTuneHistoryEntry(id: Long): TuneHistoryEntryEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertTuneProfile(profile: TuneProfileEntity) + suspend fun insertTuneProfileRow(profile: TuneProfileEntity) + + @Query("SELECT updated_at FROM tune_profiles WHERE id = :id") + suspend fun getTuneProfileUpdatedAt(id: String): Long? + + /** Stamps both sync columns; see [upsertBoard]. */ + @Transaction + suspend fun upsertTuneProfile(profile: TuneProfileEntity) { + insertTuneProfileRow( + profile.copy( + updatedAt = ratchetUpdatedAt(getTuneProfileUpdatedAt(profile.id), profile.updatedAt), + syncSeq = nextSyncSeq(SYNC_SEQ_TUNE_PROFILES), + ), + ) + } @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertTuneProfile(profile: TuneProfileEntity): Long + suspend fun insertTuneProfileRowIfAbsent(profile: TuneProfileEntity): Long + + /** Stamps both sync columns; see [upsertBoard]. Returns -1 when the row already exists. */ + @Transaction + suspend fun insertTuneProfile(profile: TuneProfileEntity): Long = + insertTuneProfileRowIfAbsent( + profile.copy( + updatedAt = ratchetUpdatedAt(getTuneProfileUpdatedAt(profile.id), profile.updatedAt), + syncSeq = nextSyncSeq(SYNC_SEQ_TUNE_PROFILES), + ), + ) @Query("SELECT COUNT(*) FROM tune_profiles WHERE board_id = :boardId AND refloat_base_version = :refloatBaseVersion") suspend fun countTuneProfilesForBoard(boardId: String, refloatBaseVersion: String): Int @@ -599,8 +705,22 @@ interface TelemetryDao { @Query("SELECT * FROM tune_history_entries WHERE profile_id = :profileId ORDER BY created_at DESC, id DESC") suspend fun getTuneHistoryEntries(profileId: String): List - @Query("UPDATE tune_profiles SET fields_json = :fieldsJson, updated_at = :updatedAt WHERE id = :profileId") - suspend fun updateProfileFields(profileId: String, fieldsJson: String, updatedAt: Long): Int + /** Targeted save that bypasses the upsert, so it moves both columns itself; see + * [setAlertRuleEnabledRow]. */ + @Query( + "UPDATE tune_profiles SET fields_json = :fieldsJson, " + + "updated_at = MAX(updated_at + 1, :updatedAt), sync_seq = :syncSeq WHERE id = :profileId", + ) + suspend fun updateProfileFieldsRow( + profileId: String, + fieldsJson: String, + updatedAt: Long, + syncSeq: Long, + ): Int + + @Transaction + suspend fun updateProfileFields(profileId: String, fieldsJson: String, updatedAt: Long): Int = + updateProfileFieldsRow(profileId, fieldsJson, updatedAt, nextSyncSeq(SYNC_SEQ_TUNE_PROFILES)) @Transaction suspend fun saveTuneProfile(profileId: String, fieldsJson: String, updatedAt: Long): TuneProfileEntity { @@ -668,7 +788,27 @@ interface TelemetryDao { suspend fun getAllBoardWarnings(): List @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBoardWarning(warning: BoardWarningEntity) + suspend fun insertBoardWarningRow(warning: BoardWarningEntity) + + @Query("SELECT updated_at FROM board_warnings WHERE board_id = :boardId AND kind = :kind") + suspend fun getBoardWarningUpdatedAt(boardId: String, kind: String): Long? + + /** + * Stamps both sync columns; see [upsertBoard]. The caller supplies detection times only — + * `updated_at` is authored here, from [BoardWarningEntity.lastDetectedAt] as the write clock. + */ + @Transaction + suspend fun upsertBoardWarning(warning: BoardWarningEntity) { + insertBoardWarningRow( + warning.copy( + updatedAt = ratchetUpdatedAt( + getBoardWarningUpdatedAt(warning.boardId, warning.kind), + warning.lastDetectedAt, + ), + syncSeq = nextSyncSeq(SYNC_SEQ_BOARD_WARNINGS), + ), + ) + } @Query("DELETE FROM board_warnings WHERE board_id = :boardId AND kind = :kind") suspend fun deleteBoardWarning(boardId: String, kind: String): Int @@ -684,14 +824,34 @@ interface TelemetryDao { suspend fun getFavorites(): List @Insert - suspend fun insertFavorite(favorite: FavoriteEntity) + suspend fun insertFavoriteRow(favorite: FavoriteEntity) @Query("SELECT * FROM favorites WHERE id = :id") suspend fun getFavorite(id: String): FavoriteEntity? - /** Re-trim/rename one row in place so its identity and Favorite Media remain stable. */ + @Query("SELECT updated_at FROM favorites WHERE id = :id") + suspend fun getFavoriteUpdatedAt(id: String): Long? + @Update - suspend fun updateFavorite(favorite: FavoriteEntity): Int + suspend fun updateFavoriteRow(favorite: FavoriteEntity): Int + + /** Stamps both sync columns; see [upsertBoard]. */ + @Transaction + suspend fun insertFavorite(favorite: FavoriteEntity) { + insertFavoriteRow(favorite.copy(syncSeq = nextSyncSeq(SYNC_SEQ_FAVORITES))) + } + + /** + * Re-trim/rename one row in place so its identity and Favorite Media remain stable. Stamps both + * sync columns; see [upsertBoard]. + */ + @Transaction + suspend fun updateFavorite(favorite: FavoriteEntity): Int = updateFavoriteRow( + favorite.copy( + updatedAt = ratchetUpdatedAt(getFavoriteUpdatedAt(favorite.id), favorite.updatedAt), + syncSeq = nextSyncSeq(SYNC_SEQ_FAVORITES), + ), + ) @Query("DELETE FROM favorites WHERE id = :id") suspend fun deleteFavoriteRow(id: String): Int @@ -784,13 +944,11 @@ private fun TelemetryMinuteBucketEntity.merge(next: TelemetryMinuteBucketEntity) }, firstMovingAtMs = mergeNullableMin(firstMovingAtMs, next.firstMovingAtMs), lastMovingAtMs = mergeNullableMax(lastMovingAtMs, next.lastMovingAtMs), - // The merged row is being written now, so `next` normally carries the fresher stamp. `maxOf` - // clamps a backwards device-clock step: the value stays at the last real write time instead of - // regressing. No `+ 1` ratchet here, unlike boards and alerts — the server writes this table - // with an unconditional upsert, so a stale stamp is never grounds for rejecting the row. - // - // Completeness is [syncSeq]'s job, not this column's. - updatedAt = maxOf(updatedAt, next.updatedAt), + // The merged row is being written now, so `next` normally carries the fresher stamp. The same + // ratchet as boards and alerts, for the same reason: the server guards this table with + // `WHERE stored.updated_at < EXCLUDED.updated_at` like every other mutable table, so a stamp + // frozen at the stored value would satisfy the scan and still be dropped server-side. + updatedAt = ratchetUpdatedAt(updatedAt, next.updatedAt), syncSeq = next.syncSeq, ) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index a7dd4f335..c149e4463 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -12,7 +12,7 @@ import java.io.File // @parity /modules/vescape-core/ios/VescapeCoreModule.swift internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" -internal const val TELEMETRY_DATABASE_VERSION = 35 +internal const val TELEMETRY_DATABASE_VERSION = 36 @Database( entities = [ @@ -556,7 +556,7 @@ abstract class TelemetryDatabase : RoomDatabase() { ) """.trimIndent(), ) - for (table in SYNC_SEQ_TABLES) { + for (table in SYNC_SEQ_TABLES_V33) { if (!hasColumn(db, table, "sync_seq")) { db.execSQL("ALTER TABLE $table ADD COLUMN sync_seq INTEGER NOT NULL DEFAULT 0") db.execSQL("UPDATE $table SET sync_seq = rowid") @@ -607,6 +607,44 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * Sync Cursors for the six remaining mutable tables (#281). `board_warnings` also gains the + * wall-clock `updated_at` every other mutable table already carries, backfilled from its newest + * detection. + * + * Existing rows are backfilled from `rowid` — distinct and non-zero, so no two rows share a + * cursor position and none of them sit at the seed value — and each table's sequence is seeded + * past the highest value handed out. Every step is guarded, so a re-run is a no-op. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v36_sync_seq_remaining` + */ + internal val MIGRATION_35_36 = object : Migration(35, 36) { + override fun migrate(db: SupportSQLiteDatabase) { + if (!hasColumn(db, "board_warnings", "updated_at")) { + db.execSQL("ALTER TABLE board_warnings ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0") + db.execSQL("UPDATE board_warnings SET updated_at = last_detected_at") + } + + for (table in SYNC_SEQ_TABLES_V36) { + if (!hasColumn(db, table, "sync_seq")) { + db.execSQL("ALTER TABLE $table ADD COLUMN sync_seq INTEGER NOT NULL DEFAULT 0") + db.execSQL("UPDATE $table SET sync_seq = rowid") + } + db.execSQL("CREATE INDEX IF NOT EXISTS index_${table}_sync_seq ON $table(sync_seq)") + db.execSQL( + "INSERT OR REPLACE INTO sync_sequences (name, last_value) " + + "VALUES ('$table', (SELECT COALESCE(MAX(sync_seq), 0) FROM $table))", + ) + } + + // Phone-local keys are defined by their absence from the scan, so the backfill above has to + // be undone for them: an uploader would otherwise ship whatever this phone happened to hold + // at upgrade time, exactly once. See NOT_SYNCED_SETTING_KEYS. + val phoneLocal = NOT_SYNCED_SETTING_KEYS.joinToString(",") { "'$it'" } + db.execSQL("UPDATE app_settings SET sync_seq = 0 WHERE key IN ($phoneLocal)") + } + } + /** * Telemetry whose `device_id` matches no Board would lose both its identity and its label: * either the Board was hard-deleted before tombstones existed (ADR 0027), or it was re-linked @@ -1012,6 +1050,7 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, + MIGRATION_35_36, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index bf5a0f818..4b69f7cf6 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -302,6 +302,7 @@ data class BoardNameRow( primaryKeys = ["board_id", "key"], indices = [ Index(value = ["board_id"]), + Index(value = ["sync_seq"]), ], ) data class BoardSettingEntity( @@ -310,8 +311,12 @@ data class BoardSettingEntity( val key: String, @ColumnInfo(name = "value_json") val valueJson: String, + /** Ratcheted last-write-wins timestamp; see [BoardEntity.updatedAt]. */ @ColumnInfo(name = "updated_at") val updatedAt: Long, + /** Device-local Sync Cursor position; see [SyncSequenceEntity]. */ + @ColumnInfo(name = "sync_seq") + val syncSeq: Long = 0, ) @Entity( @@ -383,9 +388,40 @@ data class SyncSequenceEntity( internal const val SYNC_SEQ_BOARDS = "boards" internal const val SYNC_SEQ_ALERTS = "alerts" internal const val SYNC_SEQ_MINUTE_BUCKETS = "telemetry_minute_buckets" +internal const val SYNC_SEQ_APP_SETTINGS = "app_settings" +internal const val SYNC_SEQ_BOARD_SETTINGS = "board_settings" +internal const val SYNC_SEQ_BOARD_WARNINGS = "board_warnings" +internal const val SYNC_SEQ_PRIVACY_ZONES = "privacy_zones" +internal const val SYNC_SEQ_TUNE_PROFILES = "tune_profiles" +internal const val SYNC_SEQ_FAVORITES = "favorites" + +/** + * The three tables the schema-33 migration gave a `sync_seq`, frozen at the set that existed then. + * A migration iterates the tables it actually shipped with, never the current [SYNC_SEQ_TABLES] — + * growing that list must not retroactively change what an older migration step does. + */ +internal val SYNC_SEQ_TABLES_V33 = listOf( + SYNC_SEQ_BOARDS, + SYNC_SEQ_ALERTS, + SYNC_SEQ_MINUTE_BUCKETS, +) + +/** The six remaining mutable tables, given a `sync_seq` at schema 36 (#281). */ +internal val SYNC_SEQ_TABLES_V36 = listOf( + SYNC_SEQ_APP_SETTINGS, + SYNC_SEQ_BOARD_SETTINGS, + SYNC_SEQ_BOARD_WARNINGS, + SYNC_SEQ_PRIVACY_ZONES, + SYNC_SEQ_TUNE_PROFILES, + SYNC_SEQ_FAVORITES, +) -/** Every table carrying a `sync_seq`, in the order the migration adds it. */ -internal val SYNC_SEQ_TABLES = listOf(SYNC_SEQ_BOARDS, SYNC_SEQ_ALERTS, SYNC_SEQ_MINUTE_BUCKETS) +/** + * Every table carrying a `sync_seq`. Append-only tables are deliberately absent: they declare + * `INTEGER PRIMARY KEY AUTOINCREMENT`, which SQLite guarantees monotonic and never reused, so their + * key already *is* their cursor. + */ +internal val SYNC_SEQ_TABLES = SYNC_SEQ_TABLES_V33 + SYNC_SEQ_TABLES_V36 @Entity( tableName = "metric_exclusion_ranges", @@ -410,6 +446,9 @@ data class MetricExclusionRangeEntity( @Entity( tableName = "privacy_zones", + indices = [ + Index(value = ["sync_seq"]), + ], ) data class PrivacyZoneEntity( @PrimaryKey @@ -425,18 +464,68 @@ data class PrivacyZoneEntity( val radiusMeters: Int, @ColumnInfo(name = "created_at") val createdAt: Long, + /** Ratcheted last-write-wins timestamp; see [BoardEntity.updatedAt]. */ @ColumnInfo(name = "updated_at") val updatedAt: Long, + /** Device-local Sync Cursor position; see [SyncSequenceEntity]. */ + @ColumnInfo(name = "sync_seq") + val syncSeq: Long = 0, ) -@Entity(tableName = "app_settings") +@Entity( + tableName = "app_settings", + indices = [ + Index(value = ["sync_seq"]), + ], +) data class AppSettingEntity( @PrimaryKey val key: String, @ColumnInfo(name = "value_json") val valueJson: String, + /** Ratcheted last-write-wins timestamp; see [BoardEntity.updatedAt]. */ @ColumnInfo(name = "updated_at") val updatedAt: Long, + /** + * Device-local Sync Cursor position; see [SyncSequenceEntity]. Stays 0 — below every cursor, so + * invisible to the upload scan — for the keys in [NOT_SYNCED_SETTING_KEYS]. + */ + @ColumnInfo(name = "sync_seq") + val syncSeq: Long = 0, +) + +/** + * App settings that name *this phone* rather than the Rider, and so never leave it: restoring them + * onto a second phone would overwrite that phone's own identity or session state. Enforced at the + * write path — [TelemetryDao.upsertAppSetting] leaves their `sync_seq` at 0, which is below every + * Sync Cursor, so no upload scan ever sees the row. + * + * Rider Name and Rider Color live in `app_settings` by design, so that Group Ride keeps working + * signed-out; that placement is what makes them phone-local rather than Account-scoped. See #277. + * + * @parity /modules/vescape-core/ios/telemetry/AppDataRepository.swift `notSyncedSettingKeys` + */ +internal val NOT_SYNCED_SETTING_KEYS = setOf( + // Rider identity — a second phone in the same Group Ride must not become the same Rider. + "riderId", + "riderName", + "riderColor", + // Device/session state — names this phone's current session, not the Rider's configuration. + "selectedBoardId", + "lastGpsLatitude", + "lastGpsLongitude", + "directionPointLatitude", + "directionPointLongitude", + // Connection and companion behaviour — phone-side BLE and foreground policy. + "autoConnect", + "companionPresenceEnabled", + "companionPresenceCooldownMinutes", + "connectionSoundsEnabled", + "autoCloseEnabled", + "autoCloseDelayMinutes", + // Wear pairing — the watch is paired to one phone. + "wearMirrorIntervalMs", + "wearAutoLaunchOnConnect", ) /** @@ -488,6 +577,7 @@ data class AppSettings( indices = [ Index(value = ["board_id"]), Index(value = ["board_id", "refloat_base_version"]), + Index(value = ["sync_seq"]), ], ) data class TuneProfileEntity( @@ -504,8 +594,12 @@ data class TuneProfileEntity( val fieldsJson: String, @ColumnInfo(name = "created_at") val createdAt: Long, + /** Ratcheted last-write-wins timestamp; see [BoardEntity.updatedAt]. */ @ColumnInfo(name = "updated_at") val updatedAt: Long, + /** Device-local Sync Cursor position; see [SyncSequenceEntity]. */ + @ColumnInfo(name = "sync_seq") + val syncSeq: Long = 0, ) @Entity( @@ -538,6 +632,7 @@ data class TuneHistoryEntryEntity( primaryKeys = ["board_id", "kind"], indices = [ Index(value = ["board_id"]), + Index(value = ["sync_seq"]), ], ) data class BoardWarningEntity( @@ -551,6 +646,16 @@ data class BoardWarningEntity( val lastDetectedAt: Long, @ColumnInfo(name = "payload_json") val payloadJson: String, + /** + * Ratcheted last-write-wins timestamp; see [BoardEntity.updatedAt]. Distinct from + * [lastDetectedAt], which moves only when the detector fires — a severity or payload change + * rewrites the row without necessarily being a fresh detection. + */ + @ColumnInfo(name = "updated_at") + val updatedAt: Long = 0, + /** Device-local Sync Cursor position; see [SyncSequenceEntity]. */ + @ColumnInfo(name = "sync_seq") + val syncSeq: Long = 0, ) /** @@ -568,6 +673,7 @@ data class BoardWarningEntity( indices = [ Index(value = ["start_ms", "end_ms"]), Index(value = ["board_id"]), + Index(value = ["sync_seq"]), ], ) data class FavoriteEntity( @@ -603,6 +709,9 @@ data class FavoriteEntity( val maxSpeedCentiKmh: Int, @ColumnInfo(name = "battery_used_wh_milli") val batteryUsedWhMilli: Long, + /** Device-local Sync Cursor position; see [SyncSequenceEntity]. */ + @ColumnInfo(name = "sync_seq") + val syncSeq: Long = 0, ) { /** * Board name is resolved on read from `boards`, not snapshotted, so renames propagate. diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt index 1e9b593ea..7daa07f1c 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt @@ -69,7 +69,7 @@ class BoardTombstoneTest { @Test fun migrationTargetsTheCurrentSchemaVersion() { - assertEquals(35, TELEMETRY_DATABASE_VERSION) + assertEquals(36, TELEMETRY_DATABASE_VERSION) assertEquals(33, TelemetryDatabase.MIGRATION_33_34.startVersion) assertEquals(34, TelemetryDatabase.MIGRATION_33_34.endVersion) } diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt index f91cb08ac..6138a42b2 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt @@ -98,11 +98,13 @@ class SyncCursorMigrationTest { @Test fun migrationsTargetTheCurrentSchemaVersion() { - assertEquals(35, TELEMETRY_DATABASE_VERSION) + assertEquals(36, TELEMETRY_DATABASE_VERSION) assertEquals(31, TelemetryDatabase.MIGRATION_31_32.startVersion) assertEquals(32, TelemetryDatabase.MIGRATION_31_32.endVersion) assertEquals(32, TelemetryDatabase.MIGRATION_32_33.startVersion) assertEquals(33, TelemetryDatabase.MIGRATION_32_33.endVersion) + assertEquals(35, TelemetryDatabase.MIGRATION_35_36.startVersion) + assertEquals(36, TelemetryDatabase.MIGRATION_35_36.endVersion) } /** @@ -143,7 +145,7 @@ class SyncCursorMigrationTest { "missing sync_sequences table", sql.any { it.contains("CREATE TABLE IF NOT EXISTS sync_sequences") }, ) - for (table in SYNC_SEQ_TABLES) { + for (table in SYNC_SEQ_TABLES_V33) { assertTrue( "missing sync_seq column on $table", sql.any { it == "ALTER TABLE $table ADD COLUMN sync_seq INTEGER NOT NULL DEFAULT 0" }, @@ -167,7 +169,7 @@ class SyncCursorMigrationTest { fun syncSeqMigrationBackfillsExistingRowsBeforeSeedingTheCounter() { val sql = migrationSql(TelemetryDatabase.MIGRATION_32_33) - for (table in SYNC_SEQ_TABLES) { + for (table in SYNC_SEQ_TABLES_V33) { val backfilled = sql.indexOf("UPDATE $table SET sync_seq = rowid") val seeded = sql.indexOfFirst { it.contains("INSERT OR REPLACE INTO sync_sequences") && it.contains("'$table'") @@ -186,6 +188,11 @@ class SyncCursorMigrationTest { "syncSeq = nextSyncSeq(SYNC_SEQ_BOARDS)", "syncSeq = nextSyncSeq(SYNC_SEQ_ALERTS)", "syncSeq = nextSyncSeq(SYNC_SEQ_MINUTE_BUCKETS)", + "syncSeq = nextSyncSeq(SYNC_SEQ_BOARD_SETTINGS)", + "syncSeq = nextSyncSeq(SYNC_SEQ_BOARD_WARNINGS)", + "syncSeq = nextSyncSeq(SYNC_SEQ_PRIVACY_ZONES)", + "syncSeq = nextSyncSeq(SYNC_SEQ_TUNE_PROFILES)", + "syncSeq = nextSyncSeq(SYNC_SEQ_FAVORITES)", )) { assertTrue("no write path allocates via `$marker`", dao.contains(marker)) } @@ -193,6 +200,123 @@ class SyncCursorMigrationTest { assertTrue("bucket merge drops the new sync_seq", dao.contains("syncSeq = next.syncSeq")) } + // MARK: The six remaining mutable tables (#281) + + private fun remainingTablesSql(): List = + migrationSql(TelemetryDatabase.MIGRATION_35_36) + + @Test + fun remainingTablesGainColumnIndexAndCounter() { + val sql = remainingTablesSql() + + for (table in SYNC_SEQ_TABLES_V36) { + assertTrue( + "missing sync_seq column on $table", + sql.any { it == "ALTER TABLE $table ADD COLUMN sync_seq INTEGER NOT NULL DEFAULT 0" }, + ) + assertTrue( + "missing sync_seq index on $table", + sql.any { it == "CREATE INDEX IF NOT EXISTS index_${table}_sync_seq ON $table(sync_seq)" }, + ) + val backfilled = sql.indexOf("UPDATE $table SET sync_seq = rowid") + val seeded = sql.indexOfFirst { + it.contains("INSERT OR REPLACE INTO sync_sequences") && it.contains("'$table'") + } + assertTrue("missing sync_seq backfill for $table", backfilled >= 0) + assertTrue("counter for $table is seeded before its rows are numbered", seeded > backfilled) + } + } + + /** + * `board_warnings` is the one table of the six that never had a wall-clock stamp: without it the + * server has nothing to compare and every re-detection would win or lose arbitrarily. + */ + @Test + fun boardWarningsGainUpdatedAtBackfilledFromItsNewestDetection() { + val sql = remainingTablesSql() + + val added = sql.indexOf("ALTER TABLE board_warnings ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0") + val backfilled = sql.indexOf("UPDATE board_warnings SET updated_at = last_detected_at") + assertTrue("missing updated_at on board_warnings", added >= 0) + assertTrue("backfill runs before the column is added", backfilled > added) + } + + /** + * Every step is guarded on the column being absent, so a re-run adds nothing and renumbers + * nothing — the counter would otherwise be re-seeded below positions already handed out. + */ + @Test + fun remainingTablesMigrationIsGuardedForReRun() { + val guarded = TelemetryDatabase.MIGRATION_35_36 + val sql = migrationSql(guarded) + + // `migrationSql` answers every column probe with an empty cursor, i.e. "column absent", so this + // run is the first-time path. The guarded statements are exactly the ones missing from a re-run. + assertTrue( + "column adds are unguarded", + sql.any { it.startsWith("ALTER TABLE") }, + ) + for (statement in sql.filter { it.startsWith("CREATE INDEX") }) { + assertTrue("index create is not idempotent: $statement", statement.contains("IF NOT EXISTS")) + } + } + + /** + * Rider identity and this phone's session state live in `app_settings` but name the phone, not the + * Rider (#277). They are excluded by never being given a cursor position: 0 sits below every Sync + * Cursor, so no scan sees the row. The migration's `rowid` backfill has to be undone for them. + */ + @Test + fun phoneLocalSettingsKeysAreExcludedFromTheCursor() { + val sql = remainingTablesSql() + + val reset = sql.single { it.startsWith("UPDATE app_settings SET sync_seq = 0") } + for (key in NOT_SYNCED_SETTING_KEYS) { + assertTrue("phone-local key $key is left syncable", reset.contains("'$key'")) + } + assertTrue("riderName must stay on the phone", "riderName" in NOT_SYNCED_SETTING_KEYS) + assertTrue("liveHistoryLimit is Rider config, not phone state", "liveHistoryLimit" !in NOT_SYNCED_SETTING_KEYS) + + assertTrue( + "app settings write path ignores the phone-local list", + daoSource().contains("if (phoneLocal) 0L else nextSyncSeq(SYNC_SEQ_APP_SETTINGS)"), + ) + } + + /** + * Every targeted `UPDATE` on the six tables bypasses the entity round-trip, which is exactly the + * shape that made `setAlertRuleEnabled` regress: it has to move both columns in its own SQL. + */ + @Test + fun targetedUpdatesOnTheSixTablesMoveBothSyncColumns() { + val dao = daoSource().replace(Regex("""\"\s*\+\s*\""""), "") + + for (statement in Regex("""\"(UPDATE (?:privacy_zones|tune_profiles) SET[^"]*)\"""").findAll(dao)) { + val sql = statement.groupValues[1] + assertTrue("targeted update does not ratchet: $sql", sql.contains("MAX(updated_at + 1, :updatedAt)")) + assertTrue("targeted update does not move the cursor: $sql", sql.contains("sync_seq = :syncSeq")) + } + } + + /** + * The bucket merge used to freeze `updated_at` at the stored value on a backwards clock step, on + * the premise that the server upserts this table unconditionally. It does not — the same + * last-write-wins guard applies, so a frozen stamp is a scanned, sent, silently dropped row. + */ + @Test + fun bucketMergeRatchetsLikeBoardsAndAlerts() { + val dao = daoSource() + + assertTrue( + "bucket merge does not ratchet", + dao.contains("updatedAt = ratchetUpdatedAt(updatedAt, next.updatedAt)"), + ) + assertTrue( + "the retired unconditional-upsert claim is still in the source", + !dao.contains("unconditional upsert"), + ) + } + // MARK: Last-write-wins ratchet (#275) /** diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt index 76101e8f1..10dfa650f 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt @@ -63,7 +63,7 @@ class TelemetryBoardIdMigrationTest { @Test fun migrationTargetsTheCurrentSchemaVersion() { - assertEquals(35, TELEMETRY_DATABASE_VERSION) + assertEquals(36, TELEMETRY_DATABASE_VERSION) assertEquals(34, TelemetryDatabase.MIGRATION_34_35.startVersion) assertEquals(35, TelemetryDatabase.MIGRATION_34_35.endVersion) } diff --git a/modules/vescape-core/ios/telemetry/AppDataRepository.swift b/modules/vescape-core/ios/telemetry/AppDataRepository.swift index 568303e29..41baac253 100644 --- a/modules/vescape-core/ios/telemetry/AppDataRepository.swift +++ b/modules/vescape-core/ios/telemetry/AppDataRepository.swift @@ -17,6 +17,37 @@ enum AppDataScope: String { case settings } +/// App settings that name *this phone* rather than the Rider, and so never leave it: restoring them +/// onto a second phone would overwrite that phone's own identity or session state. Enforced at the +/// write path — `writeAppSetting` leaves their `sync_seq` at 0, which is below every Sync Cursor, so +/// no upload scan ever sees the row. +/// +/// Rider Name and Rider Color live in `app_settings` by design, so that Group Ride keeps working +/// signed-out; that placement is what makes them phone-local rather than Account-scoped. See #277. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `NOT_SYNCED_SETTING_KEYS` +internal let notSyncedSettingKeys: [String] = [ + // Rider identity — a second phone in the same Group Ride must not become the same Rider. + "riderId", + "riderName", + "riderColor", + // Device/session state — names this phone's current session, not the Rider's configuration. + "selectedBoardId", + "lastGpsLatitude", + "lastGpsLongitude", + "directionPointLatitude", + "directionPointLongitude", + // Connection and companion behaviour — phone-side BLE and foreground policy. + "autoConnect", + "companionPresenceEnabled", + "companionPresenceCooldownMinutes", + "connectionSoundsEnabled", + "autoCloseEnabled", + "autoCloseDelayMinutes", + // Wear pairing — the watch is paired to one phone. + "wearMirrorIntervalMs", + "wearAutoLaunchOnConnect", +] + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt final class AppDataRepository { static let shared = AppDataRepository() @@ -153,10 +184,7 @@ final class AppDataRepository { try db.execute(sql: "DELETE FROM board_settings WHERE board_id = ? AND key = ?", arguments: [id, key]) continue } - try db.execute( - sql: "INSERT OR REPLACE INTO board_settings (board_id, key, value_json, updated_at) VALUES (?, ?, ?, ?)", - arguments: [id, key, json, updatedAt] - ) + try Self.writeBoardSetting(db, boardId: id, key: key, json: json, now: updatedAt) } } notifyDataChanged(.boards) @@ -198,10 +226,7 @@ final class AppDataRepository { let value: [String: Any] = ["percent": percent, "voltage": voltage ?? NSNull(), "at": atMs] guard let json = Self.encodeJson(value) else { return } write { db in - try db.execute( - sql: "INSERT OR REPLACE INTO board_settings (board_id, key, value_json, updated_at) VALUES (?, ?, ?, ?)", - arguments: [boardId, "lastBattery", json, atMs] - ) + try Self.writeBoardSetting(db, boardId: boardId, key: "lastBattery", json: json, now: atMs) } notifyDataChanged(.boards) } @@ -211,10 +236,7 @@ final class AppDataRepository { func updateLegalMode(boardId: String, enabled: Bool) { guard let json = Self.encodeJson(["enabled": enabled]) else { return } write { db in - try db.execute( - sql: "INSERT OR REPLACE INTO board_settings (board_id, key, value_json, updated_at) VALUES (?, ?, ?, ?)", - arguments: [boardId, "legalMode", json, self.nowMs()] - ) + try Self.writeBoardSetting(db, boardId: boardId, key: "legalMode", json: json, now: self.nowMs()) } notifyDataChanged(.boards) } @@ -456,23 +478,36 @@ final class AppDataRepository { let createdAt = Self.longValue(zone["createdAt"] ?? nil) ?? now let updatedAt = Self.longValue(zone["updatedAt"] ?? nil) ?? now write { db in + let stamp = try stampSyncColumns( + db, + table: "privacy_zones", + sequence: syncSeqPrivacyZones, + whereClause: "id = ?", + keys: [id], + now: updatedAt + ) try db.execute( sql: """ INSERT OR REPLACE INTO privacy_zones - (id, preset, name, enabled, center_latitude_e7, center_longitude_e7, radius_meters, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, preset, name, enabled, center_latitude_e7, center_longitude_e7, radius_meters, created_at, updated_at, sync_seq) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - arguments: [id, preset, name, enabled ? 1 : 0, latitude.toE7, longitude.toE7, radius, createdAt, updatedAt] + arguments: [ + id, preset, name, enabled ? 1 : 0, latitude.toE7, longitude.toE7, radius, createdAt, + stamp.updatedAt, stamp.syncSeq, + ] ) } } + /// Targeted toggle that bypasses the upsert, so it moves both sync columns itself; see + /// `setAlertRuleEnabled`. func setPrivacyZoneEnabled(_ id: String, _ enabled: Bool) { let updatedAt = nowMs() write { db in try db.execute( - sql: "UPDATE privacy_zones SET enabled = ?, updated_at = ? WHERE id = ?", - arguments: [enabled ? 1 : 0, updatedAt, id] + sql: "UPDATE privacy_zones SET enabled = ?, updated_at = MAX(updated_at + 1, ?), sync_seq = ? WHERE id = ?", + arguments: [enabled ? 1 : 0, updatedAt, try nextSyncSeq(db, syncSeqPrivacyZones), id] ) } } @@ -549,10 +584,7 @@ final class AppDataRepository { } guard let json = Self.encodeJson(value) else { return } write { db in - try db.execute( - sql: "INSERT OR REPLACE INTO app_settings (key, value_json, updated_at) VALUES (?, ?, ?)", - arguments: [key, json, updatedAt] - ) + try Self.writeAppSetting(db, key: key, json: json, now: updatedAt) } notifyDataChanged(.settings) } @@ -567,14 +599,57 @@ final class AppDataRepository { try db.execute(sql: "DELETE FROM app_settings WHERE key = 'legalPolicy'") return } - try db.execute( - sql: "INSERT OR REPLACE INTO app_settings (key, value_json, updated_at) VALUES (?, ?, ?)", - arguments: ["legalPolicy", json, self.nowMs()] - ) + try Self.writeAppSetting(db, key: "legalPolicy", json: json, now: self.nowMs()) } notifyDataChanged(.settings) } + /// Stamps both sync columns; see `upsertBoard`. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `upsertBoardSetting` + private static func writeBoardSetting( + _ db: Database, + boardId: String, + key: String, + json: String, + now: Int64 + ) throws { + let stamp = try stampSyncColumns( + db, + table: "board_settings", + sequence: syncSeqBoardSettings, + whereClause: "board_id = ? AND key = ?", + keys: [boardId, key], + now: now + ) + try db.execute( + sql: """ + INSERT OR REPLACE INTO board_settings (board_id, key, value_json, updated_at, sync_seq) + VALUES (?, ?, ?, ?, ?) + """, + arguments: [boardId, key, json, stamp.updatedAt, stamp.syncSeq] + ) + } + + /// Stamps both sync columns like `upsertBoard`, except for the phone-local keys in + /// [notSyncedSettingKeys]: those keep `sync_seq` at 0, which sits below every Sync Cursor, so the + /// upload scan never picks the row up and the key stays on this phone (#277). + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `upsertAppSetting` + private static func writeAppSetting(_ db: Database, key: String, json: String, now: Int64) throws { + let previous = try Int64.fetchOne( + db, + sql: "SELECT updated_at FROM app_settings WHERE key = ?", + arguments: [key] + ) + let syncSeq = notSyncedSettingKeys.contains(key) ? 0 : try nextSyncSeq(db, syncSeqAppSettings) + try db.execute( + sql: """ + INSERT OR REPLACE INTO app_settings (key, value_json, updated_at, sync_seq) + VALUES (?, ?, ?, ?) + """, + arguments: [key, json, ratchetUpdatedAt(previous, now), syncSeq] + ) + } + // MARK: - Shared pure helpers (also used by VescapeCoreModule bridge glue) /// Durable app-scoped settings shape. A TS/Android/iOS parity triangle — the container tag covers diff --git a/modules/vescape-core/ios/telemetry/FavoriteStore.swift b/modules/vescape-core/ios/telemetry/FavoriteStore.swift index c8aabfbe8..3a9d6abfe 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStore.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStore.swift @@ -150,11 +150,14 @@ struct FavoriteStore { moving_duration_ms INTEGER NOT NULL, avg_speed_centi_kmh INTEGER NOT NULL, max_speed_centi_kmh INTEGER NOT NULL, - battery_used_wh_milli INTEGER NOT NULL + battery_used_wh_milli INTEGER NOT NULL, + sync_seq INTEGER NOT NULL DEFAULT 0 ) """) try db.execute(sql: "CREATE INDEX index_favorites_start_ms_end_ms ON favorites(start_ms, end_ms)") try db.execute(sql: "CREATE INDEX index_favorites_board_id ON favorites(board_id)") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_favorites_sync_seq ON favorites(sync_seq)") + try createSyncSequencesTable(db) } // MARK: - Reads @@ -179,8 +182,8 @@ struct FavoriteStore { INSERT INTO favorites ( id, board_id, name, start_ms, end_ms, created_at, updated_at, sample_count, gps_point_count, distance_cm, moving_duration_ms, - avg_speed_centi_kmh, max_speed_centi_kmh, battery_used_wh_milli - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + avg_speed_centi_kmh, max_speed_centi_kmh, battery_used_wh_milli, sync_seq + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ favorite.id, favorite.boardId, favorite.name, @@ -188,6 +191,7 @@ struct FavoriteStore { favorite.summary.sampleCount, favorite.summary.gpsPointCount, favorite.summary.distanceCm, favorite.summary.movingDurationMs, favorite.summary.avgSpeedCentiKmh, favorite.summary.maxSpeedCentiKmh, favorite.summary.batteryUsedWhMilli, + try nextSyncSeq(db, syncSeqFavorites), ] ) } @@ -204,13 +208,14 @@ struct FavoriteStore { try db.execute( sql: """ UPDATE favorites SET - name = ?, start_ms = ?, end_ms = ?, updated_at = ?, + name = ?, start_ms = ?, end_ms = ?, updated_at = MAX(updated_at + 1, ?), sync_seq = ?, sample_count = ?, gps_point_count = ?, distance_cm = ?, moving_duration_ms = ?, avg_speed_centi_kmh = ?, max_speed_centi_kmh = ?, battery_used_wh_milli = ? WHERE id = ? """, arguments: [ favorite.name, favorite.startMs, favorite.endMs, favorite.updatedAtMs, + try nextSyncSeq(db, syncSeqFavorites), favorite.summary.sampleCount, favorite.summary.gpsPointCount, favorite.summary.distanceCm, favorite.summary.movingDurationMs, favorite.summary.avgSpeedCentiKmh, favorite.summary.maxSpeedCentiKmh, diff --git a/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift b/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift index 5b8a1cefc..a52916b34 100644 --- a/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift +++ b/modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift @@ -177,9 +177,10 @@ final class SyncCursorMigrationTests: XCTestCase { XCTAssertEqual(samples, 2) } - /// A device clock that steps backwards must never walk the cursor back, or the server would stop - /// seeing later writes. - func testBucketCursorIsMonotonicAcrossClockSteps() throws { + /// A device clock that steps backwards must not merely freeze the stamp: the server guards this + /// table with `stored.updated_at < EXCLUDED.updated_at` like every other mutable table, so a + /// frozen stamp is scanned, sent, and silently dropped. The merge ratchets strictly past it (#281). + func testBucketCursorRatchetsPastAStampTheClockCannotBeat() throws { try migrateToLatest() var bucket = TelemetryBucket(bucketStartMs: 60_000, boardId: "board-1") bucket.firstSampleAtMs = 60_000 @@ -190,7 +191,7 @@ final class SyncCursorMigrationTests: XCTestCase { XCTAssertEqual( try queue.read { db in try Int64.fetchOne(db, sql: "SELECT updated_at FROM telemetry_minute_buckets") }, - 5_000 + 5_001 ) } @@ -324,4 +325,126 @@ final class SyncCursorMigrationTests: XCTestCase { XCTAssertEqual(try alertCursor(), ahead + 1) XCTAssertGreaterThan(try XCTUnwrap(syncSeq(syncSeqAlerts)), seqBefore) } + + // MARK: - The six remaining mutable tables (#281) + + private func rowSyncSeq(_ table: String, _ whereClause: String, _ args: StatementArguments) throws -> Int64? { + try queue.read { db in + try Int64.fetchOne(db, sql: "SELECT sync_seq FROM \(table) WHERE \(whereClause)", arguments: args) + } + } + + func testRemainingMutableTablesCarryACursorAndItsIndex() throws { + try migrateToLatest() + + for table in syncSeqTablesV36 { + XCTAssertTrue(try columnNames(table).contains("sync_seq"), "\(table) is missing sync_seq") + XCTAssertTrue( + try indexNames(table).contains("index_\(table)_sync_seq"), + "\(table) is missing its sync_seq index" + ) + } + XCTAssertTrue(try columnNames("board_warnings").contains("updated_at")) + } + + /// Append-only tables key on `INTEGER PRIMARY KEY AUTOINCREMENT`, which SQLite guarantees + /// monotonic and never reused — that key already is their cursor, so a second one would be dead + /// weight the write paths would have to keep in step. + func testAppendOnlyTablesGainNoCursor() throws { + try migrateToLatest() + + for table in ["telemetry_frames", "telemetry_markers", "diagnostic_events", + "metric_exclusion_ranges", "tune_history_entries"] { + XCTAssertFalse(try columnNames(table).contains("sync_seq"), "\(table) should not carry sync_seq") + } + } + + func testMigrationBackfillsRemainingTablesWithDistinctPositions() throws { + try TelemetryDatabase.migrator.migrate(queue, upTo: "v35_telemetry_board_id") + try queue.write { db in + for (index, id) in ["zone-1", "zone-2", "zone-3"].enumerated() { + try db.execute( + sql: """ + INSERT INTO privacy_zones + (id, preset, name, enabled, center_latitude_e7, center_longitude_e7, radius_meters, created_at, updated_at) + VALUES (?, 'home', 'Home', 1, 0, 0, 100, ?, ?) + """, + arguments: [id, 1_000 + index, 1_000 + index] + ) + } + } + + try migrateToLatest() + + let seqs = try queue.read { db in + try Int64.fetchAll(db, sql: "SELECT sync_seq FROM privacy_zones ORDER BY sync_seq") + } + XCTAssertEqual(Set(seqs).count, 3, "backfilled positions collide") + XCTAssertFalse(seqs.contains(0), "a backfilled row sits at the seed value") + XCTAssertEqual(try counter(syncSeqPrivacyZones), seqs.max()) + } + + /// Re-running the migrator must not renumber rows or re-seed the counter below positions already + /// handed out. + func testRemainingTablesMigrationIsANoOpOnReRun() throws { + try migrateToLatest() + let repo = try makeRepository() + repo.upsertPrivacyZone(["id": "zone-1", "preset": "home", "name": "Home", + "centerLatitude": 0.0, "centerLongitude": 0.0, "radiusMeters": 100]) + let before = try counter(syncSeqPrivacyZones) + + try migrateToLatest() + + XCTAssertEqual(try counter(syncSeqPrivacyZones), before) + } + + func testPrivacyZoneToggleMovesBothColumns() throws { + try migrateToLatest() + let repo = try makeRepository() + repo.upsertPrivacyZone(["id": "zone-1", "preset": "home", "name": "Home", + "centerLatitude": 0.0, "centerLongitude": 0.0, "radiusMeters": 100]) + let first = try XCTUnwrap(syncSeq("privacy_zones")) + let stamp = try queue.read { db in try Int64.fetchOne(db, sql: "SELECT updated_at FROM privacy_zones") } + + repo.setPrivacyZoneEnabled("zone-1", false) + + XCTAssertGreaterThan(try XCTUnwrap(syncSeq("privacy_zones")), first) + XCTAssertGreaterThan( + try XCTUnwrap(queue.read { db in try Int64.fetchOne(db, sql: "SELECT updated_at FROM privacy_zones") }), + try XCTUnwrap(stamp) + ) + } + + /// Rider identity and this phone's session state live in `app_settings` but name the phone, not + /// the Rider (#277). They are excluded by never being given a cursor position: 0 sits below every + /// Sync Cursor, so no scan sees the row. + func testPhoneLocalSettingsKeepNoCursorPosition() throws { + try migrateToLatest() + let repo = try makeRepository() + + repo.updateSetting("riderName", rawValue: "Kacper") + repo.updateSetting("liveHistoryLimit", rawValue: 10) + + XCTAssertEqual(try rowSyncSeq("app_settings", "key = ?", ["riderName"]), 0) + XCTAssertGreaterThan(try XCTUnwrap(rowSyncSeq("app_settings", "key = ?", ["liveHistoryLimit"])), 0) + } + + /// The backfill numbers every existing row from `rowid`, which would hand a phone-local key a + /// position and ship it exactly once on the first upload after upgrade. + func testMigrationStripsCursorsFromPhoneLocalSettings() throws { + try TelemetryDatabase.migrator.migrate(queue, upTo: "v35_telemetry_board_id") + try queue.write { db in + for key in ["riderName", "liveHistoryLimit"] { + try db.execute( + sql: "INSERT INTO app_settings (key, value_json, updated_at) VALUES (?, ?, ?)", + arguments: [key, "1", 1_000] + ) + } + } + + try migrateToLatest() + + XCTAssertEqual(try rowSyncSeq("app_settings", "key = ?", ["riderName"]), 0) + XCTAssertGreaterThan(try XCTUnwrap(rowSyncSeq("app_settings", "key = ?", ["liveHistoryLimit"])), 0) + } } diff --git a/modules/vescape-core/ios/telemetry/TelemetryDao.swift b/modules/vescape-core/ios/telemetry/TelemetryDao.swift index d21cdd1c6..6dda8a308 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDao.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDao.swift @@ -36,7 +36,47 @@ internal func insertFrame(_ db: Database, _ state: FullTelemetryState) throws { internal let syncSeqBoards = "boards" internal let syncSeqAlerts = "alerts" internal let syncSeqMinuteBuckets = "telemetry_minute_buckets" -internal let syncSeqTables = [syncSeqBoards, syncSeqAlerts, syncSeqMinuteBuckets] +internal let syncSeqAppSettings = "app_settings" +internal let syncSeqBoardSettings = "board_settings" +internal let syncSeqBoardWarnings = "board_warnings" +internal let syncSeqPrivacyZones = "privacy_zones" +internal let syncSeqTuneProfiles = "tune_profiles" +internal let syncSeqFavorites = "favorites" + +/// The three tables the `v33_sync_seq` migration gave a `sync_seq`, frozen at the set that existed +/// then. A migration iterates the tables it actually shipped with, never the current +/// [syncSeqTables] — growing that list must not retroactively change an older migration step. +internal let syncSeqTablesV33 = [syncSeqBoards, syncSeqAlerts, syncSeqMinuteBuckets] + +/// The six remaining mutable tables, given a `sync_seq` by `v36_sync_seq_remaining` (#281). +internal let syncSeqTablesV36 = [ + syncSeqAppSettings, + syncSeqBoardSettings, + syncSeqBoardWarnings, + syncSeqPrivacyZones, + syncSeqTuneProfiles, + syncSeqFavorites, +] + +/// Every table carrying a `sync_seq`. Append-only tables are deliberately absent: they declare +/// `INTEGER PRIMARY KEY AUTOINCREMENT`, which SQLite guarantees monotonic and never reused, so their +/// key already *is* their cursor. +internal let syncSeqTables = syncSeqTablesV33 + syncSeqTablesV36 + +/// The Sync Cursor counter table. Idempotent, and called both from the migration that introduced it +/// and from the store-level `createTables` seams tests build their schema from — a table whose write +/// path allocates a cursor cannot be created without it. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `SyncSequenceEntity` +internal func createSyncSequencesTable(_ db: Database) throws { + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS sync_sequences ( + name TEXT NOT NULL PRIMARY KEY, + last_value INTEGER NOT NULL + ) + """ + ) +} /// Hands out the next Sync Cursor position for [name]. /// @@ -75,10 +115,31 @@ internal func ratchetUpdatedAt(_ previous: Int64?, _ now: Int64) -> Int64 { return max(previous + 1, now) } -/// [now] is the last-write-wins timestamp stamped on the row. `MAX` on conflict clamps a backwards -/// device-clock step so the value stays at the last real write time instead of regressing. No `+ 1` -/// ratchet here, unlike boards and alerts — the server writes this table with an unconditional -/// upsert, so a stale stamp is never grounds for rejecting the row. +/// Stamps `updated_at` and `sync_seq` on a row that `INSERT OR REPLACE` is about to rewrite. +/// +/// Read-modify-write rather than an `ON CONFLICT` fold: `INSERT OR REPLACE` deletes the old row +/// before inserting, so the ratchet has no `excluded`-style handle on the value it replaces. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `upsertBoardSetting` +internal func stampSyncColumns( + _ db: Database, + table: String, + sequence: String, + whereClause: String, + keys: StatementArguments, + now: Int64 +) throws -> (updatedAt: Int64, syncSeq: Int64) { + let previous = try Int64.fetchOne( + db, + sql: "SELECT updated_at FROM \(table) WHERE \(whereClause)", + arguments: keys + ) + return (ratchetUpdatedAt(previous, now), try nextSyncSeq(db, sequence)) +} + +/// [now] is the last-write-wins timestamp stamped on the row, ratcheted on conflict exactly as +/// boards and alerts are: the server guards this table with `WHERE stored.updated_at < +/// EXCLUDED.updated_at` like every other mutable table, so a stamp frozen at the stored value would +/// satisfy the scan and still be dropped server-side. /// /// Completeness is `sync_seq`'s job, and it moves on every write including a merge into a row the /// scan may already have passed. @@ -119,7 +180,7 @@ internal func upsertBucket(_ db: Database, _ b: TelemetryBucket, now: Int64 = te max_temp_motor_deci_c=MAX(telemetry_minute_buckets.max_temp_motor_deci_c, excluded.max_temp_motor_deci_c), first_moving_at_ms=MIN(telemetry_minute_buckets.first_moving_at_ms, excluded.first_moving_at_ms), last_moving_at_ms=MAX(telemetry_minute_buckets.last_moving_at_ms, excluded.last_moving_at_ms), - updated_at=MAX(telemetry_minute_buckets.updated_at, excluded.updated_at), + updated_at=MAX(telemetry_minute_buckets.updated_at + 1, excluded.updated_at), sync_seq=excluded.sync_seq """, arguments: [ diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index cf94523a5..5fd58db2d 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -474,7 +474,7 @@ enum TelemetryDatabase { ) """ ) - for table in syncSeqTables { + for table in syncSeqTablesV33 { let hasSyncSeq = try db.columns(in: table).contains { $0.name == "sync_seq" } if !hasSyncSeq { try db.execute(sql: "ALTER TABLE \(table) ADD COLUMN sync_seq INTEGER NOT NULL DEFAULT 0") @@ -517,6 +517,47 @@ enum TelemetryDatabase { try rebuildBucketsOnBoardId(db) } + // Sync Cursors for the six remaining mutable tables (#281). `board_warnings` also gains the + // wall-clock `updated_at` every other mutable table already carries, backfilled from its newest + // detection. + // + // Existing rows are backfilled from `rowid` — distinct and non-zero, so no two rows share a + // cursor position and none of them sit at the seed value — and each table's sequence is seeded + // past the highest value handed out. Every step is guarded, so a re-run is a no-op. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_35_36` + migrator.registerMigration("v36_sync_seq_remaining") { db in + let hasWarningUpdatedAt = try db.columns(in: "board_warnings").contains { $0.name == "updated_at" } + if !hasWarningUpdatedAt { + try db.execute(sql: "ALTER TABLE board_warnings ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0") + try db.execute(sql: "UPDATE board_warnings SET updated_at = last_detected_at") + } + + for table in syncSeqTablesV36 { + let hasSyncSeq = try db.columns(in: table).contains { $0.name == "sync_seq" } + if !hasSyncSeq { + try db.execute(sql: "ALTER TABLE \(table) ADD COLUMN sync_seq INTEGER NOT NULL DEFAULT 0") + try db.execute(sql: "UPDATE \(table) SET sync_seq = rowid") + } + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_\(table)_sync_seq ON \(table)(sync_seq)") + try db.execute( + sql: """ + INSERT OR REPLACE INTO sync_sequences (name, last_value) + VALUES (?, (SELECT COALESCE(MAX(sync_seq), 0) FROM \(table))) + """, + arguments: [table] + ) + } + + // Phone-local keys are defined by their absence from the scan, so the backfill above has to be + // undone for them: an uploader would otherwise ship whatever this phone happened to hold at + // upgrade time, exactly once. See `notSyncedSettingKeys`. + let placeholders = notSyncedSettingKeys.map { _ in "?" }.joined(separator: ",") + try db.execute( + sql: "UPDATE app_settings SET sync_seq = 0 WHERE key IN (\(placeholders))", + arguments: StatementArguments(notSyncedSettingKeys) + ) + } + return migrator } } diff --git a/modules/vescape-core/ios/telemetry/TuneProfileStore.swift b/modules/vescape-core/ios/telemetry/TuneProfileStore.swift index e5a5815fc..b1822293c 100644 --- a/modules/vescape-core/ios/telemetry/TuneProfileStore.swift +++ b/modules/vescape-core/ios/telemetry/TuneProfileStore.swift @@ -78,10 +78,13 @@ struct TuneProfileStore { color TEXT NOT NULL DEFAULT 'purple', fields_json TEXT NOT NULL, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + sync_seq INTEGER NOT NULL DEFAULT 0 ) """) try db.execute(sql: "CREATE INDEX index_tune_profiles_board_id ON tune_profiles(board_id)") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_tune_profiles_sync_seq ON tune_profiles(sync_seq)") + try createSyncSequencesTable(db) try db.execute(sql: "CREATE INDEX index_tune_profiles_board_id_refloat_base_version ON tune_profiles(board_id, refloat_base_version)") try db.execute(sql: """ @@ -153,10 +156,13 @@ struct TuneProfileStore { return try inWrite { db in try db.execute( sql: """ - INSERT INTO tune_profiles (id, board_id, refloat_base_version, name, icon, color, fields_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tune_profiles (id, board_id, refloat_base_version, name, icon, color, fields_json, created_at, updated_at, sync_seq) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - arguments: [id, boardId, compatibility, name, icon, color, fieldsJson, now, now] + arguments: [ + id, boardId, compatibility, name, icon, color, fieldsJson, now, now, + try nextSyncSeq(db, syncSeqTuneProfiles), + ] ) try Self.insertHistory(db, profileId: id, fieldsJson: fieldsJson, createdAt: now) return try Self.requireProfileMap(db, id) @@ -173,8 +179,11 @@ struct TuneProfileStore { let now = Self.nowMs() return try inWrite { db in try db.execute( - sql: "UPDATE tune_profiles SET name = ?, icon = ?, color = ?, updated_at = ? WHERE id = ?", - arguments: [name, icon, color, now, profileId] + sql: """ + UPDATE tune_profiles SET name = ?, icon = ?, color = ?, + updated_at = MAX(updated_at + 1, ?), sync_seq = ? WHERE id = ? + """, + arguments: [name, icon, color, now, try nextSyncSeq(db, syncSeqTuneProfiles), profileId] ) guard let map = try Self.fetchProfileMap(db, profileId) else { throw TuneProfileError.profileNotFound(profileId) @@ -224,8 +233,11 @@ struct TuneProfileStore { try Self.insertHistory(db, profileId: profileId, fieldsJson: profile["fields_json"], createdAt: now) try db.execute( - sql: "UPDATE tune_profiles SET fields_json = ?, updated_at = ? WHERE id = ?", - arguments: [entry["fields_json"] as String, now, profileId] + sql: """ + UPDATE tune_profiles SET fields_json = ?, updated_at = MAX(updated_at + 1, ?), sync_seq = ? + WHERE id = ? + """, + arguments: [entry["fields_json"] as String, now, try nextSyncSeq(db, syncSeqTuneProfiles), profileId] ) guard let map = try Self.fetchProfileMap(db, profileId) else { throw TuneProfileError.disappearedDuringRollback(profileId) @@ -248,10 +260,13 @@ struct TuneProfileStore { let color: String = source["color"] try db.execute( sql: """ - INSERT INTO tune_profiles (id, board_id, refloat_base_version, name, icon, color, fields_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tune_profiles (id, board_id, refloat_base_version, name, icon, color, fields_json, created_at, updated_at, sync_seq) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - arguments: [copyId, targetBoardId, source["refloat_base_version"] as String, newName, icon, color, fieldsJson, now, now] + arguments: [ + copyId, targetBoardId, source["refloat_base_version"] as String, newName, icon, color, + fieldsJson, now, now, try nextSyncSeq(db, syncSeqTuneProfiles), + ] ) try Self.insertHistory(db, profileId: copyId, fieldsJson: fieldsJson, createdAt: now) return try Self.requireProfileMap(db, copyId) @@ -270,8 +285,11 @@ struct TuneProfileStore { } try Self.insertHistory(db, profileId: profileId, fieldsJson: current["fields_json"], createdAt: now) try db.execute( - sql: "UPDATE tune_profiles SET fields_json = ?, updated_at = ? WHERE id = ?", - arguments: [fieldsJson, now, profileId] + sql: """ + UPDATE tune_profiles SET fields_json = ?, updated_at = MAX(updated_at + 1, ?), sync_seq = ? + WHERE id = ? + """, + arguments: [fieldsJson, now, try nextSyncSeq(db, syncSeqTuneProfiles), profileId] ) guard let map = try Self.fetchProfileMap(db, profileId) else { throw TuneProfileError.disappearedDuringSave(profileId) diff --git a/modules/vescape-core/ios/warnings/BoardWarningStore.swift b/modules/vescape-core/ios/warnings/BoardWarningStore.swift index afc493792..9521edeb4 100644 --- a/modules/vescape-core/ios/warnings/BoardWarningStore.swift +++ b/modules/vescape-core/ios/warnings/BoardWarningStore.swift @@ -63,10 +63,14 @@ struct BoardWarningStore { first_detected_at INTEGER NOT NULL, last_detected_at INTEGER NOT NULL, payload_json TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0, + sync_seq INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (board_id, kind) ) """) try db.execute(sql: "CREATE INDEX index_board_warnings_board_id ON board_warnings(board_id)") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_board_warnings_sync_seq ON board_warnings(sync_seq)") + try createSyncSequencesTable(db) } /// The shared pool failed to open — findings are dropped / reads come back empty, so leave the @@ -149,16 +153,20 @@ struct BoardWarningStore { try db.execute( sql: """ INSERT INTO board_warnings - (board_id, kind, severity, first_detected_at, last_detected_at, payload_json) - VALUES (?, ?, ?, ?, ?, ?) + (board_id, kind, severity, first_detected_at, last_detected_at, payload_json, + updated_at, sync_seq) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(board_id, kind) DO UPDATE SET severity = excluded.severity, last_detected_at = excluded.last_detected_at, - payload_json = excluded.payload_json + payload_json = excluded.payload_json, + updated_at = MAX(board_warnings.updated_at + 1, excluded.updated_at), + sync_seq = excluded.sync_seq """, arguments: [ warning.boardId, warning.kind, warning.severity, warning.firstDetectedAtMs, warning.lastDetectedAtMs, warning.payloadJson, + warning.lastDetectedAtMs, try nextSyncSeq(db, syncSeqBoardWarnings), ] ) } From 206ee79b177592732f7fe631cf82e25cfddec8df Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sat, 1 Aug 2026 02:46:07 +0200 Subject: [PATCH 08/24] Log Sync Actions for semantic removals #282 --- CONTEXT.md | 4 + ...027-boards-are-tombstoned-never-deleted.md | 2 +- .../vescapecore/telemetry/TelemetryDao.kt | 214 +++++++++- .../telemetry/TelemetryDatabase.kt | 31 +- .../telemetry/TelemetryEntities.kt | 77 ++++ .../telemetry/BoardTombstoneTest.kt | 6 +- .../telemetry/SyncActionLogTest.kt | 231 +++++++++++ .../telemetry/SyncCursorMigrationTest.kt | 2 +- .../TelemetryBoardIdMigrationTest.kt | 2 +- .../ios/telemetry/AppDataRepository.swift | 88 +++- .../ios/telemetry/FavoriteStore.swift | 15 +- .../ios/telemetry/SyncActionLog.swift | 191 +++++++++ .../ios/telemetry/SyncActionLogTests.swift | 385 ++++++++++++++++++ .../ios/telemetry/TelemetryDatabase.swift | 10 + .../ios/telemetry/TuneProfileStore.swift | 12 +- .../ios/warnings/BoardWarningStore.swift | 44 +- modules/vescape-core/src/index.ts | 22 + 17 files changed, 1287 insertions(+), 49 deletions(-) create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncActionLogTest.kt create mode 100644 modules/vescape-core/ios/telemetry/SyncActionLog.swift create mode 100644 modules/vescape-core/ios/telemetry/SyncActionLogTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index ca66f5b9d..90df4f3e2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,6 +12,10 @@ _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 + **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 diff --git a/docs/adr/0027-boards-are-tombstoned-never-deleted.md b/docs/adr/0027-boards-are-tombstoned-never-deleted.md index e8f68ca7f..643983145 100644 --- a/docs/adr/0027-boards-are-tombstoned-never-deleted.md +++ b/docs/adr/0027-boards-are-tombstoned-never-deleted.md @@ -14,7 +14,7 @@ A tombstone keeps the parent row alive, so the foreign key holds, history surviv ## Consequences -- `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 **Delete Action**. +- `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 **Delete 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). - `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`) check `deletedAt` and refuse. - On the server the `ON DELETE CASCADE` behind the Board-owned configuration tables stops firing, because nothing is deleted anymore. The Delete 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 Delete Action. diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 5d45c866a..ef118ff40 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -91,7 +91,14 @@ interface TelemetryDao { } @Query("DELETE FROM privacy_zones WHERE id = :id") - suspend fun deletePrivacyZone(id: String) + suspend fun deletePrivacyZoneRow(id: String) + + /** Semantic removal: the Rider deleted the zone, so the server has to lose it too. */ + @Transaction + suspend fun deletePrivacyZone(id: String) { + appendDeleteAction(DeleteTarget.PRIVACY_ZONE, null, id, getPrivacyZoneUpdatedAt(id)) + deletePrivacyZoneRow(id) + } @Insert @@ -153,6 +160,76 @@ interface TelemetryDao { return getSyncSequence(name) ?: 0L } + // Sync Actions — the append-only log of semantic removals (#282). Every write below runs inside + // the caller's transaction, so an action and the delete it describes commit together or not at all. + // @parity /modules/vescape-core/ios/telemetry/SyncActionLog.swift + + @Insert + suspend fun insertSyncAction(action: SyncActionEntity): Long + + /** + * Record that [target] identified by [boardId]/[key] was semantically removed. + * + * [rowUpdatedAt] is the removed row's own last-write-wins timestamp, read before the delete: the + * action is stamped `max(now, rowUpdatedAt)` so a rewound device clock cannot produce an action + * the server reads as older than the row it names — that action would be dropped as a no-op, and + * the phone could not self-heal by re-sending, because the row is gone. + * + * A null [rowUpdatedAt] means there was no row to remove, so no intent to record either. + */ + @Transaction + suspend fun appendDeleteAction( + target: DeleteTarget, + boardId: String?, + key: String, + rowUpdatedAt: Long?, + now: Long = System.currentTimeMillis(), + ) { + if (rowUpdatedAt == null) return + insertSyncAction( + SyncActionEntity( + target = target.wire, + boardId = boardId, + key = key, + deletedAt = maxOf(now, rowUpdatedAt), + ), + ) + } + + /** The next page of actions to upload, in cursor order. */ + @Query("SELECT * FROM sync_actions WHERE id > :afterId ORDER BY id ASC LIMIT :limit") + suspend fun getSyncActionsAfter(afterId: Long, limit: Int): List + + @Query( + "INSERT OR REPLACE INTO sync_sequences (name, last_value) VALUES (:name, " + + "MAX(:value, COALESCE((SELECT last_value FROM sync_sequences WHERE name = :name), 0)))", + ) + suspend fun commitSyncActionCursorRow(name: String, value: Long) + + /** + * Checkpoint the highest action cursor the server has accepted. Its own transaction, committed + * before [pruneUploadedSyncActions] runs: a crash between the two leaves rows that will be sent + * again — harmless, since applying an action twice is a no-op — whereas pruning first would drop + * an action nobody has accepted. Never moves backwards, so an out-of-order commit cannot un-accept + * what an earlier upload already checkpointed. + */ + @Transaction + suspend fun commitSyncActionCursor(throughId: Long) = + commitSyncActionCursorRow(SYNC_ACTIONS_UPLOADED_CURSOR, throughId) + + @Query("DELETE FROM sync_actions WHERE id <= :throughId") + suspend fun deleteSyncActionsThrough(throughId: Long): Int + + /** + * Drop what the server has already accepted. Gated on the committed cursor rather than a caller's + * number, so pruning structurally cannot outrun the checkpoint. + */ + @Transaction + suspend fun pruneUploadedSyncActions(): Int { + val accepted = getSyncSequence(SYNC_ACTIONS_UPLOADED_CURSOR) ?: return 0 + return deleteSyncActionsThrough(accepted) + } + @Transaction suspend fun insertBatch( frames: List, @@ -500,7 +577,22 @@ interface TelemetryDao { } @Query("DELETE FROM board_settings WHERE board_id = :boardId AND key = :key") - suspend fun deleteBoardSetting(boardId: String, key: String) + suspend fun deleteBoardSettingRow(boardId: String, key: String) + + /** + * Semantic removal: a Board edit that drops a key is the Rider clearing that setting, so a restore + * must not resurrect the old value. + */ + @Transaction + suspend fun deleteBoardSetting(boardId: String, key: String) { + appendDeleteAction( + DeleteTarget.BOARD_SETTING, + boardId, + key, + getBoardSettingUpdatedAt(boardId, key), + ) + deleteBoardSettingRow(boardId, key) + } @Transaction suspend fun upsertBoardWithSettings(board: BoardEntity, settings: List, deletedKeys: List) { @@ -509,8 +601,9 @@ interface TelemetryDao { settings.forEach { upsertBoardSetting(it) } } + /** Parent-covered cascade: raw, because the Board's own action covers its configuration. */ @Query("DELETE FROM board_settings WHERE board_id = :boardId") - suspend fun deleteBoardSettings(boardId: String) + suspend fun deleteBoardSettingsRaw(boardId: String) /** * The Rider-facing delete: configuration goes, the Board row stays as a tombstone (ADR 0027). @@ -518,15 +611,26 @@ interface TelemetryDao { * * The tombstone is an ordinary write, so it runs through [upsertBoard] and moves both sync * columns like any other edit. An unknown or already-tombstoned id is a no-op. + * + * The tombstone syncs as an ordinary upsert *and* emits one Sync Action, because the two say + * different things: 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 children are raw deletes — the Board's action covers + * them (#282). + * + * The action and the tombstone share one timestamp, the newly ratcheted `updated_at`, so the + * server judges both against the same moment. */ @Transaction suspend fun deleteBoardWithSettings(id: String, deletedAt: Long) { val board = getBoard(id)?.takeIf { it.deletedAt == null } ?: return - deleteBoardSettings(id) - deleteBoardWarnings(id) + val tombstonedAt = ratchetUpdatedAt(board.updatedAt, deletedAt) + deleteBoardSettingsRaw(id) + deleteBoardWarningsRaw(id) // Alert Rules are Board-owned (#254) — drop them with the Board so no orphan rows survive. - deleteAlertRules(id) - upsertBoard(board.copy(deletedAt = deletedAt, updatedAt = deletedAt)) + deleteAlertRulesRaw(id) + appendDeleteAction(DeleteTarget.BOARD, null, id, tombstonedAt, tombstonedAt) + upsertBoard(board.copy(deletedAt = tombstonedAt, updatedAt = tombstonedAt)) } @Query("SELECT * FROM alerts WHERE board_id = :boardId ORDER BY created_at ASC") @@ -579,10 +683,22 @@ interface TelemetryDao { } @Query("DELETE FROM alerts WHERE board_id = :boardId AND id = :id") - suspend fun deleteAlertRule(boardId: String, id: String) + suspend fun deleteAlertRuleRow(boardId: String, id: String) + /** + * Semantic removal, and the path preset regeneration takes too: JS regenerates a Board's preset + * rules by deleting the old ones and writing new ones, and the deleted ones have to disappear + * server-side as well. + */ + @Transaction + suspend fun deleteAlertRule(boardId: String, id: String) { + appendDeleteAction(DeleteTarget.ALERT, boardId, id, getAlertRuleUpdatedAt(boardId, id)) + deleteAlertRuleRow(boardId, id) + } + + /** Parent-covered cascade: raw, because the Board's own action covers its Alert Rules. */ @Query("DELETE FROM alerts WHERE board_id = :boardId") - suspend fun deleteAlertRules(boardId: String) + suspend fun deleteAlertRulesRaw(boardId: String) @Query("SELECT * FROM app_settings") suspend fun getAllAppSettings(): List @@ -613,7 +729,24 @@ interface TelemetryDao { } @Query("DELETE FROM app_settings WHERE key = :key") - suspend fun deleteAppSetting(key: String) + suspend fun deleteAppSettingRow(key: String) + + /** + * Semantic removal. Every caller means the same thing — the stored override is gone: an edit back + * to the default, `legalPolicy` resolving to nothing, and the corrupt-value cleanup in + * [AppDataRepository.getTypedSettings], which is deliberately semantic so a restore cannot + * resurrect a value this phone already rejected. + * + * Phone-local keys never reach the server (they carry `sync_seq = 0`), so removing one records no + * action either — an action for a row the server never held would delete nothing and say nothing. + */ + @Transaction + suspend fun deleteAppSetting(key: String) { + if (key !in NOT_SYNCED_SETTING_KEYS) { + appendDeleteAction(DeleteTarget.APP_SETTING, null, key, getAppSettingUpdatedAt(key)) + } + deleteAppSettingRow(key) + } // Tune Profile / Tune History DAO. Transactional bodies below are mirrored in Swift. // @parity /modules/vescape-core/ios/telemetry/TuneProfileStore.swift @@ -624,10 +757,11 @@ interface TelemetryDao { suspend fun getTuneProfile(id: String): TuneProfileEntity? @Query("DELETE FROM tune_profiles WHERE id = :id") - suspend fun deleteTuneProfile(id: String) + suspend fun deleteTuneProfileRow(id: String) + /** Parent-covered cascade: raw, because the profile's own action covers its Tune History. */ @Query("DELETE FROM tune_history_entries WHERE profile_id = :profileId") - suspend fun deleteTuneHistoryForProfile(profileId: String) + suspend fun deleteTuneHistoryForProfileRaw(profileId: String) /** Targeted rename that bypasses the upsert, so it moves both columns itself; see * [setAlertRuleEnabledRow]. */ @@ -742,8 +876,9 @@ interface TelemetryDao { if (countTuneProfilesForBoard(profile.boardId, profile.refloatBaseVersion) <= 1) { throw IllegalStateException("Cannot delete the last profile for a board") } - deleteTuneHistoryForProfile(profileId) - deleteTuneProfile(profileId) + deleteTuneHistoryForProfileRaw(profileId) + appendDeleteAction(DeleteTarget.TUNE_PROFILE, null, profileId, profile.updatedAt) + deleteTuneProfileRow(profileId) } @Transaction @@ -810,11 +945,47 @@ interface TelemetryDao { ) } + @Query("SELECT last_detected_at FROM board_warnings WHERE board_id = :boardId AND kind = :kind") + suspend fun getBoardWarningLastDetectedAt(boardId: String, kind: String): Long? + + @Query("SELECT kind FROM board_warnings WHERE board_id = :boardId") + suspend fun getBoardWarningKinds(boardId: String): List + @Query("DELETE FROM board_warnings WHERE board_id = :boardId AND kind = :kind") - suspend fun deleteBoardWarning(boardId: String, kind: String): Int + suspend fun deleteBoardWarningRow(boardId: String, kind: String): Int + + /** + * Semantic removal, whether the Rider cleared the warning or a detector evaluated the kind with + * real data and found the condition gone — an automatic clear is still a durable state transition + * the server has to make (#282). + * + * Stamped from `last_detected_at` rather than `updated_at`: it is the warning's own change clock, + * and it is what the row's `updated_at` was written from. + */ + @Transaction + suspend fun deleteBoardWarning(boardId: String, kind: String): Int { + appendDeleteAction( + DeleteTarget.BOARD_WARNING, + boardId, + kind, + getBoardWarningLastDetectedAt(boardId, kind), + ) + return deleteBoardWarningRow(boardId, kind) + } @Query("DELETE FROM board_warnings WHERE board_id = :boardId") - suspend fun deleteBoardWarnings(boardId: String): Int + suspend fun deleteBoardWarningsRaw(boardId: String): Int + + /** + * The Rider cleared every warning on one Board: one action per removed row, because each row is a + * separate piece of current state. Distinct from the Board delete's cascade, which is raw. + */ + @Transaction + suspend fun deleteBoardWarnings(boardId: String): Int { + var removed = 0 + for (kind in getBoardWarningKinds(boardId)) removed += deleteBoardWarning(boardId, kind) + return removed + } // Favorites — durable pins over Ride History (ADR 0029). Deleting a row only unpins; telemetry // inside the range is never touched here. @@ -868,16 +1039,21 @@ interface TelemetryDao { @Query("DELETE FROM favorite_media WHERE id = :id") suspend fun deleteFavoriteMedia(id: String): Int + /** Parent-covered cascade: raw, because the Favorite's own action covers its manifest rows. */ @Query("DELETE FROM favorite_media WHERE favorite_id = :favoriteId") - suspend fun deleteFavoriteMediaForFavorite(favoriteId: String): Int + suspend fun deleteFavoriteMediaForFavoriteRaw(favoriteId: String): Int @Query("DELETE FROM favorite_media WHERE favorite_id NOT IN (SELECT id FROM favorites)") suspend fun deleteOrphanFavoriteMedia(): Int - /** Parent-covered raw cascade: media rows and Favorite disappear in one SQLite transaction. */ + /** + * Semantic removal of the Favorite, with its Favorite Media manifest rows as a parent-covered raw + * cascade — one action, not one per media row, matching the server's own cascade. + */ @Transaction suspend fun deleteFavorite(id: String): Int { - deleteFavoriteMediaForFavorite(id) + deleteFavoriteMediaForFavoriteRaw(id) + appendDeleteAction(DeleteTarget.FAVORITE, null, id, getFavoriteUpdatedAt(id)) return deleteFavoriteRow(id) } } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index c149e4463..c958e58b1 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -12,7 +12,7 @@ import java.io.File // @parity /modules/vescape-core/ios/VescapeCoreModule.swift internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" -internal const val TELEMETRY_DATABASE_VERSION = 36 +internal const val TELEMETRY_DATABASE_VERSION = 37 @Database( entities = [ @@ -30,6 +30,7 @@ internal const val TELEMETRY_DATABASE_VERSION = 36 PrivacyZoneEntity::class, BoardWarningEntity::class, SyncSequenceEntity::class, + SyncActionEntity::class, FavoriteEntity::class, FavoriteMediaEntity::class, ], @@ -645,6 +646,33 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * The Sync Action log (#282): an append-only record of semantic removals, which no surviving row + * can express. Additive — a new table only — and guarded, so a re-run is a no-op. + * + * The log is keyed on its own `AUTOINCREMENT` cursor and carries no `sync_seq`: SQLite + * guarantees that key monotonic and never reused, so it already *is* the cursor. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v37_sync_actions` + */ + internal val MIGRATION_36_37 = object : Migration(36, 37) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS sync_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type TEXT NOT NULL, + target TEXT NOT NULL, + board_id TEXT, + key TEXT NOT NULL, + deleted_at INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_sync_actions_target ON sync_actions(target)") + } + } + /** * Telemetry whose `device_id` matches no Board would lose both its identity and its label: * either the Board was hard-deleted before tombstones existed (ADR 0027), or it was re-linked @@ -1051,6 +1079,7 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_33_34, MIGRATION_34_35, MIGRATION_35_36, + MIGRATION_36_37, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index 4b69f7cf6..686ddf575 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -423,6 +423,83 @@ internal val SYNC_SEQ_TABLES_V36 = listOf( */ internal val SYNC_SEQ_TABLES = SYNC_SEQ_TABLES_V33 + SYNC_SEQ_TABLES_V36 +/** + * What a [SyncActionEntity] can name — and, by omission, what it cannot. + * + * Every case is configuration or current state a Rider edits directly. Ride History is absent on + * purpose: Telemetry Samples, markers, minute buckets, exclusion ranges and diagnostic events are + * pruned on a retention rule, and an action naming one of those would make the server delete exactly + * the rides the backup exists to preserve. Leaving them unnameable makes that boundary structural + * rather than a rule someone has to remember (server ADR-0004). + * + * [table] is the local table the case removes from, so a test can assert no retained table is ever + * given a case. + * + * @parity /modules/vescape-core/ios/telemetry/SyncActionLog.swift `DeleteTarget` + * @parity /modules/vescape-core/src/index.ts `DeleteTarget` + */ +enum class DeleteTarget(val wire: String, val table: String) { + APP_SETTING("appSetting", "app_settings"), + BOARD("board", "boards"), + BOARD_SETTING("boardSetting", "board_settings"), + BOARD_WARNING("boardWarning", "board_warnings"), + ALERT("alert", "alerts"), + TUNE_PROFILE("tuneProfile", "tune_profiles"), + PRIVACY_ZONE("privacyZone", "privacy_zones"), + + /** + * Favorites have no server table yet (#286 owns that half), so the uploader drops this case until + * they do. The log still records it: a Favorite removed while the phone is offline has to survive + * as intent, not as a gap the restore silently re-creates. + */ + FAVORITE("favorite", "favorites"), +} + +/** The only Sync Action type today. Named rather than implied so a later intent needs no second log. */ +internal const val SYNC_ACTION_TYPE_DELETE = "delete" + +/** + * One Sync Action: an append-only record that something was semantically removed. A deleted row + * cannot carry a Change Timestamp saying it is gone, so this log is the only signal the server can + * apply the same durable state transition from. + * + * Its cursor is [id] — `AUTOINCREMENT`, which SQLite guarantees monotonic and never reused — so the + * log needs no `sync_seq` of its own. The row is transport state, not durable truth: it is pruned + * once the server has accepted it. + * + * Written only from Rider-facing removal paths, never from a trigger or a retention sweep. Intent + * cannot be inferred from SQL alone, so there is no database trigger behind this table. + * + * @parity /modules/vescape-core/ios/telemetry/SyncActionLog.swift `createSyncActionsTable` + */ +@Entity( + tableName = "sync_actions", + indices = [Index(value = ["target"])], +) +data class SyncActionEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + /** Always [SYNC_ACTION_TYPE_DELETE] today; see [DeleteTarget]. */ + val type: String = SYNC_ACTION_TYPE_DELETE, + /** [DeleteTarget.wire]. */ + val target: String, + /** Owning Board, or null when the target is not Board-owned. A Board names itself in [key]. */ + @ColumnInfo(name = "board_id") + val boardId: String?, + /** The removed row's identity within its scope: a settings key, a warning kind, a row id. */ + val key: String, + /** + * Epoch ms of the removal, stamped `max(now, row.updated_at)` from the row being removed. A plain + * `now` on a rewound clock produces an action the server treats as a no-op, and it cannot + * self-heal by re-sending because the row it would re-send is gone. + */ + @ColumnInfo(name = "deleted_at") + val deletedAt: Long, +) + +/** [SyncSequenceEntity] key holding the highest action cursor the server has accepted. */ +internal const val SYNC_ACTIONS_UPLOADED_CURSOR = "sync_actions_uploaded" + @Entity( tableName = "metric_exclusion_ranges", indices = [ diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt index 7daa07f1c..490aed435 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt @@ -69,7 +69,7 @@ class BoardTombstoneTest { @Test fun migrationTargetsTheCurrentSchemaVersion() { - assertEquals(36, TELEMETRY_DATABASE_VERSION) + assertEquals(37, TELEMETRY_DATABASE_VERSION) assertEquals(33, TelemetryDatabase.MIGRATION_33_34.startVersion) assertEquals(34, TelemetryDatabase.MIGRATION_33_34.endVersion) } @@ -100,7 +100,7 @@ class BoardTombstoneTest { assertFalse("a DELETE on boards survives", dao.contains("DELETE FROM boards")) assertTrue( "the delete path does not stamp a tombstone", - dao.contains("upsertBoard(board.copy(deletedAt = deletedAt, updatedAt = deletedAt))"), + dao.contains("upsertBoard(board.copy(deletedAt = tombstonedAt, updatedAt = tombstonedAt))"), ) } @@ -110,7 +110,7 @@ class BoardTombstoneTest { val dao = daoSource() val body = dao.substringAfter("suspend fun deleteBoardWithSettings").substringBefore("\n }") - for (call in listOf("deleteBoardSettings(id)", "deleteBoardWarnings(id)", "deleteAlertRules(id)")) { + for (call in listOf("deleteBoardSettingsRaw(id)", "deleteBoardWarningsRaw(id)", "deleteAlertRulesRaw(id)")) { assertTrue("the delete path dropped `$call`", body.contains(call)) } } diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncActionLogTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncActionLogTest.kt new file mode 100644 index 000000000..3df67af1c --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncActionLogTest.kt @@ -0,0 +1,231 @@ +package expo.modules.vescapecore.telemetry + +import android.database.Cursor +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.lang.reflect.Proxy + +/** + * The Sync Action log (#282): an append-only record of semantic removals, which no surviving row can + * express. A deleted row cannot carry a Change Timestamp saying it is gone. + * + * Room's `@Query` has BINARY retention and its generated implementation keeps the SQL in a + * method-local string, so a JVM unit test has no runtime handle on the statements Room will run — + * the classification contract is asserted against the DAO source, as in [BoardTombstoneTest]. The + * behavioural half (which action lands, with which stamp) runs against a real database on iOS. + * + * @parity /modules/vescape-core/ios/telemetry/SyncActionLogTests.swift + */ +class SyncActionLogTest { + /** + * Every DAO function that removes rows from a syncable table, and why it is allowed to. + * + * `semantic` appends a Sync Action; `parentCascade` and `maintenance` deliberately do not. A new + * delete has to be classified here before the source scan below will accept it — that is the whole + * point of the map, and it mirrors the server's own structural test. + */ + private val semantic = setOf( + "deletePrivacyZone", + "deleteBoardSetting", + "deleteAlertRule", + "deleteAppSetting", + "deleteTuneProfileSafe", + "deleteBoardWarning", + "deleteBoardWarnings", + "deleteFavorite", + // Tombstones the Board and raw-deletes its configuration under one Board action. + "deleteBoardWithSettings", + ) + + private val parentCascade = setOf( + "deleteBoardSettingsRaw", + "deleteAlertRulesRaw", + "deleteBoardWarningsRaw", + "deleteTuneHistoryForProfileRaw", + "deleteFavoriteMediaForFavoriteRaw", + // The row-level primitives the semantic wrappers above own; never called from outside the DAO. + "deletePrivacyZoneRow", + "deleteBoardSettingRow", + "deleteAlertRuleRow", + "deleteAppSettingRow", + "deleteTuneProfileRow", + "deleteBoardWarningRow", + "deleteFavoriteRow", + ) + + /** Retention, orphan sweeps and the wipe behind a database restore. Never Rider intent. */ + private val maintenance = setOf( + "deleteExclusionsRange", + "clearExclusions", + "deleteExclusionsBefore", + "deleteFramesBefore", + "deleteMarkersBefore", + "deleteBucketsBefore", + "deleteDiagnosticEventsBefore", + "deleteBefore", + "deleteFramesRange", + "deleteMarkersRange", + "deleteBucketsRange", + "deleteRange", + "deleteFramesRangeAllDevices", + "deleteMarkersRangeAllDevices", + "deleteBucketsRangeAllDevices", + "deleteRangeAllDevices", + "clearFrames", + "clearMarkers", + "clearBuckets", + "clearDiagnosticEvents", + "clearAll", + "deleteFavoriteMedia", + "deleteOrphanFavoriteMedia", + // Transport state, pruned only behind the accepted-action cursor. + "deleteSyncActionsThrough", + "pruneUploadedSyncActions", + ) + + private fun daoSource(): String = + File("src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt").readText() + + /** + * DAO source split into `fun name` -> that declaration, its body, and the annotation block above + * it — `@Query` is where a raw delete keeps its SQL. + */ + private fun daoFunctions(): Map { + val source = daoSource() + val starts = Regex("suspend fun (\\w+)").findAll(source).toList() + return starts.mapIndexed { index, match -> + val next = starts.getOrNull(index + 1)?.range?.first ?: source.length + var chunk = source.substring(match.range.first, next) + // Trailing text belongs to the next declaration's annotations, not to this body. + chunk.lastIndexOf("\n @").takeIf { it >= 0 }?.let { chunk = chunk.substring(0, it) } + val annotations = source.substring(0, match.range.first) + .substringAfterLast("\n\n") + match.groupValues[1] to annotations + chunk + }.toMap() + } + + private fun deletingFunctions(): Map = + daoFunctions().filter { (_, body) -> body.contains("DELETE FROM") } + + @Test + fun `every delete against a table is classified`() { + val classified = semantic + parentCascade + maintenance + val unclassified = deletingFunctions().keys - classified + assertTrue( + "Unclassified deletes in TelemetryDao: $unclassified — classify each as semantic, " + + "parent cascade or maintenance", + unclassified.isEmpty(), + ) + val stale = classified - daoFunctions().keys + assertTrue("Classified names that no longer exist: $stale", stale.isEmpty()) + } + + @Test + fun `semantic removals append an action and maintenance never does`() { + val functions = daoFunctions() + for (name in semantic) { + val body = functions.getValue(name) + // Either it appends the action itself, or it delegates to a wrapper that does — a clear-all is + // one action per removed row, not one for the sweep. + val delegates = (semantic - name).any { body.contains("$it(") } + assertTrue( + "$name is classified semantic but appends no Sync Action", + body.contains("appendDeleteAction") || delegates, + ) + } + for (name in parentCascade + maintenance) { + assertTrue( + "$name is a raw delete but appends a Sync Action", + !functions.getValue(name).contains("appendDeleteAction"), + ) + } + } + + /** + * The retention boundary, made structural: a target can only name configuration or current state. + * Giving one of the pruned tables a target would make the server delete exactly the rides the + * backup exists to preserve. Mirrors the server's `DELETE_ACTION_TARGETS` test. + */ + @Test + fun `no retained table can be named by a delete target`() { + val retained = setOf( + "telemetry_frames", + "telemetry_markers", + "telemetry_minute_buckets", + "metric_exclusion_ranges", + "diagnostic_events", + "tune_history_entries", + "favorite_media", + "sync_actions", + "sync_sequences", + ) + val named = DeleteTarget.entries.map { it.table }.toSet() + assertEquals(emptySet(), named intersect retained) + assertEquals(DeleteTarget.entries.size, named.size) + } + + /** The log is append-only and keyed on its own cursor; no trigger writes it. */ + @Test + fun `migration creates the log keyed on an autoincrement cursor`() { + val sql = migrationSql(TelemetryDatabase.MIGRATION_36_37).joinToString("\n") + assertTrue(sql, sql.contains("CREATE TABLE IF NOT EXISTS sync_actions")) + assertTrue(sql, sql.contains("id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL")) + assertTrue(sql, sql.contains("deleted_at INTEGER NOT NULL")) + assertTrue("the log must not be driven by a trigger", !sql.contains("CREATE TRIGGER")) + } + + /** Additive and guarded, so re-running the migration is a no-op rather than a duplicate table. */ + @Test + fun `migration is additive and re-runnable`() { + val sql = migrationSql(TelemetryDatabase.MIGRATION_36_37) + assertTrue(sql.isNotEmpty()) + for (statement in sql) { + assertTrue("not guarded: $statement", statement.contains("IF NOT EXISTS")) + assertTrue("not additive: $statement", !statement.contains("DROP ") && !statement.contains("DELETE ")) + } + } + + /** The accepted cursor is checkpointed first; pruning reads it back rather than trusting a caller. */ + @Test + fun `pruning is gated on the committed cursor`() { + val prune = daoFunctions().getValue("pruneUploadedSyncActions") + assertTrue(prune, prune.contains("getSyncSequence(SYNC_ACTIONS_UPLOADED_CURSOR)")) + val commit = daoFunctions().getValue("commitSyncActionCursorRow") + assertTrue("the cursor must never move backwards", commit.contains("MAX(:value")) + } + + private fun migrationSql(migration: Migration): List { + val sql = mutableListOf() + val db = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + sql += args?.firstOrNull() as String + null + } + "query" -> emptyCursor() + else -> throw UnsupportedOperationException(method.name) + } + } as SupportSQLiteDatabase + migration.migrate(db) + return sql + } + + private fun emptyCursor(): Cursor = Proxy.newProxyInstance( + Cursor::class.java.classLoader, + arrayOf(Cursor::class.java), + ) { _, method, _ -> + when (method.name) { + "getColumnIndex" -> 0 + "moveToNext" -> false + "close" -> null + else -> throw UnsupportedOperationException(method.name) + } + } as Cursor +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt index 6138a42b2..a288b3c1e 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt @@ -98,7 +98,7 @@ class SyncCursorMigrationTest { @Test fun migrationsTargetTheCurrentSchemaVersion() { - assertEquals(36, TELEMETRY_DATABASE_VERSION) + assertEquals(37, TELEMETRY_DATABASE_VERSION) assertEquals(31, TelemetryDatabase.MIGRATION_31_32.startVersion) assertEquals(32, TelemetryDatabase.MIGRATION_31_32.endVersion) assertEquals(32, TelemetryDatabase.MIGRATION_32_33.startVersion) diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt index 10dfa650f..bcfbb409a 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt @@ -63,7 +63,7 @@ class TelemetryBoardIdMigrationTest { @Test fun migrationTargetsTheCurrentSchemaVersion() { - assertEquals(36, TELEMETRY_DATABASE_VERSION) + assertEquals(37, TELEMETRY_DATABASE_VERSION) assertEquals(34, TelemetryDatabase.MIGRATION_34_35.startVersion) assertEquals(35, TelemetryDatabase.MIGRATION_34_35.endVersion) } diff --git a/modules/vescape-core/ios/telemetry/AppDataRepository.swift b/modules/vescape-core/ios/telemetry/AppDataRepository.swift index 41baac253..3ed65dc04 100644 --- a/modules/vescape-core/ios/telemetry/AppDataRepository.swift +++ b/modules/vescape-core/ios/telemetry/AppDataRepository.swift @@ -181,7 +181,17 @@ final class AppDataRepository { ) for (key, value) in settings { guard let value, let json = Self.encodeJson(value) else { - try db.execute(sql: "DELETE FROM board_settings WHERE board_id = ? AND key = ?", arguments: [id, key]) + // Semantic removal: a Board edit that drops a key is the Rider clearing that setting, so a + // restore must not resurrect the old value (#282). + try deleteForSync( + db, + target: .boardSetting, + boardId: id, + key: key, + whereClause: "board_id = ? AND key = ?", + keys: [id, key], + now: updatedAt + ) continue } try Self.writeBoardSetting(db, boardId: id, key: key, json: json, now: updatedAt) @@ -199,21 +209,31 @@ final class AppDataRepository { func deleteBoard(_ id: String) { let deletedAt = nowMs() write { db in - try db.execute(sql: "DELETE FROM board_settings WHERE board_id = ?", arguments: [id]) - try db.execute(sql: "DELETE FROM board_warnings WHERE board_id = ?", arguments: [id]) - // Alert Rules are Board-owned (#254) — drop them with the Board so no orphan rows survive. - try db.execute(sql: "DELETE FROM alerts WHERE board_id = ?", arguments: [id]) guard let row = try Row.fetchOne( db, sql: "SELECT updated_at, deleted_at FROM boards WHERE id = ?", arguments: [id] ), row["deleted_at"] as Int64? == nil else { return } + // The children are raw deletes — the Board's own Sync Action covers the whole cascade, so an + // upsert never quietly deletes rows in three other tables (#282). + try db.execute(sql: "DELETE FROM board_settings WHERE board_id = ?", arguments: [id]) + try db.execute(sql: "DELETE FROM board_warnings WHERE board_id = ?", arguments: [id]) + // Alert Rules are Board-owned (#254) — drop them with the Board so no orphan rows survive. + try db.execute(sql: "DELETE FROM alerts WHERE board_id = ?", arguments: [id]) + // The action and the tombstone share one timestamp, the newly ratcheted `updated_at`, so the + // server judges both against the same moment. + let tombstonedAt = ratchetUpdatedAt(row["updated_at"] as Int64?, deletedAt) + try appendDeleteAction( + db, + target: .board, + boardId: nil, + key: id, + rowStamp: tombstonedAt, + now: tombstonedAt + ) try db.execute( sql: "UPDATE boards SET deleted_at = ?, updated_at = ?, sync_seq = ? WHERE id = ?", - arguments: [ - deletedAt, ratchetUpdatedAt(row["updated_at"] as Int64?, deletedAt), - try nextSyncSeq(db, syncSeqBoards), id, - ] + arguments: [tombstonedAt, tombstonedAt, try nextSyncSeq(db, syncSeqBoards), id] ) } notifyDataChanged(.boards) @@ -438,9 +458,19 @@ final class AppDataRepository { } } + /// Semantic removal, and the path preset regeneration takes too: JS regenerates a Board's preset + /// rules by deleting the old ones and writing new ones, and the deleted ones have to disappear + /// server-side as well (#282). func deleteAlertRule(_ boardId: String, _ id: String) { write { db in - try db.execute(sql: "DELETE FROM alerts WHERE board_id = ? AND id = ?", arguments: [boardId, id]) + try deleteForSync( + db, + target: .alert, + boardId: boardId, + key: id, + whereClause: "board_id = ? AND id = ?", + keys: [boardId, id] + ) } } @@ -512,8 +542,18 @@ final class AppDataRepository { } } + /// Semantic removal: the Rider deleted the zone, so the server has to lose it too (#282). func deletePrivacyZone(_ id: String) { - write { db in try db.execute(sql: "DELETE FROM privacy_zones WHERE id = ?", arguments: [id]) } + write { db in + try deleteForSync( + db, + target: .privacyZone, + boardId: nil, + key: id, + whereClause: "id = ?", + keys: [id] + ) + } } // MARK: - Direction point @@ -554,7 +594,7 @@ final class AppDataRepository { guard key != "legalPolicy", key != "legalMode" else { return } let updatedAt = nowMs() guard let rawValue, !(rawValue is NSNull) else { - write { db in try db.execute(sql: "DELETE FROM app_settings WHERE key = ?", arguments: [key]) } + write { db in try Self.deleteAppSetting(db, key: key, now: updatedAt) } notifyDataChanged(.settings) return } @@ -596,7 +636,7 @@ final class AppDataRepository { let value = code.flatMap { $0.count == 2 ? ["jurisdictionCode": $0] : nil } write { db in guard let value, let json = Self.encodeJson(value) else { - try db.execute(sql: "DELETE FROM app_settings WHERE key = 'legalPolicy'") + try Self.deleteAppSetting(db, key: "legalPolicy", now: self.nowMs()) return } try Self.writeAppSetting(db, key: "legalPolicy", json: json, now: self.nowMs()) @@ -630,6 +670,28 @@ final class AppDataRepository { ) } + /// Semantic removal of a stored app setting. Every caller means the same thing — the stored + /// override is gone: an edit back to the default, and `legalPolicy` resolving to nothing. + /// + /// Phone-local keys never reach the server (they carry `sync_seq = 0`), so removing one records no + /// action either — an action for a row the server never held would delete nothing and say nothing. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `deleteAppSetting` + internal static func deleteAppSetting(_ db: Database, key: String, now: Int64) throws { + guard !notSyncedSettingKeys.contains(key) else { + try db.execute(sql: "DELETE FROM app_settings WHERE key = ?", arguments: [key]) + return + } + try deleteForSync( + db, + target: .appSetting, + boardId: nil, + key: key, + whereClause: "key = ?", + keys: [key], + now: now + ) + } + /// Stamps both sync columns like `upsertBoard`, except for the phone-local keys in /// [notSyncedSettingKeys]: those keep `sync_seq` at 0, which sits below every Sync Cursor, so the /// upload scan never picks the row up and the key stays on this phone (#277). diff --git a/modules/vescape-core/ios/telemetry/FavoriteStore.swift b/modules/vescape-core/ios/telemetry/FavoriteStore.swift index 3a9d6abfe..ea5bf694c 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStore.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStore.swift @@ -158,6 +158,7 @@ struct FavoriteStore { try db.execute(sql: "CREATE INDEX index_favorites_board_id ON favorites(board_id)") try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_favorites_sync_seq ON favorites(sync_seq)") try createSyncSequencesTable(db) + try createSyncActionsTable(db) } // MARK: - Reads @@ -234,15 +235,23 @@ struct FavoriteStore { } /// Unpin one Favorite. Telemetry inside its range is untouched and becomes deletable again. - /// Favorite Media rows are parent-covered and raw-deleted in the same transaction (ADR 0030); + /// Emits one Sync Action for the Favorite; its Favorite Media rows emit none, because the parent + /// action covers them. Favorite Media rows are parent-covered and raw-deleted in the same + /// transaction (ADR 0030); /// filesystem cleanup is best-effort in the repository after this commit succeeds. @discardableResult func delete(_ id: String) -> Bool { guard let writer = resolveWriter() else { return false } return (try? writer.write { db in try db.execute(sql: "DELETE FROM favorite_media WHERE favorite_id = ?", arguments: [id]) - try db.execute(sql: "DELETE FROM favorites WHERE id = ?", arguments: [id]) - return db.changesCount > 0 + return try deleteForSync( + db, + target: .favorite, + boardId: nil, + key: id, + whereClause: "id = ?", + keys: [id] + ) }) ?? false } diff --git a/modules/vescape-core/ios/telemetry/SyncActionLog.swift b/modules/vescape-core/ios/telemetry/SyncActionLog.swift new file mode 100644 index 000000000..a7144a30e --- /dev/null +++ b/modules/vescape-core/ios/telemetry/SyncActionLog.swift @@ -0,0 +1,191 @@ +import Foundation +import GRDB + +/// What a Sync Action can name — and, by omission, what it cannot. +/// +/// Every case is configuration or current state a Rider edits directly. Ride History is absent on +/// purpose: Telemetry Samples, markers, minute buckets, exclusion ranges and diagnostic events are +/// pruned on a retention rule, and an action naming one of those would make the server delete +/// exactly the rides the backup exists to preserve. Leaving them unnameable makes that boundary +/// structural rather than a rule someone has to remember (server ADR-0004). +/// +/// `table` is the local table the case removes from, so a test can assert no retained table is ever +/// given a case. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `DeleteTarget` +/// @parity /modules/vescape-core/src/index.ts `DeleteTarget` +enum DeleteTarget: String, CaseIterable { + case appSetting + case board + case boardSetting + case boardWarning + case alert + case tuneProfile + case privacyZone + /// Favorites have no server table yet (#286 owns that half), so the uploader drops this case until + /// they do. The log still records it: a Favorite removed while the phone is offline has to survive + /// as intent, not as a gap the restore silently re-creates. + case favorite + + var table: String { + switch self { + case .appSetting: return "app_settings" + case .board: return "boards" + case .boardSetting: return "board_settings" + case .boardWarning: return "board_warnings" + case .alert: return "alerts" + case .tuneProfile: return "tune_profiles" + case .privacyZone: return "privacy_zones" + case .favorite: return "favorites" + } + } +} + +/// The only Sync Action type today. Named rather than implied so a later intent needs no second log. +internal let syncActionTypeDelete = "delete" + +/// `sync_sequences` key holding the highest action cursor the server has accepted. +internal let syncActionsUploadedCursor = "sync_actions_uploaded" + +/// The Sync Action log: an append-only record that something was semantically removed. A deleted row +/// cannot carry a Change Timestamp saying it is gone, so this log is the only signal the server can +/// apply the same durable state transition from. +/// +/// Its cursor is `id` — `AUTOINCREMENT`, which SQLite guarantees monotonic and never reused — so the +/// log needs no `sync_seq` of its own. Rows are transport state, not durable truth: they are pruned +/// once the server has accepted them. +/// +/// Idempotent, and called both from the migration that introduced it and from the store-level +/// `createTables` seams tests build their schema from. No database trigger writes here — intent +/// cannot be inferred from SQL alone. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `SyncActionEntity` +internal func createSyncActionsTable(_ db: Database) throws { + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS sync_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type TEXT NOT NULL, + target TEXT NOT NULL, + board_id TEXT, + key TEXT NOT NULL, + deleted_at INTEGER NOT NULL + ) + """ + ) + try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_sync_actions_target ON sync_actions(target)") +} + +/// Record that [target] identified by `boardId`/`key` was semantically removed. +/// +/// `rowStamp` is the removed row's own change timestamp, read before the delete: the action is +/// stamped `max(now, rowStamp)` so a rewound device clock cannot produce an action the server reads +/// as older than the row it names — that action would be dropped as a no-op, and the phone could not +/// self-heal by re-sending, because the row is gone. +/// +/// A nil `rowStamp` means there was no row to remove, so no intent to record either. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `appendDeleteAction` +internal func appendDeleteAction( + _ db: Database, + target: DeleteTarget, + boardId: String?, + key: String, + rowStamp: Int64?, + now: Int64 = telemetryNowMs() +) throws { + guard let rowStamp else { return } + try db.execute( + sql: """ + INSERT INTO sync_actions (type, target, board_id, key, deleted_at) + VALUES (?, ?, ?, ?, ?) + """, + arguments: [syncActionTypeDelete, target.rawValue, boardId, key, max(now, rowStamp)] + ) +} + +/// The one semantic-removal primitive: read the row's change timestamp, append its action, delete +/// the row — all inside the caller's transaction, so the action and the delete commit together or +/// not at all. +/// +/// `stampColumn` is the row's own change clock: `updated_at` everywhere except Board Warnings, whose +/// `last_detected_at` is what their `updated_at` was written from. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `appendDeleteAction` +@discardableResult +internal func deleteForSync( + _ db: Database, + target: DeleteTarget, + boardId: String?, + key: String, + whereClause: String, + keys: StatementArguments, + stampColumn: String = "updated_at", + now: Int64 = telemetryNowMs() +) throws -> Bool { + let stamp = try Int64.fetchOne( + db, + sql: "SELECT \(stampColumn) FROM \(target.table) WHERE \(whereClause)", + arguments: keys + ) + try appendDeleteAction(db, target: target, boardId: boardId, key: key, rowStamp: stamp, now: now) + try db.execute(sql: "DELETE FROM \(target.table) WHERE \(whereClause)", arguments: keys) + return db.changesCount > 0 +} + +/// One Sync Action as it leaves the phone. The uploader (#284) owns the batching; this is the read +/// shape it pages through. +struct SyncAction { + let id: Int64 + let type: String + let target: String + let boardId: String? + let key: String + let deletedAt: Int64 +} + +/// The next page of actions to upload, in cursor order. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `getSyncActionsAfter` +internal func syncActionsAfter(_ db: Database, _ afterId: Int64, limit: Int) throws -> [SyncAction] { + try Row.fetchAll( + db, + sql: "SELECT * FROM sync_actions WHERE id > ? ORDER BY id ASC LIMIT ?", + arguments: [afterId, limit] + ).map { row in + SyncAction( + id: row["id"], + type: row["type"], + target: row["target"], + boardId: row["board_id"], + key: row["key"], + deletedAt: row["deleted_at"] + ) + } +} + +/// Checkpoint the highest action cursor the server has accepted, in its own transaction, committed +/// before `pruneUploadedSyncActions` runs: a crash between the two leaves rows that will be sent +/// again — harmless, since applying an action twice is a no-op — whereas pruning first would drop an +/// action nobody has accepted. Never moves backwards. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `commitSyncActionCursor` +internal func commitSyncActionCursor(_ db: Database, throughId: Int64) throws { + try db.execute( + sql: """ + INSERT OR REPLACE INTO sync_sequences (name, last_value) + VALUES (?, MAX(?, COALESCE((SELECT last_value FROM sync_sequences WHERE name = ?), 0))) + """, + arguments: [syncActionsUploadedCursor, throughId, syncActionsUploadedCursor] + ) +} + +/// Drop what the server has already accepted. Gated on the committed cursor rather than a caller's +/// number, so pruning structurally cannot outrun the checkpoint. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `pruneUploadedSyncActions` +@discardableResult +internal func pruneUploadedSyncActions(_ db: Database) throws -> Int { + guard let accepted = try Int64.fetchOne( + db, + sql: "SELECT last_value FROM sync_sequences WHERE name = ?", + arguments: [syncActionsUploadedCursor] + ) else { return 0 } + try db.execute(sql: "DELETE FROM sync_actions WHERE id <= ?", arguments: [accepted]) + return db.changesCount +} diff --git a/modules/vescape-core/ios/telemetry/SyncActionLogTests.swift b/modules/vescape-core/ios/telemetry/SyncActionLogTests.swift new file mode 100644 index 000000000..33bda926a --- /dev/null +++ b/modules/vescape-core/ios/telemetry/SyncActionLogTests.swift @@ -0,0 +1,385 @@ +import XCTest +import GRDB +@testable import VescapeCore + +/// The Sync Action log (#282): an append-only record of semantic removals, which no surviving row +/// can express. A deleted row cannot carry a Change Timestamp saying it is gone. +/// +/// Runs the real migrator and the real repositories/stores against an in-memory database. The +/// Android peer asserts the same classification against the DAO source, because Room keeps its SQL +/// out of reach of a JVM unit test. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncActionLogTest.kt +final class SyncActionLogTests: XCTestCase { + private var queue: DatabaseQueue! + private var repo: AppDataRepository! + + override func setUpWithError() throws { + queue = try DatabaseQueue() + try TelemetryDatabase.migrator.migrate(queue) + repo = AppDataRepository.forTesting(dbWriter: queue) + } + + override func tearDownWithError() throws { + repo = nil + queue = nil + } + + // MARK: Helpers + + private func actions() throws -> [SyncAction] { + try queue.read { db in try syncActionsAfter(db, 0, limit: 100) } + } + + private func seedBoard(_ id: String = "board-1") { + repo.upsertBoard([ + "id": id, + "name": "ADV", + "createdAt": Int64(1000), + "description": "trail board", + "link": ["bleId": "AA:BB", "transport": "direct"] as [String: Any?], + ]) + } + + private func seedFavorite(_ id: String = "fav-1") { + FavoriteStore(dbWriter: queue).insert( + Favorite( + id: id, + boardId: "board-1", + name: "commute", + startMs: 1000, + endMs: 2000, + createdAtMs: 1000, + updatedAtMs: 1000, + summary: FavoriteSummary() + ) + ) + } + + private func seedWarning(kind: String, lastDetectedAt: Int64) { + BoardWarningStore(dbWriter: queue).upsert( + BoardWarning( + boardId: "board-1", + kind: kind, + severity: "warn", + firstDetectedAtMs: 500, + lastDetectedAtMs: lastDetectedAt, + payloadJson: "{}" + ) + ) + } + + // MARK: The seven Rider-facing removals + + func testDeletingAnAlertRuleEmitsOneAction() throws { + seedBoard() + repo.upsertAlertRule(["boardId": "board-1", "id": "rule-1", "controlId": "speed"]) + + repo.deleteAlertRule("board-1", "rule-1") + + let actions = try self.actions() + XCTAssertEqual(actions.count, 1) + XCTAssertEqual(actions.first?.type, "delete") + XCTAssertEqual(actions.first?.target, DeleteTarget.alert.rawValue) + XCTAssertEqual(actions.first?.boardId, "board-1") + XCTAssertEqual(actions.first?.key, "rule-1") + } + + func testDeletingAPrivacyZoneEmitsOneAction() throws { + repo.upsertPrivacyZone([ + "id": "zone-1", "name": "home", "centerLatitude": 52.0, "centerLongitude": 21.0, + "radiusMeters": Int64(100), + ]) + + repo.deletePrivacyZone("zone-1") + + XCTAssertEqual(try actions().map { ($0.target, $0.key) }.map { "\($0.0):\($0.1)" }, ["privacyZone:zone-1"]) + } + + func testResettingAnAppSettingToItsDefaultEmitsAnAction() throws { + repo.updateSetting("telemetryPollRateHz", rawValue: 50) + + repo.updateSetting("telemetryPollRateHz", rawValue: nil) + + let actions = try self.actions() + XCTAssertEqual(actions.count, 1) + XCTAssertEqual(actions.first?.target, DeleteTarget.appSetting.rawValue) + XCTAssertNil(actions.first?.boardId, "an app setting is not Board-owned") + XCTAssertEqual(actions.first?.key, "telemetryPollRateHz") + } + + /// A phone-local key never reaches the server, so its removal has nothing to tell the server about. + func testRemovingAPhoneLocalSettingEmitsNoAction() throws { + repo.updateSetting("selectedBoardId", rawValue: "board-1") + + repo.updateSetting("selectedBoardId", rawValue: nil) + + XCTAssertEqual(try actions().count, 0) + XCTAssertNil(try queue.read { db in + try String.fetchOne(db, sql: "SELECT value_json FROM app_settings WHERE key = 'selectedBoardId'") + }) + } + + func testClearingLegalPolicyEmitsAnAppSettingAction() throws { + repo.updateLegalPolicy(jurisdictionCode: "PL") + + repo.updateLegalPolicy(jurisdictionCode: nil) + + XCTAssertEqual(try actions().map(\.key), ["legalPolicy"]) + XCTAssertEqual(try actions().map(\.target), [DeleteTarget.appSetting.rawValue]) + } + + /// A Board edit that drops a key is the Rider clearing that setting. + func testDroppingABoardSettingKeyEmitsAnAction() throws { + seedBoard() + + repo.upsertBoard([ + "id": "board-1", + "name": "ADV", + "createdAt": Int64(1000), + "description": "", + "link": ["bleId": "AA:BB", "transport": "direct"] as [String: Any?], + ]) + + let actions = try self.actions() + XCTAssertEqual(actions.map(\.target), [DeleteTarget.boardSetting.rawValue]) + XCTAssertEqual(actions.first?.boardId, "board-1") + XCTAssertEqual(actions.first?.key, "description") + } + + func testDeletingATuneProfileEmitsOneActionAndItsHistoryNone() throws { + let store = TuneProfileStore(dbWriter: queue) + _ = try store.createProfile( + boardId: "board-1", name: "keep", icon: "sliders-horizontal", color: "purple", + fields: [:], refloatBaseVersion: "1.3.0" + ) + let doomed = try store.createProfile( + boardId: "board-1", name: "drop", icon: "sliders-horizontal", color: "purple", + fields: [:], refloatBaseVersion: "1.3.0" + ) + let id = doomed["id"] as! String + + try store.deleteProfile(profileId: id) + + let actions = try self.actions() + XCTAssertEqual(actions.count, 1, "Tune History is parent-covered") + XCTAssertEqual(actions.first?.target, DeleteTarget.tuneProfile.rawValue) + XCTAssertEqual(actions.first?.key, id) + } + + func testDeletingAFavoriteEmitsOneActionAndItsMediaNone() throws { + seedFavorite() + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO favorite_media (id, favorite_id, captured_at, mime_type, media_kind, byte_count, content_hash, created_at) + VALUES ('media-1', 'fav-1', NULL, 'image/jpeg', 'photo', 10, 'hash', 1000) + """ + ) + } + + XCTAssertTrue(FavoriteStore(dbWriter: queue).delete("fav-1")) + + let actions = try self.actions() + XCTAssertEqual(actions.count, 1, "the Favorite's action covers its manifest rows") + XCTAssertEqual(actions.first?.target, DeleteTarget.favorite.rawValue) + XCTAssertEqual(actions.first?.key, "fav-1") + } + + /// An automatic clear after a clean detector evaluation is still a durable state transition. + func testClearingABoardWarningEmitsAnActionStampedFromItsDetection() throws { + seedWarning(kind: "cell-spread", lastDetectedAt: 9_000_000_000_000) + + XCTAssertTrue(BoardWarningStore(dbWriter: queue).delete("board-1", "cell-spread")) + + let actions = try self.actions() + XCTAssertEqual(actions.count, 1) + XCTAssertEqual(actions.first?.target, DeleteTarget.boardWarning.rawValue) + XCTAssertEqual(actions.first?.boardId, "board-1") + XCTAssertEqual(actions.first?.key, "cell-spread") + XCTAssertEqual( + actions.first?.deletedAt, 9_000_000_000_000, + "a detection in the future outranks the wall clock, or the server drops the action" + ) + } + + /// Clearing every warning on a Board is one action per row — each row is its own current state. + func testClearingAllWarningsForABoardEmitsOneActionPerRow() throws { + seedWarning(kind: "cell-spread", lastDetectedAt: 1_000) + seedWarning(kind: "footpad-disabled", lastDetectedAt: 2_000) + + XCTAssertTrue(BoardWarningStore(dbWriter: queue).deleteForBoard("board-1")) + + XCTAssertEqual(try actions().map(\.key).sorted(), ["cell-spread", "footpad-disabled"]) + } + + // MARK: The Board tombstone + + func testDeletingABoardEmitsOneActionAndNoneForItsCascade() throws { + seedBoard() + repo.upsertAlertRule(["boardId": "board-1", "id": "rule-1", "controlId": "speed"]) + seedWarning(kind: "cell-spread", lastDetectedAt: 1_000) + + repo.deleteBoard("board-1") + + let actions = try self.actions() + XCTAssertEqual(actions.map(\.target), [DeleteTarget.board.rawValue]) + XCTAssertEqual(actions.first?.key, "board-1") + XCTAssertNil(actions.first?.boardId, "a Board is Account-owned; it names itself in `key`") + + let row = try queue.read { db in + try Row.fetchOne(db, sql: "SELECT deleted_at, updated_at FROM boards WHERE id = 'board-1'") + } + XCTAssertEqual(row?["deleted_at"] as Int64?, actions.first?.deletedAt) + XCTAssertEqual(row?["updated_at"] as Int64?, actions.first?.deletedAt) + } + + // MARK: Stamping + + /// A rewound clock would otherwise stamp the action below the row the server already holds, and + /// the action would be dropped as a no-op with no row left to re-send. + func testDeletionStampNeverFallsBelowTheRemovedRow() throws { + let future: Int64 = 9_000_000_000_000 + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO privacy_zones + (id, preset, name, enabled, center_latitude_e7, center_longitude_e7, radius_meters, created_at, updated_at, sync_seq) + VALUES ('zone-1', 'custom', 'home', 1, 0, 0, 100, 0, ?, 1) + """, + arguments: [future] + ) + } + + repo.deletePrivacyZone("zone-1") + + XCTAssertEqual(try actions().first?.deletedAt, future) + } + + /// Nothing removed, nothing to say. + func testDeletingAMissingRowEmitsNoAction() throws { + repo.deletePrivacyZone("does-not-exist") + repo.deleteAlertRule("board-1", "missing") + + XCTAssertEqual(try actions().count, 0) + } + + // MARK: Retention + + /// The whole reason Ride History has no target: a retention sweep must not reach this log. + func testRetentionWritesNoActions() throws { + try queue.write { db in + try db.execute( + sql: "INSERT INTO telemetry_markers (occurred_at_ms, elapsed_realtime_ms, type) VALUES (1, 1, 'start')" + ) + try db.execute(sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms < 100") + } + + XCTAssertEqual(try actions().count, 0) + } + + // MARK: Cursor and pruning + + /// The accepted cursor commits first; pruning reads it back rather than trusting a caller. A crash + /// between the two re-sends an action, which is a no-op — pruning first would lose one. + func testPruningOnlyRemovesWhatTheCursorHasAccepted() throws { + seedFavorite("fav-1") + seedFavorite("fav-2") + FavoriteStore(dbWriter: queue).delete("fav-1") + FavoriteStore(dbWriter: queue).delete("fav-2") + let logged = try actions() + XCTAssertEqual(logged.count, 2) + + XCTAssertEqual(try queue.write { db in try pruneUploadedSyncActions(db) }, 0) + XCTAssertEqual(try actions().count, 2, "nothing is accepted yet") + + try queue.write { db in try commitSyncActionCursor(db, throughId: logged[0].id) } + XCTAssertEqual(try queue.write { db in try pruneUploadedSyncActions(db) }, 1) + XCTAssertEqual(try actions().map(\.id), [logged[1].id]) + } + + func testTheAcceptedCursorNeverMovesBackwards() throws { + try queue.write { db in + try commitSyncActionCursor(db, throughId: 10) + try commitSyncActionCursor(db, throughId: 3) + } + + let cursor = try queue.read { db in + try Int64.fetchOne( + db, + sql: "SELECT last_value FROM sync_sequences WHERE name = ?", + arguments: [syncActionsUploadedCursor] + ) + } + XCTAssertEqual(cursor, 10) + } + + // MARK: Schema + + func testTheLogIsKeyedOnAnAutoincrementCursor() throws { + let sql = try queue.read { db in + try String.fetchOne(db, sql: "SELECT sql FROM sqlite_master WHERE name = 'sync_actions'") + } + XCTAssertTrue(sql?.contains("AUTOINCREMENT") ?? false, sql ?? "missing sync_actions") + + let triggers = try queue.read { db in + try String.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type = 'trigger'") + } + XCTAssertEqual(triggers, [], "intent cannot be inferred from SQL — no trigger writes the log") + } + + func testMigrationIsANoOpOnReRun() throws { + XCTAssertNoThrow(try TelemetryDatabase.migrator.migrate(queue)) + } + + /// The retention boundary, made structural: a target can only name configuration or current state. + /// Mirrors the server's `DELETE_ACTION_TARGETS` test. + func testNoRetainedTableCanBeNamedByADeleteTarget() { + let retained: Set = [ + "telemetry_frames", + "telemetry_markers", + "telemetry_minute_buckets", + "metric_exclusion_ranges", + "diagnostic_events", + "tune_history_entries", + "favorite_media", + "sync_actions", + "sync_sequences", + ] + let named = Set(DeleteTarget.allCases.map(\.table)) + + XCTAssertEqual(named.intersection(retained), []) + XCTAssertEqual(named.count, DeleteTarget.allCases.count) + } + + /// Every raw `DELETE FROM` against a syncable table has to sit in a file that is allowed to write + /// one — the delete-owning stores, where each statement is either parent-covered or maintenance. + /// A new raw delete elsewhere fails here rather than silently skipping the log. + func testEverySyncableDeleteLivesInADeleteOwningStore() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + let owners: Set = [ + "AppDataRepository.swift", + "TuneProfileStore.swift", + "FavoriteStore.swift", + "BoardWarningStore.swift", + "SyncActionLog.swift", + // Schema migrations are maintenance: they rewrite what a table holds, never Rider intent. + "TelemetryDatabase.swift", + ] + let syncable = Set(DeleteTarget.allCases.map(\.table)) + + let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)? + .compactMap { $0 as? URL } + .filter { $0.pathExtension == "swift" && !$0.lastPathComponent.hasSuffix("Tests.swift") } ?? [] + + for file in files where !owners.contains(file.lastPathComponent) { + let source = try String(contentsOf: file, encoding: .utf8) + for table in syncable { + XCTAssertFalse( + source.contains("DELETE FROM \(table)"), + "\(file.lastPathComponent) deletes from \(table) outside a Sync Action-owning store" + ) + } + } + } +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 5fd58db2d..82efb25cb 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -558,6 +558,16 @@ enum TelemetryDatabase { ) } + // The Sync Action log (#282): an append-only record of semantic removals, which no surviving row + // can express. Additive — a new table only — and guarded, so a re-run is a no-op. + // + // The log is keyed on its own `AUTOINCREMENT` cursor and carries no `sync_seq`: SQLite + // guarantees that key monotonic and never reused, so it already *is* the cursor. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_36_37` + migrator.registerMigration("v37_sync_actions") { db in + try createSyncActionsTable(db) + } + return migrator } } diff --git a/modules/vescape-core/ios/telemetry/TuneProfileStore.swift b/modules/vescape-core/ios/telemetry/TuneProfileStore.swift index b1822293c..a62c73dc9 100644 --- a/modules/vescape-core/ios/telemetry/TuneProfileStore.swift +++ b/modules/vescape-core/ios/telemetry/TuneProfileStore.swift @@ -85,6 +85,7 @@ struct TuneProfileStore { try db.execute(sql: "CREATE INDEX index_tune_profiles_board_id ON tune_profiles(board_id)") try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_tune_profiles_sync_seq ON tune_profiles(sync_seq)") try createSyncSequencesTable(db) + try createSyncActionsTable(db) try db.execute(sql: "CREATE INDEX index_tune_profiles_board_id_refloat_base_version ON tune_profiles(board_id, refloat_base_version)") try db.execute(sql: """ @@ -206,8 +207,17 @@ struct TuneProfileStore { arguments: [boardId, row["refloat_base_version"] as String] ) ?? 0 if count <= 1 { throw TuneProfileError.cannotDeleteLast } + // Tune History is a parent-covered cascade: raw, because the profile's own Sync Action + // covers it (#282). try db.execute(sql: "DELETE FROM tune_history_entries WHERE profile_id = ?", arguments: [profileId]) - try db.execute(sql: "DELETE FROM tune_profiles WHERE id = ?", arguments: [profileId]) + try deleteForSync( + db, + target: .tuneProfile, + boardId: nil, + key: profileId, + whereClause: "id = ?", + keys: [profileId] + ) return true } } diff --git a/modules/vescape-core/ios/warnings/BoardWarningStore.swift b/modules/vescape-core/ios/warnings/BoardWarningStore.swift index 9521edeb4..33006edde 100644 --- a/modules/vescape-core/ios/warnings/BoardWarningStore.swift +++ b/modules/vescape-core/ios/warnings/BoardWarningStore.swift @@ -71,6 +71,7 @@ struct BoardWarningStore { try db.execute(sql: "CREATE INDEX index_board_warnings_board_id ON board_warnings(board_id)") try db.execute(sql: "CREATE INDEX IF NOT EXISTS index_board_warnings_sync_seq ON board_warnings(sync_seq)") try createSyncSequencesTable(db) + try createSyncActionsTable(db) } /// The shared pool failed to open — findings are dropped / reads come back empty, so leave the @@ -175,6 +176,13 @@ struct BoardWarningStore { } } + /// Semantic removal, whether the Rider cleared the warning or a detector evaluated the kind with + /// real data and found the condition gone — an automatic clear is still a durable state transition + /// the server has to make (#282). + /// + /// Stamped from `last_detected_at` rather than `updated_at`: it is the warning's own change clock, + /// and it is what the row's `updated_at` was written from. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `deleteBoardWarning` @discardableResult func delete(_ boardId: String, _ kind: String) -> Bool { guard let writer = resolveWriter() else { @@ -183,11 +191,15 @@ struct BoardWarningStore { } do { return try writer.write { db in - try db.execute( - sql: "DELETE FROM board_warnings WHERE board_id = ? AND kind = ?", - arguments: [boardId, kind] + try deleteForSync( + db, + target: .boardWarning, + boardId: boardId, + key: kind, + whereClause: "board_id = ? AND kind = ?", + keys: [boardId, kind], + stampColumn: "last_detected_at" ) - return db.changesCount > 0 } } catch { BoardWarningFailureReporter.shared.report(site: "store_delete", error: error) @@ -195,6 +207,10 @@ struct BoardWarningStore { } } + /// The Rider cleared every warning on one Board: one action per removed row, because each row is + /// a separate piece of current state. Distinct from the Board delete's cascade, which is raw and + /// covered by the Board's own action. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `deleteBoardWarnings` @discardableResult func deleteForBoard(_ boardId: String) -> Bool { guard let writer = resolveWriter() else { @@ -203,8 +219,24 @@ struct BoardWarningStore { } do { return try writer.write { db in - try db.execute(sql: "DELETE FROM board_warnings WHERE board_id = ?", arguments: [boardId]) - return db.changesCount > 0 + let kinds = try String.fetchAll( + db, + sql: "SELECT kind FROM board_warnings WHERE board_id = ?", + arguments: [boardId] + ) + var removed = false + for kind in kinds { + removed = try deleteForSync( + db, + target: .boardWarning, + boardId: boardId, + key: kind, + whereClause: "board_id = ? AND kind = ?", + keys: [boardId, kind], + stampColumn: "last_detected_at" + ) || removed + } + return removed } } catch { BoardWarningFailureReporter.shared.report(site: "store_delete_for_board", error: error) diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index d7d825213..04a0033dd 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -1269,6 +1269,28 @@ export interface AppDataChangedEvent { scope: 'boards' | 'settings' } +/** + * What a Sync Action can name — and, by omission, what it cannot. A deleted row cannot carry a + * Change Timestamp saying it is gone, so native appends a Sync Action for every semantic removal and + * the server replays it against the Rider's backup. + * + * Every case is configuration or current state a Rider edits directly. Ride History is absent on + * purpose: telemetry is pruned locally on a retention rule, and an action naming it would delete + * exactly the rides the backup exists to preserve. The log is native-owned — JS never writes it — + * and this union exists so the two native definitions cannot drift apart unnoticed. + * @parity /modules/vescape-core/ios/telemetry/SyncActionLog.swift `DeleteTarget` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `DeleteTarget` + */ +export type DeleteTarget = + | 'appSetting' + | 'board' + | 'boardSetting' + | 'boardWarning' + | 'alert' + | 'tuneProfile' + | 'privacyZone' + | 'favorite' + /** * Two-level Board Warning severity, fixed at detection time. * @parity /modules/vescape-core/ios/warnings/BoardWarningKind.swift `BoardWarningSeverity` From aabd235d677590a581515d6b71bf47de33182072 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sat, 1 Aug 2026 11:05:00 +0200 Subject: [PATCH 09/24] Stamp the ephemeral alert-test rule --- .../main/java/expo/modules/vescapecore/VescapeCoreModule.kt | 3 +++ modules/vescape-core/ios/VescapeCoreModule.swift | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index a410568c5..26a4aae9a 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -75,6 +75,9 @@ private fun Map.toAlertTestRule(): AlertRuleEntity? { soundType = soundType, createdAt = 0, 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, ) } diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index 0bd91d129..e6d06190c 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -933,7 +933,10 @@ public class VescapeCoreModule: Module { enabled: true, soundType: soundType, createdAt: 0, - source: nil + source: nil, + // 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 ) } From 20f0ceb3c6180d0b854d289ea8496ba321506c5c Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sat, 1 Aug 2026 11:33:17 +0200 Subject: [PATCH 10/24] Upload Sync Batches #284 --- CONTEXT.md | 12 + .../modules/vescapecore/VescapeCoreModule.kt | 17 + .../expo/modules/vescapecore/api/ApiResult.kt | 11 +- .../modules/vescapecore/api/VescapeApi.kt | 38 ++- .../vescapecore/auth/NativeAuthCoordinator.kt | 36 ++- .../modules/vescapecore/sync/SyncAccepted.kt | 74 +++++ .../vescapecore/sync/SyncBatchBuilder.kt | 121 +++++++ .../vescapecore/sync/SyncCoordinator.kt | 270 ++++++++++++++++ .../modules/vescapecore/sync/SyncEngine.kt | 210 ++++++++++++ .../expo/modules/vescapecore/sync/SyncJson.kt | 120 +++++++ .../modules/vescapecore/sync/SyncPolicy.kt | 86 +++++ .../modules/vescapecore/sync/SyncStore.kt | 108 +++++++ .../modules/vescapecore/sync/SyncTables.kt | 80 +++++ .../expo/modules/vescapecore/sync/SyncWire.kt | 263 +++++++++++++++ .../telemetry/DatabaseBackupManager.kt | 22 ++ .../vescapecore/telemetry/TelemetryDao.kt | 176 ++++++++++ .../telemetry/TelemetryDatabase.kt | 26 +- .../telemetry/TelemetryEntities.kt | 36 +++ .../telemetry/TelemetryRepository.kt | 11 +- .../vescapecore/sync/SyncAcceptedTest.kt | 57 ++++ .../vescapecore/sync/SyncBatchBuilderTest.kt | 117 +++++++ .../sync/SyncCursorContractTest.kt | 87 +++++ .../vescapecore/sync/SyncEngineTest.kt | 220 +++++++++++++ .../vescapecore/sync/SyncPolicyTest.kt | 86 +++++ .../modules/vescapecore/sync/SyncWireTest.kt | 174 ++++++++++ .../telemetry/BoardTombstoneTest.kt | 2 +- .../telemetry/SyncActionLogTest.kt | 8 + .../telemetry/SyncCursorMigrationTest.kt | 2 +- .../TelemetryBoardIdMigrationTest.kt | 2 +- .../vescape-core/ios/VescapeCoreModule.swift | 17 + modules/vescape-core/ios/api/ApiResult.swift | 4 + modules/vescape-core/ios/api/VescapeApi.swift | 37 ++- .../ios/auth/NativeAuthCoordinator.swift | 48 ++- .../vescape-core/ios/sync/SyncAccepted.swift | 94 ++++++ .../ios/sync/SyncAcceptedTests.swift | 54 ++++ .../ios/sync/SyncBatchBuilder.swift | 133 ++++++++ .../ios/sync/SyncBatchBuilderTests.swift | 87 +++++ .../ios/sync/SyncCoordinator.swift | 291 +++++++++++++++++ .../vescape-core/ios/sync/SyncEngine.swift | 199 ++++++++++++ .../ios/sync/SyncEngineTests.swift | 209 ++++++++++++ modules/vescape-core/ios/sync/SyncJson.swift | 143 ++++++++ .../vescape-core/ios/sync/SyncPolicy.swift | 81 +++++ .../ios/sync/SyncPolicyTests.swift | 71 ++++ .../ios/sync/SyncRetentionTests.swift | 138 ++++++++ modules/vescape-core/ios/sync/SyncStore.swift | 238 ++++++++++++++ .../vescape-core/ios/sync/SyncTables.swift | 106 ++++++ modules/vescape-core/ios/sync/SyncWire.swift | 304 ++++++++++++++++++ .../vescape-core/ios/sync/SyncWireTests.swift | 109 +++++++ .../ios/telemetry/TelemetryDatabase.swift | 34 ++ .../ios/telemetry/TelemetryRepository.swift | 13 +- modules/vescape-core/src/index.ts | 59 ++++ .../profile/components/DeviceAuthSync.tsx | 81 ++++- src/modules/profile/store/deviceAuthStore.ts | 15 + 53 files changed, 5008 insertions(+), 29 deletions(-) create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncAccepted.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncJson.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncStore.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncTables.kt create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncWire.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncAcceptedTest.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncBatchBuilderTest.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncCursorContractTest.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncEngineTest.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncPolicyTest.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncWireTest.kt create mode 100644 modules/vescape-core/ios/sync/SyncAccepted.swift create mode 100644 modules/vescape-core/ios/sync/SyncAcceptedTests.swift create mode 100644 modules/vescape-core/ios/sync/SyncBatchBuilder.swift create mode 100644 modules/vescape-core/ios/sync/SyncBatchBuilderTests.swift create mode 100644 modules/vescape-core/ios/sync/SyncCoordinator.swift create mode 100644 modules/vescape-core/ios/sync/SyncEngine.swift create mode 100644 modules/vescape-core/ios/sync/SyncEngineTests.swift create mode 100644 modules/vescape-core/ios/sync/SyncJson.swift create mode 100644 modules/vescape-core/ios/sync/SyncPolicy.swift create mode 100644 modules/vescape-core/ios/sync/SyncPolicyTests.swift create mode 100644 modules/vescape-core/ios/sync/SyncRetentionTests.swift create mode 100644 modules/vescape-core/ios/sync/SyncStore.swift create mode 100644 modules/vescape-core/ios/sync/SyncTables.swift create mode 100644 modules/vescape-core/ios/sync/SyncWire.swift create mode 100644 modules/vescape-core/ios/sync/SyncWireTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index 90df4f3e2..d13fd551d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,6 +16,18 @@ _Avoid_: Soft delete, archived Board 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 + +**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 diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 26a4aae9a..412101fcc 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -4,6 +4,7 @@ import expo.modules.vescapecore.alerts.AlertFeedback import expo.modules.vescapecore.alerts.AlertCoordinator import expo.modules.vescapecore.appstatus.AppStatusCoordinator 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 @@ -364,6 +365,22 @@ 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() + } + Function("setSyncWifiOnly") { enabled: Boolean -> + SyncCoordinator.get(context).setWifiOnly(enabled) + } // 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. diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/api/ApiResult.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/api/ApiResult.kt index 23f690d6b..d0fc8fd96 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/api/ApiResult.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/api/ApiResult.kt @@ -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 = emptyMap(), +) /** * The single blocking HTTP seam. Production wires OkHttp; tests wire a fake and never reach the diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/api/VescapeApi.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/api/VescapeApi.kt index 7fe41797f..442b7334b 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/api/VescapeApi.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/api/VescapeApi.kt @@ -54,6 +54,38 @@ class VescapeApi( send(request, authenticated = token.isNotEmpty(), parse = parse) } + /** + * One call whose status code is the answer, not an error to classify. The uploader needs `409`, + * `413` and `429` kept apart — each has a different recovery — so it reads the raw exchange while + * still going through this class's credential, headers and 401 policy. + * + * Never retried here: `POST /api/sync` carries no create key, and the caller's own backoff is what + * decides when the same batch is offered again. + * + * @parity /modules/vescape-core/ios/api/VescapeApi.swift `exchange` + */ + suspend fun exchange( + method: HttpMethod, + path: String, + rawBody: String?, + auth: AuthMode = AuthMode.Required, + ): ApiResponse? = withContext(Dispatchers.IO) { + val token = token(auth) ?: return@withContext ApiResponse(401, "") + val request = ApiRequest( + method = method, + url = url(path, emptyMap()), + headers = headers(token.ifEmpty { null }, rawBody != null), + body = rawBody, + ) + val response = try { + transport.execute(request) + } catch (_: Exception) { + return@withContext null + } + if (response.status == 401 && token.isNotEmpty()) onUnauthorized() + response + } + /** * Resolved bearer token, empty when the call goes out anonymously, `null` when a required * credential is missing. A credential minted against another origin belongs to another @@ -188,7 +220,11 @@ object OkHttpApiTransport : ApiTransport { } builder.method(request.method.name, body) return client.newCall(builder.build()).execute().use { response -> - ApiResponse(response.code, response.body?.string().orEmpty()) + ApiResponse( + status = response.code, + body = response.body?.string().orEmpty(), + headers = response.headers.names().associate { it.lowercase() to response.header(it).orEmpty() }, + ) } } } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/auth/NativeAuthCoordinator.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/auth/NativeAuthCoordinator.kt index 00e2ceb76..a398c5cae 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/auth/NativeAuthCoordinator.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/auth/NativeAuthCoordinator.kt @@ -6,6 +6,7 @@ import expo.modules.vescapecore.api.AuthMode import expo.modules.vescapecore.api.HttpMethod import expo.modules.vescapecore.api.VescapeApi import expo.modules.vescapecore.appstatus.AppStatusCoordinator +import expo.modules.vescapecore.sync.SyncCoordinator import org.json.JSONObject /** @@ -51,6 +52,34 @@ class NativeAuthCoordinator(private val context: Context) { else -> throw IllegalStateException("Account verification failed ($result)") } + // The database is claimed before the credential is stored: a second Account must not be able to + // upload from a database full of the first Account's Boards, Ride History and locations. The + // Rider confirms the destructive reset, and only then does [confirmAccountReset] finish this. + if (!SyncCoordinator.get(context).bindAccount(accountId)) { + return stateMap() + mapOf("accountChangeRequiresReset" to true) + } + + store.write(DeviceCredential(origin, token, accountId, null)) + AppStatusCoordinator.get(context).refresh() + SyncCoordinator.get(context).start() + return stateMap() + } + + /** + * The Rider confirmed that all local app data is erased and cannot yet be restored. + * + * One ordered transition: stop the uploader, invalidate in-flight work, replace the app-data + * database, clear Sync Cursors and pending Sync Actions, bind the fresh database to the new + * Account, install the new Device Token, start the uploader. Cancelling never reaches here, so the + * old database and Account binding stay untouched. + */ + suspend fun confirmAccountReset( + serverUrl: String, + token: String, + accountId: String, + ): Map { + val origin = serverUrl.trimEnd('/') + SyncCoordinator.get(context).resetForAccount(accountId) store.write(DeviceCredential(origin, token, accountId, null)) AppStatusCoordinator.get(context).refresh() return stateMap() @@ -75,7 +104,12 @@ class NativeAuthCoordinator(private val context: Context) { store.clear() } - fun clear() = store.clear() + fun clear() { + store.clear() + // Signing out stops the uploader but keeps the Account binding, so data recorded while signed + // out stays protected from retention for the same Account. + SyncCoordinator.get(context).stop() + } companion object { private const val ACCOUNT_PATH = "/api/account" diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncAccepted.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncAccepted.kt new file mode 100644 index 000000000..8f1f32a4b --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncAccepted.kt @@ -0,0 +1,74 @@ +package expo.modules.vescapecore.sync + +/** + * The `200` body: what the server took, per table. + * + * Validated exactly before any cursor moves. A missing table, an extra table, a non-integer count or + * a count that differs from what was submitted is a protocol failure — the server applies a batch + * whole, so anything else means the two sides disagree about what was stored, and advancing a cursor + * on that disagreement is unrecoverable. + * + * Parsed here rather than with the platform JSON so the rule runs in plain unit tests and behaves + * identically on both platforms. + * + * @parity /modules/vescape-core/ios/sync/SyncAccepted.swift + */ +object SyncAccepted { + /** Accepted counts by table, or null when the body is not exactly the expected response. */ + fun parse(body: String): Map? { + val counts = LinkedHashMap() + val scanner = Scanner(body) + if (!scanner.expect('{') || !scanner.expectKey("accepted") || !scanner.expect('{')) return null + if (scanner.peek() != '}') { + while (true) { + val name = scanner.string() ?: return null + val table = SyncTable.entries.firstOrNull { it.wire == name } ?: return null + if (counts.containsKey(table) || !scanner.expect(':')) return null + counts[table] = scanner.integer() ?: return null + if (scanner.expect(',')) continue + break + } + } + if (!scanner.expect('}') || !scanner.expect('}') || !scanner.atEnd()) return null + return if (counts.size == SyncTable.entries.size) counts else null + } + + /** True when the response accounts for exactly the rows submitted, table by table. */ + fun matches(submitted: Map, accepted: Map): Boolean = + SyncTable.entries.all { accepted[it] == (submitted[it] ?: 0) } + + private class Scanner(private val source: String) { + private var index = 0 + + fun atEnd(): Boolean = skipSpace().let { index >= source.length } + + fun peek(): Char? = skipSpace().let { source.getOrNull(index) } + + fun expect(char: Char): Boolean { + if (peek() != char) return false + index += 1 + return true + } + + fun expectKey(name: String): Boolean = string() == name && expect(':') + + fun string(): String? { + if (!expect('"')) return null + val end = source.indexOf('"', index) + // Counts and table names carry no escapes; a body that needs them is not this response. + if (end < 0) return null + return source.substring(index, end).also { index = end + 1 } + } + + fun integer(): Int? { + skipSpace() + val start = index + while (index < source.length && source[index].isDigit()) index += 1 + return if (index == start) null else source.substring(start, index).toIntOrNull() + } + + private fun skipSpace() { + while (index < source.length && source[index].isWhitespace()) index += 1 + } + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt new file mode 100644 index 000000000..85c711635 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt @@ -0,0 +1,121 @@ +package expo.modules.vescapecore.sync + +/** + * One row waiting to be uploaded: its cursor position and the compact JSON the server will read. + * + * The JSON is encoded once, by the wire layer, so the builder measures the bytes that will actually + * be sent rather than estimating from an object graph. + * + * @parity /modules/vescape-core/ios/sync/SyncBatchBuilder.swift `SyncPendingRow` + */ +data class SyncPendingRow(val cursor: Long, val json: String) { + val byteCount: Int = json.toByteArray(Charsets.UTF_8).size +} + +/** One table's pending rows, in cursor order. */ +data class SyncPendingTable(val table: SyncTable, val rows: List) + +/** + * What the builder made of the pending rows. + * + * @parity /modules/vescape-core/ios/sync/SyncBatchBuilder.swift `SyncBatchBuild` + */ +sealed interface SyncBatchBuild { + /** Nothing pending. */ + object Empty : SyncBatchBuild + + /** + * A batch and the cursor advance set describing exactly the rows in it. Cursors are committed only + * after the server accepts, and only these positions move. + */ + data class Ready( + val body: String, + val counts: Map, + val advances: Map, + val rowCount: Int, + val byteCount: Int, + ) : SyncBatchBuild + + /** + * One row cannot fit a batch of its own. Never skipped and never quarantined: the engine pauses + * with the row retained, because dropping it would silently lose data a Rider believes is backed + * up. + */ + data class RowTooLarge(val table: SyncTable, val cursor: Long, val byteCount: Int) : SyncBatchBuild +} + +/** + * Fills a Sync Batch from per-table pending rows. + * + * Pure: no database, no clock, no network. It walks [SyncTable] declaration order — the order the + * server applies a batch in — and stops at whichever cap comes first. Ordering by backlog size would + * produce a batch whose children arrive before their parents, which the server refuses whole. + * + * @parity /modules/vescape-core/ios/sync/SyncBatchBuilder.swift `SyncBatchBuilder` + */ +object SyncBatchBuilder { + fun build( + pending: List, + rowCap: Int = MAX_SYNC_BATCH_ROWS, + byteCap: Int = MAX_SYNC_BATCH_BYTES, + ): SyncBatchBuild { + val ordered = pending + .filter { it.rows.isNotEmpty() } + .sortedBy { it.table.ordinal } + if (ordered.isEmpty()) return SyncBatchBuild.Empty + + val body = StringBuilder("{") + val counts = LinkedHashMap() + val advances = LinkedHashMap() + var rowCount = 0 + // `{}`; every other cost below is added as the exact bytes appended. + var byteCount = 2 + + for (group in ordered) { + if (rowCount >= rowCap) break + // `,"appSettings":[]` — the separating comma only once a table is already open. + val header = (if (counts.isEmpty()) "" else ",") + "\"" + group.table.wire + "\":[" + val tableOverhead = header.length + 1 + if (byteCount + tableOverhead > byteCap) break + + var opened = false + for (row in group.rows) { + if (rowCount >= rowCap) break + val rowCost = row.byteCount + if (opened) 1 else 0 + val overhead = if (opened) 0 else tableOverhead + if (byteCount + overhead + rowCost > byteCap) { + // A row no empty batch could carry is a permanent local protocol error, not a cap hit. + if (counts.isEmpty() && !opened && 2 + tableOverhead + row.byteCount > byteCap) { + return SyncBatchBuild.RowTooLarge(group.table, row.cursor, row.byteCount) + } + break + } + + if (!opened) { + body.append(header) + byteCount += tableOverhead + counts[group.table] = 0 + opened = true + } else { + body.append(',') + } + body.append(row.json) + byteCount += rowCost + rowCount += 1 + counts[group.table] = counts.getValue(group.table) + 1 + advances[group.table] = row.cursor + } + if (opened) body.append(']') + } + + if (counts.isEmpty()) return SyncBatchBuild.Empty + body.append('}') + return SyncBatchBuild.Ready( + body = body.toString(), + counts = counts, + advances = advances, + rowCount = rowCount, + byteCount = byteCount, + ) + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt new file mode 100644 index 000000000..89bde2f67 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt @@ -0,0 +1,270 @@ +package expo.modules.vescapecore.sync + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.util.Log +import expo.modules.vescapecore.api.HttpMethod +import expo.modules.vescapecore.api.VescapeApi +import expo.modules.vescapecore.appstatus.AppStatusCoordinator +import expo.modules.vescapecore.auth.DeviceCredentialStore +import expo.modules.vescapecore.telemetry.DatabaseBackupManager +import expo.modules.vescapecore.telemetry.TelemetryDatabase +import expo.modules.vescapecore.telemetry.TelemetryRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +private const val TAG = "SyncCoordinator" + +/** What JS renders. Native owns every transition; JS only asks and shows. */ +data class SyncStatus( + val accountId: String?, + val pendingRows: Int, + val pause: SyncPauseReason?, + val lastUploadAtMs: Long?, +) { + fun toMap(): Map = mapOf( + "accountId" to accountId, + "pendingRows" to pendingRows, + "pause" to pause?.slug, + "lastUploadAtMs" to lastUploadAtMs, + ) +} + +/** + * The uploader's lifecycle: the loop, the kicks, and the Account binding it runs under. + * + * Runs inside the window the app already keeps alive — the foreground service during a Board Session + * or GPS, the existing background modes on iOS. Deliberately no `WorkManager`: a ride that ends + * offline on a phone that is never reopened waits for the next app open or the next ride. + * + * @parity /modules/vescape-core/ios/sync/SyncCoordinator.swift + */ +class SyncCoordinator private constructor(private val context: Context) { + /** Resolved per call: an Account reset replaces the whole database file under this object. */ + private val dao get() = TelemetryDatabase.get(context).telemetryDao() + private val credentials = DeviceCredentialStore(context) + private val scope = CoroutineScope(SupervisorJob()) + + /** Bumped by an Account reset; a response captured under an older value cannot commit. */ + @Volatile private var generation = 0L + + @Volatile private var lastSamplePersistedAtMs = 0L + @Volatile private var lastUploadAtMs: Long? = null + @Volatile private var wifiOnly = false + + /** Failure keys already recorded this process, so a wedged batch writes one event, not a stream. */ + private val recordedFailures = HashSet() + + private var loop: Job? = null + + private val store = SyncStore( + database = { dao }, + generation = { generation }, + onPermanentFailure = ::recordPermanentFailure, + ) + + private val engine = SyncEngine( + source = store, + transport = ::post, + environment = ::environment, + ) + + val pauseReason: SyncPauseReason? get() = engine.pauseReason + + /** Recording persisted samples: the ride cadence follows sample production, not session presence. */ + fun notifySamplesPersisted(atMs: Long = System.currentTimeMillis()) { + lastSamplePersistedAtMs = atMs + } + + fun setWifiOnly(enabled: Boolean) { + wifiOnly = enabled + kick() + } + + suspend fun status(): SyncStatus = SyncStatus( + accountId = dao.getBoundAccountId(), + pendingRows = store.pendingCount(), + pause = engine.pauseReason, + lastUploadAtMs = lastUploadAtMs, + ) + + fun start() { + if (loop?.isActive == true) return + loop = scope.launch { + while (isActive) { + val waitMs = try { + pass() + } catch (e: Exception) { + Log.w(TAG, "Sync pass failed: ${e.message}") + SyncPolicy.IDLE_INTERVAL_MS + } + delay(waitMs) + } + } + } + + fun stop() { + loop?.cancel() + loop = null + } + + /** Connectivity regained, ride ended, sign-in: send now rather than waiting for the next tick. */ + fun kick() { + if (loop?.isActive != true) return start() + scope.launch { runCatching { pass() } } + } + + /** + * One pass, draining while the server keeps accepting: a `200` with rows still pending sends again + * straight away, so a long backlog drains instead of trickling. + */ + private suspend fun pass(): Long { + var drains = 0 + while (drains < MAX_DRAIN_STEPS) { + when (val outcome = engine.runOnce()) { + is SyncPass.Sent -> { + lastUploadAtMs = System.currentTimeMillis() + if (!outcome.morePending) return interval() + drains += 1 + } + is SyncPass.Waiting -> + return (outcome.untilMs - System.currentTimeMillis()).coerceIn(0, SyncPolicy.BACKOFF_MAX_MS) + is SyncPass.Paused -> return SyncPolicy.IDLE_INTERVAL_MS + SyncPass.Idle -> return interval() + } + } + return 0 + } + + private fun interval(): Long = + if (samplesProducing()) SyncPolicy.RIDE_INTERVAL_MS else SyncPolicy.IDLE_INTERVAL_MS + + private fun samplesProducing(): Boolean = + System.currentTimeMillis() - lastSamplePersistedAtMs < SAMPLE_ACTIVITY_WINDOW_MS + + private fun environment(): SyncEnvironment { + val capabilities = runCatching { + val manager = context.getSystemService(ConnectivityManager::class.java) + manager?.getNetworkCapabilities(manager.activeNetwork) + }.getOrNull() + return SyncEnvironment( + ridingSamples = samplesProducing(), + online = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true, + wifiOnly = wifiOnly, + onWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true, + credentialReady = credentials.read() != null, + onlineBlocked = AppStatusCoordinator.get(context).onlineBlocked, + ) + } + + /** + * The Sync endpoints are Online Capabilities behind the App Status gate, and they authenticate with + * the shared Device Token, so the whole call goes through [VescapeApi]. + */ + private suspend fun post(body: String): SyncResponse { + val api = VescapeApi.forOrigin(context, AppStatusCoordinator.serverBaseUrl(context)) + val response = api.exchange(HttpMethod.POST, SYNC_PATH, body) + ?: return SyncResponse.Transient("network") + return when { + response.status == 200 -> SyncResponse.Accepted(response.body) + response.status == 401 -> SyncResponse.Unauthorized + response.status == 413 -> SyncResponse.TooLarge + response.status == 429 -> SyncResponse.RateLimited(retryAfterMs(response.headers)) + response.status >= 500 -> SyncResponse.Transient("http ${response.status}") + response.status >= 400 -> SyncResponse.Invalid(response.status, errorSlug(response.body)) + // A `2xx` that is not the accepted map is a protocol failure, not a success to interpret. + else -> SyncResponse.Invalid(response.status, "unexpected-success") + } + } + + /** The server's own delay in seconds, or the first backoff step when it named none. */ + private fun retryAfterMs(headers: Map): Long = + headers["retry-after"]?.trim()?.toLongOrNull()?.times(1_000L) ?: SyncPolicy.BACKOFF_START_MS + + private fun errorSlug(body: String): String = + Regex("\"error\"\\s*:\\s*\"([^\"]+)\"").find(body)?.groupValues?.get(1) ?: "invalid-request" + + // Account binding — the Device Token exchange returns a stable server Account id, and the first + // Account claims this database. + + /** + * Claim the local database for [accountId] when it is unbound or already belongs to it. + * + * False means a different Account: cursors are deliberately not reset over the existing rows, + * because that would upload the previous Account's Boards, Ride History, locations and settings to + * the new one. The Rider has to confirm the destructive reset first. + */ + suspend fun bindAccount(accountId: String): Boolean { + val bound = dao.bindAccount(accountId) + if (bound) { + engine.resume() + kick() + } + return bound + } + + /** + * The Account change transition, in the one order that cannot leak data between Accounts: stop the + * loop, invalidate in-flight work, replace the database, clear cursors and pending actions, bind + * the new Account, then start again. + * + * The wipe is local maintenance and emits no Sync Actions to either Account — replacing the file + * removes the log with everything else. + */ + suspend fun resetForAccount(accountId: String) { + stop() + // Every in-flight response now belongs to a previous Account and can no longer commit. + generation += 1 + recordedFailures.clear() + DatabaseBackupManager.replaceWithFreshDatabase(context) + dao.bindAccount(accountId) + engine.resume() + lastUploadAtMs = null + start() + } + + /** + * One coalesced Diagnostic Event per failure class, table and cursor. Metadata only: an error + * code, a table, a cursor and the app version — never row contents, coordinates, the Device Token, + * the server body or an opaque database error. + */ + private fun recordPermanentFailure(reason: SyncPauseReason, detail: String) { + val key = "${reason.slug}:$detail" + synchronized(recordedFailures) { + if (!recordedFailures.add(key)) return + } + TelemetryRepository.get(context).recordDiagnosticEvent( + "sync_upload_paused", + mapOf( + "operation" to "sync", + "phase" to reason.slug, + "message" to "Sync upload paused", + "sync_failure" to reason.slug, + "sync_detail" to detail, + "app_version" to AppStatusCoordinator.get(context).appVersion, + ), + ) + } + + companion object { + internal const val SYNC_PATH = "/api/sync" + + /** Samples persisted this recently mean a ride is producing, Idle Pause included. */ + private const val SAMPLE_ACTIVITY_WINDOW_MS = 60_000L + + /** A drain is a burst, not a loop that can never yield to the rest of the process. */ + private const val MAX_DRAIN_STEPS = 50 + + @Volatile private var instance: SyncCoordinator? = null + + fun get(context: Context): SyncCoordinator = + instance ?: synchronized(this) { + instance ?: SyncCoordinator(context.applicationContext).also { instance = it } + } + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt new file mode 100644 index 000000000..dc18388aa --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt @@ -0,0 +1,210 @@ +package expo.modules.vescapecore.sync + +/** What the transport made of one `POST /api/sync`. */ +sealed interface SyncResponse { + /** `2xx`. The body still has to be exactly the accepted map before anything is committed. */ + data class Accepted(val body: String) : SyncResponse + + /** `400`, `409`, `422` or any other unknown `4xx`: wrong request, not a bad moment. */ + data class Invalid(val status: Int, val error: String) : SyncResponse + + /** `401`: the Device Token is dead. Only sign-in resolves it. */ + object Unauthorized : SyncResponse + + /** `413`: over the wire byte bound. Retried with a smaller target, never with fewer rows dropped. */ + object TooLarge : SyncResponse + + /** `429`, with the server's own delay. */ + data class RateLimited(val retryAfterMs: Long) : SyncResponse + + /** `5xx`, a network error or a timeout — the batch may or may not have been applied. */ + data class Transient(val reason: String) : SyncResponse +} + +/** @parity /modules/vescape-core/ios/sync/SyncEngine.swift `SyncTransport` */ +fun interface SyncTransport { + suspend fun send(body: String): SyncResponse +} + +/** + * The database side of the uploader: what is pending, and where the cursors are. + * + * @parity /modules/vescape-core/ios/sync/SyncEngine.swift `SyncSource` + */ +interface SyncSource { + /** Pending rows per table, already encoded, capped at [rowLimit] rows in total. */ + suspend fun pending(rowLimit: Int): List + + /** Rows waiting across every table. Cheap enough to ask on every tick. */ + suspend fun pendingCount(): Int + + /** + * Commit the advance set in its own transaction, after the response. Never alongside the rows: a + * cursor advanced past rows the server did not take is unrecoverable, whereas a cursor left behind + * is a re-send the server upserts idempotently. Always fail toward re-sending. + */ + suspend fun commit(advances: Map) + + /** + * Bumped by an Account change. Captured before a request and re-read before the commit, so a + * response belonging to the previous Account becomes a no-op instead of advancing a cursor over + * the fresh database. + */ + fun generation(): Long + + /** One coalesced, metadata-only Diagnostic Event for a permanent failure. */ + suspend fun recordPermanentFailure(reason: SyncPauseReason, detail: String) +} + +/** Environment the policy reads. Owned by the caller, so the engine keeps no platform types. */ +data class SyncEnvironment( + val ridingSamples: Boolean, + val online: Boolean, + val wifiOnly: Boolean, + val onWifi: Boolean, + val credentialReady: Boolean, + val onlineBlocked: Boolean, +) + +/** What one pass did, for the loop and for tests. */ +sealed interface SyncPass { + object Idle : SyncPass + data class Sent(val rowCount: Int, val morePending: Boolean) : SyncPass + data class Waiting(val untilMs: Long) : SyncPass + data class Paused(val reason: SyncPauseReason) : SyncPass +} + +/** + * The uploader: scan forward from each Sync Cursor, send a small batch, advance only what the server + * accepted. + * + * Owns transport policy, backoff and the permanent pause; the two interesting decisions — which rows + * go in a batch, and whether to send at all — live in [SyncBatchBuilder] and [SyncPolicy], which are + * pure. Drives no timer of its own: [SyncCoordinator] owns the loop and the kicks. + * + * @parity /modules/vescape-core/ios/sync/SyncEngine.swift `SyncEngine` + */ +class SyncEngine( + private val source: SyncSource, + private val transport: SyncTransport, + private val environment: () -> SyncEnvironment, + private val clock: () -> Long = System::currentTimeMillis, +) { + private var retryAtMs = 0L + private var backoffMs = 0L + private var byteTarget = MAX_SYNC_BATCH_BYTES + private var pause: SyncPauseReason? = null + + val pauseReason: SyncPauseReason? get() = pause + + /** Clears a pause. Sign-in and an Account reset are the only things that may. */ + fun resume() { + pause = null + retryAtMs = 0 + backoffMs = 0 + byteTarget = MAX_SYNC_BATCH_BYTES + } + + /** + * One pass: decide, send, commit. A `200` with rows still pending returns `morePending`, so the + * loop sends again immediately rather than trickling a long backlog one tick at a time. + */ + suspend fun runOnce(): SyncPass { + val env = environment() + val decision = SyncPolicy.decide( + SyncState( + nowMs = clock(), + pendingRows = source.pendingCount(), + ridingSamples = env.ridingSamples, + online = env.online, + wifiOnly = env.wifiOnly, + onWifi = env.onWifi, + credentialReady = env.credentialReady, + onlineBlocked = env.onlineBlocked, + pause = pause, + retryAtMs = retryAtMs, + ), + ) + return when (decision) { + is SyncDecision.Paused -> SyncPass.Paused(decision.reason) + is SyncDecision.Wait -> SyncPass.Waiting(decision.atMs) + SyncDecision.SendNow -> send() + } + } + + private suspend fun send(): SyncPass { + val pending = try { + source.pending(MAX_SYNC_BATCH_ROWS) + } catch (e: SyncProtocolException) { + return pauseWith(SyncPauseReason.PROTOCOL, "${e.table.wire}.${e.field}") + } + + return when (val built = SyncBatchBuilder.build(pending, MAX_SYNC_BATCH_ROWS, byteTarget)) { + SyncBatchBuild.Empty -> SyncPass.Idle + is SyncBatchBuild.RowTooLarge -> + pauseWith(SyncPauseReason.ROW_TOO_LARGE, "${built.table.wire}@${built.cursor}") + is SyncBatchBuild.Ready -> deliver(built) + } + } + + private suspend fun deliver(batch: SyncBatchBuild.Ready): SyncPass { + val generation = source.generation() + val response = transport.send(batch.body) + // A response that outlived its Account cannot touch the fresh database it would land in. + if (source.generation() != generation) return SyncPass.Idle + + return when (response) { + is SyncResponse.Accepted -> accept(batch, response.body) + SyncResponse.Unauthorized -> pauseWith(SyncPauseReason.AUTHENTICATION, "401") + is SyncResponse.Invalid -> + pauseWith(SyncPauseReason.PROTOCOL, "${response.status}:${response.error}") + SyncResponse.TooLarge -> shrink(batch) + is SyncResponse.RateLimited -> backOff(maxOf(response.retryAfterMs, 0L)) + is SyncResponse.Transient -> backOff(SyncPolicy.nextBackoffMs(backoffMs).also { backoffMs = it }) + } + } + + private suspend fun accept(batch: SyncBatchBuild.Ready, body: String): SyncPass { + val accepted = SyncAccepted.parse(body) + if (accepted == null || !SyncAccepted.matches(batch.counts, accepted)) { + return pauseWith(SyncPauseReason.PROTOCOL, "acceptedMismatch") + } + source.commit(batch.advances) + backoffMs = 0 + retryAtMs = 0 + byteTarget = MAX_SYNC_BATCH_BYTES + return SyncPass.Sent(batch.rowCount, morePending = source.pendingCount() > 0) + } + + /** + * `413` narrows the byte target instead of dropping anything. Once the target can no longer hold + * even one row, that row is a permanent local protocol error — it is retained, not skipped. + */ + private suspend fun shrink(batch: SyncBatchBuild.Ready): SyncPass { + if (batch.rowCount <= 1) { + val table = batch.counts.keys.first() + return pauseWith( + SyncPauseReason.ROW_TOO_LARGE, + "${table.wire}@${batch.advances.getValue(table)}", + ) + } + byteTarget = maxOf(byteTarget / 2, MIN_BYTE_TARGET) + return SyncPass.Sent(0, morePending = true) + } + + private fun backOff(delayMs: Long): SyncPass { + retryAtMs = clock() + delayMs + return SyncPass.Waiting(retryAtMs) + } + + private suspend fun pauseWith(reason: SyncPauseReason, detail: String): SyncPass { + pause = reason + source.recordPermanentFailure(reason, detail) + return SyncPass.Paused(reason) + } + + private companion object { + /** Below this a batch cannot hold a realistic row, so shrinking further only hides the real fault. */ + const val MIN_BYTE_TARGET = 16 * 1024 + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncJson.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncJson.kt new file mode 100644 index 000000000..8ab7d9774 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncJson.kt @@ -0,0 +1,120 @@ +package expo.modules.vescapecore.sync + +/** + * A row the server could never store. Permanent for this phone: retrying the same bytes cannot make + * it succeed, so the engine pauses with the row retained rather than skipping it. + * + * The message names the table and the field only — never the value, which may be a coordinate, a + * Rider's text or a token. + * + * @parity /modules/vescape-core/ios/sync/SyncJson.swift `SyncProtocolError` + */ +class SyncProtocolException(val table: SyncTable, val field: String, val problem: String) : + IllegalStateException("${table.wire}.$field $problem") + +/** + * A compact JSON object writer that validates as it writes. + * + * Deliberately not `org.json`: this has to produce the exact bytes measured against the wire byte + * cap, in a stable field order, and run in plain JVM tests where the platform's JSON is a stub. The + * bounds it enforces are the server's own (`vescape-server` `src/sync/protocol.ts`), applied before + * transport so a wedged batch is impossible rather than merely unlikely. + * + * Nullable columns are written as explicit nulls: "cleared" and "not mentioned" are different + * intents, and a missing key cannot express the first. + * + * @parity /modules/vescape-core/ios/sync/SyncJson.swift `SyncRowWriter` + * @parity /modules/vescape-server/src/sync/protocol.ts + */ +class SyncRowWriter(private val table: SyncTable) { + private val out = StringBuilder("{") + + fun build(): String = out.append('}').toString() + + /** An identifier the phone chose: a Board id, a settings key, an event name. Never empty. */ + fun keyText(field: String, value: String): SyncRowWriter = apply { + if (value.isEmpty()) fail(field, "must not be empty") + boundedText(field, value) + } + + fun nullableKeyText(field: String, value: String?): SyncRowWriter = apply { + if (value == null) raw(field, "null") else keyText(field, value) + } + + /** + * A key column the phone derives rather than names, so it may legitimately be empty — a sanitizer + * writes `""` as the device id of a sample captured with no Board connected. + */ + fun derivedKeyText(field: String, value: String?): SyncRowWriter = apply { + if (value == null) raw(field, "null") else boundedText(field, value) + } + + /** Text the server stores opaquely and hands back unchanged. Uncapped, like the server's. */ + fun text(field: String, value: String?): SyncRowWriter = apply { + if (value == null) raw(field, "null") else raw(field, quote(value)) + } + + fun bool(field: String, value: Boolean): SyncRowWriter = raw(field, if (value) "true" else "false") + + /** Epoch ms, or a duration in ms: non-negative and inside the JSON-safe integer range. */ + fun timestamp(field: String, value: Long?): SyncRowWriter = bounded(field, value, 0, SYNC_SAFE_INT_MAX) + + fun int32(field: String, value: Int?): SyncRowWriter = + bounded(field, value?.toLong(), SYNC_INT32_MIN, SYNC_INT32_MAX) + + fun count(field: String, value: Int?): SyncRowWriter = + bounded(field, value?.toLong(), 0, SYNC_INT32_MAX) + + /** A 64-bit column that is not a timestamp — an odometer reading. */ + fun int64(field: String, value: Long?): SyncRowWriter = + bounded(field, value, -SYNC_SAFE_INT_MAX, SYNC_SAFE_INT_MAX) + + /** A real number. Neither infinity nor NaN is expressible in JSON. */ + fun number(field: String, value: Double?): SyncRowWriter = apply { + if (value == null) { + raw(field, "null") + return@apply + } + if (!value.isFinite()) fail(field, "must be finite") + val whole = value.toLong() + raw(field, if (value == whole.toDouble()) whole.toString() else value.toString()) + } + + private fun bounded(field: String, value: Long?, min: Long, max: Long): SyncRowWriter = apply { + if (value == null) { + raw(field, "null") + return@apply + } + if (value < min || value > max) fail(field, "is out of bounds") + raw(field, value.toString()) + } + + private fun boundedText(field: String, value: String) { + if (value.length > MAX_SYNC_KEY_LENGTH) fail(field, "exceeds $MAX_SYNC_KEY_LENGTH characters") + raw(field, quote(value)) + } + + private fun raw(field: String, encoded: String): SyncRowWriter = apply { + if (out.length > 1) out.append(',') + out.append(quote(field)).append(':').append(encoded) + } + + private fun fail(field: String, problem: String): Nothing = + throw SyncProtocolException(table, field, problem) + + private fun quote(value: String): String { + val quoted = StringBuilder(value.length + 2).append('"') + for (char in value) { + when { + char == '"' -> quoted.append("\\\"") + char == '\\' -> quoted.append("\\\\") + char == '\n' -> quoted.append("\\n") + char == '\r' -> quoted.append("\\r") + char == '\t' -> quoted.append("\\t") + char < ' ' -> quoted.append("\\u%04x".format(char.code)) + else -> quoted.append(char) + } + } + return quoted.append('"').toString() + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt new file mode 100644 index 000000000..f34389099 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt @@ -0,0 +1,86 @@ +package expo.modules.vescapecore.sync + +/** How the uploader ran out of road. A paused engine is not woken by ordinary timer kicks. */ +enum class SyncPauseReason(val slug: String) { + /** No Device Token, or the server rejected the one we hold. Sign-in is the only way out. */ + AUTHENTICATION("authentication"), + + /** The server refused this batch on its contents, or answered `2xx` with something unreadable. */ + PROTOCOL("protocol"), + + /** A single row cannot fit inside the wire byte cap. Retained, never skipped. */ + ROW_TOO_LARGE("rowTooLarge"), +} + +/** What the loop should do next. */ +sealed interface SyncDecision { + /** Send the next batch now. */ + object SendNow : SyncDecision + + /** Nothing to do until [atMs]; the loop re-decides then or when a kick lands. */ + data class Wait(val atMs: Long) : SyncDecision + + /** Stopped until the named condition changes. Timer and connectivity kicks do not bypass it. */ + data class Paused(val reason: SyncPauseReason) : SyncDecision +} + +/** + * Everything the decision depends on, read once by the caller so the decision itself stays pure. + * + * @parity /modules/vescape-core/ios/sync/SyncPolicy.swift `SyncState` + */ +data class SyncState( + val nowMs: Long, + /** Rows waiting across every table. Zero means idle, not finished. */ + val pendingRows: Int, + /** A Board Session is producing samples — Idle Pause halts production without ending the session. */ + val ridingSamples: Boolean, + val online: Boolean, + /** Metered-connection setting; the uploader waits for Wi-Fi rather than failing. */ + val wifiOnly: Boolean, + val onWifi: Boolean, + val credentialReady: Boolean, + /** The App Status gate closed, like every other Online Capability. */ + val onlineBlocked: Boolean, + /** Set by a permanent failure; cleared only by sign-in or an Account reset. */ + val pause: SyncPauseReason?, + /** Backoff or `Retry-After` deadline; before it, nothing is sent. */ + val retryAtMs: Long, +) + +/** + * The one place that turns state into "send, wait, or stopped". + * + * Pure: no database, no clock, no network. The clock is [SyncState.nowMs] and the caller owns it. + * + * @parity /modules/vescape-core/ios/sync/SyncPolicy.swift `SyncPolicy` + */ +object SyncPolicy { + /** Cadence while a ride is producing samples: a crash loses at most this much. */ + const val RIDE_INTERVAL_MS = 30_000L + + /** Cadence when nothing is pending. Cheap, because it is a no-op. */ + const val IDLE_INTERVAL_MS = 5 * 60_000L + + const val BACKOFF_START_MS = 30_000L + const val BACKOFF_MAX_MS = 15 * 60_000L + + fun decide(state: SyncState): SyncDecision { + state.pause?.let { return SyncDecision.Paused(it) } + if (!state.credentialReady) return SyncDecision.Paused(SyncPauseReason.AUTHENTICATION) + + val interval = if (state.ridingSamples) RIDE_INTERVAL_MS else IDLE_INTERVAL_MS + if (state.pendingRows <= 0) return SyncDecision.Wait(state.nowMs + interval) + // Offline, metered, or gated: a pause in the loop, never a failure that moves backoff. + if (!state.online || state.onlineBlocked) return SyncDecision.Wait(state.nowMs + interval) + if (state.wifiOnly && !state.onWifi) return SyncDecision.Wait(state.nowMs + interval) + if (state.retryAtMs > state.nowMs) return SyncDecision.Wait(state.retryAtMs) + return SyncDecision.SendNow + } + + /** Next backoff step: doubling from [BACKOFF_START_MS], capped, and reset to 0 on success. */ + fun nextBackoffMs(previousMs: Long): Long = when { + previousMs <= 0L -> BACKOFF_START_MS + else -> minOf(previousMs * 2, BACKOFF_MAX_MS) + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncStore.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncStore.kt new file mode 100644 index 000000000..e5f0e3f3d --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncStore.kt @@ -0,0 +1,108 @@ +package expo.modules.vescapecore.sync + +import expo.modules.vescapecore.telemetry.TelemetryDao + +/** + * The database side of the uploader: the forward scan, the cursor commit and the failure record. + * + * Encoding happens here rather than in the engine, so the pure batch builder measures the exact + * bytes that will be sent. Rows are read in [SyncTable] order and the scan stops once the row limit + * is reached — a table further down waits for the next batch, which is what keeps parents ahead of + * children. + * + * @parity /modules/vescape-core/ios/sync/SyncStore.swift `SyncStore` + */ +class SyncStore( + /** Resolved per call: an Account reset replaces the whole database under this object. */ + private val database: () -> TelemetryDao, + private val generation: () -> Long, + private val onPermanentFailure: (SyncPauseReason, String) -> Unit, +) : SyncSource { + + override suspend fun pending(rowLimit: Int): List { + val tables = ArrayList(SyncTable.entries.size) + var budget = rowLimit + for (table in SyncTable.entries) { + if (budget <= 0) break + val rows = read(table, database().cursorOf(table.cursorKey), budget) + if (rows.isEmpty()) continue + tables += SyncPendingTable(table, rows) + budget -= rows.size + } + return tables + } + + override suspend fun pendingCount(): Int { + var total = 0 + for (table in SyncTable.entries) { + val cursor = database().cursorOf(table.cursorKey) + total += when (table) { + SyncTable.APP_SETTINGS -> database().countAppSettingsAfter(cursor) + SyncTable.BOARDS -> database().countBoardsAfter(cursor) + SyncTable.BOARD_SETTINGS -> database().countBoardSettingsAfter(cursor) + SyncTable.BOARD_WARNINGS -> database().countBoardWarningsAfter(cursor) + SyncTable.ALERTS -> database().countAlertsAfter(cursor) + SyncTable.TUNE_PROFILES -> database().countTuneProfilesAfter(cursor) + SyncTable.TUNE_HISTORY_ENTRIES -> database().countTuneHistoryEntriesAfter(cursor) + SyncTable.PRIVACY_ZONES -> database().countPrivacyZonesAfter(cursor) + SyncTable.TELEMETRY_MARKERS -> database().countTelemetryMarkersAfter(cursor) + SyncTable.METRIC_EXCLUSION_RANGES -> database().countExclusionRangesAfter(cursor) + SyncTable.DIAGNOSTIC_EVENTS -> database().countDiagnosticEventsAfter(cursor) + SyncTable.TELEMETRY_FRAMES -> database().countTelemetryFramesAfter(cursor) + SyncTable.TELEMETRY_MINUTE_BUCKETS -> database().countMinuteBucketsAfter(cursor) + SyncTable.FAVORITES -> database().countFavoritesAfter(cursor) + SyncTable.DELETE_ACTIONS -> database().countSyncActionsAfter(cursor) + } + } + return total + } + + /** + * Cursors move only here, only after the server accepted, and each in its own statement. The + * accepted Sync Action cursor is also what prunes the log, so pruning can never outrun it. + */ + override suspend fun commit(advances: Map) { + for ((table, cursor) in advances) database().commitSyncCursor(table.cursorKey, cursor) + if (advances.containsKey(SyncTable.DELETE_ACTIONS)) database().pruneUploadedSyncActions() + } + + override fun generation(): Long = generation.invoke() + + override suspend fun recordPermanentFailure(reason: SyncPauseReason, detail: String) { + onPermanentFailure(reason, detail) + } + + private suspend fun read(table: SyncTable, cursor: Long, limit: Int): List = + when (table) { + SyncTable.APP_SETTINGS -> + database().getAppSettingsAfter(cursor, limit).map { SyncPendingRow(it.syncSeq, SyncWire.appSetting(it)) } + SyncTable.BOARDS -> + database().getBoardsAfter(cursor, limit).map { SyncPendingRow(it.syncSeq, SyncWire.board(it)) } + SyncTable.BOARD_SETTINGS -> + database().getBoardSettingsAfter(cursor, limit).map { SyncPendingRow(it.syncSeq, SyncWire.boardSetting(it)) } + SyncTable.BOARD_WARNINGS -> + database().getBoardWarningsAfter(cursor, limit).map { SyncPendingRow(it.syncSeq, SyncWire.boardWarning(it)) } + SyncTable.ALERTS -> + database().getAlertsAfter(cursor, limit).map { SyncPendingRow(it.syncSeq, SyncWire.alert(it)) } + SyncTable.TUNE_PROFILES -> + database().getTuneProfilesAfter(cursor, limit).map { SyncPendingRow(it.syncSeq, SyncWire.tuneProfile(it)) } + SyncTable.TUNE_HISTORY_ENTRIES -> + database().getTuneHistoryEntriesAfter(cursor, limit).map { SyncPendingRow(it.id, SyncWire.tuneHistoryEntry(it)) } + SyncTable.PRIVACY_ZONES -> + database().getPrivacyZonesAfter(cursor, limit).map { SyncPendingRow(it.syncSeq, SyncWire.privacyZone(it)) } + SyncTable.TELEMETRY_MARKERS -> + database().getTelemetryMarkersAfter(cursor, limit).map { SyncPendingRow(it.id, SyncWire.telemetryMarker(it)) } + SyncTable.METRIC_EXCLUSION_RANGES -> + database().getExclusionRangesAfter(cursor, limit).map { SyncPendingRow(it.id, SyncWire.metricExclusionRange(it)) } + SyncTable.DIAGNOSTIC_EVENTS -> + database().getDiagnosticEventsAfter(cursor, limit).map { SyncPendingRow(it.id, SyncWire.diagnosticEvent(it)) } + SyncTable.TELEMETRY_FRAMES -> + database().getTelemetryFramesAfter(cursor, limit).map { SyncPendingRow(it.id, SyncWire.telemetryFrame(it)) } + SyncTable.TELEMETRY_MINUTE_BUCKETS -> + database().getMinuteBucketsAfter(cursor, limit).map { SyncPendingRow(it.syncSeq, SyncWire.telemetryMinuteBucket(it)) } + SyncTable.FAVORITES -> + database().getFavoritesAfter(cursor, limit).map { SyncPendingRow(it.syncSeq, SyncWire.favorite(it)) } + SyncTable.DELETE_ACTIONS -> + database().getSyncActionsAfter(cursor, limit).map { SyncPendingRow(it.id, SyncWire.deleteAction(it)) } + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncTables.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncTables.kt new file mode 100644 index 000000000..6530d5876 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncTables.kt @@ -0,0 +1,80 @@ +package expo.modules.vescapecore.sync + +/** + * Every table a Sync Batch can carry, in the order the server writes them: a Board-owned row + * references its Board, so a batch carrying both has to put the Board first or the foreign key + * refuses the whole batch. Delete Actions come last, so an action is judged against the Change + * Timestamp the same batch just wrote. + * + * The batch builder walks this order and nothing else — never the size of a table's backlog, which + * would produce a batch the server cannot apply. + * + * [cursorColumn] is what the scan runs on: an `AUTOINCREMENT` key for append-only tables, `sync_seq` + * for mutable ones. Both are device-local counters that never cross the wire. + * + * @parity /modules/vescape-core/ios/sync/SyncTables.swift `SyncTable` + * @parity /modules/vescape-core/src/index.ts `SyncTable` + */ +enum class SyncTable(val wire: String, val table: String, val cursorColumn: String) { + APP_SETTINGS("appSettings", "app_settings", SYNC_SEQ_COLUMN), + BOARDS("boards", "boards", SYNC_SEQ_COLUMN), + BOARD_SETTINGS("boardSettings", "board_settings", SYNC_SEQ_COLUMN), + BOARD_WARNINGS("boardWarnings", "board_warnings", SYNC_SEQ_COLUMN), + ALERTS("alerts", "alerts", SYNC_SEQ_COLUMN), + TUNE_PROFILES("tuneProfiles", "tune_profiles", SYNC_SEQ_COLUMN), + TUNE_HISTORY_ENTRIES("tuneHistoryEntries", "tune_history_entries", ROW_ID_COLUMN), + PRIVACY_ZONES("privacyZones", "privacy_zones", SYNC_SEQ_COLUMN), + TELEMETRY_MARKERS("telemetryMarkers", "telemetry_markers", ROW_ID_COLUMN), + METRIC_EXCLUSION_RANGES("metricExclusionRanges", "metric_exclusion_ranges", ROW_ID_COLUMN), + DIAGNOSTIC_EVENTS("diagnosticEvents", "diagnostic_events", ROW_ID_COLUMN), + TELEMETRY_FRAMES("telemetryFrames", "telemetry_frames", ROW_ID_COLUMN), + TELEMETRY_MINUTE_BUCKETS("telemetryMinuteBuckets", "telemetry_minute_buckets", SYNC_SEQ_COLUMN), + FAVORITES("favorites", "favorites", SYNC_SEQ_COLUMN), + DELETE_ACTIONS("deleteActions", "sync_actions", ROW_ID_COLUMN), + ; + + /** + * `sync_sequences` key holding how far this table has been accepted. Distinct from the write + * counters keyed on the bare table name, which hand out `sync_seq` positions. + * + * Sync Actions keep the key #282 already shipped, so the log's prune keeps reading the same row + * the uploader commits. + */ + val cursorKey: String + get() = if (this == DELETE_ACTIONS) { + expo.modules.vescapecore.telemetry.SYNC_ACTIONS_UPLOADED_CURSOR + } else { + "$SYNC_CURSOR_PREFIX$table" + } +} + +internal const val SYNC_SEQ_COLUMN = "sync_seq" +internal const val ROW_ID_COLUMN = "id" +internal const val SYNC_CURSOR_PREFIX = "sync_cursor_" + +/** + * Rows accepted in one Sync Batch, total across every table. + * @parity /modules/vescape-core/ios/sync/SyncTables.swift `maxSyncBatchRows` + */ +const val MAX_SYNC_BATCH_ROWS = 1_000 + +/** + * Actual compact UTF-8 JSON bytes accepted by `POST /api/sync`. Measured on the encoded request, not + * estimated from object sizes — the server refuses on the byte count it actually receives. + * @parity /modules/vescape-core/ios/sync/SyncTables.swift `maxSyncBatchBytes` + */ +const val MAX_SYNC_BATCH_BYTES = 1024 * 1024 + +/** + * Longest text one column of a server key may hold. Mirrored from the server so a row that cannot be + * stored is refused here instead of wedging a batch. + * @parity /modules/vescape-core/ios/sync/SyncTables.swift `maxSyncKeyLength` + */ +const val MAX_SYNC_KEY_LENGTH = 128 + +/** Bounds of the Postgres `integer` columns the app's 32-bit values land in. */ +internal const val SYNC_INT32_MIN = -2_147_483_648L +internal const val SYNC_INT32_MAX = 2_147_483_647L + +/** `Number.MAX_SAFE_INTEGER`: past it `JSON.parse` rounds, so neither side could agree on the value. */ +internal const val SYNC_SAFE_INT_MAX = 9_007_199_254_740_991L diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncWire.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncWire.kt new file mode 100644 index 000000000..f440c2182 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncWire.kt @@ -0,0 +1,263 @@ +package expo.modules.vescapecore.sync + +import expo.modules.vescapecore.telemetry.AlertRuleEntity +import expo.modules.vescapecore.telemetry.AppSettingEntity +import expo.modules.vescapecore.telemetry.BoardEntity +import expo.modules.vescapecore.telemetry.BoardSettingEntity +import expo.modules.vescapecore.telemetry.BoardWarningEntity +import expo.modules.vescapecore.telemetry.DiagnosticEventEntity +import expo.modules.vescapecore.telemetry.FavoriteEntity +import expo.modules.vescapecore.telemetry.MetricExclusionRangeEntity +import expo.modules.vescapecore.telemetry.PrivacyZoneEntity +import expo.modules.vescapecore.telemetry.SyncActionEntity +import expo.modules.vescapecore.telemetry.TelemetryFrameEntity +import expo.modules.vescapecore.telemetry.TelemetryMarkerEntity +import expo.modules.vescapecore.telemetry.TelemetryMinuteBucketEntity +import expo.modules.vescapecore.telemetry.TuneHistoryEntryEntity +import expo.modules.vescapecore.telemetry.TuneProfileEntity + +/** + * Local rows as the server reads them. + * + * Every encoder is strongly typed and validates before transport, so a batch is refused here — with + * the row retained and one metadata-only Diagnostic Event — rather than wedging against the server. + * The field sets mirror `vescape-server` `src/sync/protocol.ts`; a column the server does not declare + * is not sent, because an unknown field rejects the whole batch. + * + * @parity /modules/vescape-core/ios/sync/SyncWire.swift + * @parity /modules/vescape-server/src/sync/protocol.ts + */ +object SyncWire { + fun appSetting(row: AppSettingEntity): String = SyncRowWriter(SyncTable.APP_SETTINGS) + .keyText("key", row.key) + .text("valueJson", row.valueJson) + .timestamp("updatedAt", row.updatedAt) + .build() + + /** + * `transport` is iOS-only; Android keeps it in board settings and sends null, exactly as the + * server's own comment describes. + */ + fun board(row: BoardEntity): String = SyncRowWriter(SyncTable.BOARDS) + .keyText("id", row.id) + .text("name", row.name) + .text("bleId", row.bleId) + .text("transport", null) + .timestamp("createdAt", row.createdAt) + .timestamp("updatedAt", row.updatedAt) + .build() + + fun boardSetting(row: BoardSettingEntity): String = SyncRowWriter(SyncTable.BOARD_SETTINGS) + .keyText("boardId", row.boardId) + .keyText("key", row.key) + .text("valueJson", row.valueJson) + .timestamp("updatedAt", row.updatedAt) + .build() + + fun boardWarning(row: BoardWarningEntity): String = SyncRowWriter(SyncTable.BOARD_WARNINGS) + .keyText("boardId", row.boardId) + .keyText("kind", row.kind) + .text("severity", row.severity) + .timestamp("firstDetectedAt", row.firstDetectedAt) + .timestamp("lastDetectedAt", row.lastDetectedAt) + .text("payloadJson", row.payloadJson) + .build() + + fun alert(row: AlertRuleEntity): String = SyncRowWriter(SyncTable.ALERTS) + .keyText("boardId", row.boardId) + .keyText("id", row.id) + .keyText("controlId", row.controlId) + .number("threshold", row.threshold) + .number("thresholdMax", row.thresholdMax) + .bool("enabled", row.enabled) + .text("soundType", row.soundType) + .text("source", row.source) + .timestamp("createdAt", row.createdAt) + .timestamp("updatedAt", row.updatedAt) + .build() + + fun tuneProfile(row: TuneProfileEntity): String = SyncRowWriter(SyncTable.TUNE_PROFILES) + .keyText("id", row.id) + .keyText("boardId", row.boardId) + // May legitimately be empty: the app defaults an unknown Refloat package version to `''`. + .derivedKeyText("refloatBaseVersion", row.refloatBaseVersion) + .text("name", row.name) + .text("icon", row.icon) + .text("color", row.color) + .text("fieldsJson", row.fieldsJson) + .timestamp("createdAt", row.createdAt) + .timestamp("updatedAt", row.updatedAt) + .build() + + /** Carries no id: the local one restarts on a fresh install, so identity is `(profileId, createdAt)`. */ + fun tuneHistoryEntry(row: TuneHistoryEntryEntity): String = + SyncRowWriter(SyncTable.TUNE_HISTORY_ENTRIES) + .keyText("profileId", row.profileId) + .text("fieldsJson", row.fieldsJson) + .timestamp("createdAt", row.createdAt) + .build() + + fun privacyZone(row: PrivacyZoneEntity): String = SyncRowWriter(SyncTable.PRIVACY_ZONES) + .keyText("id", row.id) + .text("preset", row.preset) + .text("name", row.name) + .bool("enabled", row.enabled) + .int32("centerLatitudeE7", row.centerLatitudeE7) + .int32("centerLongitudeE7", row.centerLongitudeE7) + .int32("radiusMeters", row.radiusMeters) + .timestamp("createdAt", row.createdAt) + .timestamp("updatedAt", row.updatedAt) + .build() + + fun telemetryMarker(row: TelemetryMarkerEntity): String = SyncRowWriter(SyncTable.TELEMETRY_MARKERS) + .timestamp("occurredAtMs", row.occurredAtMs) + .timestamp("elapsedRealtimeMs", row.elapsedRealtimeMs) + .keyText("type", row.type) + .derivedKeyText("deviceId", row.deviceId) + .text("deviceName", row.deviceName) + .text("message", row.message) + .timestamp("gapMs", row.gapMs) + .build() + + fun metricExclusionRange(row: MetricExclusionRangeEntity): String = + SyncRowWriter(SyncTable.METRIC_EXCLUSION_RANGES) + .derivedKeyText("deviceId", row.deviceId) + .text("reason", row.reason) + .timestamp("startMs", row.startMs) + .timestamp("endMs", row.endMs) + .count("sampleCount", row.sampleCount) + .build() + + fun diagnosticEvent(row: DiagnosticEventEntity): String = SyncRowWriter(SyncTable.DIAGNOSTIC_EVENTS) + .timestamp("occurredAtMs", row.occurredAtMs) + .timestamp("elapsedRealtimeMs", row.elapsedRealtimeMs) + .keyText("eventName", row.eventName) + .derivedKeyText("operation", row.operation) + .derivedKeyText("phase", row.phase) + .derivedKeyText("deviceId", row.deviceId) + .text("deviceName", row.deviceName) + .text("message", row.message) + .text("propertiesJson", row.propertiesJson) + .build() + + /** + * A Telemetry Sample as recorded: still delta-encoded, carrying the Changed Masks. The local row + * id and the per-row device columns never cross the wire — the Board reference replaces them + * (ADR-0028) and a restored phone's full re-upload has to be an idempotent no-op. + * + * A frame that names no Board cannot be encoded; [SyncSource] never offers one. + */ + fun telemetryFrame(row: TelemetryFrameEntity): String = SyncRowWriter(SyncTable.TELEMETRY_FRAMES) + .keyText( + "boardId", + row.boardId + ?: throw SyncProtocolException(SyncTable.TELEMETRY_FRAMES, "boardId", "must name a Board"), + ) + .timestamp("capturedAtMs", row.capturedAtMs) + .timestamp("elapsedRealtimeMs", row.elapsedRealtimeMs) + .int32("canId", row.canId) + .count("flags", row.flags) + .count("changedMask1", row.changedMask1) + .count("changedMask2", row.changedMask2) + .int32("speedCentiKmh", row.speedCentiKmh) + .int32("batteryVoltageMv", row.batteryVoltageMv) + .int32("motorCurrentMa", row.motorCurrentMa) + .int32("batteryCurrentMa", row.batteryCurrentMa) + .int32("dutyPermille", row.dutyPermille) + .int32("pitchCentiDeg", row.pitchCentiDeg) + .int32("rollCentiDeg", row.rollCentiDeg) + .int32("balancePitchCentiDeg", row.balancePitchCentiDeg) + .int32("balanceCurrentMa", row.balanceCurrentMa) + .int32("erpm", row.erpm) + .int32("state", row.state) + .int32("switchState", row.switchState) + .int32("adc1Milli", row.adc1Milli) + .int32("adc2Milli", row.adc2Milli) + .int64("odometerCm", row.odometerCm) + .int32("tempMosfetDeciC", row.tempMosfetDeciC) + .int32("tempMotorDeciC", row.tempMotorDeciC) + .int32("faultCode", row.faultCode) + .int32("latitudeE7", row.latitudeE7) + .int32("longitudeE7", row.longitudeE7) + .int32("gpsSpeedCentiMps", row.gpsSpeedCentiMps) + .int32("bearingCentiDeg", row.bearingCentiDeg) + .int32("accuracyCm", row.accuracyCm) + .int32("altitudeCm", row.altitudeCm) + .timestamp("locationTimestampMs", row.locationTimestampMs) + .build() + + fun telemetryMinuteBucket(row: TelemetryMinuteBucketEntity): String = + SyncRowWriter(SyncTable.TELEMETRY_MINUTE_BUCKETS) + .keyText("boardId", row.boardId) + .timestamp("bucketStartMs", row.bucketStartMs) + .timestamp("updatedAt", row.updatedAt) + .count("sampleCount", row.sampleCount) + .timestamp("firstSampleAtMs", row.firstSampleAtMs) + .timestamp("lastSampleAtMs", row.lastSampleAtMs) + .int64("sumAbsSpeedCentiKmh", row.sumAbsSpeedCentiKmh) + .count("movingSpeedSampleCount", row.movingSpeedSampleCount) + .int64("sumMovingAbsSpeedCentiKmh", row.sumMovingAbsSpeedCentiKmh) + .int32("maxAbsSpeedCentiKmh", row.maxAbsSpeedCentiKmh) + .int32("minBatteryVoltageMv", row.minBatteryVoltageMv) + .int32("maxMotorCurrentAbsMa", row.maxMotorCurrentAbsMa) + .int32("maxBatteryCurrentAbsMa", row.maxBatteryCurrentAbsMa) + .int64("batteryUsedWhMilli", row.batteryUsedWhMilli) + .int64("batteryRegenWhMilli", row.batteryRegenWhMilli) + .int32("maxDutyAbsPermille", row.maxDutyAbsPermille) + .count("faultCount", row.faultCount) + .int64("firstOdometerCm", row.firstOdometerCm) + .int64("lastOdometerCm", row.lastOdometerCm) + .count("gpsPointCount", row.gpsPointCount) + .count("preciseGpsPointCount", row.preciseGpsPointCount) + .int64("gpsDistanceCm", row.gpsDistanceCm) + .int32("maxGpsSpeedCentiMps", row.maxGpsSpeedCentiMps) + .int32("maxTempMosfetDeciC", row.maxTempMosfetDeciC) + .int32("maxTempMotorDeciC", row.maxTempMotorDeciC) + .int32("firstLatitudeE7", row.firstLatitudeE7) + .int32("firstLongitudeE7", row.firstLongitudeE7) + .timestamp("firstMovingAtMs", row.firstMovingAtMs) + .timestamp("lastMovingAtMs", row.lastMovingAtMs) + .build() + + /** The Board name is resolved on read rather than snapshotted, so none crosses the wire. */ + fun favorite(row: FavoriteEntity): String = SyncRowWriter(SyncTable.FAVORITES) + .keyText("id", row.id) + .nullableKeyText("boardId", row.boardId) + .text("name", row.name) + .timestamp("startMs", row.startMs) + .timestamp("endMs", row.endMs) + .timestamp("createdAt", row.createdAt) + .timestamp("updatedAt", row.updatedAt) + .count("sampleCount", row.sampleCount) + .count("gpsPointCount", row.gpsPointCount) + .int64("distanceCm", row.distanceCm) + .timestamp("movingDurationMs", row.movingDurationMs) + .int32("avgSpeedCentiKmh", row.avgSpeedCentiKmh) + .int32("maxSpeedCentiKmh", row.maxSpeedCentiKmh) + .int64("batteryUsedWhMilli", row.batteryUsedWhMilli) + .build() + + /** + * One Sync Action, flat: the target, the identity within that target's scope, and when the Rider + * removed it. The log's own `board_id`/`key` pair expands into the identity fields the server + * declares for that target, so an action reads like the row it names. + */ + fun deleteAction(row: SyncActionEntity): String { + val writer = SyncRowWriter(SyncTable.DELETE_ACTIONS).keyText("target", row.target) + when (row.target) { + "appSetting" -> writer.keyText("key", row.key) + "board" -> writer.keyText("id", row.id()) + "boardSetting" -> writer.keyText("boardId", row.board()).keyText("key", row.key) + "boardWarning" -> writer.keyText("boardId", row.board()).keyText("kind", row.key) + "alert" -> writer.keyText("boardId", row.board()).keyText("id", row.key) + "tuneProfile", "privacyZone", "favorite" -> writer.keyText("id", row.key) + else -> throw SyncProtocolException(SyncTable.DELETE_ACTIONS, "target", "is not a known target") + } + return writer.timestamp("deletedAt", row.deletedAt).build() + } + + private fun SyncActionEntity.id(): String = key + + private fun SyncActionEntity.board(): String = boardId + ?: throw SyncProtocolException(SyncTable.DELETE_ACTIONS, "boardId", "is missing for $target") +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/DatabaseBackupManager.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/DatabaseBackupManager.kt index 37889aa1f..2757d4297 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/DatabaseBackupManager.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/DatabaseBackupManager.kt @@ -95,6 +95,28 @@ object DatabaseBackupManager { } } + /** + * Replace the app-data database with an empty one, taking the Sync Cursors, the pending Sync + * Actions and the Account binding with it (#284). + * + * Deleting the file rather than clearing tables is what makes the Account change safe: nothing can + * survive with a cursor position or a binding that belonged to the previous Account. The wipe is + * local maintenance and emits no Sync Actions to either Account — the log is part of what goes. + * + * @parity /modules/vescape-core/ios/telemetry/DatabaseBackupManager.swift `replaceWithFreshDatabase` + */ + fun replaceWithFreshDatabase(context: Context) { + val appContext = context.applicationContext + resetRepositoriesAndCloseDatabase() + + val dbFile = appContext.getDatabasePath(TELEMETRY_DATABASE_NAME) + dbFile.delete() + sidecarFiles(dbFile).forEach { it.delete() } + + // Opening rebuilds the schema from the entities, so the new database starts unbound. + TelemetryDatabase.get(appContext).openHelper.readableDatabase.query("SELECT 1").close() + } + private fun extractBackup(context: Context, uriString: String, restoredDb: File): JSONObject { var manifest: JSONObject? = null val uri = Uri.parse(uriString) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index ef118ff40..0abc4c130 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -230,6 +230,182 @@ interface TelemetryDao { return deleteSyncActionsThrough(accepted) } + // Sync Cursors — the uploader's forward scan (#284). Mutable tables scan on `sync_seq`, + // append-only tables on their `AUTOINCREMENT` key; both are device-local counters that never + // cross the wire. + // @parity /modules/vescape-core/ios/sync/SyncStore.swift + + /** + * Checkpoint how far [name] has been accepted. Its own transaction, run after the response and + * never alongside the rows: a cursor advanced past rows the server did not take is unrecoverable, + * whereas a cursor left behind is a re-send the server upserts idempotently. Never moves + * backwards, so an out-of-order commit cannot un-accept an earlier one. + */ + @Transaction + suspend fun commitSyncCursor(name: String, throughValue: Long) = + commitSyncActionCursorRow(name, throughValue) + + @Query("SELECT * FROM app_settings WHERE sync_seq > :cursor ORDER BY sync_seq ASC LIMIT :limit") + suspend fun getAppSettingsAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM boards WHERE sync_seq > :cursor ORDER BY sync_seq ASC LIMIT :limit") + suspend fun getBoardsAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM board_settings WHERE sync_seq > :cursor ORDER BY sync_seq ASC LIMIT :limit") + suspend fun getBoardSettingsAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM board_warnings WHERE sync_seq > :cursor ORDER BY sync_seq ASC LIMIT :limit") + suspend fun getBoardWarningsAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM alerts WHERE sync_seq > :cursor ORDER BY sync_seq ASC LIMIT :limit") + suspend fun getAlertsAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM tune_profiles WHERE sync_seq > :cursor ORDER BY sync_seq ASC LIMIT :limit") + suspend fun getTuneProfilesAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM tune_history_entries WHERE id > :cursor ORDER BY id ASC LIMIT :limit") + suspend fun getTuneHistoryEntriesAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM privacy_zones WHERE sync_seq > :cursor ORDER BY sync_seq ASC LIMIT :limit") + suspend fun getPrivacyZonesAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM telemetry_markers WHERE id > :cursor ORDER BY id ASC LIMIT :limit") + suspend fun getTelemetryMarkersAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM metric_exclusion_ranges WHERE id > :cursor ORDER BY id ASC LIMIT :limit") + suspend fun getExclusionRangesAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM diagnostic_events WHERE id > :cursor ORDER BY id ASC LIMIT :limit") + suspend fun getDiagnosticEventsAfter(cursor: Long, limit: Int): List + + /** + * Frames that name no Board cannot be uploaded — the server keys this table on the Board and has + * nowhere to put a sample that belongs to none (ADR-0028) — so the scan does not offer them and + * the cursor moves over them. They are unowned local rows, not rows a Rider is waiting to see + * backed up. + */ + @Query( + "SELECT * FROM telemetry_frames WHERE id > :cursor AND board_id IS NOT NULL " + + "ORDER BY id ASC LIMIT :limit", + ) + suspend fun getTelemetryFramesAfter(cursor: Long, limit: Int): List + + /** Buckets whose Board is the unknown-Board sentinel are unowned in the same way as a frame. */ + @Query( + "SELECT * FROM telemetry_minute_buckets WHERE sync_seq > :cursor AND board_id != '' " + + "ORDER BY sync_seq ASC LIMIT :limit", + ) + suspend fun getMinuteBucketsAfter(cursor: Long, limit: Int): List + + @Query("SELECT * FROM favorites WHERE sync_seq > :cursor ORDER BY sync_seq ASC LIMIT :limit") + suspend fun getFavoritesAfter(cursor: Long, limit: Int): List + + @Query("SELECT COUNT(*) FROM app_settings WHERE sync_seq > :cursor") + suspend fun countAppSettingsAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM boards WHERE sync_seq > :cursor") + suspend fun countBoardsAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM board_settings WHERE sync_seq > :cursor") + suspend fun countBoardSettingsAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM board_warnings WHERE sync_seq > :cursor") + suspend fun countBoardWarningsAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM alerts WHERE sync_seq > :cursor") + suspend fun countAlertsAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM tune_profiles WHERE sync_seq > :cursor") + suspend fun countTuneProfilesAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM tune_history_entries WHERE id > :cursor") + suspend fun countTuneHistoryEntriesAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM privacy_zones WHERE sync_seq > :cursor") + suspend fun countPrivacyZonesAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM telemetry_markers WHERE id > :cursor") + suspend fun countTelemetryMarkersAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM metric_exclusion_ranges WHERE id > :cursor") + suspend fun countExclusionRangesAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM diagnostic_events WHERE id > :cursor") + suspend fun countDiagnosticEventsAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM telemetry_frames WHERE id > :cursor AND board_id IS NOT NULL") + suspend fun countTelemetryFramesAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM telemetry_minute_buckets WHERE sync_seq > :cursor AND board_id != ''") + suspend fun countMinuteBucketsAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM favorites WHERE sync_seq > :cursor") + suspend fun countFavoritesAfter(cursor: Long): Int + + @Query("SELECT COUNT(*) FROM sync_actions WHERE id > :cursor") + suspend fun countSyncActionsAfter(cursor: Long): Int + + // Account binding — which Vescape Account this local database belongs to (#284). One row, so a + // database replaced on an Account change starts unbound with no cursors and no actions. + + @Query("SELECT account_id FROM sync_binding WHERE id = 0") + suspend fun getBoundAccountId(): String? + + @Query("INSERT OR REPLACE INTO sync_binding (id, account_id, bound_at) VALUES (0, :accountId, :boundAt)") + suspend fun bindAccountRow(accountId: String, boundAt: Long) + + /** + * Claim this database for [accountId], or confirm it already belongs to it. Returns false when it + * belongs to a different Account: the caller has to replace the database first, because resetting + * the cursors over these rows would upload the previous Account's data to the new one. + */ + @Transaction + suspend fun bindAccount(accountId: String, now: Long = System.currentTimeMillis()): Boolean { + val bound = getBoundAccountId() + if (bound != null) return bound == accountId + bindAccountRow(accountId, now) + return true + } + + // Cursor-gated retention (#284). A retention cutoff is only a candidate cutoff: cleanup must not + // remove a row the uploader has not delivered. Each sweep reads its table cursor and deletes in + // one transaction, so racing an upload fails safe — before the cursor commit the rows are + // retained, after it the server has accepted them. A missing cursor is 0, protecting every row. + + @Query("DELETE FROM telemetry_frames WHERE captured_at_ms < :beforeMs AND id <= :cursor") + suspend fun deleteFramesBeforeUpTo(beforeMs: Long, cursor: Long): Int + + @Query("DELETE FROM telemetry_markers WHERE occurred_at_ms < :beforeMs AND id <= :cursor") + suspend fun deleteMarkersBeforeUpTo(beforeMs: Long, cursor: Long): Int + + @Query("DELETE FROM telemetry_minute_buckets WHERE bucket_start_ms < :beforeMs AND sync_seq <= :cursor") + suspend fun deleteBucketsBeforeUpTo(beforeMs: Long, cursor: Long): Int + + @Query("DELETE FROM diagnostic_events WHERE occurred_at_ms < :beforeMs AND id <= :cursor") + suspend fun deleteDiagnosticEventsBeforeUpTo(beforeMs: Long, cursor: Long): Int + + @Query("DELETE FROM metric_exclusion_ranges WHERE end_ms < :beforeMs AND id <= :cursor") + suspend fun deleteExclusionsBeforeUpTo(beforeMs: Long, cursor: Long): Int + + /** + * Age-only cleanup while the database has never been bound to an Account, and age plus the + * accepted Sync Cursor once it has. Emits no Sync Actions — a retention sweep is maintenance, and + * `DeleteTarget` has no case that could name a pruned table. + */ + @Transaction + suspend fun deleteBeforeGated(beforeMs: Long): Int { + if (getBoundAccountId() == null) return deleteBefore(beforeMs) + val frames = deleteFramesBeforeUpTo(beforeMs, cursorOf(SYNC_CURSOR_FRAMES)) + deleteMarkersBeforeUpTo(beforeMs, cursorOf(SYNC_CURSOR_MARKERS)) + deleteBucketsBeforeUpTo(beforeMs, cursorOf(SYNC_CURSOR_MINUTE_BUCKETS)) + deleteDiagnosticEventsBeforeUpTo(beforeMs, cursorOf(SYNC_CURSOR_DIAGNOSTIC_EVENTS)) + deleteExclusionsBeforeUpTo(beforeMs, cursorOf(SYNC_CURSOR_EXCLUSION_RANGES)) + return frames + } + + /** A table with no committed cursor has delivered nothing, so none of its rows may be pruned. */ + suspend fun cursorOf(name: String): Long = getSyncSequence(name) ?: 0L + @Transaction suspend fun insertBatch( frames: List, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index c958e58b1..405cd7b35 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -12,7 +12,7 @@ import java.io.File // @parity /modules/vescape-core/ios/VescapeCoreModule.swift internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" -internal const val TELEMETRY_DATABASE_VERSION = 37 +internal const val TELEMETRY_DATABASE_VERSION = 38 @Database( entities = [ @@ -31,6 +31,7 @@ internal const val TELEMETRY_DATABASE_VERSION = 37 BoardWarningEntity::class, SyncSequenceEntity::class, SyncActionEntity::class, + SyncBindingEntity::class, FavoriteEntity::class, FavoriteMediaEntity::class, ], @@ -673,6 +674,28 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * The Account binding (#284): which Vescape Account this local database belongs to. Additive and + * guarded, and deliberately left empty — an existing install is unbound until an Account signs + * in and claims it, which is also what keeps the current age-only retention behaviour until + * then. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v38_sync_binding` + */ + internal val MIGRATION_37_38 = object : Migration(37, 38) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS sync_binding ( + id INTEGER PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL, + bound_at INTEGER NOT NULL + ) + """.trimIndent(), + ) + } + } + /** * Telemetry whose `device_id` matches no Board would lose both its identity and its label: * either the Board was hard-deleted before tombstones existed (ADR 0027), or it was re-linked @@ -1080,6 +1103,7 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_34_35, MIGRATION_35_36, MIGRATION_36_37, + MIGRATION_37_38, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index 686ddf575..fb9b9ea59 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -500,6 +500,42 @@ data class SyncActionEntity( /** [SyncSequenceEntity] key holding the highest action cursor the server has accepted. */ internal const val SYNC_ACTIONS_UPLOADED_CURSOR = "sync_actions_uploaded" +/** + * [SyncSequenceEntity] keys holding how far each table has been accepted — the Sync Cursors the + * uploader commits and cursor-gated retention reads back. Prefixed so a cursor can never collide + * with the write counters, which are keyed on the bare table name. + * + * The five below are the retained tables; every other table's key is derived the same way from + * `SyncTable`, and a test pins the two spellings together. + */ +internal const val SYNC_CURSOR_PREFIX = "sync_cursor_" +internal const val SYNC_CURSOR_FRAMES = "sync_cursor_telemetry_frames" +internal const val SYNC_CURSOR_MARKERS = "sync_cursor_telemetry_markers" +internal const val SYNC_CURSOR_MINUTE_BUCKETS = "sync_cursor_telemetry_minute_buckets" +internal const val SYNC_CURSOR_DIAGNOSTIC_EVENTS = "sync_cursor_diagnostic_events" +internal const val SYNC_CURSOR_EXCLUSION_RANGES = "sync_cursor_metric_exclusion_ranges" + +/** + * Which Vescape Account this local database belongs to. One row, claimed by the first Account to + * sign in and never rewritten in place: a different Account replaces the whole database, because + * resetting the cursors over these rows would upload the previous Account's Boards, Ride History, + * locations and settings to the new one. + * + * Signing out does not clear the binding, so data recorded while signed out keeps its retention + * protection for the same Account. + * + * @parity /modules/vescape-core/ios/sync/SyncStore.swift `createSyncBindingTable` + */ +@Entity(tableName = "sync_binding") +data class SyncBindingEntity( + @PrimaryKey + val id: Int = 0, + @ColumnInfo(name = "account_id") + val accountId: String, + @ColumnInfo(name = "bound_at") + val boundAt: Long, +) + @Entity( tableName = "metric_exclusion_ranges", indices = [ diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index 732c155dc..d5b6c4d0e 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.SystemClock import android.util.Log import expo.modules.kotlin.jni.NativeArrayBuffer +import expo.modules.vescapecore.sync.SyncCoordinator import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.UUID @@ -549,8 +550,13 @@ class TelemetryRepository private constructor(context: Context) { dao.clearDiagnosticEvents() } + /** + * Retention sweep. Age-only while this database has never been bound to an Account, and age plus + * the accepted Sync Cursor once it has — cleanup must not remove a row the uploader has not + * delivered (#284). + */ suspend fun deleteBefore(beforeMs: Long): Int = withContext(Dispatchers.IO) { - dao.deleteBefore(beforeMs) + dao.deleteBeforeGated(beforeMs) } suspend fun deleteRange(options: Map): Int = withContext(Dispatchers.IO) { @@ -882,6 +888,9 @@ class TelemetryRepository private constructor(context: Context) { markers = markers, exclusions = sanitization.exclusions, ) + // Samples are actually being produced, which is what the uploader's ride cadence follows — + // Idle Pause halts production without ending the Board Session. + SyncCoordinator.get(appContext).notifySamplesPersisted() } catch (e: Exception) { Log.w(TAG, "Telemetry flush failed: ${e.message}") } diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncAcceptedTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncAcceptedTest.kt new file mode 100644 index 000000000..7104fc110 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncAcceptedTest.kt @@ -0,0 +1,57 @@ +package expo.modules.vescapecore.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The `200` body is the last thing standing between an accepted batch and a cursor that can never be + * walked back, so it is validated exactly rather than trusted. + * + * @parity /modules/vescape-core/ios/sync/SyncAcceptedTests.swift + */ +class SyncAcceptedTest { + private fun body(counts: Map = emptyMap(), tables: List = SyncTable.entries): String { + val pairs = tables.joinToString(",") { "\"${it.wire}\":${counts[it] ?: 0}" } + return "{\"accepted\":{$pairs}}" + } + + @Test + fun `every table accounted for parses`() { + val parsed = SyncAccepted.parse(body(mapOf(SyncTable.BOARDS to 3))) + assertEquals(3, parsed?.get(SyncTable.BOARDS)) + assertEquals(0, parsed?.get(SyncTable.FAVORITES)) + } + + @Test + fun `a missing table, an extra table or a duplicate is refused`() { + assertNull(SyncAccepted.parse(body(tables = SyncTable.entries.drop(1)))) + assertNull(SyncAccepted.parse("{\"accepted\":{\"unknownTable\":0}}")) + assertNull(SyncAccepted.parse("{\"accepted\":{\"boards\":1,\"boards\":1}}")) + } + + @Test + fun `anything that is not this response is refused rather than half-read`() { + assertNull(SyncAccepted.parse("")) + assertNull(SyncAccepted.parse("{}")) + assertNull(SyncAccepted.parse("{\"ok\":true}")) + assertNull(SyncAccepted.parse(body() + "trailing")) + } + + @Test + fun `counts have to equal what was submitted, table by table`() { + val submitted = mapOf(SyncTable.BOARDS to 2) + assertTrue(SyncAccepted.matches(submitted, SyncAccepted.parse(body(submitted))!!)) + assertFalse( + SyncAccepted.matches(submitted, SyncAccepted.parse(body(mapOf(SyncTable.BOARDS to 1)))!!), + ) + assertFalse( + SyncAccepted.matches( + submitted, + SyncAccepted.parse(body(mapOf(SyncTable.BOARDS to 2, SyncTable.ALERTS to 1)))!!, + ), + ) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncBatchBuilderTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncBatchBuilderTest.kt new file mode 100644 index 000000000..b1c588342 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncBatchBuilderTest.kt @@ -0,0 +1,117 @@ +package expo.modules.vescapecore.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The batch builder is pure: no database, no clock, no network. What it has to get right is the + * order tables go out in, the two caps, and an advance set that describes exactly the rows sent. + * + * @parity /modules/vescape-core/ios/sync/SyncBatchBuilderTests.swift + */ +class SyncBatchBuilderTest { + private fun rows(count: Int, size: Int = 10, from: Long = 1): List = + (0 until count).map { SyncPendingRow(from + it, "\"" + "x".repeat(size) + "\"") } + + @Test + fun `walks server table order regardless of backlog size`() { + val built = SyncBatchBuilder.build( + listOf( + SyncPendingTable(SyncTable.TELEMETRY_FRAMES, rows(5)), + SyncPendingTable(SyncTable.BOARDS, rows(1)), + SyncPendingTable(SyncTable.APP_SETTINGS, rows(1)), + ), + ) as SyncBatchBuild.Ready + + assertEquals( + listOf(SyncTable.APP_SETTINGS, SyncTable.BOARDS, SyncTable.TELEMETRY_FRAMES), + built.counts.keys.toList(), + ) + assertTrue(built.body.indexOf("appSettings") < built.body.indexOf("boards")) + assertTrue(built.body.indexOf("boards") < built.body.indexOf("telemetryFrames")) + } + + @Test + fun `advance set names the last row actually included, per table`() { + val built = SyncBatchBuilder.build( + listOf( + SyncPendingTable(SyncTable.BOARDS, rows(2, from = 40)), + SyncPendingTable(SyncTable.FAVORITES, rows(3, from = 7)), + ), + rowCap = 4, + ) as SyncBatchBuild.Ready + + assertEquals(4, built.rowCount) + assertEquals(mapOf(SyncTable.BOARDS to 2, SyncTable.FAVORITES to 2), built.counts) + assertEquals(mapOf(SyncTable.BOARDS to 41L, SyncTable.FAVORITES to 8L), built.advances) + } + + @Test + fun `exactly-at and one-over the row cap behave the same way on every platform`() { + val atCap = SyncBatchBuilder.build( + listOf(SyncPendingTable(SyncTable.BOARDS, rows(3))), + rowCap = 3, + ) as SyncBatchBuild.Ready + assertEquals(3, atCap.rowCount) + + val overCap = SyncBatchBuilder.build( + listOf(SyncPendingTable(SyncTable.BOARDS, rows(4))), + rowCap = 3, + ) as SyncBatchBuild.Ready + assertEquals(3, overCap.rowCount) + assertEquals(3L, overCap.advances.getValue(SyncTable.BOARDS)) + } + + /** The cap is on the bytes actually sent, so the encoded body is what gets measured. */ + @Test + fun `byte cap counts the encoded body, boundary included`() { + val one = SyncBatchBuilder.build( + listOf(SyncPendingTable(SyncTable.BOARDS, rows(2, size = 8))), + byteCap = Int.MAX_VALUE, + ) as SyncBatchBuild.Ready + assertEquals(one.body.toByteArray(Charsets.UTF_8).size, one.byteCount) + + val atCap = SyncBatchBuilder.build( + listOf(SyncPendingTable(SyncTable.BOARDS, rows(2, size = 8))), + byteCap = one.byteCount, + ) as SyncBatchBuild.Ready + assertEquals(2, atCap.rowCount) + + val oneUnder = SyncBatchBuilder.build( + listOf(SyncPendingTable(SyncTable.BOARDS, rows(2, size = 8))), + byteCap = one.byteCount - 1, + ) as SyncBatchBuild.Ready + assertEquals(1, oneUnder.rowCount) + assertEquals(oneUnder.body.toByteArray(Charsets.UTF_8).size, oneUnder.byteCount) + } + + /** Multi-byte characters count as their UTF-8 bytes, not as characters. */ + @Test + fun `measures utf-8 bytes rather than characters`() { + val row = SyncPendingRow(1, "\"ąęółśż\"") + val built = SyncBatchBuilder.build( + listOf(SyncPendingTable(SyncTable.BOARDS, listOf(row))), + ) as SyncBatchBuild.Ready + assertEquals(built.body.toByteArray(Charsets.UTF_8).size, built.byteCount) + } + + @Test + fun `a row no empty batch could carry is a permanent error, not a silent skip`() { + val huge = SyncPendingRow(9, "\"" + "x".repeat(500) + "\"") + val built = SyncBatchBuilder.build( + listOf(SyncPendingTable(SyncTable.BOARDS, listOf(huge))), + byteCap = 100, + ) + assertEquals(SyncBatchBuild.RowTooLarge(SyncTable.BOARDS, 9, huge.byteCount), built) + } + + @Test + fun `nothing pending is idle, not an empty batch`() { + assertEquals(SyncBatchBuild.Empty, SyncBatchBuilder.build(emptyList())) + assertEquals( + SyncBatchBuild.Empty, + SyncBatchBuilder.build(listOf(SyncPendingTable(SyncTable.BOARDS, emptyList()))), + ) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncCursorContractTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncCursorContractTest.kt new file mode 100644 index 000000000..b99da9073 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncCursorContractTest.kt @@ -0,0 +1,87 @@ +package expo.modules.vescapecore.sync + +import expo.modules.vescapecore.telemetry.SYNC_ACTIONS_UPLOADED_CURSOR +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The two contracts the uploader cannot express in code alone: the cursor key each table commits + * under, and the promise that retention deletes nothing the uploader has not delivered. + * + * Room keeps its SQL out of reach of a JVM test, so the retention half is asserted against the DAO + * source — the same technique the Sync Action classification test uses. + * + * @parity /modules/vescape-core/ios/sync/SyncCursorContractTests.swift + */ +class SyncCursorContractTest { + private fun daoSource(): String = + File("src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt").readText() + + /** The retained tables, and the column whose cursor decides what may be pruned. */ + private val gatedSweeps = mapOf( + "deleteFramesBeforeUpTo" to "id <= :cursor", + "deleteMarkersBeforeUpTo" to "id <= :cursor", + "deleteBucketsBeforeUpTo" to "sync_seq <= :cursor", + "deleteDiagnosticEventsBeforeUpTo" to "id <= :cursor", + "deleteExclusionsBeforeUpTo" to "id <= :cursor", + ) + + @Test + fun `every retained table prunes only up to its accepted cursor`() { + val source = daoSource() + for ((name, predicate) in gatedSweeps) { + val declaration = source.substringBefore("suspend fun $name") + val query = declaration.substringAfterLast("@Query(") + assertTrue("$name must gate on $predicate", query.contains(predicate)) + assertTrue("$name must still apply the age cutoff", query.contains("< :beforeMs")) + } + } + + /** + * A mutable bucket is protected by `sync_seq`, not by its row id: a bucket rewritten after an + * earlier version uploaded gets a fresh position and has to survive until that one is accepted. + */ + @Test + fun `minute buckets are gated on the counter their scan runs on`() { + assertEquals(SYNC_SEQ_COLUMN, SyncTable.TELEMETRY_MINUTE_BUCKETS.cursorColumn) + assertEquals(ROW_ID_COLUMN, SyncTable.TELEMETRY_FRAMES.cursorColumn) + } + + @Test + fun `cursor keys are namespaced away from the write counters`() { + val keys = SyncTable.entries.map { it.cursorKey } + assertEquals(keys.size, keys.toSet().size) + for (table in SyncTable.entries - SyncTable.DELETE_ACTIONS) { + assertEquals("$SYNC_CURSOR_PREFIX${table.table}", table.cursorKey) + } + // Sync Actions keep the key #282 shipped, so the log's prune reads what the uploader commits. + assertEquals(SYNC_ACTIONS_UPLOADED_CURSOR, SyncTable.DELETE_ACTIONS.cursorKey) + } + + /** Parents before children, and Delete Actions last: the order the server applies a batch in. */ + @Test + fun `table order matches the server's declared batch order`() { + assertEquals( + listOf( + "appSettings", + "boards", + "boardSettings", + "boardWarnings", + "alerts", + "tuneProfiles", + "tuneHistoryEntries", + "privacyZones", + "telemetryMarkers", + "metricExclusionRanges", + "diagnosticEvents", + "telemetryFrames", + "telemetryMinuteBuckets", + "favorites", + "deleteActions", + ), + SyncTable.entries.map { it.wire }, + ) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncEngineTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncEngineTest.kt new file mode 100644 index 000000000..81d07031b --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncEngineTest.kt @@ -0,0 +1,220 @@ +package expo.modules.vescapecore.sync + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The engine against a fake transport: the cases that decide whether a Rider's data survives — a + * wedged batch, a failure part-way through a drain, a dead token, and a response that outlived the + * Account it was sent for. + * + * @parity /modules/vescape-core/ios/sync/SyncEngineTests.swift + */ +class SyncEngineTest { + private class FakeSource(rows: Int = 3) : SyncSource { + var remaining = rows + val committed = mutableListOf>() + var generation = 0L + var failures = mutableListOf>() + var encodeFailure: SyncProtocolException? = null + var rowJson = "\"row\"" + + override suspend fun pending(rowLimit: Int): List { + encodeFailure?.let { throw it } + if (remaining <= 0) return emptyList() + val take = minOf(remaining, 2) + val sent = (0 until take).map { SyncPendingRow(cursor = (it + 1).toLong(), json = rowJson) } + return listOf(SyncPendingTable(SyncTable.BOARDS, sent)) + } + + override suspend fun pendingCount(): Int = remaining + + override suspend fun commit(advances: Map) { + committed += advances + remaining -= advances.size.let { 2 }.coerceAtMost(remaining) + } + + override fun generation(): Long = generation + + override suspend fun recordPermanentFailure(reason: SyncPauseReason, detail: String) { + failures += reason to detail + } + } + + private fun accepted(boards: Int): String { + val counts = SyncTable.entries.joinToString(",") { + "\"${it.wire}\":${if (it == SyncTable.BOARDS) boards else 0}" + } + return "{\"accepted\":{$counts}}" + } + + private fun engine( + source: SyncSource, + responses: MutableList, + sent: MutableList = mutableListOf(), + ) = SyncEngine( + source = source, + transport = { body -> + sent += body + responses.removeAt(0) + }, + environment = { + SyncEnvironment( + ridingSamples = false, + online = true, + wifiOnly = false, + onWifi = false, + credentialReady = true, + onlineBlocked = false, + ) + }, + clock = { 1_000 }, + ) + + @Test + fun `a valid 200 advances only the rows it accounted for`() = runBlocking { + val source = FakeSource(rows = 2) + val engine = engine(source, mutableListOf(SyncResponse.Accepted(accepted(2)))) + + assertEquals(SyncPass.Sent(2, morePending = false), engine.runOnce()) + assertEquals(listOf(mapOf(SyncTable.BOARDS to 2L)), source.committed) + } + + @Test + fun `a mismatched accepted count is a protocol failure and moves no cursor`() = runBlocking { + val source = FakeSource(rows = 2) + val engine = engine(source, mutableListOf(SyncResponse.Accepted(accepted(1)))) + + assertEquals(SyncPass.Paused(SyncPauseReason.PROTOCOL), engine.runOnce()) + assertTrue(source.committed.isEmpty()) + assertEquals(listOf(SyncPauseReason.PROTOCOL to "acceptedMismatch"), source.failures) + } + + @Test + fun `a malformed success body never advances a cursor`() = runBlocking { + val source = FakeSource(rows = 2) + val engine = engine(source, mutableListOf(SyncResponse.Accepted("not json"))) + + assertEquals(SyncPass.Paused(SyncPauseReason.PROTOCOL), engine.runOnce()) + assertTrue(source.committed.isEmpty()) + } + + @Test + fun `a refused batch leaves every cursor untouched and does not retry on a kick`() = runBlocking { + val source = FakeSource(rows = 2) + val engine = engine( + source, + mutableListOf(SyncResponse.Invalid(409, "dependency-conflict")), + ) + + assertEquals(SyncPass.Paused(SyncPauseReason.PROTOCOL), engine.runOnce()) + assertTrue(source.committed.isEmpty()) + // No second response is queued, so a pass that sent again would fail the test outright. + assertEquals(SyncPass.Paused(SyncPauseReason.PROTOCOL), engine.runOnce()) + } + + @Test + fun `a failure part-way through a drain leaves cursors at the last accepted batch`() = runBlocking { + val source = FakeSource(rows = 4) + val engine = engine( + source, + mutableListOf( + SyncResponse.Accepted(accepted(2)), + SyncResponse.Transient("5xx"), + ), + ) + + assertEquals(SyncPass.Sent(2, morePending = true), engine.runOnce()) + val second = engine.runOnce() + assertTrue(second is SyncPass.Waiting) + assertEquals(listOf(mapOf(SyncTable.BOARDS to 2L)), source.committed) + } + + @Test + fun `a dead token stops the loop for sign-in`() = runBlocking { + val source = FakeSource(rows = 2) + val engine = engine(source, mutableListOf(SyncResponse.Unauthorized)) + + assertEquals(SyncPass.Paused(SyncPauseReason.AUTHENTICATION), engine.runOnce()) + assertEquals(SyncPauseReason.AUTHENTICATION, engine.pauseReason) + assertTrue(source.committed.isEmpty()) + } + + @Test + fun `a response from the previous Account cannot advance a cursor`() = runBlocking { + val source = FakeSource(rows = 2) + val engine = SyncEngine( + source = source, + transport = { + // The Account changed while this request was in flight. + source.generation += 1 + SyncResponse.Accepted(accepted(2)) + }, + environment = { + SyncEnvironment( + ridingSamples = false, + online = true, + wifiOnly = false, + onWifi = false, + credentialReady = true, + onlineBlocked = false, + ) + }, + clock = { 1_000 }, + ) + + assertEquals(SyncPass.Idle, engine.runOnce()) + assertTrue(source.committed.isEmpty()) + } + + @Test + fun `a timeout after the server committed resends the identical batch`() = runBlocking { + val source = FakeSource(rows = 2) + val sent = mutableListOf() + val engine = engine( + source, + mutableListOf(SyncResponse.Transient("timeout"), SyncResponse.Accepted(accepted(2))), + sent, + ) + + engine.runOnce() + engine.resume() + engine.runOnce() + assertEquals(2, sent.size) + assertEquals(sent[0], sent[1]) + } + + @Test + fun `413 narrows the byte target and a single row that still fails pauses without being skipped`() = + runBlocking { + val source = FakeSource(rows = 1) + val engine = engine(source, mutableListOf(SyncResponse.TooLarge)) + + assertEquals(SyncPass.Paused(SyncPauseReason.ROW_TOO_LARGE), engine.runOnce()) + assertTrue(source.committed.isEmpty()) + assertEquals(1, source.remaining) + } + + @Test + fun `429 waits for the server's own delay`() = runBlocking { + val source = FakeSource(rows = 2) + val engine = engine(source, mutableListOf(SyncResponse.RateLimited(90_000))) + + assertEquals(SyncPass.Waiting(91_000), engine.runOnce()) + assertNull(engine.pauseReason) + } + + @Test + fun `a row that cannot be encoded pauses with the row retained`() = runBlocking { + val source = FakeSource(rows = 2) + source.encodeFailure = SyncProtocolException(SyncTable.BOARDS, "id", "must not be empty") + val engine = engine(source, mutableListOf()) + + assertEquals(SyncPass.Paused(SyncPauseReason.PROTOCOL), engine.runOnce()) + assertEquals(listOf(SyncPauseReason.PROTOCOL to "boards.id"), source.failures) + assertEquals(2, source.remaining) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncPolicyTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncPolicyTest.kt new file mode 100644 index 000000000..559bface9 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncPolicyTest.kt @@ -0,0 +1,86 @@ +package expo.modules.vescapecore.sync + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The send/wait/paused decision, with no database, clock or network behind it. + * + * @parity /modules/vescape-core/ios/sync/SyncPolicyTests.swift + */ +class SyncPolicyTest { + private fun state( + pendingRows: Int = 1, + ridingSamples: Boolean = false, + online: Boolean = true, + wifiOnly: Boolean = false, + onWifi: Boolean = false, + credentialReady: Boolean = true, + onlineBlocked: Boolean = false, + pause: SyncPauseReason? = null, + retryAtMs: Long = 0, + ) = SyncState( + nowMs = 1_000, + pendingRows = pendingRows, + ridingSamples = ridingSamples, + online = online, + wifiOnly = wifiOnly, + onWifi = onWifi, + credentialReady = credentialReady, + onlineBlocked = onlineBlocked, + pause = pause, + retryAtMs = retryAtMs, + ) + + @Test + fun `pending rows on a live connection send now`() { + assertEquals(SyncDecision.SendNow, SyncPolicy.decide(state())) + } + + @Test + fun `cadence follows sample production, not session presence`() { + assertEquals( + SyncDecision.Wait(1_000 + SyncPolicy.RIDE_INTERVAL_MS), + SyncPolicy.decide(state(pendingRows = 0, ridingSamples = true)), + ) + assertEquals( + SyncDecision.Wait(1_000 + SyncPolicy.IDLE_INTERVAL_MS), + SyncPolicy.decide(state(pendingRows = 0)), + ) + } + + /** Offline, metered and gated are pauses in the loop, never failures that move backoff. */ + @Test + fun `offline, wifi-only on cellular and a closed gate all wait`() { + val idle = SyncDecision.Wait(1_000 + SyncPolicy.IDLE_INTERVAL_MS) + assertEquals(idle, SyncPolicy.decide(state(online = false))) + assertEquals(idle, SyncPolicy.decide(state(wifiOnly = true, onWifi = false))) + assertEquals(idle, SyncPolicy.decide(state(onlineBlocked = true))) + assertEquals(SyncDecision.SendNow, SyncPolicy.decide(state(wifiOnly = true, onWifi = true))) + } + + @Test + fun `backoff deadline holds the loop until it passes`() { + assertEquals(SyncDecision.Wait(5_000), SyncPolicy.decide(state(retryAtMs = 5_000))) + assertEquals(SyncDecision.SendNow, SyncPolicy.decide(state(retryAtMs = 999))) + } + + @Test + fun `a pause is not bypassed by an ordinary kick`() { + assertEquals( + SyncDecision.Paused(SyncPauseReason.PROTOCOL), + SyncPolicy.decide(state(pause = SyncPauseReason.PROTOCOL)), + ) + assertEquals( + SyncDecision.Paused(SyncPauseReason.AUTHENTICATION), + SyncPolicy.decide(state(credentialReady = false)), + ) + } + + @Test + fun `backoff doubles from the first step and stops at the cap`() { + assertEquals(SyncPolicy.BACKOFF_START_MS, SyncPolicy.nextBackoffMs(0)) + assertEquals(60_000, SyncPolicy.nextBackoffMs(30_000)) + assertEquals(SyncPolicy.BACKOFF_MAX_MS, SyncPolicy.nextBackoffMs(SyncPolicy.BACKOFF_MAX_MS)) + } +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncWireTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncWireTest.kt new file mode 100644 index 000000000..e6c2b1c4f --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncWireTest.kt @@ -0,0 +1,174 @@ +package expo.modules.vescapecore.sync + +import expo.modules.vescapecore.telemetry.AppSettingEntity +import expo.modules.vescapecore.telemetry.BoardEntity +import expo.modules.vescapecore.telemetry.SyncActionEntity +import expo.modules.vescapecore.telemetry.TelemetryFrameEntity +import expo.modules.vescapecore.telemetry.TelemetryMinuteBucketEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Wire encoding and the bounds it refuses on. The valid/invalid boundary cases mirror the server's + * own schema (`vescape-server` `src/sync/protocol.ts`), so a row this side accepts is a row that + * side can store — a batch is whole or refused, and a bad row must never reach transport. + * + * @parity /modules/vescape-core/ios/sync/SyncWireTests.swift + */ +class SyncWireTest { + private fun board(id: String = "board-1", name: String = "Board") = BoardEntity( + id = id, + name = name, + bleId = null, + createdAt = 10, + updatedAt = 20, + ) + + private fun frame(boardId: String? = "board-1", speed: Int? = 100) = TelemetryFrameEntity( + id = 5, + capturedAtMs = 1_000, + elapsedRealtimeMs = 500, + boardId = boardId, + canId = null, + flags = 1, + changedMask1 = 3, + changedMask2 = 0, + speedCentiKmh = speed, + batteryVoltageMv = null, + motorCurrentMa = null, + batteryCurrentMa = null, + dutyPermille = null, + pitchCentiDeg = null, + rollCentiDeg = null, + balancePitchCentiDeg = null, + balanceCurrentMa = null, + erpm = null, + state = null, + switchState = null, + adc1Milli = null, + adc2Milli = null, + odometerCm = null, + tempMosfetDeciC = null, + tempMotorDeciC = null, + faultCode = null, + latitudeE7 = null, + longitudeE7 = null, + gpsSpeedCentiMps = null, + bearingCentiDeg = null, + accuracyCm = null, + altitudeCm = null, + locationTimestampMs = null, + ) + + @Test + fun `a board encodes exactly the declared fields, nulls included`() { + assertEquals( + """{"id":"board-1","name":"Board","bleId":null,"transport":null,"createdAt":10,"updatedAt":20}""", + SyncWire.board(board()), + ) + } + + /** "Cleared" and "not mentioned" are different intents, and only one survives a missing key. */ + @Test + fun `nullable columns are explicit nulls, never omitted keys`() { + assertTrue(SyncWire.telemetryFrame(frame(speed = null)).contains("\"speedCentiKmh\":null")) + } + + @Test + fun `text is escaped so the body stays parseable`() { + val encoded = SyncWire.board(board(name = "He said \"go\"\n")) + assertTrue(encoded.contains("""\"go\"""")) + assertTrue(encoded.contains("""\n""")) + } + + @Test + fun `a key at the length limit is valid and one over is refused`() { + val atLimit = "b".repeat(MAX_SYNC_KEY_LENGTH) + SyncWire.board(board(id = atLimit)) + assertThrows(SyncProtocolException::class.java) { + SyncWire.board(board(id = "b".repeat(MAX_SYNC_KEY_LENGTH + 1))) + } + } + + @Test + fun `an empty key is refused where the server names it, and allowed where the phone derives it`() { + assertThrows(SyncProtocolException::class.java) { SyncWire.board(board(id = "")) } + SyncWire.appSetting(AppSettingEntity(key = "mapStyleKey", valueJson = "\"\"", updatedAt = 1)) + } + + /** A sample that names no Board has nowhere to go on the server, so it never reaches transport. */ + @Test + fun `a frame without a board is a protocol error`() { + val error = assertThrows(SyncProtocolException::class.java) { + SyncWire.telemetryFrame(frame(boardId = null)) + } + assertEquals("boardId", error.field) + } + + @Test + fun `integer bounds are enforced at the edge, not left to the server`() { + SyncWire.telemetryFrame(frame(speed = Int.MAX_VALUE)) + val error = assertThrows(SyncProtocolException::class.java) { + SyncWire.telemetryMinuteBucket(bucket(sampleCount = -1)) + } + assertEquals("sampleCount", error.field) + } + + @Test + fun `a non-finite number is refused because JSON cannot express it`() { + val error = assertThrows(SyncProtocolException::class.java) { + SyncRowWriter(SyncTable.ALERTS).number("threshold", Double.NaN) + } + assertEquals("threshold", error.field) + } + + /** An action reads like the row it names: flat identity fields, not a nested envelope. */ + @Test + fun `a delete action expands into the identity its target declares`() { + assertEquals( + """{"target":"boardSetting","boardId":"board-1","key":"transport","deletedAt":9}""", + SyncWire.deleteAction( + SyncActionEntity(id = 1, target = "boardSetting", boardId = "board-1", key = "transport", deletedAt = 9), + ), + ) + assertEquals( + """{"target":"tuneProfile","id":"profile-1","deletedAt":4}""", + SyncWire.deleteAction( + SyncActionEntity(id = 2, target = "tuneProfile", boardId = null, key = "profile-1", deletedAt = 4), + ), + ) + assertThrows(SyncProtocolException::class.java) { + SyncWire.deleteAction( + SyncActionEntity(id = 3, target = "somethingElse", boardId = null, key = "x", deletedAt = 1), + ) + } + } + + private fun bucket(sampleCount: Int) = TelemetryMinuteBucketEntity( + bucketStartMs = 60_000, + boardId = "board-1", + sampleCount = sampleCount, + firstSampleAtMs = 60_000, + lastSampleAtMs = 60_500, + sumAbsSpeedCentiKmh = 1, + movingSpeedSampleCount = null, + sumMovingAbsSpeedCentiKmh = null, + maxAbsSpeedCentiKmh = 1, + minBatteryVoltageMv = null, + maxMotorCurrentAbsMa = 0, + maxBatteryCurrentAbsMa = 0, + batteryUsedWhMilli = 0, + batteryRegenWhMilli = 0, + maxDutyAbsPermille = 0, + faultCount = 0, + firstOdometerCm = null, + lastOdometerCm = null, + gpsPointCount = 0, + preciseGpsPointCount = 0, + gpsDistanceCm = 0, + maxGpsSpeedCentiMps = null, + updatedAt = 1, + ) +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt index 490aed435..080425584 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/BoardTombstoneTest.kt @@ -69,7 +69,7 @@ class BoardTombstoneTest { @Test fun migrationTargetsTheCurrentSchemaVersion() { - assertEquals(37, TELEMETRY_DATABASE_VERSION) + assertEquals(38, TELEMETRY_DATABASE_VERSION) assertEquals(33, TelemetryDatabase.MIGRATION_33_34.startVersion) assertEquals(34, TelemetryDatabase.MIGRATION_33_34.endVersion) } diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncActionLogTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncActionLogTest.kt index 3df67af1c..0e8049935 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncActionLogTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncActionLogTest.kt @@ -67,6 +67,14 @@ class SyncActionLogTest { "deleteBucketsBefore", "deleteDiagnosticEventsBefore", "deleteBefore", + // Cursor-gated retention (#284): the same sweep, refusing to prune a row the uploader has not + // delivered yet. + "deleteBeforeGated", + "deleteFramesBeforeUpTo", + "deleteMarkersBeforeUpTo", + "deleteBucketsBeforeUpTo", + "deleteDiagnosticEventsBeforeUpTo", + "deleteExclusionsBeforeUpTo", "deleteFramesRange", "deleteMarkersRange", "deleteBucketsRange", diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt index a288b3c1e..2f64bd9eb 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt @@ -98,7 +98,7 @@ class SyncCursorMigrationTest { @Test fun migrationsTargetTheCurrentSchemaVersion() { - assertEquals(37, TELEMETRY_DATABASE_VERSION) + assertEquals(38, TELEMETRY_DATABASE_VERSION) assertEquals(31, TelemetryDatabase.MIGRATION_31_32.startVersion) assertEquals(32, TelemetryDatabase.MIGRATION_31_32.endVersion) assertEquals(32, TelemetryDatabase.MIGRATION_32_33.startVersion) diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt index bcfbb409a..64b17364d 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryBoardIdMigrationTest.kt @@ -63,7 +63,7 @@ class TelemetryBoardIdMigrationTest { @Test fun migrationTargetsTheCurrentSchemaVersion() { - assertEquals(37, TELEMETRY_DATABASE_VERSION) + assertEquals(38, TELEMETRY_DATABASE_VERSION) assertEquals(34, TelemetryDatabase.MIGRATION_34_35.startVersion) assertEquals(35, TelemetryDatabase.MIGRATION_34_35.endVersion) } diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index e6d06190c..c772f8e06 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -322,6 +322,23 @@ public class VescapeCoreModule: Module { Function("clearDeviceCredential") { NativeAuthCoordinator.shared.clear() } + // The Rider confirmed the destructive Account change; native performs the ordered transition. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `confirmSyncAccountReset` + AsyncFunction("confirmSyncAccountReset") { + (serverUrl: String, deviceToken: String, accountId: String) async throws -> [String: Any?] in + try await NativeAuthCoordinator.shared.confirmAccountReset( + serverUrl: serverUrl, + token: deviceToken, + accountId: accountId + ) + } + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `getSyncStatus` + AsyncFunction("getSyncStatus") { () -> [String: Any?] in + SyncCoordinator.shared.status().toMap() + } + Function("setSyncWifiOnly") { (enabled: Bool) in + SyncCoordinator.shared.setWifiOnly(enabled) + } // Stable Vescape route keeps the app decoupled from the final store destination. // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `openAppUpdate` diff --git a/modules/vescape-core/ios/api/ApiResult.swift b/modules/vescape-core/ios/api/ApiResult.swift index 66abebad7..d4f36372c 100644 --- a/modules/vescape-core/ios/api/ApiResult.swift +++ b/modules/vescape-core/ios/api/ApiResult.swift @@ -57,6 +57,10 @@ struct ApiRequest { struct ApiResponse { let status: Int let 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. + var headers: [String: String] = [:] } /// The single HTTP seam. Production wires `URLSession`; tests wire a fake and never reach the diff --git a/modules/vescape-core/ios/api/VescapeApi.swift b/modules/vescape-core/ios/api/VescapeApi.swift index 8f0ed2536..cb49e468d 100644 --- a/modules/vescape-core/ios/api/VescapeApi.swift +++ b/modules/vescape-core/ios/api/VescapeApi.swift @@ -56,6 +56,32 @@ final class VescapeApi { return await send(request, authenticated: !token.isEmpty, parse: parse) } + /// One call whose status code is the answer, not an error to classify. The uploader needs `409`, + /// `413` and `429` kept apart — each has a different recovery — so it reads the raw exchange while + /// still going through this class's credential, headers and 401 policy. + /// + /// Never retried here: `POST /api/sync` carries no create key, and the caller's own backoff is + /// what decides when the same batch is offered again. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/api/VescapeApi.kt `exchange` + func exchange( + _ method: HttpMethod, + path: String, + rawBody: String?, + auth: AuthMode = .required + ) async -> ApiResponse? { + guard let token = token(for: auth) else { return ApiResponse(status: 401, body: "") } + let request = ApiRequest( + method: method, + url: url(path: path, query: [:]), + headers: headers(token: token.isEmpty ? nil : token, hasBody: rawBody != nil), + body: rawBody + ) + guard let response = try? await transport.execute(request) else { return nil } + if response.status == 401 && !token.isEmpty { onUnauthorized() } + return response + } + /// Resolved bearer token, empty when the call goes out anonymously, `nil` when a required /// credential is missing. A credential minted against another origin belongs to another /// environment, so it counts as missing rather than being sent to this one. @@ -195,6 +221,15 @@ struct UrlSessionApiTransport: ApiTransport { guard let http = response as? HTTPURLResponse else { throw NSError(domain: "VescapeApi", code: -2) } - return ApiResponse(status: http.statusCode, body: String(data: data, encoding: .utf8) ?? "") + var headers: [String: String] = [:] + for (name, value) in http.allHeaderFields { + guard let name = name as? String, let value = value as? String else { continue } + headers[name.lowercased()] = value + } + return ApiResponse( + status: http.statusCode, + body: String(data: data, encoding: .utf8) ?? "", + headers: headers + ) } } diff --git a/modules/vescape-core/ios/auth/NativeAuthCoordinator.swift b/modules/vescape-core/ios/auth/NativeAuthCoordinator.swift index aa107a781..ad98d6062 100644 --- a/modules/vescape-core/ios/auth/NativeAuthCoordinator.swift +++ b/modules/vescape-core/ios/auth/NativeAuthCoordinator.swift @@ -51,13 +51,42 @@ final class NativeAuthCoordinator { throw NSError(domain: "NativeAuth", code: -5) } - let credential = DeviceCredential( - serverUrl: origin, - token: token, - accountId: accountId, - expiresAt: nil + // The database is claimed before the credential is stored: a second Account must not be able to + // upload from a database full of the first Account's Boards, Ride History and locations. The + // Rider confirms the destructive reset, and only then does `confirmAccountReset` finish this. + guard SyncCoordinator.shared.bindAccount(accountId) else { + var state = stateMap() + state["accountChangeRequiresReset"] = true + return state + } + + try store.write( + DeviceCredential(serverUrl: origin, token: token, accountId: accountId, expiresAt: nil) + ) + await MainActor.run { + AppStatusCoordinator.shared.refresh() + } + SyncCoordinator.shared.start() + return stateMap() + } + + /// The Rider confirmed that all local app data is erased and cannot yet be restored. + /// + /// One ordered transition: stop the uploader, invalidate in-flight work, replace the app-data + /// database, clear Sync Cursors and pending Sync Actions, bind the fresh database to the new + /// Account, install the new Device Token, start the uploader. Cancelling never reaches here, so + /// the old database and Account binding stay untouched. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/auth/NativeAuthCoordinator.kt `confirmAccountReset` + func confirmAccountReset( + serverUrl: String, + token: String, + accountId: String + ) async throws -> [String: Any?] { + let origin = serverUrl.hasSuffix("/") ? String(serverUrl.dropLast()) : serverUrl + try SyncCoordinator.shared.resetForAccount(accountId) + try store.write( + DeviceCredential(serverUrl: origin, token: token, accountId: accountId, expiresAt: nil) ) - try store.write(credential) await MainActor.run { AppStatusCoordinator.shared.refresh() } @@ -81,5 +110,10 @@ final class NativeAuthCoordinator { store.clear() } - func clear() { store.clear() } + func clear() { + store.clear() + // Signing out stops the uploader but keeps the Account binding, so data recorded while signed + // out stays protected from retention for the same Account. + SyncCoordinator.shared.stop() + } } diff --git a/modules/vescape-core/ios/sync/SyncAccepted.swift b/modules/vescape-core/ios/sync/SyncAccepted.swift new file mode 100644 index 000000000..d839f284c --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncAccepted.swift @@ -0,0 +1,94 @@ +import Foundation + +/// The `200` body: what the server took, per table. +/// +/// Validated exactly before any cursor moves. A missing table, an extra table, a non-integer count +/// or a count that differs from what was submitted is a protocol failure — the server applies a +/// batch whole, so anything else means the two sides disagree about what was stored, and advancing a +/// cursor on that disagreement is unrecoverable. +/// +/// Parsed here rather than with `JSONSerialization` so the rule behaves identically on both +/// platforms. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncAccepted.kt +enum SyncAccepted { + /// Accepted counts by table, or nil when the body is not exactly the expected response. + static func parse(_ body: String) -> [SyncTable: Int]? { + var counts: [SyncTable: Int] = [:] + var scanner = Scanner(body) + guard scanner.expect("{"), scanner.expectKey("accepted"), scanner.expect("{") else { return nil } + if scanner.peek() != "}" { + while true { + guard let name = scanner.string(), let table = SyncTable(rawValue: name) else { return nil } + if counts[table] != nil || !scanner.expect(":") { return nil } + guard let value = scanner.integer() else { return nil } + counts[table] = value + if scanner.expect(",") { continue } + break + } + } + guard scanner.expect("}"), scanner.expect("}"), scanner.atEnd() else { return nil } + return counts.count == SyncTable.allCases.count ? counts : nil + } + + /// True when the response accounts for exactly the rows submitted, table by table. + static func matches(submitted: [SyncTable: Int], accepted: [SyncTable: Int]) -> Bool { + SyncTable.allCases.allSatisfy { accepted[$0] == (submitted[$0] ?? 0) } + } + + private struct Scanner { + private let source: [Character] + private var index = 0 + + init(_ source: String) { + self.source = Array(source) + } + + mutating func atEnd() -> Bool { + skipSpace() + return index >= source.count + } + + mutating func peek() -> Character? { + skipSpace() + return index < source.count ? source[index] : nil + } + + mutating func expect(_ character: Character) -> Bool { + guard peek() == character else { return false } + index += 1 + return true + } + + mutating func expectKey(_ name: String) -> Bool { + string() == name && expect(":") + } + + mutating func string() -> String? { + guard expect("\"") else { return nil } + var value = "" + // Counts and table names carry no escapes; a body that needs them is not this response. + while index < source.count, source[index] != "\"" { + value.append(source[index]) + index += 1 + } + guard index < source.count else { return nil } + index += 1 + return value + } + + mutating func integer() -> Int? { + skipSpace() + var digits = "" + while index < source.count, source[index].isNumber { + digits.append(source[index]) + index += 1 + } + return digits.isEmpty ? nil : Int(digits) + } + + private mutating func skipSpace() { + while index < source.count, source[index].isWhitespace { index += 1 } + } + } +} diff --git a/modules/vescape-core/ios/sync/SyncAcceptedTests.swift b/modules/vescape-core/ios/sync/SyncAcceptedTests.swift new file mode 100644 index 000000000..840a5b227 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncAcceptedTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import VescapeCore + +/// The `200` body is the last thing standing between an accepted batch and a cursor that can never +/// be walked back, so it is validated exactly rather than trusted. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncAcceptedTest.kt +final class SyncAcceptedTests: XCTestCase { + private func body( + _ counts: [SyncTable: Int] = [:], + tables: [SyncTable] = SyncTable.allCases + ) -> String { + let pairs = tables.map { "\"\($0.wire)\":\(counts[$0] ?? 0)" }.joined(separator: ",") + return "{\"accepted\":{\(pairs)}}" + } + + func testEveryTableAccountedForParses() { + let parsed = SyncAccepted.parse(body([.boards: 3])) + XCTAssertEqual(parsed?[.boards], 3) + XCTAssertEqual(parsed?[.favorites], 0) + } + + func testAMissingTableAnExtraTableOrADuplicateIsRefused() { + XCTAssertNil(SyncAccepted.parse(body(tables: Array(SyncTable.allCases.dropFirst())))) + XCTAssertNil(SyncAccepted.parse("{\"accepted\":{\"unknownTable\":0}}")) + XCTAssertNil(SyncAccepted.parse("{\"accepted\":{\"boards\":1,\"boards\":1}}")) + } + + func testAnythingThatIsNotThisResponseIsRefused() { + XCTAssertNil(SyncAccepted.parse("")) + XCTAssertNil(SyncAccepted.parse("{}")) + XCTAssertNil(SyncAccepted.parse("{\"ok\":true}")) + XCTAssertNil(SyncAccepted.parse(body() + "trailing")) + } + + func testCountsHaveToEqualWhatWasSubmitted() throws { + let submitted: [SyncTable: Int] = [.boards: 2] + XCTAssertTrue( + SyncAccepted.matches(submitted: submitted, accepted: try XCTUnwrap(SyncAccepted.parse(body(submitted)))) + ) + XCTAssertFalse( + SyncAccepted.matches( + submitted: submitted, + accepted: try XCTUnwrap(SyncAccepted.parse(body([.boards: 1]))) + ) + ) + XCTAssertFalse( + SyncAccepted.matches( + submitted: submitted, + accepted: try XCTUnwrap(SyncAccepted.parse(body([.boards: 2, .alerts: 1]))) + ) + ) + } +} diff --git a/modules/vescape-core/ios/sync/SyncBatchBuilder.swift b/modules/vescape-core/ios/sync/SyncBatchBuilder.swift new file mode 100644 index 000000000..c4e99ee45 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncBatchBuilder.swift @@ -0,0 +1,133 @@ +import Foundation + +/// One row waiting to be uploaded: its cursor position and the compact JSON the server will read. +/// +/// The JSON is encoded once, by the wire layer, so the builder measures the bytes that will actually +/// be sent rather than estimating from an object graph. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt `SyncPendingRow` +struct SyncPendingRow: Equatable { + let cursor: Int64 + let json: String + let byteCount: Int + + init(cursor: Int64, json: String) { + self.cursor = cursor + self.json = json + self.byteCount = json.utf8.count + } +} + +/// One table's pending rows, in cursor order. +struct SyncPendingTable: Equatable { + let table: SyncTable + let rows: [SyncPendingRow] +} + +/// What the builder made of the pending rows. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt `SyncBatchBuild` +enum SyncBatchBuild: Equatable { + /// Nothing pending. + case empty + + /// A batch and the cursor advance set describing exactly the rows in it. Cursors are committed + /// only after the server accepts, and only these positions move. + case ready(SyncBuiltBatch) + + /// One row cannot fit a batch of its own. Never skipped and never quarantined: the engine pauses + /// with the row retained, because dropping it would silently lose data a Rider believes is backed + /// up. + case rowTooLarge(table: SyncTable, cursor: Int64, byteCount: Int) +} + +struct SyncBuiltBatch: Equatable { + let body: String + /// Table order preserved, so a test can assert parents precede children. + let tables: [SyncTable] + let counts: [SyncTable: Int] + let advances: [SyncTable: Int64] + let rowCount: Int + let byteCount: Int +} + +/// Fills a Sync Batch from per-table pending rows. +/// +/// Pure: no database, no clock, no network. It walks `SyncTable` declaration order — the order the +/// server applies a batch in — and stops at whichever cap comes first. Ordering by backlog size +/// would produce a batch whose children arrive before their parents, which the server refuses whole. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt `SyncBatchBuilder` +enum SyncBatchBuilder { + static func build( + _ pending: [SyncPendingTable], + rowCap: Int = maxSyncBatchRows, + byteCap: Int = maxSyncBatchBytes + ) -> SyncBatchBuild { + let order = SyncTable.allCases + let ordered = pending + .filter { !$0.rows.isEmpty } + .sorted { left, right in + (order.firstIndex(of: left.table) ?? 0) < (order.firstIndex(of: right.table) ?? 0) + } + if ordered.isEmpty { return .empty } + + var body = "{" + var tables: [SyncTable] = [] + var counts: [SyncTable: Int] = [:] + var advances: [SyncTable: Int64] = [:] + var rowCount = 0 + // `{}`; every other cost below is added as the exact bytes appended. + var byteCount = 2 + + for group in ordered { + if rowCount >= rowCap { break } + // `,"appSettings":[]` — the separating comma only once a table is already open. + let header = (tables.isEmpty ? "" : ",") + "\"" + group.table.wire + "\":[" + let tableOverhead = header.utf8.count + 1 + if byteCount + tableOverhead > byteCap { break } + + var opened = false + for row in group.rows { + if rowCount >= rowCap { break } + let rowCost = row.byteCount + (opened ? 1 : 0) + let overhead = opened ? 0 : tableOverhead + if byteCount + overhead + rowCost > byteCap { + // A row no empty batch could carry is a permanent local protocol error, not a cap hit. + if tables.isEmpty, !opened, 2 + tableOverhead + row.byteCount > byteCap { + return .rowTooLarge(table: group.table, cursor: row.cursor, byteCount: row.byteCount) + } + break + } + + if !opened { + body += header + byteCount += tableOverhead + tables.append(group.table) + counts[group.table] = 0 + opened = true + } else { + body += "," + } + body += row.json + byteCount += rowCost + rowCount += 1 + counts[group.table] = (counts[group.table] ?? 0) + 1 + advances[group.table] = row.cursor + } + if opened { body += "]" } + } + + if tables.isEmpty { return .empty } + body += "}" + return .ready( + SyncBuiltBatch( + body: body, + tables: tables, + counts: counts, + advances: advances, + rowCount: rowCount, + byteCount: byteCount + ) + ) + } +} diff --git a/modules/vescape-core/ios/sync/SyncBatchBuilderTests.swift b/modules/vescape-core/ios/sync/SyncBatchBuilderTests.swift new file mode 100644 index 000000000..12b43e9fd --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncBatchBuilderTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import VescapeCore + +/// The batch builder is pure: no database, no clock, no network. What it has to get right is the +/// order tables go out in, the two caps, and an advance set that describes exactly the rows sent. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncBatchBuilderTest.kt +final class SyncBatchBuilderTests: XCTestCase { + private func rows(_ count: Int, size: Int = 10, from: Int64 = 1) -> [SyncPendingRow] { + (0.. SyncBuiltBatch { + guard case .ready(let batch) = build else { + throw XCTSkip("expected a batch, got \(build)") + } + return batch + } + + func testWalksServerTableOrderRegardlessOfBacklogSize() throws { + let batch = try ready( + SyncBatchBuilder.build([ + SyncPendingTable(table: .telemetryFrames, rows: rows(5)), + SyncPendingTable(table: .boards, rows: rows(1)), + SyncPendingTable(table: .appSettings, rows: rows(1)), + ]) + ) + + XCTAssertEqual(batch.tables, [.appSettings, .boards, .telemetryFrames]) + } + + func testAdvanceSetNamesTheLastRowActuallyIncluded() throws { + let batch = try ready( + SyncBatchBuilder.build( + [ + SyncPendingTable(table: .boards, rows: rows(2, from: 40)), + SyncPendingTable(table: .favorites, rows: rows(3, from: 7)), + ], + rowCap: 4 + ) + ) + + XCTAssertEqual(batch.rowCount, 4) + XCTAssertEqual(batch.counts, [.boards: 2, .favorites: 2]) + XCTAssertEqual(batch.advances, [.boards: 41, .favorites: 8]) + } + + func testExactlyAtAndOneOverTheRowCapBehaveIdentically() throws { + let atCap = try ready(SyncBatchBuilder.build([SyncPendingTable(table: .boards, rows: rows(3))], rowCap: 3)) + XCTAssertEqual(atCap.rowCount, 3) + + let overCap = try ready(SyncBatchBuilder.build([SyncPendingTable(table: .boards, rows: rows(4))], rowCap: 3)) + XCTAssertEqual(overCap.rowCount, 3) + XCTAssertEqual(overCap.advances[.boards], 3) + } + + /// The cap is on the bytes actually sent, so the encoded body is what gets measured. + func testByteCapCountsTheEncodedBodyBoundaryIncluded() throws { + let pending = [SyncPendingTable(table: .boards, rows: rows(2, size: 8))] + let one = try ready(SyncBatchBuilder.build(pending, byteCap: Int.max)) + XCTAssertEqual(one.body.utf8.count, one.byteCount) + + let atCap = try ready(SyncBatchBuilder.build(pending, byteCap: one.byteCount)) + XCTAssertEqual(atCap.rowCount, 2) + + let oneUnder = try ready(SyncBatchBuilder.build(pending, byteCap: one.byteCount - 1)) + XCTAssertEqual(oneUnder.rowCount, 1) + XCTAssertEqual(oneUnder.body.utf8.count, oneUnder.byteCount) + } + + func testMeasuresUtf8BytesRatherThanCharacters() throws { + let row = SyncPendingRow(cursor: 1, json: "\"ąęółśż\"") + let batch = try ready(SyncBatchBuilder.build([SyncPendingTable(table: .boards, rows: [row])])) + XCTAssertEqual(batch.body.utf8.count, batch.byteCount) + } + + func testARowNoEmptyBatchCouldCarryIsAPermanentErrorNotASilentSkip() { + let huge = SyncPendingRow(cursor: 9, json: "\"" + String(repeating: "x", count: 500) + "\"") + let build = SyncBatchBuilder.build([SyncPendingTable(table: .boards, rows: [huge])], byteCap: 100) + XCTAssertEqual(build, .rowTooLarge(table: .boards, cursor: 9, byteCount: huge.byteCount)) + } + + func testNothingPendingIsIdleNotAnEmptyBatch() { + XCTAssertEqual(SyncBatchBuilder.build([]), .empty) + XCTAssertEqual(SyncBatchBuilder.build([SyncPendingTable(table: .boards, rows: [])]), .empty) + } +} diff --git a/modules/vescape-core/ios/sync/SyncCoordinator.swift b/modules/vescape-core/ios/sync/SyncCoordinator.swift new file mode 100644 index 000000000..ce0f38c04 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncCoordinator.swift @@ -0,0 +1,291 @@ +import Foundation +import Network + +/// What JS renders. Native owns every transition; JS only asks and shows. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt `SyncStatus` +struct SyncStatus { + let accountId: String? + let pendingRows: Int + let pause: SyncPauseReason? + let lastUploadAtMs: Int64? + + func toMap() -> [String: Any?] { + [ + "accountId": accountId, + "pendingRows": pendingRows, + "pause": pause?.slug, + "lastUploadAtMs": lastUploadAtMs, + ] + } +} + +/// The uploader's lifecycle: the loop, the kicks, and the Account binding it runs under. +/// +/// Runs inside the window the app already keeps alive — the existing background modes during a ride, +/// the foreground otherwise. Deliberately no `BGTaskScheduler`: a ride that ends offline on a phone +/// that is never reopened waits for the next app open or the next ride. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt +final class SyncCoordinator { + static let shared = SyncCoordinator() + + internal static let syncPath = "/api/sync" + + /// Samples persisted this recently mean a ride is producing, Idle Pause included. + private static let sampleActivityWindowMs: Int64 = 60_000 + + /// A drain is a burst, not a loop that can never yield to the rest of the process. + private static let maxDrainSteps = 50 + + private let lock = NSLock() + private var generation: Int64 = 0 + private var lastSamplePersistedAtMs: Int64 = 0 + private var lastUploadAtMs: Int64? + private var wifiOnly = false + private var onWifi = false + private var online = true + /// Failure keys already recorded this process, so a wedged batch writes one event, not a stream. + private var recordedFailures = Set() + private var loop: Task? + + private let monitor = NWPathMonitor() + private lazy var store = SyncStore( + generation: { [weak self] in self?.currentGeneration() ?? 0 }, + onPermanentFailure: { [weak self] reason, detail in + self?.recordPermanentFailure(reason, detail: detail) + } + ) + private lazy var engine = SyncEngine( + source: store, + transport: { [weak self] body in + await self?.post(body) ?? .transient(reason: "stopped") + }, + environment: { [weak self] in + self?.environment() ?? SyncEnvironment( + ridingSamples: false, + online: false, + wifiOnly: false, + onWifi: false, + credentialReady: false, + onlineBlocked: true + ) + } + ) + + private init() { + monitor.pathUpdateHandler = { [weak self] path in + guard let self else { return } + let reachable = path.status == .satisfied + self.lock.lock() + let regained = reachable && !self.online + self.online = reachable + self.onWifi = path.usesInterfaceType(.wifi) + self.lock.unlock() + // Connectivity regained is one of the immediate kicks, next to ride end and sign-in. + if regained { self.kick() } + } + monitor.start(queue: DispatchQueue(label: "app.vescape.sync.path")) + } + + var pauseReason: SyncPauseReason? { engine.pauseReason } + + /// Recording persisted samples: the ride cadence follows sample production, not session presence. + func notifySamplesPersisted(atMs: Int64 = telemetryNowMs()) { + lock.lock() + lastSamplePersistedAtMs = atMs + lock.unlock() + } + + func setWifiOnly(_ enabled: Bool) { + lock.lock() + wifiOnly = enabled + lock.unlock() + kick() + } + + func status() -> SyncStatus { + lock.lock() + let uploadedAt = lastUploadAtMs + lock.unlock() + return SyncStatus( + accountId: store.boundAccountId(), + pendingRows: store.pendingCount(), + pause: engine.pauseReason, + lastUploadAtMs: uploadedAt + ) + } + + func start() { + guard loop == nil else { return } + loop = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + let waitMs = await self.pass() + try? await Task.sleep(nanoseconds: UInt64(max(waitMs, 0)) * 1_000_000) + } + } + } + + func stop() { + loop?.cancel() + loop = nil + } + + /// Connectivity regained, ride ended, sign-in: send now rather than waiting for the next tick. + func kick() { + guard loop != nil else { return start() } + Task { [weak self] in _ = await self?.pass() } + } + + /// One pass, draining while the server keeps accepting: a `200` with rows still pending sends + /// again straight away, so a long backlog drains instead of trickling. + private func pass() async -> Int64 { + var drains = 0 + while drains < Self.maxDrainSteps { + switch await engine.runOnce() { + case .sent(_, let morePending): + lock.lock() + lastUploadAtMs = telemetryNowMs() + lock.unlock() + if !morePending { return interval() } + drains += 1 + case .waiting(let untilMs): + return min(max(untilMs - telemetryNowMs(), 0), SyncPolicy.backoffMaxMs) + case .paused: + return SyncPolicy.idleIntervalMs + case .idle: + return interval() + } + } + return 0 + } + + private func interval() -> Int64 { + samplesProducing() ? SyncPolicy.rideIntervalMs : SyncPolicy.idleIntervalMs + } + + private func samplesProducing() -> Bool { + lock.lock() + defer { lock.unlock() } + return telemetryNowMs() - lastSamplePersistedAtMs < Self.sampleActivityWindowMs + } + + private func currentGeneration() -> Int64 { + lock.lock() + defer { lock.unlock() } + return generation + } + + private func environment() -> SyncEnvironment { + lock.lock() + let reachable = online + let wifi = onWifi + let meteredOnly = wifiOnly + lock.unlock() + let status = AppStatusCoordinator.shared.current?.version.status + return SyncEnvironment( + ridingSamples: samplesProducing(), + online: reachable, + wifiOnly: meteredOnly, + onWifi: wifi, + credentialReady: DeviceCredentialStore.shared.read() != nil, + onlineBlocked: status == .onlineBlocked || status == .appBlocked + ) + } + + /// The Sync endpoints are Online Capabilities behind the App Status gate, and they authenticate + /// with the shared Device Token, so the whole call goes through `VescapeApi`. + private func post(_ body: String) async -> SyncResponse { + let api = VescapeApi.forOrigin(AppStatusCoordinator.serverBaseUrl) + guard let response = await api.exchange(.post, path: Self.syncPath, rawBody: body) else { + return .transient(reason: "network") + } + switch response.status { + case 200: return .accepted(body: response.body) + case 401: return .unauthorized + case 413: return .tooLarge + case 429: return .rateLimited(retryAfterMs: retryAfterMs(response.headers)) + case 500...599: return .transient(reason: "http \(response.status)") + case 400...499: return .invalid(status: response.status, error: errorSlug(response.body)) + // A `2xx` that is not the accepted map is a protocol failure, not a success to interpret. + default: return .invalid(status: response.status, error: "unexpected-success") + } + } + + /// The server's own delay in seconds, or the first backoff step when it named none. + private func retryAfterMs(_ headers: [String: String]) -> Int64 { + guard let value = headers["retry-after"], let seconds = Int64(value.trimmingCharacters(in: .whitespaces)) + else { return SyncPolicy.backoffStartMs } + return seconds * 1_000 + } + + private func errorSlug(_ body: String) -> String { + guard let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? String, !error.isEmpty + else { return "invalid-request" } + return error + } + + // Account binding — the Device Token exchange returns a stable server Account id, and the first + // Account claims this database. + + /// Claim the local database for `accountId` when it is unbound or already belongs to it. + /// + /// False means a different Account: cursors are deliberately not reset over the existing rows, + /// because that would upload the previous Account's Boards, Ride History, locations and settings + /// to the new one. The Rider has to confirm the destructive reset first. + @discardableResult + func bindAccount(_ accountId: String) -> Bool { + let bound = store.bindAccount(accountId) + if bound { + engine.resume() + kick() + } + return bound + } + + /// The Account change transition, in the one order that cannot leak data between Accounts: stop + /// the loop, invalidate in-flight work, replace the database, clear cursors and pending actions, + /// bind the new Account, then start again. + /// + /// The wipe is local maintenance and emits no Sync Actions to either Account — replacing the file + /// removes the log with everything else. + func resetForAccount(_ accountId: String) throws { + stop() + lock.lock() + // Every in-flight response now belongs to a previous Account and can no longer commit. + generation += 1 + recordedFailures.removeAll() + lastUploadAtMs = nil + lock.unlock() + + try TelemetryDatabase.replaceWithFreshDatabase() + store.bindAccount(accountId) + engine.resume() + start() + } + + /// One coalesced Diagnostic Event per failure class, table and cursor. Metadata only: an error + /// code, a table, a cursor and the app version — never row contents, coordinates, the Device + /// Token, the server body or an opaque database error. + private func recordPermanentFailure(_ reason: SyncPauseReason, detail: String) { + let key = "\(reason.slug):\(detail)" + lock.lock() + let isNew = recordedFailures.insert(key).inserted + lock.unlock() + guard isNew else { return } + + TelemetryRepository.shared.recordDiagnosticEvent( + eventName: "sync_upload_paused", + properties: [ + "operation": "sync", + "phase": reason.slug, + "message": "Sync upload paused", + "sync_failure": reason.slug, + "sync_detail": detail, + "app_version": AppStatusCoordinator.installedMarketingVersion(), + ] + ) + } +} diff --git a/modules/vescape-core/ios/sync/SyncEngine.swift b/modules/vescape-core/ios/sync/SyncEngine.swift new file mode 100644 index 000000000..1f997da6b --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncEngine.swift @@ -0,0 +1,199 @@ +import Foundation + +/// What the transport made of one `POST /api/sync`. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt `SyncResponse` +enum SyncResponse { + /// `2xx`. The body still has to be exactly the accepted map before anything is committed. + case accepted(body: String) + /// `400`, `409`, `422` or any other unknown `4xx`: wrong request, not a bad moment. + case invalid(status: Int, error: String) + /// `401`: the Device Token is dead. Only sign-in resolves it. + case unauthorized + /// `413`: over the wire byte bound. Retried with a smaller target, never with fewer rows dropped. + case tooLarge + /// `429`, with the server's own delay. + case rateLimited(retryAfterMs: Int64) + /// `5xx`, a network error or a timeout — the batch may or may not have been applied. + case transient(reason: String) +} + +/// The database side of the uploader: what is pending, and where the cursors are. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt `SyncSource` +protocol SyncSource { + /// Pending rows per table, already encoded, capped at `rowLimit` rows in total. + func pending(rowLimit: Int) throws -> [SyncPendingTable] + + /// Rows waiting across every table. Cheap enough to ask on every tick. + func pendingCount() -> Int + + /// Commit the advance set in its own transaction, after the response. Never alongside the rows: a + /// cursor advanced past rows the server did not take is unrecoverable, whereas a cursor left + /// behind is a re-send the server upserts idempotently. Always fail toward re-sending. + func commit(_ advances: [SyncTable: Int64]) + + /// Bumped by an Account change. Captured before a request and re-read before the commit, so a + /// response belonging to the previous Account becomes a no-op instead of advancing a cursor over + /// the fresh database. + func generation() -> Int64 + + /// One coalesced, metadata-only Diagnostic Event for a permanent failure. + func recordPermanentFailure(_ reason: SyncPauseReason, detail: String) +} + +/// Environment the policy reads. Owned by the caller, so the engine keeps no platform types. +struct SyncEnvironment { + let ridingSamples: Bool + let online: Bool + let wifiOnly: Bool + let onWifi: Bool + let credentialReady: Bool + let onlineBlocked: Bool +} + +/// What one pass did, for the loop and for tests. +enum SyncPass: Equatable { + case idle + case sent(rowCount: Int, morePending: Bool) + case waiting(untilMs: Int64) + case paused(SyncPauseReason) +} + +/// The uploader: scan forward from each Sync Cursor, send a small batch, advance only what the +/// server accepted. +/// +/// Owns transport policy, backoff and the permanent pause; the two interesting decisions — which +/// rows go in a batch, and whether to send at all — live in `SyncBatchBuilder` and `SyncPolicy`, +/// which are pure. Drives no timer of its own: `SyncCoordinator` owns the loop and the kicks. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt `SyncEngine` +final class SyncEngine { + /// Below this a batch cannot hold a realistic row, so shrinking further only hides the real fault. + private static let minByteTarget = 16 * 1024 + + private let source: SyncSource + private let transport: (String) async -> SyncResponse + private let environment: () -> SyncEnvironment + private let clock: () -> Int64 + + private var retryAtMs: Int64 = 0 + private var backoffMs: Int64 = 0 + private var byteTarget = maxSyncBatchBytes + private(set) var pauseReason: SyncPauseReason? + + init( + source: SyncSource, + transport: @escaping (String) async -> SyncResponse, + environment: @escaping () -> SyncEnvironment, + clock: @escaping () -> Int64 = { telemetryNowMs() } + ) { + self.source = source + self.transport = transport + self.environment = environment + self.clock = clock + } + + /// Clears a pause. Sign-in and an Account reset are the only things that may. + func resume() { + pauseReason = nil + retryAtMs = 0 + backoffMs = 0 + byteTarget = maxSyncBatchBytes + } + + /// One pass: decide, send, commit. A `200` with rows still pending returns `morePending`, so the + /// loop sends again immediately rather than trickling a long backlog one tick at a time. + func runOnce() async -> SyncPass { + let env = environment() + let decision = SyncPolicy.decide( + SyncState( + nowMs: clock(), + pendingRows: source.pendingCount(), + ridingSamples: env.ridingSamples, + online: env.online, + wifiOnly: env.wifiOnly, + onWifi: env.onWifi, + credentialReady: env.credentialReady, + onlineBlocked: env.onlineBlocked, + pause: pauseReason, + retryAtMs: retryAtMs + ) + ) + switch decision { + case .paused(let reason): return .paused(reason) + case .wait(let atMs): return .waiting(untilMs: atMs) + case .sendNow: return await send() + } + } + + private func send() async -> SyncPass { + let pending: [SyncPendingTable] + do { + pending = try source.pending(rowLimit: maxSyncBatchRows) + } catch let error as SyncProtocolError { + return pause(.protocolFailure, detail: "\(error.table.wire).\(error.field)") + } catch { + return pause(.protocolFailure, detail: "encode") + } + + switch SyncBatchBuilder.build(pending, rowCap: maxSyncBatchRows, byteCap: byteTarget) { + case .empty: + return .idle + case .rowTooLarge(let table, let cursor, _): + return pause(.rowTooLarge, detail: "\(table.wire)@\(cursor)") + case .ready(let batch): + return await deliver(batch) + } + } + + private func deliver(_ batch: SyncBuiltBatch) async -> SyncPass { + let generation = source.generation() + let response = await transport(batch.body) + // A response that outlived its Account cannot touch the fresh database it would land in. + if source.generation() != generation { return .idle } + + switch response { + case .accepted(let body): return accept(batch, body: body) + case .unauthorized: return pause(.authentication, detail: "401") + case .invalid(let status, let error): return pause(.protocolFailure, detail: "\(status):\(error)") + case .tooLarge: return shrink(batch) + case .rateLimited(let retryAfterMs): return backOff(max(retryAfterMs, 0)) + case .transient: + backoffMs = SyncPolicy.nextBackoffMs(backoffMs) + return backOff(backoffMs) + } + } + + private func accept(_ batch: SyncBuiltBatch, body: String) -> SyncPass { + guard let accepted = SyncAccepted.parse(body), + SyncAccepted.matches(submitted: batch.counts, accepted: accepted) + else { + return pause(.protocolFailure, detail: "acceptedMismatch") + } + source.commit(batch.advances) + backoffMs = 0 + retryAtMs = 0 + byteTarget = maxSyncBatchBytes + return .sent(rowCount: batch.rowCount, morePending: source.pendingCount() > 0) + } + + /// `413` narrows the byte target instead of dropping anything. Once the target can no longer hold + /// even one row, that row is a permanent local protocol error — it is retained, not skipped. + private func shrink(_ batch: SyncBuiltBatch) -> SyncPass { + if batch.rowCount <= 1, let table = batch.tables.first { + return pause(.rowTooLarge, detail: "\(table.wire)@\(batch.advances[table] ?? 0)") + } + byteTarget = max(byteTarget / 2, Self.minByteTarget) + return .sent(rowCount: 0, morePending: true) + } + + private func backOff(_ delayMs: Int64) -> SyncPass { + retryAtMs = clock() + delayMs + return .waiting(untilMs: retryAtMs) + } + + private func pause(_ reason: SyncPauseReason, detail: String) -> SyncPass { + pauseReason = reason + source.recordPermanentFailure(reason, detail: detail) + return .paused(reason) + } +} diff --git a/modules/vescape-core/ios/sync/SyncEngineTests.swift b/modules/vescape-core/ios/sync/SyncEngineTests.swift new file mode 100644 index 000000000..fad3efd30 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncEngineTests.swift @@ -0,0 +1,209 @@ +import XCTest +@testable import VescapeCore + +/// The engine against a fake transport: the cases that decide whether a Rider's data survives — a +/// wedged batch, a failure part-way through a drain, a dead token, and a response that outlived the +/// Account it was sent for. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncEngineTest.kt +final class SyncEngineTests: XCTestCase { + private final class FakeSource: SyncSource { + var remaining: Int + var committed: [[SyncTable: Int64]] = [] + var currentGeneration: Int64 = 0 + var failures: [(SyncPauseReason, String)] = [] + var encodeFailure: SyncProtocolError? + + init(rows: Int) { self.remaining = rows } + + func pending(rowLimit: Int) throws -> [SyncPendingTable] { + if let encodeFailure { throw encodeFailure } + guard remaining > 0 else { return [] } + let take = min(remaining, 2) + let rows = (0.. Int { remaining } + + func commit(_ advances: [SyncTable: Int64]) { + committed.append(advances) + remaining = max(0, remaining - 2) + } + + func generation() -> Int64 { currentGeneration } + + func recordPermanentFailure(_ reason: SyncPauseReason, detail: String) { + failures.append((reason, detail)) + } + } + + private func accepted(boards: Int) -> String { + let counts = SyncTable.allCases + .map { "\"\($0.wire)\":\($0 == .boards ? boards : 0)" } + .joined(separator: ",") + return "{\"accepted\":{\(counts)}}" + } + + private func environment() -> SyncEnvironment { + SyncEnvironment( + ridingSamples: false, + online: true, + wifiOnly: false, + onWifi: false, + credentialReady: true, + onlineBlocked: false + ) + } + + private func engine( + _ source: SyncSource, + _ responses: [SyncResponse], + sent: Sent = Sent() + ) -> SyncEngine { + var queue = responses + return SyncEngine( + source: source, + transport: { body in + sent.bodies.append(body) + return queue.isEmpty ? .transient(reason: "no response queued") : queue.removeFirst() + }, + environment: environment, + clock: { 1_000 } + ) + } + + final class Sent { + var bodies: [String] = [] + } + + func testAValid200AdvancesOnlyTheRowsItAccountedFor() async { + let source = FakeSource(rows: 2) + let engine = engine(source, [.accepted(body: accepted(boards: 2))]) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .sent(rowCount: 2, morePending: false)) + XCTAssertEqual(source.committed, [[.boards: 2]]) + } + + func testAMismatchedAcceptedCountIsAProtocolFailureAndMovesNoCursor() async { + let source = FakeSource(rows: 2) + let engine = engine(source, [.accepted(body: accepted(boards: 1))]) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .paused(.protocolFailure)) + XCTAssertTrue(source.committed.isEmpty) + XCTAssertEqual(source.failures.first?.1, "acceptedMismatch") + } + + func testAMalformedSuccessBodyNeverAdvancesACursor() async { + let source = FakeSource(rows: 2) + let engine = engine(source, [.accepted(body: "not json")]) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .paused(.protocolFailure)) + XCTAssertTrue(source.committed.isEmpty) + } + + func testARefusedBatchLeavesEveryCursorUntouchedAndDoesNotRetryOnAKick() async { + let source = FakeSource(rows: 2) + let sent = Sent() + let engine = engine(source, [.invalid(status: 409, error: "dependency-conflict")], sent: sent) + + let first = await engine.runOnce() + XCTAssertEqual(first, .paused(.protocolFailure)) + let onKick = await engine.runOnce() + XCTAssertEqual(onKick, .paused(.protocolFailure)) + XCTAssertTrue(source.committed.isEmpty) + // The paused engine never reached the transport a second time. + XCTAssertEqual(sent.bodies.count, 1) + } + + func testAFailurePartWayThroughADrainLeavesCursorsAtTheLastAcceptedBatch() async { + let source = FakeSource(rows: 4) + let engine = engine( + source, + [.accepted(body: accepted(boards: 2)), .transient(reason: "5xx")] + ) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .sent(rowCount: 2, morePending: true)) + let second = await engine.runOnce() + if case .waiting = second {} else { XCTFail("expected a backoff wait") } + XCTAssertEqual(source.committed, [[.boards: 2]]) + } + + func testADeadTokenStopsTheLoopForSignIn() async { + let source = FakeSource(rows: 2) + let engine = engine(source, [.unauthorized]) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .paused(.authentication)) + XCTAssertEqual(engine.pauseReason, .authentication) + XCTAssertTrue(source.committed.isEmpty) + } + + func testAResponseFromThePreviousAccountCannotAdvanceACursor() async { + let source = FakeSource(rows: 2) + let engine = SyncEngine( + source: source, + transport: { _ in + // The Account changed while this request was in flight. + source.currentGeneration += 1 + return .accepted(body: self.accepted(boards: 2)) + }, + environment: environment, + clock: { 1_000 } + ) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .idle) + XCTAssertTrue(source.committed.isEmpty) + } + + func testATimeoutAfterTheServerCommittedResendsTheIdenticalBatch() async { + let source = FakeSource(rows: 2) + let sent = Sent() + let engine = engine( + source, + [.transient(reason: "timeout"), .accepted(body: accepted(boards: 2))], + sent: sent + ) + + _ = await engine.runOnce() + engine.resume() + _ = await engine.runOnce() + XCTAssertEqual(sent.bodies.count, 2) + XCTAssertEqual(sent.bodies.first, sent.bodies.last) + } + + func test413PausesOnASingleRowRatherThanSkippingIt() async { + let source = FakeSource(rows: 1) + let engine = engine(source, [.tooLarge]) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .paused(.rowTooLarge)) + XCTAssertTrue(source.committed.isEmpty) + XCTAssertEqual(source.remaining, 1) + } + + func test429WaitsForTheServersOwnDelay() async { + let source = FakeSource(rows: 2) + let engine = engine(source, [.rateLimited(retryAfterMs: 90_000)]) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .waiting(untilMs: 91_000)) + XCTAssertNil(engine.pauseReason) + } + + func testARowThatCannotBeEncodedPausesWithTheRowRetained() async { + let source = FakeSource(rows: 2) + source.encodeFailure = SyncProtocolError(table: .boards, field: "id", problem: "must not be empty") + let engine = engine(source, []) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .paused(.protocolFailure)) + XCTAssertEqual(source.failures.first?.1, "boards.id") + XCTAssertEqual(source.remaining, 2) + } +} diff --git a/modules/vescape-core/ios/sync/SyncJson.swift b/modules/vescape-core/ios/sync/SyncJson.swift new file mode 100644 index 000000000..c678c5379 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncJson.swift @@ -0,0 +1,143 @@ +import Foundation + +/// A row the server could never store. Permanent for this phone: retrying the same bytes cannot make +/// it succeed, so the engine pauses with the row retained rather than skipping it. +/// +/// It names the table and the field only — never the value, which may be a coordinate, a Rider's +/// text or a token. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncJson.kt `SyncProtocolException` +struct SyncProtocolError: Error, Equatable { + let table: SyncTable + let field: String + let problem: String +} + +/// A compact JSON object writer that validates as it writes. +/// +/// Deliberately not `JSONSerialization`: this has to produce the exact bytes measured against the +/// wire byte cap, in a stable field order. The bounds it enforces are the server's own +/// (`vescape-server` `src/sync/protocol.ts`), applied before transport so a wedged batch is +/// impossible rather than merely unlikely. +/// +/// Nullable columns are written as explicit nulls: "cleared" and "not mentioned" are different +/// intents, and a missing key cannot express the first. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncJson.kt `SyncRowWriter` +/// @parity /modules/vescape-server/src/sync/protocol.ts +final class SyncRowWriter { + private let table: SyncTable + private var out = "{" + + init(_ table: SyncTable) { + self.table = table + } + + func build() -> String { out + "}" } + + /// An identifier the phone chose: a Board id, a settings key, an event name. Never empty. + @discardableResult + func keyText(_ field: String, _ value: String) throws -> SyncRowWriter { + if value.isEmpty { throw fail(field, "must not be empty") } + return try boundedText(field, value) + } + + @discardableResult + func nullableKeyText(_ field: String, _ value: String?) throws -> SyncRowWriter { + guard let value else { return raw(field, "null") } + return try keyText(field, value) + } + + /// A key column the phone derives rather than names, so it may legitimately be empty — a sanitizer + /// writes `""` as the device id of a sample captured with no Board connected. + @discardableResult + func derivedKeyText(_ field: String, _ value: String?) throws -> SyncRowWriter { + guard let value else { return raw(field, "null") } + return try boundedText(field, value) + } + + /// Text the server stores opaquely and hands back unchanged. Uncapped, like the server's. + @discardableResult + func text(_ field: String, _ value: String?) -> SyncRowWriter { + guard let value else { return raw(field, "null") } + return raw(field, quote(value)) + } + + @discardableResult + func bool(_ field: String, _ value: Bool) -> SyncRowWriter { + raw(field, value ? "true" : "false") + } + + /// Epoch ms, or a duration in ms: non-negative and inside the JSON-safe integer range. + @discardableResult + func timestamp(_ field: String, _ value: Int64?) throws -> SyncRowWriter { + try bounded(field, value, 0, syncSafeIntMax) + } + + @discardableResult + func int32(_ field: String, _ value: Int64?) throws -> SyncRowWriter { + try bounded(field, value, syncInt32Min, syncInt32Max) + } + + @discardableResult + func count(_ field: String, _ value: Int64?) throws -> SyncRowWriter { + try bounded(field, value, 0, syncInt32Max) + } + + /// A 64-bit column that is not a timestamp — an odometer reading. + @discardableResult + func int64(_ field: String, _ value: Int64?) throws -> SyncRowWriter { + try bounded(field, value, -syncSafeIntMax, syncSafeIntMax) + } + + /// A real number. Neither infinity nor NaN is expressible in JSON. + @discardableResult + func number(_ field: String, _ value: Double?) throws -> SyncRowWriter { + guard let value else { return raw(field, "null") } + if !value.isFinite { throw fail(field, "must be finite") } + let whole = Int64(exactly: value.rounded(.towardZero)) ?? 0 + return raw(field, value == Double(whole) ? String(whole) : String(value)) + } + + private func bounded(_ field: String, _ value: Int64?, _ min: Int64, _ max: Int64) throws -> SyncRowWriter { + guard let value else { return raw(field, "null") } + if value < min || value > max { throw fail(field, "is out of bounds") } + return raw(field, String(value)) + } + + private func boundedText(_ field: String, _ value: String) throws -> SyncRowWriter { + if value.count > maxSyncKeyLength { throw fail(field, "exceeds \(maxSyncKeyLength) characters") } + return raw(field, quote(value)) + } + + @discardableResult + private func raw(_ field: String, _ encoded: String) -> SyncRowWriter { + if out.count > 1 { out += "," } + out += quote(field) + ":" + encoded + return self + } + + private func fail(_ field: String, _ problem: String) -> SyncProtocolError { + SyncProtocolError(table: table, field: field, problem: problem) + } + + private func quote(_ value: String) -> String { + var quoted = "\"" + for character in value.unicodeScalars { + switch character { + case "\"": quoted += "\\\"" + case "\\": quoted += "\\\\" + case "\n": quoted += "\\n" + case "\r": quoted += "\\r" + case "\t": quoted += "\\t" + default: + if character.value < 0x20 { + quoted += String(format: "\\u%04x", character.value) + } else { + quoted.unicodeScalars.append(character) + } + } + } + return quoted + "\"" + } +} diff --git a/modules/vescape-core/ios/sync/SyncPolicy.swift b/modules/vescape-core/ios/sync/SyncPolicy.swift new file mode 100644 index 000000000..f7462cfc9 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncPolicy.swift @@ -0,0 +1,81 @@ +import Foundation + +/// How the uploader ran out of road. A paused engine is not woken by ordinary timer kicks. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt `SyncPauseReason` +/// @parity /modules/vescape-core/src/index.ts `SyncPauseReason` +enum SyncPauseReason: String { + /// No Device Token, or the server rejected the one we hold. Sign-in is the only way out. + case authentication + + /// The server refused this batch on its contents, or answered `2xx` with something unreadable. + case protocolFailure = "protocol" + + /// A single row cannot fit inside the wire byte cap. Retained, never skipped. + case rowTooLarge + + var slug: String { rawValue } +} + +/// What the loop should do next. +enum SyncDecision: Equatable { + case sendNow + /// Nothing to do until this moment; the loop re-decides then or when a kick lands. + case wait(atMs: Int64) + /// Stopped until the named condition changes. Timer and connectivity kicks do not bypass it. + case paused(SyncPauseReason) +} + +/// Everything the decision depends on, read once by the caller so the decision itself stays pure. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt `SyncState` +struct SyncState { + let nowMs: Int64 + /// Rows waiting across every table. Zero means idle, not finished. + let pendingRows: Int + /// A Board Session is producing samples — Idle Pause halts production without ending the session. + let ridingSamples: Bool + let online: Bool + /// Metered-connection setting; the uploader waits for Wi-Fi rather than failing. + let wifiOnly: Bool + let onWifi: Bool + let credentialReady: Bool + /// The App Status gate closed, like every other Online Capability. + let onlineBlocked: Bool + /// Set by a permanent failure; cleared only by sign-in or an Account reset. + let pause: SyncPauseReason? + /// Backoff or `Retry-After` deadline; before it, nothing is sent. + let retryAtMs: Int64 +} + +/// The one place that turns state into "send, wait, or stopped". +/// +/// Pure: no database, no clock, no network. The clock is `SyncState.nowMs` and the caller owns it. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt `SyncPolicy` +enum SyncPolicy { + /// Cadence while a ride is producing samples: a crash loses at most this much. + static let rideIntervalMs: Int64 = 30_000 + + /// Cadence when nothing is pending. Cheap, because it is a no-op. + static let idleIntervalMs: Int64 = 5 * 60_000 + + static let backoffStartMs: Int64 = 30_000 + static let backoffMaxMs: Int64 = 15 * 60_000 + + static func decide(_ state: SyncState) -> SyncDecision { + if let pause = state.pause { return .paused(pause) } + if !state.credentialReady { return .paused(.authentication) } + + let interval = state.ridingSamples ? rideIntervalMs : idleIntervalMs + if state.pendingRows <= 0 { return .wait(atMs: state.nowMs + interval) } + // Offline, metered, or gated: a pause in the loop, never a failure that moves backoff. + if !state.online || state.onlineBlocked { return .wait(atMs: state.nowMs + interval) } + if state.wifiOnly && !state.onWifi { return .wait(atMs: state.nowMs + interval) } + if state.retryAtMs > state.nowMs { return .wait(atMs: state.retryAtMs) } + return .sendNow + } + + /// Next backoff step: doubling from `backoffStartMs`, capped, and reset to 0 on success. + static func nextBackoffMs(_ previousMs: Int64) -> Int64 { + previousMs <= 0 ? backoffStartMs : min(previousMs * 2, backoffMaxMs) + } +} diff --git a/modules/vescape-core/ios/sync/SyncPolicyTests.swift b/modules/vescape-core/ios/sync/SyncPolicyTests.swift new file mode 100644 index 000000000..ab0416862 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncPolicyTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import VescapeCore + +/// The send/wait/paused decision, with no database, clock or network behind it. +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncPolicyTest.kt +final class SyncPolicyTests: XCTestCase { + private func state( + pendingRows: Int = 1, + ridingSamples: Bool = false, + online: Bool = true, + wifiOnly: Bool = false, + onWifi: Bool = false, + credentialReady: Bool = true, + onlineBlocked: Bool = false, + pause: SyncPauseReason? = nil, + retryAtMs: Int64 = 0 + ) -> SyncState { + SyncState( + nowMs: 1_000, + pendingRows: pendingRows, + ridingSamples: ridingSamples, + online: online, + wifiOnly: wifiOnly, + onWifi: onWifi, + credentialReady: credentialReady, + onlineBlocked: onlineBlocked, + pause: pause, + retryAtMs: retryAtMs + ) + } + + func testPendingRowsOnALiveConnectionSendNow() { + XCTAssertEqual(SyncPolicy.decide(state()), .sendNow) + } + + func testCadenceFollowsSampleProductionNotSessionPresence() { + XCTAssertEqual( + SyncPolicy.decide(state(pendingRows: 0, ridingSamples: true)), + .wait(atMs: 1_000 + SyncPolicy.rideIntervalMs) + ) + XCTAssertEqual( + SyncPolicy.decide(state(pendingRows: 0)), + .wait(atMs: 1_000 + SyncPolicy.idleIntervalMs) + ) + } + + /// Offline, metered and gated are pauses in the loop, never failures that move backoff. + func testOfflineMeteredAndClosedGateAllWait() { + let idle = SyncDecision.wait(atMs: 1_000 + SyncPolicy.idleIntervalMs) + XCTAssertEqual(SyncPolicy.decide(state(online: false)), idle) + XCTAssertEqual(SyncPolicy.decide(state(wifiOnly: true, onWifi: false)), idle) + XCTAssertEqual(SyncPolicy.decide(state(onlineBlocked: true)), idle) + XCTAssertEqual(SyncPolicy.decide(state(wifiOnly: true, onWifi: true)), .sendNow) + } + + func testBackoffDeadlineHoldsTheLoopUntilItPasses() { + XCTAssertEqual(SyncPolicy.decide(state(retryAtMs: 5_000)), .wait(atMs: 5_000)) + XCTAssertEqual(SyncPolicy.decide(state(retryAtMs: 999)), .sendNow) + } + + func testAPauseIsNotBypassedByAnOrdinaryKick() { + XCTAssertEqual(SyncPolicy.decide(state(pause: .protocolFailure)), .paused(.protocolFailure)) + XCTAssertEqual(SyncPolicy.decide(state(credentialReady: false)), .paused(.authentication)) + } + + func testBackoffDoublesFromTheFirstStepAndStopsAtTheCap() { + XCTAssertEqual(SyncPolicy.nextBackoffMs(0), SyncPolicy.backoffStartMs) + XCTAssertEqual(SyncPolicy.nextBackoffMs(30_000), 60_000) + XCTAssertEqual(SyncPolicy.nextBackoffMs(SyncPolicy.backoffMaxMs), SyncPolicy.backoffMaxMs) + } +} diff --git a/modules/vescape-core/ios/sync/SyncRetentionTests.swift b/modules/vescape-core/ios/sync/SyncRetentionTests.swift new file mode 100644 index 000000000..73ec1cbb0 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncRetentionTests.swift @@ -0,0 +1,138 @@ +import XCTest +import GRDB +@testable import VescapeCore + +/// Cursor-gated retention against a real database: a bound database must not prune a row the +/// uploader has not delivered, and an unbound one must keep the age-only behaviour it shipped with. +/// +/// The Android peer asserts the same predicates against the DAO source, because Room keeps its SQL +/// out of reach of a JVM unit test. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncCursorContractTest.kt +final class SyncRetentionTests: XCTestCase { + private var queue: DatabaseQueue! + private let old: Int64 = 1_000 + private let cutoff: Int64 = 10_000 + + override func setUpWithError() throws { + queue = try DatabaseQueue() + try TelemetryDatabase.migrator.migrate(queue) + } + + override func tearDownWithError() throws { + queue = nil + } + + private func seedFrame(id: Int64, capturedAtMs: Int64) throws { + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO telemetry_frames + (id, captured_at_ms, elapsed_realtime_ms, board_id, flags, changed_mask_1, changed_mask_2) + VALUES (?, ?, 0, 'board-1', 0, 0, 0) + """, + arguments: [id, capturedAtMs] + ) + } + } + + private func seedBucket(startMs: Int64, syncSeq: Int64) throws { + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO telemetry_minute_buckets + (bucket_start_ms, board_id, sample_count, first_sample_at_ms, last_sample_at_ms, + sum_abs_speed_centi_kmh, max_abs_speed_centi_kmh, max_motor_current_abs_ma, + max_battery_current_abs_ma, battery_used_wh_milli, battery_regen_wh_milli, + max_duty_abs_permille, fault_count, gps_point_count, precise_gps_point_count, + gps_distance_cm, updated_at, sync_seq) + VALUES (?, 'board-1', 1, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?, ?) + """, + arguments: [startMs, startMs, startMs, startMs, syncSeq] + ) + } + } + + private func frameIds() throws -> [Int64] { + try queue.read { db in try Int64.fetchAll(db, sql: "SELECT id FROM telemetry_frames ORDER BY id") } + } + + private func bucketStarts() throws -> [Int64] { + try queue.read { db in + try Int64.fetchAll(db, sql: "SELECT bucket_start_ms FROM telemetry_minute_buckets ORDER BY bucket_start_ms") + } + } + + private func bind(_ accountId: String = "account-1") throws { + try queue.write { db in + try db.execute( + sql: "INSERT OR REPLACE INTO sync_binding (id, account_id, bound_at) VALUES (0, ?, 0)", + arguments: [accountId] + ) + } + } + + private func sweep() throws { + _ = try queue.write { db in try deleteBeforeGated(db, beforeMs: cutoff) } + } + + func testANeverBoundDatabaseKeepsTheAgeOnlyCleanup() throws { + try seedFrame(id: 1, capturedAtMs: old) + try seedFrame(id: 2, capturedAtMs: cutoff + 1) + + try sweep() + XCTAssertEqual(try frameIds(), [2]) + } + + func testABoundDatabaseRetainsEveryRowItsCursorHasNotPassed() throws { + try bind() + try seedFrame(id: 1, capturedAtMs: old) + try seedFrame(id: 2, capturedAtMs: old) + + try sweep() + XCTAssertEqual(try frameIds(), [1, 2], "a missing cursor protects every row in the table") + + try queue.write { db in try commitSyncCursor(db, syncCursorFrames, 1) } + try sweep() + XCTAssertEqual(try frameIds(), [2], "only rows at or below the accepted cursor may be pruned") + } + + /// A bucket rewritten after its earlier version uploaded gets a fresh `sync_seq`, so it has to + /// survive until that new position is accepted — a row id could not express this. + func testAnOldBucketRewrittenAfterUploadSurvivesUntilItsNewSeqIsAccepted() throws { + try bind() + try seedBucket(startMs: old, syncSeq: 1) + try queue.write { db in try commitSyncCursor(db, syncCursorMinuteBuckets, 1) } + + // The minute is re-merged, which renumbers the row above the accepted cursor. + try queue.write { db in + try db.execute(sql: "UPDATE telemetry_minute_buckets SET sync_seq = 7 WHERE bucket_start_ms = ?", arguments: [old]) + } + try sweep() + XCTAssertEqual(try bucketStarts(), [old]) + + try queue.write { db in try commitSyncCursor(db, syncCursorMinuteBuckets, 7) } + try sweep() + XCTAssertTrue(try bucketStarts().isEmpty) + } + + /// Signing out does not clear the binding, so data recorded afterwards keeps its protection. + func testSignOutKeepsRetentionProtectionForTheBoundAccount() throws { + try bind() + try seedFrame(id: 1, capturedAtMs: old) + + // Nothing about a sign-out touches `sync_binding`. + try sweep() + XCTAssertEqual(try frameIds(), [1]) + } + + func testABindingIsClaimedOnceAndRefusesADifferentAccount() throws { + try queue.write { db in + XCTAssertNil(try String.fetchOne(db, sql: "SELECT account_id FROM sync_binding WHERE id = 0")) + } + try bind("account-1") + try queue.read { db in + XCTAssertEqual(try String.fetchOne(db, sql: "SELECT account_id FROM sync_binding WHERE id = 0"), "account-1") + } + } +} diff --git a/modules/vescape-core/ios/sync/SyncStore.swift b/modules/vescape-core/ios/sync/SyncStore.swift new file mode 100644 index 000000000..258c1591c --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncStore.swift @@ -0,0 +1,238 @@ +import Foundation +import GRDB + +/// Which Vescape Account this local database belongs to. One row, claimed by the first Account to +/// sign in and never rewritten in place: a different Account replaces the whole database, because +/// resetting the cursors over these rows would upload the previous Account's Boards, Ride History, +/// locations and settings to the new one. +/// +/// Signing out does not clear the binding, so data recorded while signed out keeps its retention +/// protection for the same Account. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `SyncBindingEntity` +internal func createSyncBindingTable(_ db: Database) throws { + try db.execute( + sql: """ + CREATE TABLE IF NOT EXISTS sync_binding ( + id INTEGER PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL, + bound_at INTEGER NOT NULL + ) + """ + ) +} + +/// How far a table has been accepted. A table with no committed cursor has delivered nothing. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `cursorOf` +internal func syncCursor(_ db: Database, _ name: String) throws -> Int64 { + try Int64.fetchOne( + db, + sql: "SELECT last_value FROM sync_sequences WHERE name = ?", + arguments: [name] + ) ?? 0 +} + +/// Checkpoint how far a table has been accepted. Run after the response and never alongside the +/// rows: a cursor advanced past rows the server did not take is unrecoverable, whereas a cursor left +/// behind is a re-send the server upserts idempotently. Never moves backwards. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `commitSyncCursor` +internal func commitSyncCursor(_ db: Database, _ name: String, _ throughValue: Int64) throws { + try db.execute( + sql: """ + INSERT OR REPLACE INTO sync_sequences (name, last_value) + VALUES (?, MAX(?, COALESCE((SELECT last_value FROM sync_sequences WHERE name = ?), 0))) + """, + arguments: [name, throughValue, name] + ) +} + +/// The database side of the uploader: the forward scan, the cursor commit and the failure record. +/// +/// Encoding happens here rather than in the engine, so the pure batch builder measures the exact +/// bytes that will be sent. Rows are read in `SyncTable` order and the scan stops once the row limit +/// is reached — a table further down waits for the next batch, which is what keeps parents ahead of +/// children. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncStore.kt `SyncStore` +final class SyncStore: SyncSource { + private let generationProvider: () -> Int64 + private let onPermanentFailure: (SyncPauseReason, String) -> Void + + init( + generation: @escaping () -> Int64, + onPermanentFailure: @escaping (SyncPauseReason, String) -> Void + ) { + self.generationProvider = generation + self.onPermanentFailure = onPermanentFailure + } + + /// Resolved per call: an Account reset replaces the whole database under this object. + private var pool: DatabasePool? { TelemetryDatabase.pool } + + /// Frames and buckets that name no Board are not offered: the server keys those tables on the + /// Board and has nowhere to put a sample that belongs to none (ADR-0028). They are unowned local + /// rows, not rows a Rider is waiting to see backed up. + private func scanPredicate(_ table: SyncTable) -> String { + switch table { + case .telemetryFrames: return " AND board_id IS NOT NULL" + case .telemetryMinuteBuckets: return " AND board_id != ''" + default: return "" + } + } + + func pending(rowLimit: Int) throws -> [SyncPendingTable] { + guard let pool else { return [] } + var tables: [SyncPendingTable] = [] + var budget = rowLimit + var encodeError: Error? + + try pool.read { db in + for table in SyncTable.allCases { + if budget <= 0 { break } + let cursor = try syncCursor(db, table.cursorKey) + let rows = try Row.fetchAll( + db, + sql: """ + SELECT * FROM \(table.table) + WHERE \(table.cursorColumn) > ?\(scanPredicate(table)) + ORDER BY \(table.cursorColumn) ASC + LIMIT ? + """, + arguments: [cursor, budget] + ) + if rows.isEmpty { continue } + do { + let encoded = try rows.map { row in + SyncPendingRow( + cursor: row[table.cursorColumn] as Int64? ?? 0, + json: try SyncWire.encode(table, row) + ) + } + tables.append(SyncPendingTable(table: table, rows: encoded)) + budget -= encoded.count + } catch { + encodeError = error + return + } + } + } + + if let encodeError { throw encodeError } + return tables + } + + func pendingCount() -> Int { + guard let pool else { return 0 } + return (try? pool.read { db in + var total = 0 + for table in SyncTable.allCases { + let cursor = try syncCursor(db, table.cursorKey) + total += try Int.fetchOne( + db, + sql: """ + SELECT COUNT(*) FROM \(table.table) + WHERE \(table.cursorColumn) > ?\(scanPredicate(table)) + """, + arguments: [cursor] + ) ?? 0 + } + return total + }) ?? 0 + } + + /// Cursors move only here, only after the server accepted. The accepted Sync Action cursor is also + /// what prunes the log, so pruning can never outrun it. + func commit(_ advances: [SyncTable: Int64]) { + guard let pool else { return } + try? pool.write { db in + for (table, cursor) in advances { + try commitSyncCursor(db, table.cursorKey, cursor) + } + } + guard advances[.deleteActions] != nil else { return } + try? pool.write { db in + try pruneUploadedSyncActions(db) + } + } + + func generation() -> Int64 { generationProvider() } + + func recordPermanentFailure(_ reason: SyncPauseReason, detail: String) { + onPermanentFailure(reason, detail) + } + + // Account binding. + + func boundAccountId() -> String? { + guard let pool else { return nil } + return try? pool.read { db in + try String.fetchOne(db, sql: "SELECT account_id FROM sync_binding WHERE id = 0") + } + } + + /// Claim this database for `accountId`, or confirm it already belongs to it. False means it + /// belongs to a different Account: the caller has to replace the database first. + @discardableResult + func bindAccount(_ accountId: String) -> Bool { + guard let pool else { return false } + return (try? pool.write { db -> Bool in + if let bound = try String.fetchOne(db, sql: "SELECT account_id FROM sync_binding WHERE id = 0") { + return bound == accountId + } + try db.execute( + sql: "INSERT OR REPLACE INTO sync_binding (id, account_id, bound_at) VALUES (0, ?, ?)", + arguments: [accountId, telemetryNowMs()] + ) + return true + }) ?? false + } +} + +/// Cursor-gated retention. A retention cutoff is only a candidate cutoff: cleanup must not remove a +/// row the uploader has not delivered. The sweep reads its table cursor and deletes in one +/// transaction, so racing an upload fails safe — before the cursor commit the rows are retained, +/// after it the server has accepted them. A missing cursor is 0, protecting every row. +/// +/// Emits no Sync Actions: a retention sweep is maintenance, and `DeleteTarget` has no case that +/// could name a pruned table. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `deleteBeforeGated` +internal func deleteBeforeGated(_ db: Database, beforeMs: Int64) throws -> Int { + let bound = try String.fetchOne(db, sql: "SELECT account_id FROM sync_binding WHERE id = 0") + + if bound == nil { + // Never bound to an Account: the existing age-only cleanup, unchanged. + try db.execute(sql: "DELETE FROM telemetry_frames WHERE captured_at_ms < ?", arguments: [beforeMs]) + let count = db.changesCount + try db.execute(sql: "DELETE FROM telemetry_minute_buckets WHERE bucket_start_ms < ?", arguments: [beforeMs]) + try db.execute(sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms < ?", arguments: [beforeMs]) + try db.execute(sql: "DELETE FROM metric_exclusion_ranges WHERE end_ms < ?", arguments: [beforeMs]) + try db.execute(sql: "DELETE FROM diagnostic_events WHERE occurred_at_ms < ?", arguments: [beforeMs]) + return count + } + + try db.execute( + sql: "DELETE FROM telemetry_frames WHERE captured_at_ms < ? AND id <= ?", + arguments: [beforeMs, try syncCursor(db, syncCursorFrames)] + ) + let count = db.changesCount + // A bucket is protected by `sync_seq`, not by a row id: one rewritten after an earlier version + // uploaded gets a fresh position and has to survive until that one is accepted. + try db.execute( + sql: "DELETE FROM telemetry_minute_buckets WHERE bucket_start_ms < ? AND sync_seq <= ?", + arguments: [beforeMs, try syncCursor(db, syncCursorMinuteBuckets)] + ) + try db.execute( + sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms < ? AND id <= ?", + arguments: [beforeMs, try syncCursor(db, syncCursorMarkers)] + ) + try db.execute( + sql: "DELETE FROM metric_exclusion_ranges WHERE end_ms < ? AND id <= ?", + arguments: [beforeMs, try syncCursor(db, syncCursorExclusionRanges)] + ) + try db.execute( + sql: "DELETE FROM diagnostic_events WHERE occurred_at_ms < ? AND id <= ?", + arguments: [beforeMs, try syncCursor(db, syncCursorDiagnosticEvents)] + ) + return count +} diff --git a/modules/vescape-core/ios/sync/SyncTables.swift b/modules/vescape-core/ios/sync/SyncTables.swift new file mode 100644 index 000000000..1303971eb --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncTables.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Every table a Sync Batch can carry, in the order the server writes them: a Board-owned row +/// references its Board, so a batch carrying both has to put the Board first or the foreign key +/// refuses the whole batch. Delete Actions come last, so an action is judged against the Change +/// Timestamp the same batch just wrote. +/// +/// The batch builder walks this order and nothing else — never the size of a table's backlog, which +/// would produce a batch the server cannot apply. +/// +/// `cursorColumn` is what the scan runs on: an `AUTOINCREMENT` key for append-only tables, +/// `sync_seq` for mutable ones. Both are device-local counters that never cross the wire. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncTables.kt `SyncTable` +/// @parity /modules/vescape-core/src/index.ts `SyncTable` +enum SyncTable: String, CaseIterable { + case appSettings + case boards + case boardSettings + case boardWarnings + case alerts + case tuneProfiles + case tuneHistoryEntries + case privacyZones + case telemetryMarkers + case metricExclusionRanges + case diagnosticEvents + case telemetryFrames + case telemetryMinuteBuckets + case favorites + case deleteActions + + var wire: String { rawValue } + + var table: String { + switch self { + case .appSettings: return "app_settings" + case .boards: return "boards" + case .boardSettings: return "board_settings" + case .boardWarnings: return "board_warnings" + case .alerts: return "alerts" + case .tuneProfiles: return "tune_profiles" + case .tuneHistoryEntries: return "tune_history_entries" + case .privacyZones: return "privacy_zones" + case .telemetryMarkers: return "telemetry_markers" + case .metricExclusionRanges: return "metric_exclusion_ranges" + case .diagnosticEvents: return "diagnostic_events" + case .telemetryFrames: return "telemetry_frames" + case .telemetryMinuteBuckets: return "telemetry_minute_buckets" + case .favorites: return "favorites" + case .deleteActions: return "sync_actions" + } + } + + var cursorColumn: String { + switch self { + case .tuneHistoryEntries, .telemetryMarkers, .metricExclusionRanges, .diagnosticEvents, + .telemetryFrames, .deleteActions: + return syncRowIdColumn + default: + return syncSeqColumn + } + } + + /// `sync_sequences` key holding how far this table has been accepted. Distinct from the write + /// counters keyed on the bare table name, which hand out `sync_seq` positions. + /// + /// Sync Actions keep the key #282 already shipped, so the log's prune keeps reading the same row + /// the uploader commits. + var cursorKey: String { + self == .deleteActions ? syncActionsUploadedCursor : syncCursorPrefix + table + } +} + +internal let syncSeqColumn = "sync_seq" +internal let syncRowIdColumn = "id" +internal let syncCursorPrefix = "sync_cursor_" + +/// Rows accepted in one Sync Batch, total across every table. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncTables.kt `MAX_SYNC_BATCH_ROWS` +let maxSyncBatchRows = 1_000 + +/// Actual compact UTF-8 JSON bytes accepted by `POST /api/sync`. Measured on the encoded request, +/// not estimated from object sizes — the server refuses on the byte count it actually receives. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncTables.kt `MAX_SYNC_BATCH_BYTES` +let maxSyncBatchBytes = 1024 * 1024 + +/// Longest text one column of a server key may hold. Mirrored from the server so a row that cannot +/// be stored is refused here instead of wedging a batch. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncTables.kt `MAX_SYNC_KEY_LENGTH` +let maxSyncKeyLength = 128 + +/// Bounds of the Postgres `integer` columns the app's 32-bit values land in. +internal let syncInt32Min: Int64 = -2_147_483_648 +internal let syncInt32Max: Int64 = 2_147_483_647 + +/// `Number.MAX_SAFE_INTEGER`: past it `JSON.parse` rounds, so neither side could agree on the value. +internal let syncSafeIntMax: Int64 = 9_007_199_254_740_991 + +/// The five retained tables' cursor keys, named so cursor-gated retention reads what the uploader +/// commits. +internal let syncCursorFrames = "sync_cursor_telemetry_frames" +internal let syncCursorMarkers = "sync_cursor_telemetry_markers" +internal let syncCursorMinuteBuckets = "sync_cursor_telemetry_minute_buckets" +internal let syncCursorDiagnosticEvents = "sync_cursor_diagnostic_events" +internal let syncCursorExclusionRanges = "sync_cursor_metric_exclusion_ranges" diff --git a/modules/vescape-core/ios/sync/SyncWire.swift b/modules/vescape-core/ios/sync/SyncWire.swift new file mode 100644 index 000000000..743f72dd9 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncWire.swift @@ -0,0 +1,304 @@ +import Foundation +import GRDB + +/// Local rows as the server reads them. +/// +/// Every encoder validates before transport, so a batch is refused here — with the row retained and +/// one metadata-only Diagnostic Event — rather than wedging against the server. The field sets +/// mirror `vescape-server` `src/sync/protocol.ts`; a column the server does not declare is not sent, +/// because an unknown field rejects the whole batch. +/// +/// Rows arrive as GRDB rows rather than typed structs: the iOS side stores telemetry in raw SQL, and +/// re-modelling fifteen tables here would add a second schema to keep in step with the first. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncWire.kt +/// @parity /modules/vescape-server/src/sync/protocol.ts +enum SyncWire { + static func encode(_ table: SyncTable, _ row: Row) throws -> String { + switch table { + case .appSettings: return try appSetting(row) + case .boards: return try board(row) + case .boardSettings: return try boardSetting(row) + case .boardWarnings: return try boardWarning(row) + case .alerts: return try alert(row) + case .tuneProfiles: return try tuneProfile(row) + case .tuneHistoryEntries: return try tuneHistoryEntry(row) + case .privacyZones: return try privacyZone(row) + case .telemetryMarkers: return try telemetryMarker(row) + case .metricExclusionRanges: return try metricExclusionRange(row) + case .diagnosticEvents: return try diagnosticEvent(row) + case .telemetryFrames: return try telemetryFrame(row) + case .telemetryMinuteBuckets: return try telemetryMinuteBucket(row) + case .favorites: return try favorite(row) + case .deleteActions: return try deleteAction(row) + } + } + + static func appSetting(_ row: Row) throws -> String { + let writer = SyncRowWriter(.appSettings) + try writer.keyText("key", text(row, "key")) + writer.text("valueJson", row["value_json"]) + try writer.timestamp("updatedAt", row["updated_at"]) + return writer.build() + } + + /// `transport` is iOS-only in the local schema but not a Board column the server declares; it is + /// sent as null on both platforms so a Board row reads identically from either phone. + static func board(_ row: Row) throws -> String { + let writer = SyncRowWriter(.boards) + try writer.keyText("id", text(row, "id")) + writer.text("name", row["name"]) + writer.text("bleId", row["ble_id"]) + writer.text("transport", nil) + try writer.timestamp("createdAt", row["created_at"]) + try writer.timestamp("updatedAt", row["updated_at"]) + return writer.build() + } + + static func boardSetting(_ row: Row) throws -> String { + let writer = SyncRowWriter(.boardSettings) + try writer.keyText("boardId", text(row, "board_id")) + try writer.keyText("key", text(row, "key")) + writer.text("valueJson", row["value_json"]) + try writer.timestamp("updatedAt", row["updated_at"]) + return writer.build() + } + + static func boardWarning(_ row: Row) throws -> String { + let writer = SyncRowWriter(.boardWarnings) + try writer.keyText("boardId", text(row, "board_id")) + try writer.keyText("kind", text(row, "kind")) + writer.text("severity", row["severity"]) + try writer.timestamp("firstDetectedAt", row["first_detected_at"]) + try writer.timestamp("lastDetectedAt", row["last_detected_at"]) + writer.text("payloadJson", row["payload_json"]) + return writer.build() + } + + static func alert(_ row: Row) throws -> String { + let writer = SyncRowWriter(.alerts) + try writer.keyText("boardId", text(row, "board_id")) + try writer.keyText("id", text(row, "id")) + try writer.keyText("controlId", text(row, "control_id")) + try writer.number("threshold", row["threshold"]) + try writer.number("thresholdMax", row["threshold_max"]) + writer.bool("enabled", (row["enabled"] as Int64? ?? 0) != 0) + writer.text("soundType", row["sound_type"]) + writer.text("source", row["source"]) + try writer.timestamp("createdAt", row["created_at"]) + try writer.timestamp("updatedAt", row["updated_at"]) + return writer.build() + } + + static func tuneProfile(_ row: Row) throws -> String { + let writer = SyncRowWriter(.tuneProfiles) + try writer.keyText("id", text(row, "id")) + try writer.keyText("boardId", text(row, "board_id")) + // May legitimately be empty: the app defaults an unknown Refloat package version to `''`. + try writer.derivedKeyText("refloatBaseVersion", row["refloat_base_version"]) + writer.text("name", row["name"]) + writer.text("icon", row["icon"]) + writer.text("color", row["color"]) + writer.text("fieldsJson", row["fields_json"]) + try writer.timestamp("createdAt", row["created_at"]) + try writer.timestamp("updatedAt", row["updated_at"]) + return writer.build() + } + + /// Carries no id: the local one restarts on a fresh install, so identity is `(profileId, createdAt)`. + static func tuneHistoryEntry(_ row: Row) throws -> String { + let writer = SyncRowWriter(.tuneHistoryEntries) + try writer.keyText("profileId", text(row, "profile_id")) + writer.text("fieldsJson", row["fields_json"]) + try writer.timestamp("createdAt", row["created_at"]) + return writer.build() + } + + static func privacyZone(_ row: Row) throws -> String { + let writer = SyncRowWriter(.privacyZones) + try writer.keyText("id", text(row, "id")) + writer.text("preset", row["preset"]) + writer.text("name", row["name"]) + writer.bool("enabled", (row["enabled"] as Int64? ?? 0) != 0) + try writer.int32("centerLatitudeE7", row["center_latitude_e7"]) + try writer.int32("centerLongitudeE7", row["center_longitude_e7"]) + try writer.int32("radiusMeters", row["radius_meters"]) + try writer.timestamp("createdAt", row["created_at"]) + try writer.timestamp("updatedAt", row["updated_at"]) + return writer.build() + } + + static func telemetryMarker(_ row: Row) throws -> String { + let writer = SyncRowWriter(.telemetryMarkers) + try writer.timestamp("occurredAtMs", row["occurred_at_ms"]) + try writer.timestamp("elapsedRealtimeMs", row["elapsed_realtime_ms"]) + try writer.keyText("type", text(row, "type")) + try writer.derivedKeyText("deviceId", row["device_id"]) + writer.text("deviceName", row["device_name"]) + writer.text("message", row["message"]) + try writer.timestamp("gapMs", row["gap_ms"]) + return writer.build() + } + + static func metricExclusionRange(_ row: Row) throws -> String { + let writer = SyncRowWriter(.metricExclusionRanges) + try writer.derivedKeyText("deviceId", row["device_id"]) + writer.text("reason", row["reason"]) + try writer.timestamp("startMs", row["start_ms"]) + try writer.timestamp("endMs", row["end_ms"]) + try writer.count("sampleCount", row["sample_count"]) + return writer.build() + } + + static func diagnosticEvent(_ row: Row) throws -> String { + let writer = SyncRowWriter(.diagnosticEvents) + try writer.timestamp("occurredAtMs", row["occurred_at_ms"]) + try writer.timestamp("elapsedRealtimeMs", row["elapsed_realtime_ms"]) + try writer.keyText("eventName", text(row, "event_name")) + try writer.derivedKeyText("operation", row["operation"]) + try writer.derivedKeyText("phase", row["phase"]) + try writer.derivedKeyText("deviceId", row["device_id"]) + writer.text("deviceName", row["device_name"]) + writer.text("message", row["message"]) + writer.text("propertiesJson", row["properties_json"]) + return writer.build() + } + + /// A Telemetry Sample as recorded: still delta-encoded, carrying the Changed Masks. The local row + /// id and the per-row device columns never cross the wire — the Board reference replaces them + /// (ADR-0028) and a restored phone's full re-upload has to be an idempotent no-op. + /// + /// A frame that names no Board cannot be encoded; the scan never offers one. + static func telemetryFrame(_ row: Row) throws -> String { + let writer = SyncRowWriter(.telemetryFrames) + guard let boardId: String = row["board_id"] else { + throw SyncProtocolError(table: .telemetryFrames, field: "boardId", problem: "must name a Board") + } + try writer.keyText("boardId", boardId) + try writer.timestamp("capturedAtMs", row["captured_at_ms"]) + try writer.timestamp("elapsedRealtimeMs", row["elapsed_realtime_ms"]) + try writer.int32("canId", row["can_id"]) + try writer.count("flags", row["flags"]) + try writer.count("changedMask1", row["changed_mask_1"]) + try writer.count("changedMask2", row["changed_mask_2"]) + try writer.int32("speedCentiKmh", row["speed_centi_kmh"]) + try writer.int32("batteryVoltageMv", row["battery_voltage_mv"]) + try writer.int32("motorCurrentMa", row["motor_current_ma"]) + try writer.int32("batteryCurrentMa", row["battery_current_ma"]) + try writer.int32("dutyPermille", row["duty_permille"]) + try writer.int32("pitchCentiDeg", row["pitch_centi_deg"]) + try writer.int32("rollCentiDeg", row["roll_centi_deg"]) + try writer.int32("balancePitchCentiDeg", row["balance_pitch_centi_deg"]) + try writer.int32("balanceCurrentMa", row["balance_current_ma"]) + try writer.int32("erpm", row["erpm"]) + try writer.int32("state", row["state"]) + try writer.int32("switchState", row["switch_state"]) + try writer.int32("adc1Milli", row["adc1_milli"]) + try writer.int32("adc2Milli", row["adc2_milli"]) + try writer.int64("odometerCm", row["odometer_cm"]) + try writer.int32("tempMosfetDeciC", row["temp_mosfet_deci_c"]) + try writer.int32("tempMotorDeciC", row["temp_motor_deci_c"]) + try writer.int32("faultCode", row["fault_code"]) + try writer.int32("latitudeE7", row["latitude_e7"]) + try writer.int32("longitudeE7", row["longitude_e7"]) + try writer.int32("gpsSpeedCentiMps", row["gps_speed_centi_mps"]) + try writer.int32("bearingCentiDeg", row["bearing_centi_deg"]) + try writer.int32("accuracyCm", row["accuracy_cm"]) + try writer.int32("altitudeCm", row["altitude_cm"]) + try writer.timestamp("locationTimestampMs", row["location_timestamp_ms"]) + return writer.build() + } + + static func telemetryMinuteBucket(_ row: Row) throws -> String { + let writer = SyncRowWriter(.telemetryMinuteBuckets) + try writer.keyText("boardId", text(row, "board_id")) + try writer.timestamp("bucketStartMs", row["bucket_start_ms"]) + try writer.timestamp("updatedAt", row["updated_at"]) + try writer.count("sampleCount", row["sample_count"]) + try writer.timestamp("firstSampleAtMs", row["first_sample_at_ms"]) + try writer.timestamp("lastSampleAtMs", row["last_sample_at_ms"]) + try writer.int64("sumAbsSpeedCentiKmh", row["sum_abs_speed_centi_kmh"]) + try writer.count("movingSpeedSampleCount", row["moving_speed_sample_count"]) + try writer.int64("sumMovingAbsSpeedCentiKmh", row["sum_moving_abs_speed_centi_kmh"]) + try writer.int32("maxAbsSpeedCentiKmh", row["max_abs_speed_centi_kmh"]) + try writer.int32("minBatteryVoltageMv", row["min_battery_voltage_mv"]) + try writer.int32("maxMotorCurrentAbsMa", row["max_motor_current_abs_ma"]) + try writer.int32("maxBatteryCurrentAbsMa", row["max_battery_current_abs_ma"]) + try writer.int64("batteryUsedWhMilli", row["battery_used_wh_milli"]) + try writer.int64("batteryRegenWhMilli", row["battery_regen_wh_milli"]) + try writer.int32("maxDutyAbsPermille", row["max_duty_abs_permille"]) + try writer.count("faultCount", row["fault_count"]) + try writer.int64("firstOdometerCm", row["first_odometer_cm"]) + try writer.int64("lastOdometerCm", row["last_odometer_cm"]) + try writer.count("gpsPointCount", row["gps_point_count"]) + try writer.count("preciseGpsPointCount", row["precise_gps_point_count"]) + try writer.int64("gpsDistanceCm", row["gps_distance_cm"]) + try writer.int32("maxGpsSpeedCentiMps", row["max_gps_speed_centi_mps"]) + try writer.int32("maxTempMosfetDeciC", row["max_temp_mosfet_deci_c"]) + try writer.int32("maxTempMotorDeciC", row["max_temp_motor_deci_c"]) + try writer.int32("firstLatitudeE7", row["first_latitude_e7"]) + try writer.int32("firstLongitudeE7", row["first_longitude_e7"]) + try writer.timestamp("firstMovingAtMs", row["first_moving_at_ms"]) + try writer.timestamp("lastMovingAtMs", row["last_moving_at_ms"]) + return writer.build() + } + + /// The Board name is resolved on read rather than snapshotted, so none crosses the wire. + static func favorite(_ row: Row) throws -> String { + let writer = SyncRowWriter(.favorites) + try writer.keyText("id", text(row, "id")) + try writer.nullableKeyText("boardId", row["board_id"]) + writer.text("name", row["name"]) + try writer.timestamp("startMs", row["start_ms"]) + try writer.timestamp("endMs", row["end_ms"]) + try writer.timestamp("createdAt", row["created_at"]) + try writer.timestamp("updatedAt", row["updated_at"]) + try writer.count("sampleCount", row["sample_count"]) + try writer.count("gpsPointCount", row["gps_point_count"]) + try writer.int64("distanceCm", row["distance_cm"]) + try writer.timestamp("movingDurationMs", row["moving_duration_ms"]) + try writer.int32("avgSpeedCentiKmh", row["avg_speed_centi_kmh"]) + try writer.int32("maxSpeedCentiKmh", row["max_speed_centi_kmh"]) + try writer.int64("batteryUsedWhMilli", row["battery_used_wh_milli"]) + return writer.build() + } + + /// One Sync Action, flat: the target, the identity within that target's scope, and when the Rider + /// removed it. The log's own `board_id`/`key` pair expands into the identity fields the server + /// declares for that target, so an action reads like the row it names. + static func deleteAction(_ row: Row) throws -> String { + let writer = SyncRowWriter(.deleteActions) + let target = text(row, "target") + let key = text(row, "key") + try writer.keyText("target", target) + switch target { + case "appSetting": try writer.keyText("key", key) + case "board": try writer.keyText("id", key) + case "boardSetting": + try writer.keyText("boardId", try board(row, target: target)) + try writer.keyText("key", key) + case "boardWarning": + try writer.keyText("boardId", try board(row, target: target)) + try writer.keyText("kind", key) + case "alert": + try writer.keyText("boardId", try board(row, target: target)) + try writer.keyText("id", key) + case "tuneProfile", "privacyZone", "favorite": try writer.keyText("id", key) + default: + throw SyncProtocolError(table: .deleteActions, field: "target", problem: "is not a known target") + } + try writer.timestamp("deletedAt", row["deleted_at"]) + return writer.build() + } + + private static func board(_ row: Row, target: String) throws -> String { + guard let boardId: String = row["board_id"] else { + throw SyncProtocolError(table: .deleteActions, field: "boardId", problem: "is missing for \(target)") + } + return boardId + } + + private static func text(_ row: Row, _ column: String) -> String { + (row[column] as String?) ?? "" + } +} diff --git a/modules/vescape-core/ios/sync/SyncWireTests.swift b/modules/vescape-core/ios/sync/SyncWireTests.swift new file mode 100644 index 000000000..fb7b17dc8 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncWireTests.swift @@ -0,0 +1,109 @@ +import XCTest +import GRDB +@testable import VescapeCore + +/// Wire encoding and the bounds it refuses on. The valid/invalid boundary cases mirror the server's +/// own schema (`vescape-server` `src/sync/protocol.ts`), so a row this side accepts is a row that +/// side can store — a batch is whole or refused, and a bad row must never reach transport. +/// +/// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncWireTest.kt +final class SyncWireTests: XCTestCase { + private func boardRow(id: String = "board-1", name: String = "Board") -> Row { + Row(["id": id, "name": name, "ble_id": nil, "created_at": 10, "updated_at": 20]) + } + + private func frameRow(boardId: String? = "board-1", speed: Int64? = 100) -> Row { + var values: [String: DatabaseValueConvertible?] = [ + "id": 5, + "board_id": boardId, + "captured_at_ms": 1_000, + "elapsed_realtime_ms": 500, + "flags": 1, + "changed_mask_1": 3, + "changed_mask_2": 0, + "speed_centi_kmh": speed, + ] + for column in [ + "can_id", "battery_voltage_mv", "motor_current_ma", "battery_current_ma", "duty_permille", + "pitch_centi_deg", "roll_centi_deg", "balance_pitch_centi_deg", "balance_current_ma", "erpm", + "state", "switch_state", "adc1_milli", "adc2_milli", "odometer_cm", "temp_mosfet_deci_c", + "temp_motor_deci_c", "fault_code", "latitude_e7", "longitude_e7", "gps_speed_centi_mps", + "bearing_centi_deg", "accuracy_cm", "altitude_cm", "location_timestamp_ms", + ] { + values[column] = nil + } + return Row(values) + } + + func testABoardEncodesExactlyTheDeclaredFieldsNullsIncluded() throws { + XCTAssertEqual( + try SyncWire.board(boardRow()), + #"{"id":"board-1","name":"Board","bleId":null,"transport":null,"createdAt":10,"updatedAt":20}"# + ) + } + + /// "Cleared" and "not mentioned" are different intents, and only one survives a missing key. + func testNullableColumnsAreExplicitNullsNeverOmittedKeys() throws { + let encoded = try SyncWire.telemetryFrame(frameRow(speed: nil)) + XCTAssertTrue(encoded.contains(#""speedCentiKmh":null"#)) + } + + func testTextIsEscapedSoTheBodyStaysParseable() throws { + let encoded = try SyncWire.board(boardRow(name: "He said \"go\"\n")) + XCTAssertTrue(encoded.contains(#"\"go\""#)) + XCTAssertTrue(encoded.contains(#"\n"#)) + } + + func testAKeyAtTheLengthLimitIsValidAndOneOverIsRefused() throws { + _ = try SyncWire.board(boardRow(id: String(repeating: "b", count: maxSyncKeyLength))) + XCTAssertThrowsError( + try SyncWire.board(boardRow(id: String(repeating: "b", count: maxSyncKeyLength + 1))) + ) + } + + func testAnEmptyKeyIsRefusedWhereTheServerNamesIt() throws { + XCTAssertThrowsError(try SyncWire.board(boardRow(id: ""))) + _ = try SyncWire.appSetting( + Row(["key": "mapStyleKey", "value_json": "\"\"", "updated_at": 1]) + ) + } + + /// A sample that names no Board has nowhere to go on the server, so it never reaches transport. + func testAFrameWithoutABoardIsAProtocolError() { + XCTAssertThrowsError(try SyncWire.telemetryFrame(frameRow(boardId: nil))) { error in + XCTAssertEqual((error as? SyncProtocolError)?.field, "boardId") + } + } + + func testIntegerBoundsAreEnforcedAtTheEdge() throws { + _ = try SyncWire.telemetryFrame(frameRow(speed: Int64(Int32.max))) + XCTAssertThrowsError(try SyncRowWriter(.telemetryFrames).int32("speedCentiKmh", 2_147_483_648)) + } + + func testANonFiniteNumberIsRefusedBecauseJsonCannotExpressIt() { + XCTAssertThrowsError(try SyncRowWriter(.alerts).number("threshold", Double.nan)) { error in + XCTAssertEqual((error as? SyncProtocolError)?.field, "threshold") + } + } + + /// An action reads like the row it names: flat identity fields, not a nested envelope. + func testADeleteActionExpandsIntoTheIdentityItsTargetDeclares() throws { + XCTAssertEqual( + try SyncWire.deleteAction( + Row(["target": "boardSetting", "board_id": "board-1", "key": "transport", "deleted_at": 9]) + ), + #"{"target":"boardSetting","boardId":"board-1","key":"transport","deletedAt":9}"# + ) + XCTAssertEqual( + try SyncWire.deleteAction( + Row(["target": "tuneProfile", "board_id": nil, "key": "profile-1", "deleted_at": 4]) + ), + #"{"target":"tuneProfile","id":"profile-1","deletedAt":4}"# + ) + XCTAssertThrowsError( + try SyncWire.deleteAction( + Row(["target": "somethingElse", "board_id": nil, "key": "x", "deleted_at": 1]) + ) + ) + } +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 82efb25cb..3905437ec 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -115,6 +115,31 @@ enum TelemetryDatabase { } } + /// Replace the app-data database with an empty one, taking the Sync Cursors, the pending Sync + /// Actions and the Account binding with it (#284). + /// + /// Deleting the file rather than clearing tables is what makes the Account change safe: nothing + /// can survive with a cursor position or a binding that belonged to the previous Account. The wipe + /// is local maintenance and emits no Sync Actions to either Account — the log is part of what + /// goes. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/DatabaseBackupManager.kt `replaceWithFreshDatabase` + static func replaceWithFreshDatabase() throws { + guard let target = databaseURL else { throw CocoaError(.fileNoSuchFile) } + if let reopened { try? reopened.close() } + else if case let .success(pool) = poolResult { try? pool.close() } + + let fm = FileManager.default + for suffix in ["", "-wal", "-shm"] { + try? fm.removeItem(at: URL(fileURLWithPath: target.path + suffix)) + } + + // Migrating rebuilds the schema, so the new database starts unbound with no cursors. + let pool = try DatabasePool(path: target.path) + try migrator.migrate(pool) + reopened = pool + } + /// Internal, not private, so migration tests can run the real migrator against an in-memory /// database and stop at a chosen version with `migrate(_:upTo:)`. internal static var migrator: DatabaseMigrator { @@ -568,6 +593,15 @@ enum TelemetryDatabase { try createSyncActionsTable(db) } + // The Account binding (#284): which Vescape Account this local database belongs to. Additive and + // guarded, and deliberately left empty — an existing install is unbound until an Account signs + // in and claims it, which is also what keeps the current age-only retention behaviour until + // then. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_37_38` + migrator.registerMigration("v38_sync_binding") { db in + try createSyncBindingTable(db) + } + return migrator } } diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index 0ed0a1f6f..abb3cd0e5 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -420,15 +420,13 @@ internal final class TelemetryRepository { return buildFavoriteSummary(buildTelemetryBuckets(sanitized)) } + /// Retention sweep. Age-only while this database has never been bound to an Account, and age plus + /// the accepted Sync Cursor once it has — cleanup must not remove a row the uploader has not + /// delivered (#284). func deleteBefore(_ beforeMs: Int64) -> Int { guard let pool else { return 0 } return (try? pool.write { db in - let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM telemetry_frames WHERE captured_at_ms < ?", arguments: [beforeMs]) ?? 0 - try db.execute(sql: "DELETE FROM telemetry_frames WHERE captured_at_ms < ?", arguments: [beforeMs]) - try db.execute(sql: "DELETE FROM telemetry_minute_buckets WHERE bucket_start_ms < ?", arguments: [beforeMs]) - try db.execute(sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms < ?", arguments: [beforeMs]) - try db.execute(sql: "DELETE FROM metric_exclusion_ranges WHERE end_ms < ?", arguments: [beforeMs]) - return count + try deleteBeforeGated(db, beforeMs: beforeMs) }) ?? 0 } @@ -598,6 +596,9 @@ internal final class TelemetryRepository { for marker in markers { try insertMarker(db, marker) } for range in sanitization.exclusions { try insertExclusion(db, range) } } + // Samples are actually being produced, which is what the uploader's ride cadence follows — Idle + // Pause halts production without ending the Board Session. + SyncCoordinator.shared.notifySamplesPersisted() } private func marker(type: String, capture: TelemetryCapture, gapMs: Int64?) -> [String: Any?] { diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index b26cc76ea..b044ccdc9 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -1447,6 +1447,35 @@ export interface DeviceCredentialStatus { state: DeviceCredentialState accountId: string | null expiresAt: string | null + /** + * A different Vescape Account signed in on a phone whose local database already belongs to another + * one. Native refuses to bind — resetting the Sync Cursors over the existing rows would upload the + * previous Account's data to the new one — so the credential is not stored until the Rider + * confirms through `confirmSyncAccountReset` that all local app data is erased. + */ + accountChangeRequiresReset?: boolean +} + +/** + * Why the uploader stopped. A paused engine is not woken by ordinary timer or connectivity kicks. + * + * @parity /modules/vescape-core/ios/sync/SyncPolicy.swift `SyncPauseReason` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt `SyncPauseReason` + */ +export type SyncPauseReason = 'authentication' | 'protocol' | 'rowTooLarge' + +/** + * Native-owned backup state. JS renders it and never infers one of its own. + * + * @parity /modules/vescape-core/ios/sync/SyncCoordinator.swift `SyncStatus` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt `SyncStatus` + */ +export interface SyncStatus { + /** The Account this local database is bound to, or null while it has never been claimed. */ + accountId: string | null + pendingRows: number + pause: SyncPauseReason | null + lastUploadAtMs: number | null } export type CriticalRideNotificationPermissionStatus = @@ -1564,6 +1593,13 @@ type VescapeCoreNativeModule = NativeEventEmitter & { getDeviceCredentialState(): DeviceCredentialStatus revokeDeviceCredential(): Promise clearDeviceCredential(): void + confirmSyncAccountReset( + serverUrl: string, + deviceToken: string, + accountId: string, + ): Promise + getSyncStatus(): Promise + setSyncWifiOnly(enabled: boolean): void openAppUpdate(): void getRemoteTiltState(): RemoteTiltState | null setSelectedBoard(boardId: string | null): void @@ -2023,6 +2059,29 @@ export function getDeviceCredentialState(): DeviceCredentialStatus { return native.getDeviceCredentialState() } +/** + * Erase all local app data and hand the fresh database to a different Account. Destructive, and only + * ever called after the Rider confirms — cloud restore does not exist in this version, so what is + * erased is gone. + */ +export async function confirmSyncAccountReset( + serverUrl: string, + deviceToken: string, + accountId: string, +): Promise { + return native.confirmSyncAccountReset(serverUrl, deviceToken, accountId) +} + +/** Read native-owned backup state: what is bound, what is pending, and why it stopped. */ +export async function getSyncStatus(): Promise { + return native.getSyncStatus() +} + +/** Back up over Wi-Fi only. Native waits for Wi-Fi rather than failing on a metered connection. */ +export function setSyncWifiOnly(enabled: boolean): void { + native.setSyncWifiOnly(enabled) +} + export async function revokeDeviceCredential(): Promise { return native.revokeDeviceCredential() } diff --git a/src/modules/profile/components/DeviceAuthSync.tsx b/src/modules/profile/components/DeviceAuthSync.tsx index 653e9fe67..e466c11f1 100644 --- a/src/modules/profile/components/DeviceAuthSync.tsx +++ b/src/modules/profile/components/DeviceAuthSync.tsx @@ -1,19 +1,30 @@ import { useAuth, useSession } from '@clerk/expo' import Constants from 'expo-constants' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { SERVER_URL } from '@/config/server' import { addAppStatusListener, clearDeviceCredential, + confirmSyncAccountReset, getDeviceCredentialState, provisionDeviceCredential, } from 'vescape-core' -import { useDeviceAuthStore } from '@/modules/profile/store/deviceAuthStore' +import { ConfirmModal } from '@/components/modals/ConfirmModal' + +import { + useDeviceAuthStore, + type PendingAccountReset, +} from '@/modules/profile/store/deviceAuthStore' import { exchangeDeviceToken } from '@/modules/profile/lib/deviceAuth' let provisioning: Promise | null = null + +const ACCOUNT_RESET_MESSAGE = + 'This phone already holds another rider account\u2019s data. Signing in with a different ' + + 'account erases every board, ride, favorite and setting stored here. Backups cannot be ' + + 'restored in this version, so what is erased is gone.' const attemptedSessionIds = new Set() export function DeviceAuthSync() { @@ -23,6 +34,9 @@ export function DeviceAuthSync() { const { session } = useSession() const retryRequestId = useDeviceAuthStore((state) => state.retryRequestId) const setStatus = useDeviceAuthStore((state) => state.setStatus) + const setPendingAccountReset = useDeviceAuthStore((state) => state.setPendingAccountReset) + const pendingAccountReset = useDeviceAuthStore((state) => state.pendingAccountReset) + const [resetting, setResetting] = useState(false) const tryProvision = useCallback(() => { if (!isLoaded || !isSignedIn || !session) return @@ -42,7 +56,12 @@ export function DeviceAuthSync() { attemptedSessionIds.add(session.id) setStatus('provisioning') provisioning = provision(getToken) - .then(() => setStatus('ready')) + .then((pending) => { + // A different Account cannot activate backup until the Rider confirms that all local app + // data is erased; native has stored nothing yet, so cancelling leaves this phone untouched. + setPendingAccountReset(pending) + setStatus(pending ? 'idle' : 'ready') + }) .catch((error: unknown) => { attemptedSessionIds.delete(session.id) setStatus('failed', visibleError(error)) @@ -50,7 +69,7 @@ export function DeviceAuthSync() { .finally(() => { provisioning = null }) - }, [getToken, isLoaded, isSignedIn, session, setStatus, signOut]) + }, [getToken, isLoaded, isSignedIn, session, setPendingAccountReset, setStatus, signOut]) useEffect(() => { if (isLoaded && !isSignedIn) setStatus('idle') @@ -74,16 +93,64 @@ export function DeviceAuthSync() { return () => subscription.remove() }, [isSignedIn, signOut, tryProvision]) - return null + const confirmReset = useCallback(async () => { + if (!pendingAccountReset) return + setResetting(true) + try { + await confirmSyncAccountReset( + pendingAccountReset.serverUrl, + pendingAccountReset.deviceToken, + pendingAccountReset.accountId, + ) + setPendingAccountReset(null) + setStatus('ready') + } catch (error: unknown) { + setStatus('failed', visibleError(error)) + } finally { + setResetting(false) + } + }, [pendingAccountReset, setPendingAccountReset, setStatus]) + + // Cancelling leaves the old database and its Account binding untouched, so the Rider stays signed + // out of backup rather than losing anything. + const cancelReset = useCallback(() => { + setPendingAccountReset(null) + clearDeviceCredential() + void signOut() + }, [setPendingAccountReset, signOut]) + + return ( + + ) } -async function provision(getToken: () => Promise): Promise { +/** + * Exchange the Clerk session for a Device Token and hand it to native. + * + * Resolves with the pending reset when native reports that this phone's local database belongs to a + * different Account, and with `null` when provisioning completed. + */ +async function provision( + getToken: () => Promise, +): Promise { const clerkToken = await getToken() if (!clerkToken) throw new Error('Clerk session token is unavailable') const appVersion = Constants.expoConfig?.version if (!appVersion) throw new Error('Installed app version is unavailable') const body = await exchangeDeviceToken({ serverUrl: SERVER_URL, clerkToken, appVersion }) - await provisionDeviceCredential(SERVER_URL, body.deviceToken, body.accountId) + const state = await provisionDeviceCredential(SERVER_URL, body.deviceToken, body.accountId) + if (!state.accountChangeRequiresReset) return null + return { serverUrl: SERVER_URL, deviceToken: body.deviceToken, accountId: body.accountId } } function visibleError(error: unknown): string { diff --git a/src/modules/profile/store/deviceAuthStore.ts b/src/modules/profile/store/deviceAuthStore.ts index b9ec1ffd3..ecde92670 100644 --- a/src/modules/profile/store/deviceAuthStore.ts +++ b/src/modules/profile/store/deviceAuthStore.ts @@ -2,11 +2,24 @@ import { create } from 'zustand' export type DeviceAuthStatus = 'idle' | 'provisioning' | 'ready' | 'failed' +/** + * A different Vescape Account signed in on a phone whose local database belongs to another one. + * Native refuses to bind until the Rider confirms, so this carries what the confirmation needs. + */ +export interface PendingAccountReset { + serverUrl: string + deviceToken: string + accountId: string +} + interface DeviceAuthState { status: DeviceAuthStatus error: string | null retryRequestId: number + /** Non-null while the destructive Account-change warning is waiting on the Rider. */ + pendingAccountReset: PendingAccountReset | null setStatus: (status: DeviceAuthStatus, error?: string | null) => void + setPendingAccountReset: (pending: PendingAccountReset | null) => void retry: () => void } @@ -20,6 +33,8 @@ export const useDeviceAuthStore = create((set) => ({ status: 'idle', error: null, retryRequestId: 0, + pendingAccountReset: null, setStatus: (status, error = null) => set({ status, error }), + setPendingAccountReset: (pendingAccountReset) => set({ pendingAccountReset }), retry: () => set((state) => ({ retryRequestId: state.retryRequestId + 1 })), })) From e0c36c7f62ccfdf580fafbc19012a522eec961e7 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sat, 1 Aug 2026 11:51:27 +0200 Subject: [PATCH 11/24] Harden the Sync uploader after review #284 --- .../modules/vescapecore/VescapeCoreModule.kt | 3 + .../vescapecore/auth/NativeAuthCoordinator.kt | 3 + .../vescapecore/sync/SyncBatchBuilder.kt | 7 ++ .../vescapecore/sync/SyncCoordinator.kt | 60 +++++++++++--- .../modules/vescapecore/sync/SyncEngine.kt | 42 +++++++--- .../telemetry/DatabaseBackupManager.kt | 4 +- .../vescapecore/telemetry/TelemetryDao.kt | 5 ++ .../vescapecore/sync/SyncBatchBuilderTest.kt | 28 +++++++ .../vescapecore/sync/SyncEngineTest.kt | 42 ++++++++++ .../vescape-core/ios/VescapeCoreModule.swift | 3 + .../ios/auth/NativeAuthCoordinator.swift | 5 +- .../ios/sync/SyncBatchBuilder.swift | 7 ++ .../ios/sync/SyncBatchBuilderTests.swift | 16 ++++ .../ios/sync/SyncCoordinator.swift | 80 +++++++++++++++---- .../vescape-core/ios/sync/SyncEngine.swift | 38 ++++++--- .../ios/sync/SyncEngineTests.swift | 42 +++++++++- modules/vescape-core/ios/sync/SyncJson.swift | 7 +- modules/vescape-core/ios/sync/SyncStore.swift | 18 ++++- modules/vescape-core/ios/sync/SyncWire.swift | 8 +- .../vescape-core/ios/sync/SyncWireTests.swift | 31 ++++++- .../profile/components/DeviceAuthSync.tsx | 3 + 21 files changed, 394 insertions(+), 58 deletions(-) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 412101fcc..22c79f643 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -246,6 +246,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 { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/auth/NativeAuthCoordinator.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/auth/NativeAuthCoordinator.kt index a398c5cae..4780f2ac8 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/auth/NativeAuthCoordinator.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/auth/NativeAuthCoordinator.kt @@ -80,8 +80,11 @@ class NativeAuthCoordinator(private val context: Context) { ): Map { val origin = serverUrl.trimEnd('/') SyncCoordinator.get(context).resetForAccount(accountId) + // The token is installed before the uploader starts: a loop running on the previous Account's + // credential against the new Account's database is exactly what this ordering exists to prevent. store.write(DeviceCredential(origin, token, accountId, null)) AppStatusCoordinator.get(context).refresh() + SyncCoordinator.get(context).start() return stateMap() } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt index 85c711635..8b0f05635 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncBatchBuilder.kt @@ -79,6 +79,7 @@ object SyncBatchBuilder { if (byteCount + tableOverhead > byteCap) break var opened = false + var truncated = false for (row in group.rows) { if (rowCount >= rowCap) break val rowCost = row.byteCount + if (opened) 1 else 0 @@ -88,6 +89,7 @@ object SyncBatchBuilder { if (counts.isEmpty() && !opened && 2 + tableOverhead + row.byteCount > byteCap) { return SyncBatchBuild.RowTooLarge(group.table, row.cursor, row.byteCount) } + truncated = true break } @@ -106,6 +108,11 @@ object SyncBatchBuilder { advances[group.table] = row.cursor } if (opened) body.append(']') + // A table cut short by the byte cap may still hold a parent — a Board whose settings, alerts + // or Tune Profiles sit further down this same batch. Carrying on would send the child ahead of + // it, and the server refuses that whole batch on the foreign key. The rest waits for the next + // batch, which starts where this one stopped. + if (truncated) break } if (counts.isEmpty()) return SyncBatchBuild.Empty diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt index 89bde2f67..91a72c54f 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock private const val TAG = "SyncCoordinator" @@ -62,6 +64,12 @@ class SyncCoordinator private constructor(private val context: Context) { private var loop: Job? = null + /** Kicks in flight, so [stop] leaves nothing running against a database about to be replaced. */ + private val kicks = java.util.concurrent.CopyOnWriteArrayList() + + /** Serializes passes against each other and against [resetForAccount]. */ + private val passLock = Mutex() + private val store = SyncStore( database = { dao }, generation = { generation }, @@ -93,6 +101,19 @@ class SyncCoordinator private constructor(private val context: Context) { lastUploadAtMs = lastUploadAtMs, ) + /** + * Pick the uploader back up on a cold launch: the credential outlives the process, so a phone that + * was signed in stays signed in, and nothing else would ever start the loop again. Binding the + * stored Account is a no-op when this database already belongs to it, and cannot claim a database + * that belongs to another one. + */ + fun resumeIfBound() { + val credential = credentials.read() ?: return + scope.launch { + if (bindAccount(credential.accountId)) start() + } + } + fun start() { if (loop?.isActive == true) return loop = scope.launch { @@ -108,22 +129,31 @@ class SyncCoordinator private constructor(private val context: Context) { } } + /** Stops the loop and every kick in flight, so nothing is left running over a replaced database. */ fun stop() { loop?.cancel() loop = null + kicks.forEach { it.cancel() } + kicks.clear() } /** Connectivity regained, ride ended, sign-in: send now rather than waiting for the next tick. */ fun kick() { if (loop?.isActive != true) return start() - scope.launch { runCatching { pass() } } + val job = scope.launch { runCatching { pass() } } + kicks.add(job) + job.invokeOnCompletion { kicks.remove(job) } } /** * One pass, draining while the server keeps accepting: a `200` with rows still pending sends again * straight away, so a long backlog drains instead of trickling. + * + * Serialized against every other pass and against an Account reset: the whole scan → send → + * commit sequence holds the lock, so a reset can never land between reading a previous Account's + * rows and checkpointing them onto the fresh database. */ - private suspend fun pass(): Long { + private suspend fun pass(): Long = passLock.withLock { var drains = 0 while (drains < MAX_DRAIN_STEPS) { when (val outcome = engine.runOnce()) { @@ -132,13 +162,16 @@ class SyncCoordinator private constructor(private val context: Context) { if (!outcome.morePending) return interval() drains += 1 } + // Nothing was accepted, but the next attempt differs — a narrowed byte target. + SyncPass.Retry -> drains += 1 is SyncPass.Waiting -> return (outcome.untilMs - System.currentTimeMillis()).coerceIn(0, SyncPolicy.BACKOFF_MAX_MS) is SyncPass.Paused -> return SyncPolicy.IDLE_INTERVAL_MS SyncPass.Idle -> return interval() } } - return 0 + // A drain that never finishes yields rather than spinning; the next tick resumes it. + return SyncPolicy.RIDE_INTERVAL_MS } private fun interval(): Long = @@ -218,14 +251,19 @@ class SyncCoordinator private constructor(private val context: Context) { */ suspend fun resetForAccount(accountId: String) { stop() - // Every in-flight response now belongs to a previous Account and can no longer commit. - generation += 1 - recordedFailures.clear() - DatabaseBackupManager.replaceWithFreshDatabase(context) - dao.bindAccount(accountId) - engine.resume() - lastUploadAtMs = null - start() + // Held across the whole transition: a pass that started before `stop()` finishes its scan, send + // and commit against the old database before the file is replaced, and none can start midway. + passLock.withLock { + // Every in-flight response now belongs to a previous Account and can no longer commit. + generation += 1 + recordedFailures.clear() + DatabaseBackupManager.replaceWithFreshDatabase(context) + check(dao.bindAccount(accountId)) { "Fresh database did not accept the new Account" } + engine.resume() + lastUploadAtMs = null + } + // Deliberately not started here: the caller installs the new Device Token first, so the loop + // never runs with the previous Account's credential against the new Account's database. } /** diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt index dc18388aa..b246198e6 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncEngine.kt @@ -42,6 +42,9 @@ interface SyncSource { * Commit the advance set in its own transaction, after the response. Never alongside the rows: a * cursor advanced past rows the server did not take is unrecoverable, whereas a cursor left behind * is a re-send the server upserts idempotently. Always fail toward re-sending. + * + * Throws rather than swallowing a write failure: an uncommitted cursor leaves the same rows + * pending, and a caller that believed the checkpoint landed would resend them without pause. */ suspend fun commit(advances: Map) @@ -69,6 +72,10 @@ data class SyncEnvironment( /** What one pass did, for the loop and for tests. */ sealed interface SyncPass { object Idle : SyncPass + + /** Nothing was accepted, but the next attempt differs from this one — a narrowed byte target. */ + object Retry : SyncPass + data class Sent(val rowCount: Int, val morePending: Boolean) : SyncPass data class Waiting(val untilMs: Long) : SyncPass data class Paused(val reason: SyncPauseReason) : SyncPass @@ -133,6 +140,10 @@ class SyncEngine( } private suspend fun send(): SyncPass { + // Captured before the rows are read, not after: an Account reset between the scan and the + // request would otherwise leave a batch of the previous Account's rows looking current, and its + // cursor advance would land on the fresh database. + val generation = source.generation() val pending = try { source.pending(MAX_SYNC_BATCH_ROWS) } catch (e: SyncProtocolException) { @@ -143,12 +154,11 @@ class SyncEngine( SyncBatchBuild.Empty -> SyncPass.Idle is SyncBatchBuild.RowTooLarge -> pauseWith(SyncPauseReason.ROW_TOO_LARGE, "${built.table.wire}@${built.cursor}") - is SyncBatchBuild.Ready -> deliver(built) + is SyncBatchBuild.Ready -> deliver(built, generation) } } - private suspend fun deliver(batch: SyncBatchBuild.Ready): SyncPass { - val generation = source.generation() + private suspend fun deliver(batch: SyncBatchBuild.Ready, generation: Long): SyncPass { val response = transport.send(batch.body) // A response that outlived its Account cannot touch the fresh database it would land in. if (source.generation() != generation) return SyncPass.Idle @@ -169,7 +179,15 @@ class SyncEngine( if (accepted == null || !SyncAccepted.matches(batch.counts, accepted)) { return pauseWith(SyncPauseReason.PROTOCOL, "acceptedMismatch") } - source.commit(batch.advances) + try { + source.commit(batch.advances) + } catch (e: Exception) { + // The server took the rows but the checkpoint did not land. Backing off re-sends the identical + // batch, which the server upserts idempotently — reporting success here would spin instead, + // because the same rows are still pending. + backoffMs = SyncPolicy.nextBackoffMs(backoffMs) + return backOff(backoffMs) + } backoffMs = 0 retryAtMs = 0 byteTarget = MAX_SYNC_BATCH_BYTES @@ -181,15 +199,15 @@ class SyncEngine( * even one row, that row is a permanent local protocol error — it is retained, not skipped. */ private suspend fun shrink(batch: SyncBatchBuild.Ready): SyncPass { - if (batch.rowCount <= 1) { - val table = batch.counts.keys.first() - return pauseWith( - SyncPauseReason.ROW_TOO_LARGE, - "${table.wire}@${batch.advances.getValue(table)}", - ) - } + val table = batch.counts.keys.first() + val detail = "${table.wire}@${batch.advances.getValue(table)}" + if (batch.rowCount <= 1) return pauseWith(SyncPauseReason.ROW_TOO_LARGE, detail) + // Already as small as a batch gets: halving again would resend the same bytes forever, so the + // disagreement about the wire limit is treated as what it is — permanent, with the rows kept. + if (byteTarget <= MIN_BYTE_TARGET) return pauseWith(SyncPauseReason.ROW_TOO_LARGE, detail) + byteTarget = maxOf(byteTarget / 2, MIN_BYTE_TARGET) - return SyncPass.Sent(0, morePending = true) + return SyncPass.Retry } private fun backOff(delayMs: Long): SyncPass { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/DatabaseBackupManager.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/DatabaseBackupManager.kt index 2757d4297..a0a7e0919 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/DatabaseBackupManager.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/DatabaseBackupManager.kt @@ -110,7 +110,9 @@ object DatabaseBackupManager { resetRepositoriesAndCloseDatabase() val dbFile = appContext.getDatabasePath(TELEMETRY_DATABASE_NAME) - dbFile.delete() + // Checked rather than best-effort: a delete that quietly failed would reopen the previous + // Account's database, which the caller is about to hand a different Account's Device Token. + check(!dbFile.exists() || dbFile.delete()) { "Could not remove the existing database" } sidecarFiles(dbFile).forEach { it.delete() } // Opening rebuilds the schema from the entities, so the new database starts unbound. diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 0abc4c130..2faa9feb7 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -283,6 +283,11 @@ interface TelemetryDao { * nowhere to put a sample that belongs to none (ADR-0028) — so the scan does not offer them and * the cursor moves over them. They are unowned local rows, not rows a Rider is waiting to see * backed up. + * + * The consequence is deliberate: a later owned frame carries the cursor past a skipped one, so + * cursor-gated retention prunes unowned telemetry on age alone, exactly as it did before the + * Account binding existed. Holding it forever would be the only alternative, because no future + * upload can ever accept it. */ @Query( "SELECT * FROM telemetry_frames WHERE id > :cursor AND board_id IS NOT NULL " + diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncBatchBuilderTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncBatchBuilderTest.kt index b1c588342..f83a9c346 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncBatchBuilderTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncBatchBuilderTest.kt @@ -106,6 +106,34 @@ class SyncBatchBuilderTest { assertEquals(SyncBatchBuild.RowTooLarge(SyncTable.BOARDS, 9, huge.byteCount), built) } + /** + * A Board left behind by the byte cap must not be followed by its Alert Rules in the same batch — + * the server writes them in this order and refuses the whole batch on the foreign key. + */ + @Test + fun `a table truncated by the byte cap ends the batch instead of sending children`() { + val full = SyncBatchBuilder.build( + listOf( + SyncPendingTable(SyncTable.BOARDS, rows(2, size = 40)), + SyncPendingTable(SyncTable.ALERTS, rows(1, size = 4)), + ), + byteCap = Int.MAX_VALUE, + ) as SyncBatchBuild.Ready + assertEquals(3, full.rowCount) + + val truncated = SyncBatchBuilder.build( + listOf( + SyncPendingTable(SyncTable.BOARDS, rows(2, size = 40)), + SyncPendingTable(SyncTable.ALERTS, rows(1, size = 4)), + ), + byteCap = full.byteCount - 20, + ) as SyncBatchBuild.Ready + + assertEquals(listOf(SyncTable.BOARDS), truncated.counts.keys.toList()) + assertEquals(1, truncated.counts.getValue(SyncTable.BOARDS)) + assertEquals(truncated.body.toByteArray(Charsets.UTF_8).size, truncated.byteCount) + } + @Test fun `nothing pending is idle, not an empty batch`() { assertEquals(SyncBatchBuild.Empty, SyncBatchBuilder.build(emptyList())) diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncEngineTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncEngineTest.kt index 81d07031b..8147a5a87 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncEngineTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncEngineTest.kt @@ -20,6 +20,7 @@ class SyncEngineTest { var generation = 0L var failures = mutableListOf>() var encodeFailure: SyncProtocolException? = null + var commitFailure: Exception? = null var rowJson = "\"row\"" override suspend fun pending(rowLimit: Int): List { @@ -33,6 +34,7 @@ class SyncEngineTest { override suspend fun pendingCount(): Int = remaining override suspend fun commit(advances: Map) { + commitFailure?.let { throw it } committed += advances remaining -= advances.size.let { 2 }.coerceAtMost(remaining) } @@ -198,6 +200,46 @@ class SyncEngineTest { assertEquals(1, source.remaining) } + /** A shrink accepted nothing, so it must not be reported as an upload. */ + @Test + fun `413 on a multi-row batch narrows the target and retries`() = runBlocking { + val source = FakeSource(rows = 4) + val engine = engine(source, mutableListOf(SyncResponse.TooLarge)) + + assertEquals(SyncPass.Retry, engine.runOnce()) + assertTrue(source.committed.isEmpty()) + assertEquals(4, source.remaining) + } + + /** Halving forever against a server that keeps refusing would be an unbounded request storm. */ + @Test + fun `413 at the smallest batch pauses instead of resending the same bytes forever`() = runBlocking { + val source = FakeSource(rows = 4) + val engine = engine(source, MutableList(10) { SyncResponse.TooLarge }) + + var passes = 0 + var outcome = engine.runOnce() + while (outcome == SyncPass.Retry && passes < 10) { + outcome = engine.runOnce() + passes += 1 + } + assertEquals(SyncPass.Paused(SyncPauseReason.ROW_TOO_LARGE), outcome) + assertTrue(source.committed.isEmpty()) + } + + /** The server took the rows; the checkpoint did not land. Resending is safe, claiming success is not. */ + @Test + fun `a failed cursor commit backs off instead of reporting an upload`() = runBlocking { + val source = FakeSource(rows = 2) + source.commitFailure = IllegalStateException("disk full") + val engine = engine(source, mutableListOf(SyncResponse.Accepted(accepted(2)))) + + val outcome = engine.runOnce() + assertTrue(outcome is SyncPass.Waiting) + assertTrue(source.committed.isEmpty()) + assertEquals(2, source.remaining) + } + @Test fun `429 waits for the server's own delay`() = runBlocking { val source = FakeSource(rows = 2) diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index c772f8e06..9e91eadbc 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -121,6 +121,9 @@ public class VescapeCoreModule: Module { // Cold start: fetch App Status before JS asks. A foreground event arriving right after is // coalesced into this request. AppStatusCoordinator.shared.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.shared.resumeIfBound() self.attachToCoordinator() AppDataRepository.onDataChanged = { [weak self] scope in self?.sendAppDataChanged(scope) } // JS keeps a dumb mirror of the durable Board Warning registry; push the full board list on diff --git a/modules/vescape-core/ios/auth/NativeAuthCoordinator.swift b/modules/vescape-core/ios/auth/NativeAuthCoordinator.swift index ad98d6062..13db6cd57 100644 --- a/modules/vescape-core/ios/auth/NativeAuthCoordinator.swift +++ b/modules/vescape-core/ios/auth/NativeAuthCoordinator.swift @@ -83,13 +83,16 @@ final class NativeAuthCoordinator { accountId: String ) async throws -> [String: Any?] { let origin = serverUrl.hasSuffix("/") ? String(serverUrl.dropLast()) : serverUrl - try SyncCoordinator.shared.resetForAccount(accountId) + try await SyncCoordinator.shared.resetForAccount(accountId) + // The token is installed before the uploader starts: a loop running on the previous Account's + // credential against the new Account's database is exactly what this ordering exists to prevent. try store.write( DeviceCredential(serverUrl: origin, token: token, accountId: accountId, expiresAt: nil) ) await MainActor.run { AppStatusCoordinator.shared.refresh() } + SyncCoordinator.shared.start() return stateMap() } diff --git a/modules/vescape-core/ios/sync/SyncBatchBuilder.swift b/modules/vescape-core/ios/sync/SyncBatchBuilder.swift index c4e99ee45..eea98689a 100644 --- a/modules/vescape-core/ios/sync/SyncBatchBuilder.swift +++ b/modules/vescape-core/ios/sync/SyncBatchBuilder.swift @@ -87,6 +87,7 @@ enum SyncBatchBuilder { if byteCount + tableOverhead > byteCap { break } var opened = false + var truncated = false for row in group.rows { if rowCount >= rowCap { break } let rowCost = row.byteCount + (opened ? 1 : 0) @@ -96,6 +97,7 @@ enum SyncBatchBuilder { if tables.isEmpty, !opened, 2 + tableOverhead + row.byteCount > byteCap { return .rowTooLarge(table: group.table, cursor: row.cursor, byteCount: row.byteCount) } + truncated = true break } @@ -115,6 +117,11 @@ enum SyncBatchBuilder { advances[group.table] = row.cursor } if opened { body += "]" } + // A table cut short by the byte cap may still hold a parent — a Board whose settings, alerts + // or Tune Profiles sit further down this same batch. Carrying on would send the child ahead of + // it, and the server refuses that whole batch on the foreign key. The rest waits for the next + // batch, which starts where this one stopped. + if truncated { break } } if tables.isEmpty { return .empty } diff --git a/modules/vescape-core/ios/sync/SyncBatchBuilderTests.swift b/modules/vescape-core/ios/sync/SyncBatchBuilderTests.swift index 12b43e9fd..e5979a4d3 100644 --- a/modules/vescape-core/ios/sync/SyncBatchBuilderTests.swift +++ b/modules/vescape-core/ios/sync/SyncBatchBuilderTests.swift @@ -80,6 +80,22 @@ final class SyncBatchBuilderTests: XCTestCase { XCTAssertEqual(build, .rowTooLarge(table: .boards, cursor: 9, byteCount: huge.byteCount)) } + /// A Board left behind by the byte cap must not be followed by its Alert Rules in the same batch — + /// the server writes them in this order and refuses the whole batch on the foreign key. + func testATableTruncatedByTheByteCapEndsTheBatch() throws { + let pending = [ + SyncPendingTable(table: .boards, rows: rows(2, size: 40)), + SyncPendingTable(table: .alerts, rows: rows(1, size: 4)), + ] + let full = try ready(SyncBatchBuilder.build(pending, byteCap: Int.max)) + XCTAssertEqual(full.rowCount, 3) + + let truncated = try ready(SyncBatchBuilder.build(pending, byteCap: full.byteCount - 20)) + XCTAssertEqual(truncated.tables, [.boards]) + XCTAssertEqual(truncated.counts[.boards], 1) + XCTAssertEqual(truncated.body.utf8.count, truncated.byteCount) + } + func testNothingPendingIsIdleNotAnEmptyBatch() { XCTAssertEqual(SyncBatchBuilder.build([]), .empty) XCTAssertEqual(SyncBatchBuilder.build([SyncPendingTable(table: .boards, rows: [])]), .empty) diff --git a/modules/vescape-core/ios/sync/SyncCoordinator.swift b/modules/vescape-core/ios/sync/SyncCoordinator.swift index ce0f38c04..0d9c3af87 100644 --- a/modules/vescape-core/ios/sync/SyncCoordinator.swift +++ b/modules/vescape-core/ios/sync/SyncCoordinator.swift @@ -47,6 +47,9 @@ final class SyncCoordinator { /// Failure keys already recorded this process, so a wedged batch writes one event, not a stream. private var recordedFailures = Set() private var loop: Task? + /// Every pass chains onto this, so scan → send → commit never interleaves with another pass or + /// with an Account reset. Cancelled by `stop()` together with the loop. + private var chain: Task? private let monitor = NWPathMonitor() private lazy var store = SyncStore( @@ -115,26 +118,57 @@ final class SyncCoordinator { ) } + /// Pick the uploader back up on a cold launch: the credential outlives the process, so a phone + /// that was signed in stays signed in, and nothing else would ever start the loop again. Binding + /// the stored Account is a no-op when this database already belongs to it, and cannot claim a + /// database that belongs to another one. + func resumeIfBound() { + guard let credential = DeviceCredentialStore.shared.read() else { return } + if bindAccount(credential.accountId) { start() } + } + func start() { guard loop == nil else { return } loop = Task { [weak self] in while !Task.isCancelled { guard let self else { return } - let waitMs = await self.pass() + let waitMs = await self.serialized { await self.pass() } try? await Task.sleep(nanoseconds: UInt64(max(waitMs, 0)) * 1_000_000) } } } + /// Stops the loop and every pass in flight, so nothing is left running over a replaced database. func stop() { loop?.cancel() loop = nil + chain?.cancel() + chain = nil } /// Connectivity regained, ride ended, sign-in: send now rather than waiting for the next tick. func kick() { guard loop != nil else { return start() } - Task { [weak self] in _ = await self?.pass() } + Task { [weak self] in + guard let self else { return } + _ = await self.serialized { await self.pass() } + } + } + + /// Runs `work` after whatever is already queued, so a scan, its request and its cursor commit + /// always complete against one database — an Account reset waits its turn rather than landing in + /// the middle. + private func serialized(_ work: @escaping () async -> T) async -> T { + lock.lock() + let previous = chain + let task = Task { + await previous?.value + return await work() + } + // The chain only has to say "the previous link finished", so its own value is discarded. + chain = Task { _ = await task.value } + lock.unlock() + return await task.value } /// One pass, draining while the server keeps accepting: a `200` with rows still pending sends @@ -149,6 +183,9 @@ final class SyncCoordinator { lock.unlock() if !morePending { return interval() } drains += 1 + // Nothing was accepted, but the next attempt differs — a narrowed byte target. + case .retry: + drains += 1 case .waiting(let untilMs): return min(max(untilMs - telemetryNowMs(), 0), SyncPolicy.backoffMaxMs) case .paused: @@ -157,7 +194,8 @@ final class SyncCoordinator { return interval() } } - return 0 + // A drain that never finishes yields rather than spinning; the next tick resumes it. + return SyncPolicy.rideIntervalMs } private func interval() -> Int64 { @@ -251,19 +289,33 @@ final class SyncCoordinator { /// /// The wipe is local maintenance and emits no Sync Actions to either Account — replacing the file /// removes the log with everything else. - func resetForAccount(_ accountId: String) throws { + func resetForAccount(_ accountId: String) async throws { stop() - lock.lock() - // Every in-flight response now belongs to a previous Account and can no longer commit. - generation += 1 - recordedFailures.removeAll() - lastUploadAtMs = nil - lock.unlock() + // Queued behind any pass still in flight: one that started before `stop()` finishes its scan, + // send and commit against the old database before the file is replaced, and none can start + // midway through the transition. + let outcome: Result = await serialized { [self] in + lock.lock() + // Every in-flight response now belongs to a previous Account and can no longer commit. + generation += 1 + recordedFailures.removeAll() + lastUploadAtMs = nil + lock.unlock() - try TelemetryDatabase.replaceWithFreshDatabase() - store.bindAccount(accountId) - engine.resume() - start() + do { + try TelemetryDatabase.replaceWithFreshDatabase() + guard store.bindAccount(accountId) else { + throw SyncStoreError.databaseUnavailable + } + engine.resume() + return .success(()) + } catch { + return .failure(error) + } + } + try outcome.get() + // Deliberately not started here: the caller installs the new Device Token first, so the loop + // never runs with the previous Account's credential against the new Account's database. } /// One coalesced Diagnostic Event per failure class, table and cursor. Metadata only: an error diff --git a/modules/vescape-core/ios/sync/SyncEngine.swift b/modules/vescape-core/ios/sync/SyncEngine.swift index 1f997da6b..31c6bce46 100644 --- a/modules/vescape-core/ios/sync/SyncEngine.swift +++ b/modules/vescape-core/ios/sync/SyncEngine.swift @@ -29,7 +29,10 @@ protocol SyncSource { /// Commit the advance set in its own transaction, after the response. Never alongside the rows: a /// cursor advanced past rows the server did not take is unrecoverable, whereas a cursor left /// behind is a re-send the server upserts idempotently. Always fail toward re-sending. - func commit(_ advances: [SyncTable: Int64]) + /// + /// Throws rather than swallowing a write failure: an uncommitted cursor leaves the same rows + /// pending, and a caller that believed the checkpoint landed would resend them without pause. + func commit(_ advances: [SyncTable: Int64]) throws /// Bumped by an Account change. Captured before a request and re-read before the commit, so a /// response belonging to the previous Account becomes a no-op instead of advancing a cursor over @@ -53,6 +56,8 @@ struct SyncEnvironment { /// What one pass did, for the loop and for tests. enum SyncPass: Equatable { case idle + /// Nothing was accepted, but the next attempt differs from this one — a narrowed byte target. + case retry case sent(rowCount: Int, morePending: Bool) case waiting(untilMs: Int64) case paused(SyncPauseReason) @@ -126,6 +131,10 @@ final class SyncEngine { } private func send() async -> SyncPass { + // Captured before the rows are read, not after: an Account reset between the scan and the + // request would otherwise leave a batch of the previous Account's rows looking current, and its + // cursor advance would land on the fresh database. + let generation = source.generation() let pending: [SyncPendingTable] do { pending = try source.pending(rowLimit: maxSyncBatchRows) @@ -141,12 +150,11 @@ final class SyncEngine { case .rowTooLarge(let table, let cursor, _): return pause(.rowTooLarge, detail: "\(table.wire)@\(cursor)") case .ready(let batch): - return await deliver(batch) + return await deliver(batch, generation: generation) } } - private func deliver(_ batch: SyncBuiltBatch) async -> SyncPass { - let generation = source.generation() + private func deliver(_ batch: SyncBuiltBatch, generation: Int64) async -> SyncPass { let response = await transport(batch.body) // A response that outlived its Account cannot touch the fresh database it would land in. if source.generation() != generation { return .idle } @@ -169,7 +177,15 @@ final class SyncEngine { else { return pause(.protocolFailure, detail: "acceptedMismatch") } - source.commit(batch.advances) + do { + try source.commit(batch.advances) + } catch { + // The server took the rows but the checkpoint did not land. Backing off re-sends the identical + // batch, which the server upserts idempotently — reporting success here would spin instead, + // because the same rows are still pending. + backoffMs = SyncPolicy.nextBackoffMs(backoffMs) + return backOff(backoffMs) + } backoffMs = 0 retryAtMs = 0 byteTarget = maxSyncBatchBytes @@ -179,11 +195,15 @@ final class SyncEngine { /// `413` narrows the byte target instead of dropping anything. Once the target can no longer hold /// even one row, that row is a permanent local protocol error — it is retained, not skipped. private func shrink(_ batch: SyncBuiltBatch) -> SyncPass { - if batch.rowCount <= 1, let table = batch.tables.first { - return pause(.rowTooLarge, detail: "\(table.wire)@\(batch.advances[table] ?? 0)") - } + let table = batch.tables.first + let detail = "\(table?.wire ?? "batch")@\(table.flatMap { batch.advances[$0] } ?? 0)" + if batch.rowCount <= 1 { return pause(.rowTooLarge, detail: detail) } + // Already as small as a batch gets: halving again would resend the same bytes forever, so the + // disagreement about the wire limit is treated as what it is — permanent, with the rows kept. + if byteTarget <= Self.minByteTarget { return pause(.rowTooLarge, detail: detail) } + byteTarget = max(byteTarget / 2, Self.minByteTarget) - return .sent(rowCount: 0, morePending: true) + return .retry } private func backOff(_ delayMs: Int64) -> SyncPass { diff --git a/modules/vescape-core/ios/sync/SyncEngineTests.swift b/modules/vescape-core/ios/sync/SyncEngineTests.swift index fad3efd30..879ef7e2d 100644 --- a/modules/vescape-core/ios/sync/SyncEngineTests.swift +++ b/modules/vescape-core/ios/sync/SyncEngineTests.swift @@ -13,6 +13,7 @@ final class SyncEngineTests: XCTestCase { var currentGeneration: Int64 = 0 var failures: [(SyncPauseReason, String)] = [] var encodeFailure: SyncProtocolError? + var commitFailure: Error? init(rows: Int) { self.remaining = rows } @@ -26,7 +27,8 @@ final class SyncEngineTests: XCTestCase { func pendingCount() -> Int { remaining } - func commit(_ advances: [SyncTable: Int64]) { + func commit(_ advances: [SyncTable: Int64]) throws { + if let commitFailure { throw commitFailure } committed.append(advances) remaining = max(0, remaining - 2) } @@ -187,6 +189,44 @@ final class SyncEngineTests: XCTestCase { XCTAssertEqual(source.remaining, 1) } + /// A shrink accepted nothing, so it must not be reported as an upload. + func test413OnAMultiRowBatchNarrowsTheTargetAndRetries() async { + let source = FakeSource(rows: 4) + let engine = engine(source, [.tooLarge]) + + let pass = await engine.runOnce() + XCTAssertEqual(pass, .retry) + XCTAssertTrue(source.committed.isEmpty) + XCTAssertEqual(source.remaining, 4) + } + + /// Halving forever against a server that keeps refusing would be an unbounded request storm. + func test413AtTheSmallestBatchPausesInsteadOfResendingForever() async { + let source = FakeSource(rows: 4) + let engine = engine(source, Array(repeating: SyncResponse.tooLarge, count: 10)) + + var outcome = await engine.runOnce() + var passes = 0 + while outcome == .retry, passes < 10 { + outcome = await engine.runOnce() + passes += 1 + } + XCTAssertEqual(outcome, .paused(.rowTooLarge)) + XCTAssertTrue(source.committed.isEmpty) + } + + /// The server took the rows; the checkpoint did not land. Resending is safe, claiming success is not. + func testAFailedCursorCommitBacksOffInsteadOfReportingAnUpload() async { + let source = FakeSource(rows: 2) + source.commitFailure = SyncStoreError.databaseUnavailable + let engine = engine(source, [.accepted(body: accepted(boards: 2))]) + + let outcome = await engine.runOnce() + if case .waiting = outcome {} else { XCTFail("expected a backoff wait") } + XCTAssertTrue(source.committed.isEmpty) + XCTAssertEqual(source.remaining, 2) + } + func test429WaitsForTheServersOwnDelay() async { let source = FakeSource(rows: 2) let engine = engine(source, [.rateLimited(retryAfterMs: 90_000)]) diff --git a/modules/vescape-core/ios/sync/SyncJson.swift b/modules/vescape-core/ios/sync/SyncJson.swift index c678c5379..fc4b3f422 100644 --- a/modules/vescape-core/ios/sync/SyncJson.swift +++ b/modules/vescape-core/ios/sync/SyncJson.swift @@ -106,7 +106,12 @@ final class SyncRowWriter { } private func boundedText(_ field: String, _ value: String) throws -> SyncRowWriter { - if value.count > maxSyncKeyLength { throw fail(field, "exceeds \(maxSyncKeyLength) characters") } + // UTF-16 code units, matching the server's compiled `value.length <= 128` and Kotlin's + // `String.length`. Swift's `count` is grapheme clusters, which would let a key through here that + // the server refuses — and a refused batch is a permanent pause. + if value.utf16.count > maxSyncKeyLength { + throw fail(field, "exceeds \(maxSyncKeyLength) characters") + } return raw(field, quote(value)) } diff --git a/modules/vescape-core/ios/sync/SyncStore.swift b/modules/vescape-core/ios/sync/SyncStore.swift index 258c1591c..3be1ddac3 100644 --- a/modules/vescape-core/ios/sync/SyncStore.swift +++ b/modules/vescape-core/ios/sync/SyncStore.swift @@ -22,6 +22,11 @@ internal func createSyncBindingTable(_ db: Database) throws { ) } +/// The database went away underneath the uploader — a swap, or a pool that failed to open. +enum SyncStoreError: Error { + case databaseUnavailable +} + /// How far a table has been accepted. A table with no committed cursor has delivered nothing. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt `cursorOf` internal func syncCursor(_ db: Database, _ name: String) throws -> Int64 { @@ -72,6 +77,11 @@ final class SyncStore: SyncSource { /// Frames and buckets that name no Board are not offered: the server keys those tables on the /// Board and has nowhere to put a sample that belongs to none (ADR-0028). They are unowned local /// rows, not rows a Rider is waiting to see backed up. + /// + /// The consequence is deliberate: a later owned row carries the cursor past a skipped one, so + /// cursor-gated retention prunes unowned telemetry on age alone, exactly as it did before the + /// Account binding existed. Holding it forever would be the only alternative, because no future + /// upload can ever accept it. private func scanPredicate(_ table: SyncTable) -> String { switch table { case .telemetryFrames: return " AND board_id IS NOT NULL" @@ -142,14 +152,16 @@ final class SyncStore: SyncSource { /// Cursors move only here, only after the server accepted. The accepted Sync Action cursor is also /// what prunes the log, so pruning can never outrun it. - func commit(_ advances: [SyncTable: Int64]) { - guard let pool else { return } - try? pool.write { db in + func commit(_ advances: [SyncTable: Int64]) throws { + guard let pool else { throw SyncStoreError.databaseUnavailable } + try pool.write { db in for (table, cursor) in advances { try commitSyncCursor(db, table.cursorKey, cursor) } } guard advances[.deleteActions] != nil else { return } + // Pruning is a follow-up to the checkpoint, not part of it: a failure here leaves accepted + // actions on disk, which re-send as no-ops, so it must not fail the commit itself. try? pool.write { db in try pruneUploadedSyncActions(db) } diff --git a/modules/vescape-core/ios/sync/SyncWire.swift b/modules/vescape-core/ios/sync/SyncWire.swift index 743f72dd9..624b022cf 100644 --- a/modules/vescape-core/ios/sync/SyncWire.swift +++ b/modules/vescape-core/ios/sync/SyncWire.swift @@ -42,14 +42,16 @@ enum SyncWire { return writer.build() } - /// `transport` is iOS-only in the local schema but not a Board column the server declares; it is - /// sent as null on both platforms so a Board row reads identically from either phone. + /// `transport` is the one column only iOS stores on the Board itself — Android keeps it in board + /// settings and sends null there. The server declares the field for exactly this reason, so a + /// restored iPhone keeps the Board Link's selected transport instead of re-probing for it. + /// @platform-diff Android has no `boards.transport` column and sends null. static func board(_ row: Row) throws -> String { let writer = SyncRowWriter(.boards) try writer.keyText("id", text(row, "id")) writer.text("name", row["name"]) writer.text("bleId", row["ble_id"]) - writer.text("transport", nil) + writer.text("transport", row["transport"]) try writer.timestamp("createdAt", row["created_at"]) try writer.timestamp("updatedAt", row["updated_at"]) return writer.build() diff --git a/modules/vescape-core/ios/sync/SyncWireTests.swift b/modules/vescape-core/ios/sync/SyncWireTests.swift index fb7b17dc8..1a19c2b2c 100644 --- a/modules/vescape-core/ios/sync/SyncWireTests.swift +++ b/modules/vescape-core/ios/sync/SyncWireTests.swift @@ -8,8 +8,19 @@ import GRDB /// /// @parity /modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncWireTest.kt final class SyncWireTests: XCTestCase { - private func boardRow(id: String = "board-1", name: String = "Board") -> Row { - Row(["id": id, "name": name, "ble_id": nil, "created_at": 10, "updated_at": 20]) + private func boardRow( + id: String = "board-1", + name: String = "Board", + transport: String? = nil + ) -> Row { + Row([ + "id": id, + "name": name, + "ble_id": nil, + "transport": transport, + "created_at": 10, + "updated_at": 20, + ]) } private func frameRow(boardId: String? = "board-1", speed: Int64? = 100) -> Row { @@ -42,6 +53,13 @@ final class SyncWireTests: XCTestCase { ) } + /// iOS is the platform that stores Board Transport on the Board, and the server declares the field + /// for exactly that — dropping it would lose the Board Link's transport on restore. + func testABoardCarriesTheTransportOnlyIosStores() throws { + let encoded = try SyncWire.board(boardRow(transport: "direct")) + XCTAssertTrue(encoded.contains(#""transport":"direct""#)) + } + /// "Cleared" and "not mentioned" are different intents, and only one survives a missing key. func testNullableColumnsAreExplicitNullsNeverOmittedKeys() throws { let encoded = try SyncWire.telemetryFrame(frameRow(speed: nil)) @@ -61,6 +79,15 @@ final class SyncWireTests: XCTestCase { ) } + /// The server compiles `value.length <= 128`, which counts UTF-16 code units — so an emoji is two. + /// Swift's `count` would see 64 characters here and let a key through that the server refuses. + func testKeyLengthIsMeasuredInUtf16CodeUnitsLikeTheServer() { + let emoji = String(repeating: "🛹", count: 65) + XCTAssertEqual(emoji.count, 65) + XCTAssertEqual(emoji.utf16.count, 130) + XCTAssertThrowsError(try SyncWire.board(boardRow(id: emoji))) + } + func testAnEmptyKeyIsRefusedWhereTheServerNamesIt() throws { XCTAssertThrowsError(try SyncWire.board(boardRow(id: ""))) _ = try SyncWire.appSetting( diff --git a/src/modules/profile/components/DeviceAuthSync.tsx b/src/modules/profile/components/DeviceAuthSync.tsx index e466c11f1..b8347d460 100644 --- a/src/modules/profile/components/DeviceAuthSync.tsx +++ b/src/modules/profile/components/DeviceAuthSync.tsx @@ -59,6 +59,9 @@ export function DeviceAuthSync() { .then((pending) => { // A different Account cannot activate backup until the Rider confirms that all local app // data is erased; native has stored nothing yet, so cancelling leaves this phone untouched. + // The session is un-attempted again so a Rider who cancels, or whose confirm fails, can + // retry instead of being stuck with no credential and no way to ask for one. + if (pending) attemptedSessionIds.delete(session.id) setPendingAccountReset(pending) setStatus(pending ? 'idle' : 'ready') }) From 11603e70b26a21f3f44c92fe32863b9f21766f2e Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sat, 1 Aug 2026 12:06:27 +0200 Subject: [PATCH 12/24] Show backup status #285 --- CONTEXT.md | 4 + .../modules/vescapecore/VescapeCoreModule.kt | 24 ++++- .../vescapecore/sync/SyncCoordinator.kt | 82 +++++++++++++++-- .../modules/vescapecore/sync/SyncNotifier.kt | 88 +++++++++++++++++++ .../modules/vescapecore/sync/SyncPolicy.kt | 34 +++++++ .../telemetry/AppDataRepository.kt | 13 +++ .../telemetry/TelemetryEntities.kt | 7 ++ .../vescapecore/sync/SyncPolicyTest.kt | 39 ++++++++ .../vescape-core/ios/VescapeCoreModule.swift | 26 +++++- .../ios/sync/SyncCoordinator.swift | 76 +++++++++++++++- .../vescape-core/ios/sync/SyncNotifier.swift | 45 ++++++++++ .../vescape-core/ios/sync/SyncPolicy.swift | 30 +++++++ .../ios/sync/SyncPolicyTests.swift | 26 ++++++ .../ios/telemetry/AppDataRepository.swift | 15 ++++ modules/vescape-core/src/e2eFake.ts | 2 + modules/vescape-core/src/index.ts | 44 ++++++++-- src/app/_layout.tsx | 3 + src/app/settings/components/widgets.tsx | 32 +++++++ src/app/settings/database.tsx | 36 +++++++- src/modules/moduleBoundaries.test.ts | 2 + .../profile/components/AccountWidget.tsx | 5 +- .../profile/components/BackupChoiceModal.tsx | 82 +++++++++++++++++ .../profile/components/BackupStatusLine.tsx | 51 +++++++++++ .../profile/components/DeviceAuthSync.tsx | 26 +++--- src/modules/profile/lib/backupStatus.test.ts | 56 ++++++++++++ src/modules/profile/lib/backupStatus.ts | 73 +++++++++++++++ src/modules/profile/store/syncStatusStore.ts | 51 +++++++++++ .../settings/store/settingsStore.test.ts | 2 + src/modules/settings/store/settingsStore.ts | 2 + 29 files changed, 938 insertions(+), 38 deletions(-) create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncNotifier.kt create mode 100644 modules/vescape-core/ios/sync/SyncNotifier.swift create mode 100644 src/modules/profile/components/BackupChoiceModal.tsx create mode 100644 src/modules/profile/components/BackupStatusLine.tsx create mode 100644 src/modules/profile/lib/backupStatus.test.ts create mode 100644 src/modules/profile/lib/backupStatus.ts create mode 100644 src/modules/profile/store/syncStatusStore.ts diff --git a/CONTEXT.md b/CONTEXT.md index d13fd551d..47ae08e1c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,6 +24,10 @@ _Avoid_: Watermark, sync token, last-synced timestamp, offset 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 diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 22c79f643..ed711788b 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -163,6 +163,7 @@ class VescapeCoreModule : Module() { "onAppDataChanged", "onBoardWarnings", "onAppStatus", + "onSyncStatus", ) // Native owns App Status truth; JS mirrors it. Push every successful refresh (late subscribers @@ -236,6 +237,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("onAppStatus") { startObserving("onAppStatus") sendEvent("onAppStatus", mapOf("status" to AppStatusCoordinator.get(context).current?.toMap())) @@ -381,9 +402,6 @@ class VescapeCoreModule : Module() { AsyncFunction("getSyncStatus") Coroutine { -> SyncCoordinator.get(context).status().toMap() } - Function("setSyncWifiOnly") { enabled: Boolean -> - SyncCoordinator.get(context).setWifiOnly(enabled) - } // 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. diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt index 91a72c54f..579bab933 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncCoordinator.kt @@ -8,6 +8,7 @@ import expo.modules.vescapecore.api.HttpMethod import expo.modules.vescapecore.api.VescapeApi import expo.modules.vescapecore.appstatus.AppStatusCoordinator import expo.modules.vescapecore.auth.DeviceCredentialStore +import expo.modules.vescapecore.telemetry.AppDataRepository import expo.modules.vescapecore.telemetry.DatabaseBackupManager import expo.modules.vescapecore.telemetry.TelemetryDatabase import expo.modules.vescapecore.telemetry.TelemetryRepository @@ -26,12 +27,14 @@ private const val TAG = "SyncCoordinator" data class SyncStatus( val accountId: String?, val pendingRows: Int, + val activity: SyncActivity, val pause: SyncPauseReason?, val lastUploadAtMs: Long?, ) { fun toMap(): Map = mapOf( "accountId" to accountId, "pendingRows" to pendingRows, + "activity" to activity.slug, "pause" to pause?.slug, "lastUploadAtMs" to lastUploadAtMs, ) @@ -84,22 +87,72 @@ class SyncCoordinator private constructor(private val context: Context) { val pauseReason: SyncPauseReason? get() = engine.pauseReason + /** + * Wired by the module: every status transition, pushed to JS. Native owns the state; JS renders it + * and never derives one of its own. + */ + @Volatile var onStatusChanged: ((Map) -> Unit)? = null + + /** Last map handed out, so an unchanged status emits nothing and raises no second notification. */ + @Volatile private var publishedStatus: Map? = null + /** Recording persisted samples: the ride cadence follows sample production, not session presence. */ fun notifySamplesPersisted(atMs: Long = System.currentTimeMillis()) { lastSamplePersistedAtMs = atMs } + /** + * The "Back up over Wi-Fi only" App Setting, pushed by [AppDataRepository] on every write and read + * back on launch. Native reads the setting itself — JS never carries the switch to the uploader. + */ fun setWifiOnly(enabled: Boolean) { + if (wifiOnly == enabled) return wifiOnly = enabled kick() + scope.launch { publishStatus() } } - suspend fun status(): SyncStatus = SyncStatus( - accountId = dao.getBoundAccountId(), - pendingRows = store.pendingCount(), - pause = engine.pauseReason, - lastUploadAtMs = lastUploadAtMs, - ) + suspend fun status(): SyncStatus { + val environment = environment() + val pending = store.pendingCount() + val pause = engine.pauseReason + return SyncStatus( + accountId = dao.getBoundAccountId(), + pendingRows = pending, + activity = SyncPolicy.describe( + SyncState( + nowMs = System.currentTimeMillis(), + pendingRows = pending, + ridingSamples = environment.ridingSamples, + online = environment.online, + wifiOnly = environment.wifiOnly, + onWifi = environment.onWifi, + credentialReady = environment.credentialReady, + onlineBlocked = environment.onlineBlocked, + pause = pause, + // Backoff is invisible to the Rider: a batch waiting to be retried is still syncing. + retryAtMs = 0, + ), + ), + pause = pause, + lastUploadAtMs = lastUploadAtMs, + ) + } + + /** + * Emit the current status when it differs from the last one, and raise the notification a pause + * needs: a permanent failure does not resolve through ordinary retry, so a backup that stopped + * weeks ago must not wait for the Rider to open the social sheet. + */ + private suspend fun publishStatus() { + val status = runCatching { status() }.getOrNull() ?: return + val map = status.toMap() + if (map == publishedStatus) return + val previousPause = publishedStatus?.get("pause") as? String + publishedStatus = map + if (status.pause?.slug != previousPause) SyncNotifier.get(context).update(status.pause) + onStatusChanged?.invoke(map) + } /** * Pick the uploader back up on a cold launch: the credential outlives the process, so a phone that @@ -108,9 +161,15 @@ class SyncCoordinator private constructor(private val context: Context) { * that belongs to another one. */ fun resumeIfBound() { - val credential = credentials.read() ?: return scope.launch { - if (bindAccount(credential.accountId)) start() + // The switch is a durable App Setting, so the uploader restores it before the first pass of + // this process — otherwise a cold launch on mobile data would upload once before JS loaded. + wifiOnly = runCatching { + AppDataRepository.get(context).getTypedSettings().syncWifiOnly + }.getOrDefault(false) + val credential = credentials.read() + if (credential != null && bindAccount(credential.accountId)) start() + publishStatus() } } @@ -124,6 +183,7 @@ class SyncCoordinator private constructor(private val context: Context) { Log.w(TAG, "Sync pass failed: ${e.message}") SyncPolicy.IDLE_INTERVAL_MS } + publishStatus() delay(waitMs) } } @@ -140,7 +200,10 @@ class SyncCoordinator private constructor(private val context: Context) { /** Connectivity regained, ride ended, sign-in: send now rather than waiting for the next tick. */ fun kick() { if (loop?.isActive != true) return start() - val job = scope.launch { runCatching { pass() } } + val job = scope.launch { + runCatching { pass() } + publishStatus() + } kicks.add(job) job.invokeOnCompletion { kicks.remove(job) } } @@ -264,6 +327,7 @@ class SyncCoordinator private constructor(private val context: Context) { } // Deliberately not started here: the caller installs the new Device Token first, so the loop // never runs with the previous Account's credential against the new Account's database. + publishStatus() } /** diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncNotifier.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncNotifier.kt new file mode 100644 index 000000000..a0b348d11 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncNotifier.kt @@ -0,0 +1,88 @@ +package expo.modules.vescapecore.sync + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import expo.modules.vescapecore.R + +/** + * The one notification backup raises: it has stopped, and only the Rider can restart it. + * + * Deliberately narrow — ordinary retries, offline stretches and a metered connection say nothing. + * A [SyncPauseReason] does not resolve on its own, and a backup that has silently stopped for weeks + * is the failure this feature can least afford, so each reason gets one actionable notification and + * is cleared again the moment the pause lifts. + * + * @parity /modules/vescape-core/ios/sync/SyncNotifier.swift + */ +internal class SyncNotifier private constructor(private val context: Context) { + private var channelReady = false + + /** Show the notification for [reason], replacing any previous one, or clear it when null. */ + fun update(reason: SyncPauseReason?) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (reason == null) { + manager.cancel(NOTIFICATION_ID) + return + } + ensureChannel(manager) + manager.notify(NOTIFICATION_ID, build(reason)) + } + + private fun ensureChannel(manager: NotificationManager) { + if (channelReady) return + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Backup", NotificationManager.IMPORTANCE_DEFAULT).apply { + description = "Tells you when ride backup has stopped and needs you" + }, + ) + channelReady = true + } + + private fun build(reason: SyncPauseReason) = NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle("Backup paused") + .setContentText(text(reason)) + .setStyle(NotificationCompat.BigTextStyle().bigText(text(reason))) + .setSmallIcon(R.drawable.ic_vesc_notification) + .setContentIntent(openApp()) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .build() + + private fun openApp(): PendingIntent { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: Intent() + return PendingIntent.getActivity( + context, + REQUEST_OPEN, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + internal companion object { + private const val CHANNEL_ID = "vescape_backup" + private const val NOTIFICATION_ID = 4271 + private const val REQUEST_OPEN = 1 + + /** + * What the Rider has to do, in the same three shapes the account widget names. + * + * @parity /modules/vescape-core/ios/sync/SyncNotifier.swift `text` + */ + fun text(reason: SyncPauseReason): String = when (reason) { + SyncPauseReason.AUTHENTICATION -> "Sign in again to keep backing up your rides." + SyncPauseReason.PROTOCOL -> "Update Vescape to keep backing up your rides." + SyncPauseReason.ROW_TOO_LARGE -> "Backup hit an error. Check the event log in settings." + } + + @Volatile private var instance: SyncNotifier? = null + + fun get(context: Context): SyncNotifier = + instance ?: synchronized(this) { + instance ?: SyncNotifier(context.applicationContext).also { instance = it } + } + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt index f34389099..24bf066ae 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt @@ -12,6 +12,25 @@ enum class SyncPauseReason(val slug: String) { ROW_TOO_LARGE("rowTooLarge"), } +/** + * The backup state the Rider is shown. Derived from the same [SyncState] the loop decides on, so the + * status line can never disagree with what the uploader is actually doing. + * + * @parity /modules/vescape-core/ios/sync/SyncPolicy.swift `SyncActivity` + * @parity /modules/vescape-core/src/index.ts `SyncActivity` + */ +enum class SyncActivity(val slug: String) { + /** No credential: backup has never been turned on, or the Rider signed out. */ + SIGNED_OUT("signedOut"), + UP_TO_DATE("upToDate"), + SYNCING("syncing"), + WAITING_FOR_WIFI("waitingForWifi"), + OFFLINE("offline"), + + /** Stopped on a permanent failure; [SyncStatus.pause] names which one. */ + PAUSED("paused"), +} + /** What the loop should do next. */ sealed interface SyncDecision { /** Send the next batch now. */ @@ -78,6 +97,21 @@ object SyncPolicy { return SyncDecision.SendNow } + /** + * The same state, as the one line the Rider reads. + * + * Signed out wins over the pause it produces: a phone with no credential is not a broken backup, + * it is one that was never turned on. Everything below the pause is ordinary waiting. + */ + fun describe(state: SyncState): SyncActivity = when { + !state.credentialReady -> SyncActivity.SIGNED_OUT + state.pause != null -> SyncActivity.PAUSED + state.pendingRows <= 0 -> SyncActivity.UP_TO_DATE + !state.online || state.onlineBlocked -> SyncActivity.OFFLINE + state.wifiOnly && !state.onWifi -> SyncActivity.WAITING_FOR_WIFI + else -> SyncActivity.SYNCING + } + /** Next backoff step: doubling from [BACKOFF_START_MS], capped, and reset to 0 on success. */ fun nextBackoffMs(previousMs: Long): Long = when { previousMs <= 0L -> BACKOFF_START_MS diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt index e00da1e5b..e04c31746 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt @@ -7,6 +7,7 @@ import expo.modules.vescapecore.diagnostics.DiagnosticReporter import expo.modules.vescapecore.service.CoreForegroundService import expo.modules.vescapecore.connection.BoardTransport +import expo.modules.vescapecore.sync.SyncCoordinator import android.content.Context import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -271,6 +272,8 @@ class AppDataRepository private constructor(private val context: Context) { companionPresenceCooldownMinutes = req("companionPresenceCooldownMinutes", 60, ::validCompanionCooldownMinutes), autoCloseEnabled = req("autoCloseEnabled", false) { it as? Boolean }, autoCloseDelayMinutes = req("autoCloseDelayMinutes", 15, ::validAutoCloseDelayMinutes), + syncWifiOnly = req("syncWifiOnly", false) { it as? Boolean }, + syncBackupChoiceMade = req("syncBackupChoiceMade", false) { it as? Boolean }, riderId = opt("riderId") { it as? String }, riderName = opt("riderName") { it as? String }, riderColor = opt("riderColor") { it as? String }, @@ -343,6 +346,7 @@ class AppDataRepository private constructor(private val context: Context) { "autoCloseEnabled" -> value as? Boolean ?: return@withContext "autoCloseDelayMinutes" -> validAutoCloseDelayMinutes(value) ?: return@withContext + "syncWifiOnly", "syncBackupChoiceMade" -> value as? Boolean ?: return@withContext "riderId", "riderName", "riderColor" -> value as? String // Legal Policy is native-owned. JS can request refresh through the dedicated intent. "legalPolicy" -> return@withContext @@ -388,6 +392,8 @@ class AppDataRepository private constructor(private val context: Context) { "companionPresenceCooldownMinutes" -> d.companionPresenceCooldownMinutes "autoCloseEnabled" -> d.autoCloseEnabled "autoCloseDelayMinutes" -> d.autoCloseDelayMinutes + "syncWifiOnly" -> d.syncWifiOnly + "syncBackupChoiceMade" -> d.syncBackupChoiceMade "riderId" -> d.riderId "riderName" -> d.riderName "riderColor" -> d.riderColor @@ -407,6 +413,11 @@ class AppDataRepository private constructor(private val context: Context) { ), ) } + // The uploader reads the Wi-Fi switch from native truth, not from a JS call, so a write from any + // source — the settings row, the one-time choice, a restored backup — reaches it the same way. + if (normalizedKey == "syncWifiOnly") { + SyncCoordinator.get(context).setWifiOnly(coerced as? Boolean ?: false) + } notifyDataChanged(AppDataScope.SETTINGS) } @@ -724,6 +735,8 @@ fun AppSettings.toMap(): Map = mapOf( "companionPresenceCooldownMinutes" to companionPresenceCooldownMinutes, "autoCloseEnabled" to autoCloseEnabled, "autoCloseDelayMinutes" to autoCloseDelayMinutes, + "syncWifiOnly" to syncWifiOnly, + "syncBackupChoiceMade" to syncBackupChoiceMade, "riderId" to riderId, "riderName" to riderName, "riderColor" to riderColor, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index fb9b9ea59..9e8a9669f 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -639,6 +639,9 @@ internal val NOT_SYNCED_SETTING_KEYS = setOf( // Wear pairing — the watch is paired to one phone. "wearMirrorIntervalMs", "wearAutoLaunchOnConnect", + // The backup choice is per phone: the expensive first upload belongs to the phone that holds the + // backlog, so a restore onto a second phone asks that Rider again rather than deciding for them. + "syncBackupChoiceMade", ) /** @@ -678,6 +681,10 @@ data class AppSettings( val companionPresenceCooldownMinutes: Int = 60, val autoCloseEnabled: Boolean = false, val autoCloseDelayMinutes: Int = 15, + /** Nothing uploads on a metered connection while this is on — mid-ride included. */ + val syncWifiOnly: Boolean = false, + /** The one-time backup choice has been offered on this phone and answered. */ + val syncBackupChoiceMade: Boolean = false, val riderId: String? = null, val riderName: String? = null, val riderColor: String? = null, diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncPolicyTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncPolicyTest.kt index 559bface9..31d5a1e41 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncPolicyTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/sync/SyncPolicyTest.kt @@ -77,6 +77,45 @@ class SyncPolicyTest { ) } + @Test + fun `a phone with no credential reads as signed out, not as a broken backup`() { + assertEquals(SyncActivity.SIGNED_OUT, SyncPolicy.describe(state(credentialReady = false))) + assertEquals( + SyncActivity.SIGNED_OUT, + SyncPolicy.describe(state(credentialReady = false, pause = SyncPauseReason.AUTHENTICATION)), + ) + } + + @Test + fun `every waiting reason is named separately`() { + assertEquals(SyncActivity.UP_TO_DATE, SyncPolicy.describe(state(pendingRows = 0))) + assertEquals(SyncActivity.SYNCING, SyncPolicy.describe(state())) + assertEquals(SyncActivity.OFFLINE, SyncPolicy.describe(state(online = false))) + assertEquals(SyncActivity.OFFLINE, SyncPolicy.describe(state(onlineBlocked = true))) + assertEquals( + SyncActivity.WAITING_FOR_WIFI, + SyncPolicy.describe(state(wifiOnly = true, onWifi = false)), + ) + assertEquals(SyncActivity.SYNCING, SyncPolicy.describe(state(wifiOnly = true, onWifi = true))) + } + + @Test + fun `a pause outranks everything except being signed out`() { + assertEquals( + SyncActivity.PAUSED, + SyncPolicy.describe(state(pendingRows = 0, pause = SyncPauseReason.PROTOCOL)), + ) + assertEquals( + SyncActivity.PAUSED, + SyncPolicy.describe(state(online = false, pause = SyncPauseReason.ROW_TOO_LARGE)), + ) + } + + @Test + fun `a batch waiting on backoff still reads as syncing`() { + assertEquals(SyncActivity.SYNCING, SyncPolicy.describe(state(retryAtMs = 60_000))) + } + @Test fun `backoff doubles from the first step and stops at the cap`() { assertEquals(SyncPolicy.BACKOFF_START_MS, SyncPolicy.nextBackoffMs(0)) diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index 9e91eadbc..fd3f59a70 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -76,7 +76,7 @@ public class VescapeCoreModule: Module { // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `Events` // @parity /modules/vescape-core/src/index.ts `VescapeCoreEvents` - Events("onDevice", "onError", "onLiveState", "onLiveTick", "onLiveSeries", "onTelemetryHistory", "onBms", "onBmsSeries", "onLocation", "onTelemetryRebuildProgress", "onBoardProbeProgress", "onAppDataChanged", "onGroupRideConnection", "onGroupRideSnapshot", "onGroupRideCreated", "onGroupRideUpdated", "onGroupRideEnded", "onGroupRideJoined", "onGroupRideRoster", "onGroupRideError", "onBoardWarnings", "onAppStatus") + Events("onDevice", "onError", "onLiveState", "onLiveTick", "onLiveSeries", "onTelemetryHistory", "onBms", "onBmsSeries", "onLocation", "onTelemetryRebuildProgress", "onBoardProbeProgress", "onAppDataChanged", "onGroupRideConnection", "onGroupRideSnapshot", "onGroupRideCreated", "onGroupRideUpdated", "onGroupRideEnded", "onGroupRideJoined", "onGroupRideRoster", "onGroupRideError", "onBoardWarnings", "onAppStatus", "onSyncStatus") // Track per-event JS listeners so native skips emitting into the void, and gate the whole // firehose on app foreground (see `frontendActive`). Mirrors Android's observing + lifecycle @@ -113,6 +113,12 @@ public class VescapeCoreModule: Module { self.sendEvent("onAppStatus", ["status": AppStatusCoordinator.shared.current?.toMap()]) } OnStopObserving("onAppStatus") { self.observedEvents.remove("onAppStatus") } + OnStartObserving("onSyncStatus") { + self.observedEvents.insert("onSyncStatus") + // Late subscriber: replay the current backup status so JS is immediately consistent. + self.sendSyncStatus(SyncCoordinator.shared.status().toMap()) + } + OnStopObserving("onSyncStatus") { self.observedEvents.remove("onSyncStatus") } OnCreate { // Native owns App Status truth; JS mirrors it. Push every successful refresh (late @@ -123,6 +129,9 @@ public class VescapeCoreModule: Module { AppStatusCoordinator.shared.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. + // Native owns backup state; JS mirrors it. Push every transition (late subscribers replay + // above and through `getSyncStatus`). + SyncCoordinator.shared.onStatusChanged = { [weak self] status in self?.sendSyncStatus(status) } SyncCoordinator.shared.resumeIfBound() self.attachToCoordinator() AppDataRepository.onDataChanged = { [weak self] scope in self?.sendAppDataChanged(scope) } @@ -339,9 +348,6 @@ public class VescapeCoreModule: Module { AsyncFunction("getSyncStatus") { () -> [String: Any?] in SyncCoordinator.shared.status().toMap() } - Function("setSyncWifiOnly") { (enabled: Bool) in - SyncCoordinator.shared.setWifiOnly(enabled) - } // Stable Vescape route keeps the app decoupled from the final store destination. // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `openAppUpdate` @@ -1211,6 +1217,18 @@ public class VescapeCoreModule: Module { } } + /// Emit `onSyncStatus` with the uploader's current state. `sendEvent` must run on the main thread; + /// drop the emit when no JS listener is attached — the replay on subscribe and the next transition + /// self-heal it. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `onSyncStatus` + /// @parity /modules/vescape-core/src/index.ts `SyncStatusEvent` + private func sendSyncStatus(_ status: [String: Any?]) { + DispatchQueue.main.async { + guard self.shouldEmitToFrontend("onSyncStatus") else { return } + self.sendEvent("onSyncStatus", status) + } + } + /// Map Point failures carry a code JS branches on; anything else is an unexpected native fault. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/mappoints/MapPointApi.kt `MapPointApiException` private static func rejectMapPoint(_ promise: Promise, _ error: Error) { diff --git a/modules/vescape-core/ios/sync/SyncCoordinator.swift b/modules/vescape-core/ios/sync/SyncCoordinator.swift index 0d9c3af87..b043e7aef 100644 --- a/modules/vescape-core/ios/sync/SyncCoordinator.swift +++ b/modules/vescape-core/ios/sync/SyncCoordinator.swift @@ -6,6 +6,7 @@ import Network struct SyncStatus { let accountId: String? let pendingRows: Int + let activity: SyncActivity let pause: SyncPauseReason? let lastUploadAtMs: Int64? @@ -13,6 +14,7 @@ struct SyncStatus { [ "accountId": accountId, "pendingRows": pendingRows, + "activity": activity.slug, "pause": pause?.slug, "lastUploadAtMs": lastUploadAtMs, ] @@ -92,6 +94,17 @@ final class SyncCoordinator { var pauseReason: SyncPauseReason? { engine.pauseReason } + /// Wired by the module: every status transition, pushed to JS. Native owns the state; JS renders + /// it and never derives one of its own. + var onStatusChanged: (([String: Any?]) -> Void)? + + /// Last status handed out, so an unchanged status emits nothing and raises no second notification. + private var publishedActivity: String? + private var publishedPause: String? + private var publishedPending: Int? + private var publishedUploadAtMs: Int64? + private var publishedAccountId: String? + /// Recording persisted samples: the ride cadence follows sample production, not session presence. func notifySamplesPersisted(atMs: Int64 = telemetryNowMs()) { lock.lock() @@ -99,32 +112,84 @@ final class SyncCoordinator { lock.unlock() } + /// The "Back up over Wi-Fi only" App Setting, pushed by `AppDataRepository` on every write and + /// read back on launch. Native reads the setting itself — JS never carries the switch to the + /// uploader. func setWifiOnly(_ enabled: Bool) { lock.lock() + let changed = wifiOnly != enabled wifiOnly = enabled lock.unlock() + guard changed else { return } kick() + publishStatus() } func status() -> SyncStatus { lock.lock() let uploadedAt = lastUploadAtMs lock.unlock() + let environment = environment() + let pending = store.pendingCount() + let pause = engine.pauseReason return SyncStatus( accountId: store.boundAccountId(), - pendingRows: store.pendingCount(), - pause: engine.pauseReason, + pendingRows: pending, + activity: SyncPolicy.describe( + SyncState( + nowMs: telemetryNowMs(), + pendingRows: pending, + ridingSamples: environment.ridingSamples, + online: environment.online, + wifiOnly: environment.wifiOnly, + onWifi: environment.onWifi, + credentialReady: environment.credentialReady, + onlineBlocked: environment.onlineBlocked, + pause: pause, + // Backoff is invisible to the Rider: a batch waiting to be retried is still syncing. + retryAtMs: 0 + ) + ), + pause: pause, lastUploadAtMs: uploadedAt ) } + /// Emit the current status when it differs from the last one, and raise the notification a pause + /// needs: a permanent failure does not resolve through ordinary retry, so a backup that stopped + /// weeks ago must not wait for the Rider to open the social sheet. + private func publishStatus() { + let status = status() + lock.lock() + let unchanged = status.activity.slug == publishedActivity + && status.pause?.slug == publishedPause + && status.pendingRows == publishedPending + && status.lastUploadAtMs == publishedUploadAtMs + && status.accountId == publishedAccountId + let previousPause = publishedPause + publishedActivity = status.activity.slug + publishedPause = status.pause?.slug + publishedPending = status.pendingRows + publishedUploadAtMs = status.lastUploadAtMs + publishedAccountId = status.accountId + lock.unlock() + guard !unchanged else { return } + if status.pause?.slug != previousPause { SyncNotifier.shared.update(status.pause) } + onStatusChanged?(status.toMap()) + } + /// Pick the uploader back up on a cold launch: the credential outlives the process, so a phone /// that was signed in stays signed in, and nothing else would ever start the loop again. Binding /// the stored Account is a no-op when this database already belongs to it, and cannot claim a /// database that belongs to another one. func resumeIfBound() { - guard let credential = DeviceCredentialStore.shared.read() else { return } - if bindAccount(credential.accountId) { start() } + // The switch is a durable App Setting, so the uploader restores it before the first pass of this + // process — otherwise a cold launch on mobile data would upload once before JS loaded. + setWifiOnly(AppDataRepository.shared.getSettings()["syncWifiOnly"] as? Bool ?? false) + if let credential = DeviceCredentialStore.shared.read(), bindAccount(credential.accountId) { + start() + } + publishStatus() } func start() { @@ -133,6 +198,7 @@ final class SyncCoordinator { while !Task.isCancelled { guard let self else { return } let waitMs = await self.serialized { await self.pass() } + self.publishStatus() try? await Task.sleep(nanoseconds: UInt64(max(waitMs, 0)) * 1_000_000) } } @@ -152,6 +218,7 @@ final class SyncCoordinator { Task { [weak self] in guard let self else { return } _ = await self.serialized { await self.pass() } + self.publishStatus() } } @@ -314,6 +381,7 @@ final class SyncCoordinator { } } try outcome.get() + publishStatus() // Deliberately not started here: the caller installs the new Device Token first, so the loop // never runs with the previous Account's credential against the new Account's database. } diff --git a/modules/vescape-core/ios/sync/SyncNotifier.swift b/modules/vescape-core/ios/sync/SyncNotifier.swift new file mode 100644 index 000000000..7834e65b1 --- /dev/null +++ b/modules/vescape-core/ios/sync/SyncNotifier.swift @@ -0,0 +1,45 @@ +import Foundation +import UserNotifications + +/// The one notification backup raises: it has stopped, and only the Rider can restart it. +/// +/// Deliberately narrow — ordinary retries, offline stretches and a metered connection say nothing. +/// A `SyncPauseReason` does not resolve on its own, and a backup that has silently stopped for weeks +/// is the failure this feature can least afford, so each reason gets one actionable notification and +/// is cleared again the moment the pause lifts. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncNotifier.kt +final class SyncNotifier { + static let shared = SyncNotifier() + + private static let identifier = "vescape.backupPaused" + + private init() {} + + /// Show the notification for `reason`, replacing any previous one, or clear it when nil. + func update(_ reason: SyncPauseReason?) { + let center = UNUserNotificationCenter.current() + guard let reason else { + center.removePendingNotificationRequests(withIdentifiers: [Self.identifier]) + center.removeDeliveredNotifications(withIdentifiers: [Self.identifier]) + return + } + let content = UNMutableNotificationContent() + content.title = "Backup paused" + content.body = Self.text(reason) + content.sound = .default + center.add( + UNNotificationRequest(identifier: Self.identifier, content: content, trigger: nil) + ) + } + + /// What the Rider has to do, in the same three shapes the account widget names. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncNotifier.kt `text` + static func text(_ reason: SyncPauseReason) -> String { + switch reason { + case .authentication: return "Sign in again to keep backing up your rides." + case .protocolFailure: return "Update Vescape to keep backing up your rides." + case .rowTooLarge: return "Backup hit an error. Check the event log in settings." + } + } +} diff --git a/modules/vescape-core/ios/sync/SyncPolicy.swift b/modules/vescape-core/ios/sync/SyncPolicy.swift index f7462cfc9..538d6c4ea 100644 --- a/modules/vescape-core/ios/sync/SyncPolicy.swift +++ b/modules/vescape-core/ios/sync/SyncPolicy.swift @@ -16,6 +16,23 @@ enum SyncPauseReason: String { var slug: String { rawValue } } +/// The backup state the Rider is shown. Derived from the same `SyncState` the loop decides on, so +/// the status line can never disagree with what the uploader is actually doing. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt `SyncActivity` +/// @parity /modules/vescape-core/src/index.ts `SyncActivity` +enum SyncActivity: String { + /// No credential: backup has never been turned on, or the Rider signed out. + case signedOut + case upToDate + case syncing + case waitingForWifi + case offline + /// Stopped on a permanent failure; `SyncStatus.pause` names which one. + case paused + + var slug: String { rawValue } +} + /// What the loop should do next. enum SyncDecision: Equatable { case sendNow @@ -74,6 +91,19 @@ enum SyncPolicy { return .sendNow } + /// The same state, as the one line the Rider reads. + /// + /// Signed out wins over the pause it produces: a phone with no credential is not a broken backup, + /// it is one that was never turned on. Everything below the pause is ordinary waiting. + static func describe(_ state: SyncState) -> SyncActivity { + if !state.credentialReady { return .signedOut } + if state.pause != nil { return .paused } + if state.pendingRows <= 0 { return .upToDate } + if !state.online || state.onlineBlocked { return .offline } + if state.wifiOnly && !state.onWifi { return .waitingForWifi } + return .syncing + } + /// Next backoff step: doubling from `backoffStartMs`, capped, and reset to 0 on success. static func nextBackoffMs(_ previousMs: Int64) -> Int64 { previousMs <= 0 ? backoffStartMs : min(previousMs * 2, backoffMaxMs) diff --git a/modules/vescape-core/ios/sync/SyncPolicyTests.swift b/modules/vescape-core/ios/sync/SyncPolicyTests.swift index ab0416862..4722e0049 100644 --- a/modules/vescape-core/ios/sync/SyncPolicyTests.swift +++ b/modules/vescape-core/ios/sync/SyncPolicyTests.swift @@ -63,6 +63,32 @@ final class SyncPolicyTests: XCTestCase { XCTAssertEqual(SyncPolicy.decide(state(credentialReady: false)), .paused(.authentication)) } + func testAPhoneWithNoCredentialReadsAsSignedOutNotAsABrokenBackup() { + XCTAssertEqual(SyncPolicy.describe(state(credentialReady: false)), .signedOut) + XCTAssertEqual( + SyncPolicy.describe(state(credentialReady: false, pause: .authentication)), + .signedOut + ) + } + + func testEveryWaitingReasonIsNamedSeparately() { + XCTAssertEqual(SyncPolicy.describe(state(pendingRows: 0)), .upToDate) + XCTAssertEqual(SyncPolicy.describe(state()), .syncing) + XCTAssertEqual(SyncPolicy.describe(state(online: false)), .offline) + XCTAssertEqual(SyncPolicy.describe(state(onlineBlocked: true)), .offline) + XCTAssertEqual(SyncPolicy.describe(state(wifiOnly: true, onWifi: false)), .waitingForWifi) + XCTAssertEqual(SyncPolicy.describe(state(wifiOnly: true, onWifi: true)), .syncing) + } + + func testAPauseOutranksEverythingExceptBeingSignedOut() { + XCTAssertEqual(SyncPolicy.describe(state(pendingRows: 0, pause: .protocolFailure)), .paused) + XCTAssertEqual(SyncPolicy.describe(state(online: false, pause: .rowTooLarge)), .paused) + } + + func testABatchWaitingOnBackoffStillReadsAsSyncing() { + XCTAssertEqual(SyncPolicy.describe(state(retryAtMs: 60_000)), .syncing) + } + func testBackoffDoublesFromTheFirstStepAndStopsAtTheCap() { XCTAssertEqual(SyncPolicy.nextBackoffMs(0), SyncPolicy.backoffStartMs) XCTAssertEqual(SyncPolicy.nextBackoffMs(30_000), 60_000) diff --git a/modules/vescape-core/ios/telemetry/AppDataRepository.swift b/modules/vescape-core/ios/telemetry/AppDataRepository.swift index 3ed65dc04..37dc87729 100644 --- a/modules/vescape-core/ios/telemetry/AppDataRepository.swift +++ b/modules/vescape-core/ios/telemetry/AppDataRepository.swift @@ -46,6 +46,9 @@ internal let notSyncedSettingKeys: [String] = [ // Wear pairing — the watch is paired to one phone. "wearMirrorIntervalMs", "wearAutoLaunchOnConnect", + // The backup choice is per phone: the expensive first upload belongs to the phone that holds the + // backlog, so a restore onto a second phone asks that Rider again rather than deciding for them. + "syncBackupChoiceMade", ] /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt @@ -611,6 +614,11 @@ final class AppDataRepository { } else if key == "satelliteImagerySaturation" { guard let saturation = Self.satelliteImagerySaturation(rawValue) else { return } value = saturation + } else if key == "syncWifiOnly" || key == "syncBackupChoiceMade" { + // Strict Bool (Android rejects non-Boolean too): the backup switch must never persist a + // malformed value that reads back truthy. + guard let flag = rawValue as? Bool else { return } + value = flag } else if key == "boardWarningsEnabled" { // Strict Bool (Android rejects non-Boolean too): the board-warnings kill switch must never // persist a malformed value that reads back truthy. @@ -626,6 +634,9 @@ final class AppDataRepository { write { db in try Self.writeAppSetting(db, key: key, json: json, now: updatedAt) } + // The uploader reads the Wi-Fi switch from native truth, not from a JS call, so a write from any + // source — the settings row, the one-time choice, a restored backup — reaches it the same way. + if key == "syncWifiOnly" { SyncCoordinator.shared.setWifiOnly(value as? Bool ?? false) } notifyDataChanged(.settings) } @@ -729,6 +740,10 @@ final class AppDataRepository { // the keys exist here only so getSettings() returns the full settings shape. "autoCloseEnabled": false, "autoCloseDelayMinutes": 15, + // Nothing uploads on a metered connection while this is on — mid-ride included. + "syncWifiOnly": false, + // The one-time backup choice has been offered on this phone and answered. + "syncBackupChoiceMade": false, "selectedBoardId": NSNull(), "riderId": NSNull(), "riderName": NSNull(), diff --git a/modules/vescape-core/src/e2eFake.ts b/modules/vescape-core/src/e2eFake.ts index b0fa8efef..85d407bfa 100644 --- a/modules/vescape-core/src/e2eFake.ts +++ b/modules/vescape-core/src/e2eFake.ts @@ -75,6 +75,8 @@ const e2eSettings: AppSettings = { companionPresenceCooldownMinutes: 60, autoCloseEnabled: false, autoCloseDelayMinutes: 15, + syncWifiOnly: false, + syncBackupChoiceMade: false, telemetryPollRateHz: 20, wearMirrorIntervalMs: 500, wearAutoLaunchOnConnect: true, diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index b044ccdc9..1355268c8 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -1047,6 +1047,16 @@ export interface AppSettings { autoCloseEnabled: boolean /** Minutes without a board connection before auto close fires. UI offers 1–480; native accepts up to 1440. */ autoCloseDelayMinutes: number + /** + * Nothing uploads on a metered connection while this is on — mid-ride included. No row classes, + * no backlog thresholds, no partial exceptions. + */ + syncWifiOnly: boolean + /** + * The one-time backup choice has been offered on this phone and answered. Phone-local: the + * expensive first upload belongs to the phone that holds the backlog. + */ + syncBackupChoiceMade: boolean /** * Max telemetry poll rate in Hz, applied as a minimum spacing floor between * requests. Polling stays response-paced (the next request is only sent once @@ -1464,6 +1474,20 @@ export interface DeviceCredentialStatus { */ export type SyncPauseReason = 'authentication' | 'protocol' | 'rowTooLarge' +/** + * The backup state the Rider is shown, derived natively from the same state the uploader decides on. + * + * @parity /modules/vescape-core/ios/sync/SyncPolicy.swift `SyncActivity` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/sync/SyncPolicy.kt `SyncActivity` + */ +export type SyncActivity = + | 'signedOut' + | 'upToDate' + | 'syncing' + | 'waitingForWifi' + | 'offline' + | 'paused' + /** * Native-owned backup state. JS renders it and never infers one of its own. * @@ -1474,10 +1498,20 @@ export interface SyncStatus { /** The Account this local database is bound to, or null while it has never been claimed. */ accountId: string | null pendingRows: number + activity: SyncActivity + /** Which permanent failure stopped the uploader, when `activity` is `paused`. */ pause: SyncPauseReason | null lastUploadAtMs: number | null } +/** + * Backup state changed. Emitted on every transition and replayed on subscribe, so a late listener is + * immediately consistent. + * @parity /modules/vescape-core/ios/VescapeCoreModule.swift `sendSyncStatus` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `onSyncStatus` + */ +export type SyncStatusEvent = SyncStatus + export type CriticalRideNotificationPermissionStatus = | 'not-determined' | 'denied' @@ -1524,6 +1558,7 @@ type VescapeCoreEvents = { onBoardWarnings: (event: BoardWarningsEvent) => void /** Native App Status, on every successful refresh and on subscribe. */ onAppStatus: (event: AppStatusEvent) => void + onSyncStatus: (event: SyncStatusEvent) => void } interface NativeEventEmitter void>> { @@ -1599,7 +1634,6 @@ type VescapeCoreNativeModule = NativeEventEmitter & { accountId: string, ): Promise getSyncStatus(): Promise - setSyncWifiOnly(enabled: boolean): void openAppUpdate(): void getRemoteTiltState(): RemoteTiltState | null setSelectedBoard(boardId: string | null): void @@ -2078,10 +2112,6 @@ export async function getSyncStatus(): Promise { } /** Back up over Wi-Fi only. Native waits for Wi-Fi rather than failing on a metered connection. */ -export function setSyncWifiOnly(enabled: boolean): void { - native.setSyncWifiOnly(enabled) -} - export async function revokeDeviceCredential(): Promise { return native.revokeDeviceCredential() } @@ -2577,6 +2607,10 @@ export function addAppStatusListener(cb: (event: AppStatusEvent) => void): Event return emitter.addListener('onAppStatus', cb) } +export function addSyncStatusListener(cb: (event: SyncStatusEvent) => void): EventSubscription { + return emitter.addListener('onSyncStatus', cb) +} + export function addLiveStateListener(cb: (event: LiveStateEvent) => void): EventSubscription { if (E2E_ENABLED) { return e2eFake.addLiveStateListener(cb) diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index cf319ee56..ed3a5f139 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -23,6 +23,7 @@ import { stackScreens } from '@/navigation/routes' import { startAlertsBoardSync } from '@/bootstrap/alertsBoardSync' import { startAppDataSync } from '@/bootstrap/appDataSync' import { startBoardWarningsSync } from '@/modules/board/store/boardWarningsStore' +import { startSyncStatusSync } from '@/modules/profile/store/syncStatusStore' import { useGroupRideStore } from '@/modules/group-ride/store/groupRideStore' import { useRiderStore } from '@/modules/group-ride/store/riderStore' import { ReleaseSurfaces } from '@/modules/release/components/ReleaseSurfaces' @@ -116,12 +117,14 @@ function RootLayout() { const stopBoardWarningsSync = startBoardWarningsSync() const stopAlertsBoardSync = startAlertsBoardSync() const stopAppStatusSync = startAppStatusSync() + const stopSyncStatusSync = startSyncStatusSync() return () => { useGroupRideStore.getState().stopObserving() stopAppDataSync() stopBoardWarningsSync() stopAlertsBoardSync() stopAppStatusSync() + stopSyncStatusSync() } }, []) diff --git a/src/app/settings/components/widgets.tsx b/src/app/settings/components/widgets.tsx index 6b8eb8c2c..59bc4a072 100644 --- a/src/app/settings/components/widgets.tsx +++ b/src/app/settings/components/widgets.tsx @@ -28,6 +28,7 @@ import { FloatingSheet } from '@/components/overlays/AnchoredSheet' import { useTriggerRef } from '@/components/overlays/measureTrigger' import { IconHero } from '@/components/settings/IconHero' import { ShowcaseCard } from '@/components/dev/ShowcaseCard' +import { BackupStatusLine } from '@/modules/profile/components/BackupStatusLine' import { theme } from '@/constants/theme' /** A horizontal grid row — each `Cell` child takes an equal fraction of the width. */ @@ -443,6 +444,35 @@ function CanvasWidgetShowcase() { ) } +/** A fixed upload time, so the preview renders the same line on every pass. */ +const SHOWCASE_UPLOADED_AT = Date.now() - 5 * 60_000 + +/** Every backup state native can report, so the copy for each stays browsable without a server. */ +function BackupStatusLineShowcase() { + const base = { + accountId: 'acc_1', + pendingRows: 0, + pause: null, + lastUploadAtMs: null, + } as const + return ( + + + + + + + + + + + + + ) +} + export default function WidgetsPage() { return ( @@ -459,6 +489,7 @@ export default function WidgetsPage() { + ) @@ -468,6 +499,7 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.palette.slate.bg }, content: { padding: 12, gap: 12, paddingBottom: 40 }, row: { flexDirection: 'row', gap: 10, alignItems: 'flex-start' }, + statusList: { gap: 8 }, cell: { flex: 1 }, sizeLabel: { color: theme.palette.slate.textMuted, diff --git a/src/app/settings/database.tsx b/src/app/settings/database.tsx index 3396b94fd..4e239411c 100644 --- a/src/app/settings/database.tsx +++ b/src/app/settings/database.tsx @@ -1,4 +1,4 @@ -import { View, StyleSheet, ScrollView, Pressable } from 'react-native' +import { View, StyleSheet, ScrollView, Pressable, Switch } from 'react-native' import { Text } from '@/components/base/Text' import { SafeAreaView } from 'react-native-safe-area-context' import { @@ -7,6 +7,8 @@ import { DownloadSimpleIcon, UploadSimpleIcon, DatabaseIcon, + CloudArrowUpIcon, + WifiHighIcon, } from 'phosphor-react-native' import { theme } from '@/constants/theme' @@ -16,9 +18,14 @@ import { Button } from '@/components/base/Button' import { ConfirmModal } from '@/components/modals/ConfirmModal' import { useSettingsDatabaseOps } from '@/modules/settings/hooks/useSettingsDatabaseOps' import { IconHero } from '@/components/settings/IconHero' +import { SettingsSectionTitle } from '@/components/settings/SettingsSectionTitle' +import { BackupStatusLine } from '@/modules/profile/components/BackupStatusLine' +import { useSettingsStore } from '@/modules/settings/store/settingsStore' export default function DatabaseSettingsScreen() { const db = useSettingsDatabaseOps() + const syncWifiOnly = useSettingsStore((s) => s.syncWifiOnly) + const set = useSettingsStore((s) => s.set) return ( @@ -27,6 +34,33 @@ export default function DatabaseSettingsScreen() { icon={DatabaseIcon} description="Back up, restore, and rebuild your ride history database." /> + Backup + + + + + void set('syncWifiOnly', v)} + trackColor={{ false: theme.palette.slate.border, true: theme.palette.sky.border }} + thumbColor={syncWifiOnly ? theme.palette.sky.color : theme.palette.slate.textMuted} + /> + } + /> + + + Local database settings', // release reads/persists dismissed Community Message IDs through App Settings 'release -> settings', + // the backup choice reads/persists the Wi-Fi-only switch through App Settings + 'profile -> settings', // settings defaults sourced from owning domains 'settings -> alerts', 'settings -> history', diff --git a/src/modules/profile/components/AccountWidget.tsx b/src/modules/profile/components/AccountWidget.tsx index 44c3436a8..ad3ef5cf3 100644 --- a/src/modules/profile/components/AccountWidget.tsx +++ b/src/modules/profile/components/AccountWidget.tsx @@ -9,6 +9,7 @@ import { Text } from '@/components/base/Text' import { LinkWidget } from '@/components/widgets/LinkWidget' import { widgetSurface } from '@/components/widgets/widgetSurface' import { theme } from '@/constants/theme' +import { BackupStatusLine } from '@/modules/profile/components/BackupStatusLine' import { useDeviceAuthStore } from '@/modules/profile/store/deviceAuthStore' import { routes } from '@/navigation/routes' @@ -43,7 +44,7 @@ export function AccountWidget({ onNavigate }: AccountWidgetProps) { icon={UserCircleIcon} accent={theme.palette.cyan.color} label="Vescape account" - hint="Optional — sign in for online features" + hint="Sign in to back up your rides, boards and tunes" onPress={() => navigate(routes.signIn)} /> ) @@ -80,6 +81,8 @@ export function AccountWidget({ onNavigate }: AccountWidgetProps) { /> + + {deviceAuthStatus === 'provisioning' ? ( diff --git a/src/modules/profile/components/BackupChoiceModal.tsx b/src/modules/profile/components/BackupChoiceModal.tsx new file mode 100644 index 000000000..f90e2f703 --- /dev/null +++ b/src/modules/profile/components/BackupChoiceModal.tsx @@ -0,0 +1,82 @@ +import { StyleSheet, View } from 'react-native' +import { CloudArrowUpIcon } from 'phosphor-react-native' + +import { Button } from '@/components/base/Button' +import { Text } from '@/components/base/Text' +import { FadeCardModal } from '@/components/modals/FadeCardModal' +import { theme } from '@/constants/theme' +import { useSettingsStore } from '@/modules/settings/store/settingsStore' +import { useSyncStatusStore } from '@/modules/profile/store/syncStatusStore' + +interface BackupChoiceModalProps { + /** Render regardless of the stored choice, with a fixed pending volume — the showcase. */ + preview?: { pendingRows: number } +} + +/** + * The one expensive moment in this feature's life — the first upload on a phone with months of + * history — offered as a decision rather than something that happened to the Rider. + * + * Shown once, with the pending volume, in the flow where backup is turned on. Both answers set the + * same App Setting the ordinary settings row does; afterwards this never appears again. + */ +export function BackupChoiceModal({ preview }: BackupChoiceModalProps) { + const status = useSyncStatusStore((state) => state.status) + const loaded = useSettingsStore((state) => state.loaded) + const choiceMade = useSettingsStore((state) => state.syncBackupChoiceMade) + const set = useSettingsStore((state) => state.set) + + // Only once backup is actually on: a signed-out phone has nothing to decide about yet. + const visible = + preview != null || (loaded && !choiceMade && status.accountId !== null && status.pause === null) + const pendingRows = preview?.pendingRows ?? status.pendingRows + + const choose = (wifiOnly: boolean) => { + void set('syncWifiOnly', wifiOnly) + void set('syncBackupChoiceMade', true) + } + + return ( + +