From a166e07d589e9f785e70a74c0f7c1f8d5ee406a3 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Sun, 26 Jul 2026 17:57:05 +0200 Subject: [PATCH 1/2] 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 2/2] 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(