Parent
What to build
Give the six remaining mutable tables a Sync Cursor column, so an upload scan can ask each one "what changed since I last looked".
app_settings board_settings board_warnings
privacy_zones tune_profiles favorites
boards, alerts and telemetry_minute_buckets already have sync_seq and updated_at from earlier on this branch. #287 lands Favorites first with stable identity and ordinary timestamps but deliberately no sync dependency. This slice adds/backfills its sync_seq, registers favorites with the shared sequence, and switches its create/rename paths to the same ratcheted write mechanism. This extends the same mechanism — the shared sequence table and the bump-then-read primitive already in the DAO — to the rest, and nothing about the design is new. vescape-server ADR-0007 records why the cursor is a counter and not a clock.
The append-only tables need nothing: telemetry frames, markers, diagnostic events, metric exclusion ranges and tune history entries all declare INTEGER PRIMARY KEY AUTOINCREMENT on both platforms, which SQLite guarantees monotonic and never reused, so their existing key is their cursor. Do not add sync_seq to them.
Two corrections to existing behaviour belong here, since they are the same mechanism:
Minute buckets adopt the ratchet. The bucket merge currently clamps its Change Timestamp with max(existing, incoming) rather than max(previous + 1, now), on the stated premise that the server upserts that table unconditionally. It does not — the server guards it with WHERE stored.updated_at < EXCLUDED.updated_at, the same rule as every other mutable table. On a backwards clock step the row is scanned, sent, and silently dropped server-side. Use the same ratchet as boards and alerts, and delete the comment claiming the exception.
Decide which app settings are per-Account and which are per-phone. This is the HITL part and it must be settled before the table's sync shape is fixed. app_settings currently syncs wholesale, but Rider Name and Rider Color live in it by design so that Group Ride keeps working signed-out. Restoring those onto a second phone overwrites that phone's Rider identity. Other keys may be equally phone-shaped — connection preferences, debug toggles, anything naming this device rather than this Rider. The outcome is either a per-key sync flag or an explicit not-synced list, plus a decision recorded on #277 about which keys fall on which side.
Likely files
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt - the six entities and SYNC_SEQ_TABLES
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt - nextSyncSeq, ratchetUpdatedAt, the bucket merge, and the seven upsert paths
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt - Room version and the migration
modules/vescape-core/ios/telemetry/TelemetryDatabase.swift and TelemetryDao.swift - the GRDB peers
modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt - the migration test to extend
src/modules/settings/store/ - the JS side of app settings, for the per-key decision
modules/vescape-core/src/index.ts - setting read/write surface across the bridge
Implementation hints
nextSyncSeq(name) seeds, bumps, then reads, inside the caller's transaction — bump-then-read specifically so two writes racing in the same database cannot be handed the same number. Reuse it; do not derive a sequence as MAX(sync_seq) + 1 per table, which hands out a duplicate as soon as the highest row is deleted.
ratchetUpdatedAt(previous, now) is max(previous + 1, now) and already carries a comment explaining that a plain max satisfies the scan while still losing the edit server-side. That comment is the argument for changing the bucket merge.
Each of the six tables needs its sequence name added to SYNC_SEQ_TABLES, which the migration iterates. Fresh installs build the schema from the entities and never run the migration, which is why nextSyncSeq seeds its row first — keep that.
setAlertRuleEnabled was the regression that motivated the original work: a targeted UPDATE that bypassed the row rewrite and so never moved the change columns. Check the six tables for the same shape — any targeted UPDATE that skips the upsert path.
Acceptance criteria
Blocked by
The app settings decision on #277 must also be settled before the app_settings half is implemented.
Related
Parent
What to build
Give the six remaining mutable tables a Sync Cursor column, so an upload scan can ask each one "what changed since I last looked".
boards,alertsandtelemetry_minute_bucketsalready havesync_seqandupdated_atfrom earlier on this branch. #287 lands Favorites first with stable identity and ordinary timestamps but deliberately no sync dependency. This slice adds/backfills itssync_seq, registersfavoriteswith the shared sequence, and switches its create/rename paths to the same ratcheted write mechanism. This extends the same mechanism — the shared sequence table and the bump-then-read primitive already in the DAO — to the rest, and nothing about the design is new.vescape-serverADR-0007 records why the cursor is a counter and not a clock.The append-only tables need nothing: telemetry frames, markers, diagnostic events, metric exclusion ranges and tune history entries all declare
INTEGER PRIMARY KEY AUTOINCREMENTon both platforms, which SQLite guarantees monotonic and never reused, so their existing key is their cursor. Do not addsync_seqto them.Two corrections to existing behaviour belong here, since they are the same mechanism:
Minute buckets adopt the ratchet. The bucket merge currently clamps its Change Timestamp with
max(existing, incoming)rather thanmax(previous + 1, now), on the stated premise that the server upserts that table unconditionally. It does not — the server guards it withWHERE stored.updated_at < EXCLUDED.updated_at, the same rule as every other mutable table. On a backwards clock step the row is scanned, sent, and silently dropped server-side. Use the same ratchet as boards and alerts, and delete the comment claiming the exception.Decide which app settings are per-Account and which are per-phone. This is the HITL part and it must be settled before the table's sync shape is fixed.
app_settingscurrently syncs wholesale, but Rider Name and Rider Color live in it by design so that Group Ride keeps working signed-out. Restoring those onto a second phone overwrites that phone's Rider identity. Other keys may be equally phone-shaped — connection preferences, debug toggles, anything naming this device rather than this Rider. The outcome is either a per-key sync flag or an explicit not-synced list, plus a decision recorded on #277 about which keys fall on which side.Likely files
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt- the six entities andSYNC_SEQ_TABLESmodules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt-nextSyncSeq,ratchetUpdatedAt, the bucket merge, and the seven upsert pathsmodules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt- Room version and the migrationmodules/vescape-core/ios/telemetry/TelemetryDatabase.swiftandTelemetryDao.swift- the GRDB peersmodules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt- the migration test to extendsrc/modules/settings/store/- the JS side of app settings, for the per-key decisionmodules/vescape-core/src/index.ts- setting read/write surface across the bridgeImplementation hints
nextSyncSeq(name)seeds, bumps, then reads, inside the caller's transaction — bump-then-read specifically so two writes racing in the same database cannot be handed the same number. Reuse it; do not derive a sequence asMAX(sync_seq) + 1per table, which hands out a duplicate as soon as the highest row is deleted.ratchetUpdatedAt(previous, now)ismax(previous + 1, now)and already carries a comment explaining that a plainmaxsatisfies the scan while still losing the edit server-side. That comment is the argument for changing the bucket merge.Each of the six tables needs its sequence name added to
SYNC_SEQ_TABLES, which the migration iterates. Fresh installs build the schema from the entities and never run the migration, which is whynextSyncSeqseeds its row first — keep that.setAlertRuleEnabledwas the regression that motivated the original work: a targetedUPDATEthat bypassed the row rewrite and so never moved the change columns. Check the six tables for the same shape — any targetedUPDATEthat skips the upsert path.Acceptance criteria
sync_seqand a Change Timestamp, stamped from the native clock on every writesync_seqUPDATEthat bypasses an upsertsync_seqand ratchetedupdated_atBoardInputandAlertRuleInputalready workapp_settingskeys sync and which stay on the phone, and the code enforces itBlocked by
The app settings decision on #277 must also be settled before the
app_settingshalf is implemented.Related