Skip to content

[Sync] 4 - Log Sync Actions #282

Description

@KacperKozak

Parent

What to build

A local append-only log of semantic removals that no table row can express on its own, so the server applies the same durable state transition. Most begin with a Rider intent; some are automatic, such as clearing a Board Warning after a clean detector evaluation.

A row that has been deleted cannot carry a Change Timestamp saying so. Sync Actions are that missing signal. The log is typed — delete is the only type today — and named for what it is so a later intent does not need a second mechanism. The server side is already built in KacperKozak/vescape-server#12; this is the phone's half.

Two shapes, and the difference matters:

  • Tombstoned entities. A Board syncs its deleted_at as an ordinary upsert and emits a Sync Action. The upsert stays a dumb upsert; the cascade over the Board's configuration stays an explicit, replay-safe action. An upsert that quietly deletes rows in four other tables is exactly the kind of hidden side effect that bites later.
  • Removed entities. Alert Rules, Tune Profiles, Privacy Zones, Favorites, app settings, board settings and board warnings leave no row behind, so the action is the only signal there is. Deleting a Favorite emits one Favorite action; its favorite_media manifest rows are a raw parent-covered cascade, matching the future server cascade.

The boundary is semantic removal, not a particular public function. If a future restore must preserve a row's absence, that removal emits a Sync Action. This covers explicit deletes, reset-to-default storage, replacement flows that remove old rows, and automatic current-state clears.

Maintenance has explicit no-action paths: cursor-gated retention, schema migrations, parent-covered cascades, and the confirmed Account reset. A DeleteTarget enum has cases only for configuration/current-state kinds, so retention cannot name a telemetry frame or minute bucket. No database triggers — intent cannot be inferred from SQL alone.

Action insertion and the corresponding delete or tombstone happen in one local database transaction. The log is transport state rather than durable truth. Its cursor is its own AUTOINCREMENT key; after upload the accepted action cursor commits first and pruning happens afterwards. Any crash therefore fails toward re-sending.

Likely files

  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt - the new entity and the target enum
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt - transactional semantic-delete APIs, raw cascade/maintenance deletes, replacement paths and silent retention
  • modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt - Room version and migration
  • modules/vescape-core/ios/telemetry/TelemetryDatabase.swift, TelemetryDao.swift, AppDataRepository.swift, TuneProfileStore.swift and warnings/BoardWarningStore.swift - the GRDB peers and their distributed delete paths
  • modules/vescape-core/src/index.ts - the TS mirror of the target enum, which needs @parity lines to both platforms
  • docs/adr/0027-boards-are-tombstoned-never-deleted.md - why a Board delete is both a column and an action

Implementation hints

Start with an inventory of every DELETE touching a syncable table on each platform and classify it explicitly:

  • Semantic: deleteAlertRule, deleteTuneProfileSafe, deletePrivacyZone, deleteAppSetting, deleteBoardSetting, deleteBoardWarning; the deleted-key side of Board edits; reset-to-default and legalPolicy = null; Board Warning clean-evaluation; preset-rule regeneration. Each removed row gets an action.
  • Parent-covered cascade: Board configuration removed by deleteBoardWithSettings, and Tune History removed with a Tune Profile. Raw deletes, covered by one parent action.
  • Maintenance: cursor-gated retention, migrations and confirmed Account reset. Raw deletes, no actions.

Corrupt-setting cleanup must also be classified rather than inheriting behavior accidentally. Default: semantic deletion, so restore cannot resurrect the corrupt stored value; changing that requires an explicit documented exception.

Keep raw delete primitives private to the transaction-owning repository/store. Expose intent-specific operations rather than letting callers choose whether to append an action:

deleteForSync(target, identity)   -> read + action + delete in one transaction
replaceForSync(...)               -> actions for removed rows + replacement in one transaction
deleteCoveredByParent(...)        -> raw delete inside parent transaction
deleteForMaintenance(...)         -> raw delete, no action

A deletion is stamped from the row being removed as max(now, row.updated_at); Board Warnings use last_detected_at. A plain now on a rewound clock produces an action the server treats as a no-op and cannot self-heal because its row is gone. A Board tombstone uses the newly ratcheted Board timestamp for both the row and action.

The server applies actions after every upsert in a batch, so a batch that deletes and re-creates the same identifier has already written the newer row. The action still has its own cursor position and travels in dependency order.

The target enum is a TS-to-native mirror, so it carries two @parity lines per AGENTS.md, and the native nodes carry back-pointers to it.

Acceptance criteria

  • A local Sync Action log exists on both platforms, append-only, keyed by AUTOINCREMENT
  • Every semantic removal appends its action and deletes/tombstones the row in the same transaction
  • Explicit deletes of Alert Rules, Tune Profiles, Privacy Zones, Favorites, App Settings, Board Settings and Board Warnings emit one action per removed row
  • Deleting a Favorite emits one Favorite action; its Favorite Media manifest rows emit none because the parent action covers them
  • Board Setting deleted keys, reset-to-default App Settings, legalPolicy = null, preset-rule regeneration and automatic Board Warning clears emit actions
  • Deleting a Board emits one Board action, tombstones the Board and raw-deletes its configuration atomically; child deletes emit no actions
  • Deleting a Tune Profile emits one profile action; its Tune History cascade emits none
  • Cursor-gated retention, migrations and confirmed Account reset write no actions
  • Every SQL delete against a syncable table is covered by a classification test: semantic, parent cascade, retention, migration or Account reset
  • A test fails if a retained table is ever given a DeleteTarget, mirroring the server's structural test
  • No database trigger writes to the log
  • Deletions use max(now, row.updated_at); Board Warnings use last_detected_at; Board tombstone row and action share the new ratcheted timestamp
  • Raw delete primitives are private to transaction-owning repositories/stores
  • The target enum is mirrored in TS with @parity lines to both platforms
  • Accepted action cursor commits before pruning; crashes can leave a resend but cannot lose an uncheckpointed action
  • Migration is additive and no-op on re-run, tested on both platforms

Blocked by

Related

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:dbTouches database / persistent storagearea:nativeTouches native side (modules/vesc-ble, Swift/Kotlin)area:syncBackup sync — native uploader, Sync Cursors, Sync Actions, Device Tokencomplexity:mediumNeeds care, moderate integration surface. Use sonnet.ready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions