Skip to content

Add updated_at sync cursors for incremental server sync - #273

Closed
KacperKozak wants to merge 2 commits into
devfrom
sync-cursors-updated-at
Closed

Add updated_at sync cursors for incremental server sync#273
KacperKozak wants to merge 2 commits into
devfrom
sync-cursors-updated-at

Conversation

@KacperKozak

@KacperKozak KacperKozak commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an updated_at monotonic cursor column to boards, alerts, and telemetry_minute_buckets on both platforms, so the client can answer "everything changed since T" for the incremental sync landing in vescape-server.

Every other mutable table already had one (app_settings, board_settings, tune_profiles, privacy_zones, map_points; board_warnings has last_detected_at). Boards and alerts had created_at only, so a board rename or an alert toggle was invisible to sync. Minute buckets had no cursor at all despite being append-and-merge targets. No tombstones/soft-delete here — tracked separately.

Room schema 27 -> 28 (MIGRATION_27_28) and GRDB v28_sync_cursors, both additive, both backfilling existing rows rather than leaving them at DEFAULT 0:

table backfill source
boards created_at
alerts created_at
telemetry_minute_buckets last_sample_at_ms

Buckets have no created_at; last_sample_at_ms is the closest record of last change. All three get an index on updated_at since cursor sync scans on it.

Native stamps the cursor from its own clock on every write, never trusting the bridge value. That includes setAlertRuleEnabled on both platforms — a targeted UPDATE rather than a row rewrite, and the specific regression this change exists to prevent. Bucket merges use MAX(existing, incoming) so a backwards clock step can never walk the cursor back.

On the TS side Board and AlertRule gain updatedAt: number, and upsertBoard/upsertAlertRule now take BoardInput/AlertRuleInput (Omit<…, 'updatedAt'>) so no call site fabricates a cursor value — the types encode that native owns it.

Verification

bun run ts, 534 bun tests, 482 Android tests, lint (0 errors), format, and knip all pass.

Implementation notes

  • bun run test:ios is already red on dev, before this branch. replay/ConfigReplayHarness.swift fails to resolve ConfigSafetyValues, ConfigRWController, and VescPacketReassembler. Verified identical output with this branch's changes stashed. Untouched here.
  • The new iOS test cannot run in that harness either. Package.swift excludes every GRDB file (TelemetryDatabase.swift, AppDataRepository.swift, TelemetryDao.swift) from the SPM target — GRDB arrives via Pods. The existing GRDB tests (TuneProfileStoreTests, BoardWarningRegistryTests, AppDataRepositorySettingsTests) are likewise absent from the test target. SyncCursorMigrationTests.swift sits alongside those peers and runs wherever they do. It is syntax-checked via swiftc -parse but has not been type-checked or executed.
  • The Android setAlertRuleEnabled assertion reads DAO source text, not executed SQL. Room's @Query is BINARY-retention (invisible to reflection) and the generated impl keeps the statement method-local. There is no schema export and no androidTest source set, so the migration tests follow the existing Proxy-over-SupportSQLiteDatabase style (see MapPointEntityTest). A real MigrationTestHelper run would need that harness wired up first.
  • AppDataRepository (iOS) gained a dbWriter seam via forTesting(dbWriter:), mirroring TuneProfileStore(dbWriter:) / BoardWarningStore(dbWriter:). It was a pool-bound singleton with no way to point at an in-memory database.
  • The TS TelemetryMinuteBucket interface deliberately does not gain updatedAt. It is a presentation projection (converted units, derived id, boundaryBefore), not a row mirror, and the server reads the native DB rather than that shape. Easy to add if sync ends up routed through JS.

Summary by CodeRabbit

  • New Features
    • Added native-managed update timestamps to boards, alert rules, and telemetry data.
    • Added incremental synchronization support so changes are tracked consistently across Android and iOS.
    • Updated board and alert APIs to accept write inputs without client-managed timestamps.
  • Bug Fixes
    • Ensured alert toggles and telemetry updates advance synchronization timestamps reliably.
    • Preserved timestamp ordering when device clocks move backward.
  • Tests
    • Added coverage for database migrations, timestamp backfilling, synchronization behavior, and monotonic updates.

Review outcome

Reviewed by Codex (/rr-codex). Two Medium findings, both resolved against the vescape-server sync contract.

Finding 1 — cursor does not advance across a backwards clock step. Real, and it affects all three tables (boards/alerts regress below the watermark; buckets freeze). Not fixed here, deliberately. The obvious fix — a ratcheting logical clock max(existing + 1, now) — is actively wrong: updated_at is the server's last-write-wins key, and a ratcheting counter never comes back down, so one clock rewind would permanently bias every future conflict for that device. updated_at therefore stays a truthful wall clock.

The bucket MAX(existing, now) fold is kept: it is bounded and self-correcting (truthful again once the clock passes the old value), and with the server's recommended >= watermark query a frozen stamp is still picked up. The comments on both platforms were reworded — they previously claimed a monotonicity guarantee the code does not deliver. Tracked in #275, where the preferred fix is a separate strictly-monotonic local sequence column, since the Sync Cursor is client-held and never crosses the wire.

Finding 2 — backfill hides pre-upgrade changes. Rejected. It requires a server watermark newer than created_at, which cannot exist: the cursor is client-held, so a client upgrading to schema 28 starts from watermark 0 and sends everything — backfill cannot affect completeness. The server side independently confirmed created_at is the right conservative choice for boards/alerts (a migrating device failing to clobber other devices is the better failure), and called last_sample_at_ms the genuinely truthful analogue for buckets.

Follow-ups

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds native-managed updatedAt sync cursors across Android, iOS, and JavaScript. Database migrations backfill and index cursors, persistence paths stamp mutations, bucket merges remain monotonic, and stores expose separate input types.

Changes

Sync cursor propagation

Layer / File(s) Summary
JS cursor contracts and optimistic state
modules/vescape-core/src/index.ts, src/modules/alerts/..., src/modules/board/..., modules/vescape-core/src/e2eFake.ts
Adds native-owned updatedAt fields, input types without cursors, optimistic local timestamps, seeded cursor values, and updated fixtures.
Android cursor schema and writes
modules/vescape-core/android/src/main/java/.../telemetry/*, modules/vescape-core/android/src/main/java/.../alerts/AlertEngine.kt
Adds Room migration and indexes, stamps native updates, exports cursors through mappings, updates toggles, and preserves monotonic bucket cursors.
iOS cursor schema and writes
modules/vescape-core/ios/telemetry/*, modules/vescape-core/ios/alerts/AlertEngine.swift
Adds GRDB migration and test-writer injection, persists cursors for boards and alerts, and advances bucket cursors monotonically.
Migration and mapping validation
modules/vescape-core/android/src/test/..., modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift, modules/vescape-core/ios/alerts/AlertEngineTests.swift
Validates schema changes, backfills, cursor stamping, targeted toggles, bucket merges, and updated entity fixtures.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JSStore
  participant AppDataRepository
  participant TelemetryDao
  participant TelemetryDatabase
  JSStore->>AppDataRepository: Submit board or alert input
  AppDataRepository->>TelemetryDao: Stamp and persist updatedAt
  TelemetryDao->>TelemetryDatabase: Update row and cursor index
  TelemetryDatabase-->>AppDataRepository: Stored entity
  AppDataRepository-->>JSStore: Return mapped updatedAt
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding updated_at sync cursors for incremental server sync.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sync-cursors-updated-at

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt`:
- Line 681: Update the native board-setting write transaction near the
"updatedAt" mapping so writes to lastBattery and legalMode also advance the
parent boards.updated_at cursor. Ensure the board_settings update and
parent-board timestamp update occur transactionally, preserving the existing
updatedAt value used for the returned Board fields.

In
`@modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt`:
- Around line 388-396: Make all native cursor writes monotonic: in
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt:388-396,
update setAlertRuleEnabled to persist MAX(updated_at, :updatedAt); in
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt:927-937,
have the board replacement flow derive its cursor from the maximum persisted and
current timestamps; and in AppDataRepository.kt:1084-1097, apply the same
maximum-cursor logic to alert replacement before invoking the existing
upsert/replacement operation.

In `@modules/vescape-core/ios/telemetry/AppDataRepository.swift`:
- Around line 117-128: Make board and alert cursor updates monotonic across
device clock rollback by preserving the greater of the current timestamp and
each row’s existing updated_at within the same write transaction. In
modules/vescape-core/ios/telemetry/AppDataRepository.swift lines 117-128, update
the board upsert around updatedAt to retain the existing board cursor; in lines
335-360, apply the same maximum-preserving behavior to both alert full upserts
and targeted enable/disable updates.

In `@src/modules/alerts/store/alertsStore.ts`:
- Around line 15-20: Keep optimistic cursors current for all local mutations: in
src/modules/alerts/store/alertsStore.ts lines 15-20, apply withLocalCursor() to
the replacement values used by update() and setEnabled(); in
src/modules/board/store/boardStore.ts lines 89-97, assign a fresh optimistic
cursor in updateBoard() before updating state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c0f4332f-fb5e-4c1f-a175-933c657bd983

📥 Commits

Reviewing files that changed from the base of the PR and between fe0b1a9 and a166e07.

📒 Files selected for processing (22)
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/alerts/AlertEngine.kt
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryBucketBuilder.kt
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt
  • modules/vescape-core/android/src/test/java/expo/modules/vescapecore/alerts/AlertEngineTest.kt
  • modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/ProfileStatsRepositoryTest.kt
  • modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/SyncCursorMigrationTest.kt
  • modules/vescape-core/ios/alerts/AlertEngine.swift
  • modules/vescape-core/ios/alerts/AlertEngineTests.swift
  • modules/vescape-core/ios/telemetry/AppDataRepository.swift
  • modules/vescape-core/ios/telemetry/SyncCursorMigrationTests.swift
  • modules/vescape-core/ios/telemetry/TelemetryDao.swift
  • modules/vescape-core/ios/telemetry/TelemetryDatabase.swift
  • modules/vescape-core/src/e2eFake.ts
  • modules/vescape-core/src/index.ts
  • src/modules/alerts/lib/customAlertRules.ts
  • src/modules/alerts/store/alertPresetStore.test.ts
  • src/modules/alerts/store/alertsStore.ts
  • src/modules/board/store/boardStore.test.ts
  • src/modules/board/store/boardStore.ts

"alertPresetsOnboarded" to (values["alertPresetsOnboarded"] ?: false),
"legalMode" to (values["legalMode"] ?: mapOf("enabled" to false)),
"link" to link,
"updatedAt" to updatedAt,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Advance the parent board cursor for native board-setting writes.

lastBattery and legalMode are returned as Board fields, but their direct native writes only update board_settings. Without transactionally updating boards.updated_at, an incremental board scan can miss those changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt`
at line 681, Update the native board-setting write transaction near the
"updatedAt" mapping so writes to lastBattery and legalMode also advance the
parent boards.updated_at cursor. Ensure the board_settings update and
parent-board timestamp update occur transactionally, preserving the existing
updatedAt value used for the returned Board fields.

Comment on lines +388 to +396
/**
* 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make all native cursor writes monotonic. Bucket merges already preserve the maximum cursor, but targeted alert updates and replacing board/alert upserts can overwrite a newer persisted timestamp after a device clock rollback.

  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt#L388-L396: update with MAX(updated_at, :updatedAt).
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt#L927-L937: derive the board cursor from the maximum of persisted and current timestamps before replacing the row.
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt#L1084-L1097: derive the alert cursor from the maximum of persisted and current timestamps before replacing the row.
📍 Affects 2 files
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt#L388-L396 (this comment)
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt#L927-L937
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt#L1084-L1097
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt`
around lines 388 - 396, Make all native cursor writes monotonic: in
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt:388-396,
update setAlertRuleEnabled to persist MAX(updated_at, :updatedAt); in
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt:927-937,
have the board replacement flow derive its cursor from the maximum persisted and
current timestamps; and in AppDataRepository.kt:1084-1097, apply the same
maximum-cursor logic to alert replacement before invoking the existing
upsert/replacement operation.

Comment on lines +117 to +128
// 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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep board and alert cursors monotonic across clock rollback.

A later write can set updated_at below its existing value when the device clock moves backward. Incremental sync clients that have already advanced past the old cursor can then miss that mutation. Compute max(nowMs(), existing updated_at) within the same write transaction, or use conflict updates that preserve the maximum.

  • modules/vescape-core/ios/telemetry/AppDataRepository.swift#L117-L128: preserve the existing board cursor when stamping an upsert.
  • modules/vescape-core/ios/telemetry/AppDataRepository.swift#L335-L360: preserve the existing alert cursor for both full upserts and targeted enable/disable updates.
📍 Affects 1 file
  • modules/vescape-core/ios/telemetry/AppDataRepository.swift#L117-L128 (this comment)
  • modules/vescape-core/ios/telemetry/AppDataRepository.swift#L335-L360
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/vescape-core/ios/telemetry/AppDataRepository.swift` around lines 117
- 128, Make board and alert cursor updates monotonic across device clock
rollback by preserving the greater of the current timestamp and each row’s
existing updated_at within the same write transaction. In
modules/vescape-core/ios/telemetry/AppDataRepository.swift lines 117-128, update
the board upsert around updatedAt to retain the existing board cursor; in lines
335-360, apply the same maximum-preserving behavior to both alert full upserts
and targeted enable/disable updates.

Comment on lines +15 to +20
/**
* 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() })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep optimistic cursors current for every local mutation. Creation and explicit upsert stamp local cursors, but normal edit paths preserve stale values until a reload.

  • src/modules/alerts/store/alertsStore.ts#L15-L20: use withLocalCursor() for optimistic update() and setEnabled() replacements.
  • src/modules/board/store/boardStore.ts#L89-L97: stamp a fresh optimistic cursor in updateBoard() before updating state.
📍 Affects 2 files
  • src/modules/alerts/store/alertsStore.ts#L15-L20 (this comment)
  • src/modules/board/store/boardStore.ts#L89-L97
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/alerts/store/alertsStore.ts` around lines 15 - 20, Keep
optimistic cursors current for all local mutations: in
src/modules/alerts/store/alertsStore.ts lines 15-20, apply withLocalCursor() to
the replacement values used by update() and setEnabled(); in
src/modules/board/store/boardStore.ts lines 89-97, assign a fresh optimistic
cursor in updateBoard() before updating state.

@KacperKozak KacperKozak added area:core App shell, storage, lifecycle, infra area:native Touches native side (modules/vesc-ble, Swift/Kotlin) area:db Touches database / persistent storage area:server Vescape backend APIs, relay behavior, server policy, and deployment-facing contracts labels Jul 27, 2026
@KacperKozak

Copy link
Copy Markdown
Collaborator Author

Superseded by #276, which widens this branch to the whole app side of Ride History backup and matches the server PR name (KacperKozak/vescape-server#12). Same commits, new branch feat/ride-history-backup. The clock-rewind follow-up this PR deferred (#275) is implemented there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core App shell, storage, lifecycle, infra area:db Touches database / persistent storage area:native Touches native side (modules/vesc-ble, Swift/Kotlin) area:server Vescape backend APIs, relay behavior, server policy, and deployment-facing contracts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant