Skip to content

TASK-012: Implement Sync Orchestrator#61

Merged
Gabriel-Pereira1788 merged 8 commits into
developfrom
feat/sync-orchestrator
Jul 13, 2026
Merged

TASK-012: Implement Sync Orchestrator#61
Gabriel-Pereira1788 merged 8 commits into
developfrom
feat/sync-orchestrator

Conversation

@Gabriel-Pereira1788

@Gabriel-Pereira1788 Gabriel-Pereira1788 commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

Implements SyncOrchestrator::triggerSync (previously a stub) end-to-end, plus two foundational gaps discovered while analyzing the issue that neither the issue nor the docs covered:

  • Sync contract capture: MigrationEngine::parseSchemaJson only read sync.enabled, discarding endpoint/pagination/request/response. Added SyncDefinitionRegistry to keep the full contract available to the orchestrator.
  • Cursor persistence: no table stored a sync cursor across sessions. Added _salve_sync_cursors + SyncCursorStore.
  • updatedAt requirement: registerSchema now requires a datetime column named updatedAt on any sync-enabled schema, used for lastWriteWins.
  • SyncQueueReader.readPage: filters by entity and returns the row id needed to safely clear only what was sent.
  • SyncHttpCaller: builds the arbitrary sync-page HTTP request (method/path/headers/auth) on top of the existing platform::httpExecute transport — no injectable IHttpClient (that design was abandoned on the unmerged feat/http-client branch).
  • SyncOperationApplier: applies server operations with lastWriteWins, always inside SyncApplyGuard's bypass transaction.
  • SyncOrchestrator::triggerSync: the real pagination loop — fixed retry (3x/5s, test-only seam for the delay), transparent 401→refresh→retry, atomic apply+cursor+queue-cleanup per page, stops at maxPagesPerSession and resumes from the persisted cursor.

A full write-up of the analysis (status inconsistencies, design gaps, architecture proposal) was posted as a comment on the issue before implementation started.

Closes #13

Test plan

  • npm run test:native — 315 assertions / 123 test cases passing
  • New unit coverage for every new collaborator (SyncDefinitionRegistry, SyncCursorStore, SyncQueueReader.readPage, SyncHttpCaller, SyncOperationApplier)
  • SyncOrchestratorTests.cpp mapped 1:1 to the issue's 7 acceptance criteria (full cycle, multi-page, maxPagesPerSession cutoff + resume, network retry exhaustion, 401 refresh, no queue re-entry, mid-apply rollback)
  • HybridSalveDatabaseSyncTests.cpp — same flow end-to-end through the real JSI bridge
  • Pre-existing tests updated where the new updatedAt requirement affected them

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added end-to-end data synchronization with paginated requests, retries, credential refresh, cursor persistence, and conflict resolution.
    • Sync operations can now insert, update, and delete local records while reporting results.
    • Added support for custom request headers and JSON-based sync endpoints.
    • Sync-enabled schemas now require an updatedAt datetime column.
  • Bug Fixes

    • Prevented stale sync configuration from persisting across database reopenings.
  • Documentation

    • Updated schema documentation with the required updatedAt field and synchronization guidance.

Gabriel-Pereira1788 and others added 5 commits July 13, 2026 11:05
…rsor

MigrationEngine::parseSchemaJson discarded everything in `sync` except
`enabled`, and nothing persisted a sync cursor across sessions — two
foundational gaps blocking the Sync Orchestrator (issue #13).

- SyncDefinitionRegistry: process-wide registry of each schema's full
  sync contract (endpoint/pagination/request/response), populated by
  registerSchema.
- registerSchema now requires a `datetime` column named `updatedAt`
  when sync.enabled, used later for lastWriteWins.
- New `_salve_sync_cursors` table + SyncCursorStore for per-entity
  cursor persistence surviving a restart.
- Updates pre-existing sync-enabled test schemas to add the now-
  required `updatedAt` column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
readOperations read the whole sync_queue with no per-schema filter
and no row id, making it impossible to safely clear only the rows a
sync session actually sent. readPage adds both, keyed by entity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CredentialHttpCaller only builds the fixed POST+JSON refresh shape.
The Sync Orchestrator needs an arbitrary method/path/headers request
per schema's `endpoint` definition, built on the same platform::
httpExecute transport (no injectable IHttpClient — that design was
abandoned on the unmerged feat/http-client branch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Applies server-sent operations (insert/update/delete) to SQLite,
comparing the schema's required updatedAt column against the
incoming value so a stale write never overwrites a newer local one.
Meant to run inside SyncApplyGuard's bypass transaction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real pagination loop replacing the stub: reads sync_queue per entity,
sends each page via SyncHttpCaller with fixed retry (3x/5s) and
transparent 401-refresh-and-retry, applies the response's operations
and advances the cursor atomically inside SyncApplyGuard's bypass
transaction, and clears exactly the queue rows it sent. Stops at
maxPagesPerSession, resuming from the persisted cursor on the next
call. Adds a test-only retry-delay seam so the retry-exhaustion test
doesn't sleep for real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 30102554-ca04-4f0a-8609-579db5da7c08

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sync-orchestrator

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: 5

🧹 Nitpick comments (3)
cpp/tests/sync/SyncOperationApplierTests.cpp (1)

86-99: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a tie-case test for the delete boundary.

Test name says "not older than local," but the data (remote=150, local=100) only exercises the strictly-newer case, not the remote == local boundary that the wording implies.

+TEST_CASE("apply deletes a row when the remote updatedAt equals local (tie goes to remote)", "[sync][SyncOperationApplier]") {
+  auto conn = openWithCustomers("applier_delete_tie");
+  conn->execute("INSERT INTO customers (id, name, updatedAt) VALUES ('1', 'a', 150)", {});
+  SyncOperationApplier applier(conn);
+
+  auto ops = json::parse(R"([
+    { "operation": "delete", "entity": "customers", "primaryKey": "1",
+      "payload": { "id": "1" }, "updatedAt": 150 }
+  ])").asArray();
+
+  auto stats = applier.apply(ops);
+  REQUIRE(stats.deleted == 1);
+}
🤖 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 `@cpp/tests/sync/SyncOperationApplierTests.cpp` around lines 86 - 99, Add a
tie-case test alongside the existing SyncOperationApplier delete test, using
equal local and remote updatedAt values while retaining the delete operation and
asserting stats.deleted is 1 and the row is absent. Keep the existing
strictly-newer test unchanged.
cpp/sync/SyncQueueReader.cpp (2)

41-45: 🚀 Performance & Scalability | 🔵 Trivial

Consider an index on sync_queue(entity, id).

readPage now filters by entity and orders by id on every page fetch, but sync_queue (per the MigrationEngine schema) only has a primary key on id. As the shared queue table accumulates rows across multiple sync-enabled schemas, this query becomes a full-table scan per page.

🤖 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 `@cpp/sync/SyncQueueReader.cpp` around lines 41 - 45, Update the
MigrationEngine schema definition for sync_queue to add a composite index on
(entity, id), supporting the entity filter and ascending id ordering used by
SyncQueueReader::readPage. Keep the existing primary key and queue behavior
unchanged.

9-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared row→operation conversion to reduce duplication.

readOperations and readPage build near-identical JSON operation objects, differing only by a one-column offset (readPage adds id at index 0). This is copy/paste that's already drifted; a future column change is easy to apply to one method and miss the other.

Consider a shared private helper that converts a row (given a column offset) into the json::Object, called from both methods.

Also applies to: 36-64

🤖 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 `@cpp/sync/SyncQueueReader.cpp` around lines 9 - 34, Extract the duplicated
row-to-operation mapping from SyncQueueReader::readOperations and readPage into
a shared private helper that accepts the row and column offset. Have both
methods call this helper, using offset 0 for readOperations and the offset
needed for readPage’s leading id column, while preserving the existing JSON
field names and value conversions.
🤖 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 `@cpp/database/MigrationEngine.cpp`:
- Around line 391-404: The sync-enabled schema validation in the root sync
parsing block must also reject nullable updatedAt columns. Update the
schema.columns lookup condition alongside the existing datetime type check to
require the updatedAt column’s not-null constraint, preserving the current error
path for invalid sync schemas.

In `@cpp/http/SyncHttpCaller.cpp`:
- Around line 51-66: Update the response handling in SyncHttpCaller so invalid
or empty JSON does not throw for non-success HTTP responses; preserve and return
the original response.statusCode with an appropriate placeholder payload,
allowing SyncOrchestrator’s 401 refresh and non-2xx retry/error branches to
execute. Keep normal JSON parsing for successful responses and retain
network-error handling.

In `@cpp/sync/SyncOperationApplier.cpp`:
- Around line 62-71: Align the delete branch in SyncOperationApplier with the
upsert lastWriteWins rule by changing its eligibility check so deletes are
skipped when remoteUpdatedAt is equal to or older than localUpdatedAt. Preserve
deletion for newer remote timestamps and the existing handling when
localUpdatedAt is absent.
- Around line 12-21: Restrict sync SQL construction to the registered schema
contract: validate each operation’s entity, pkCol, and payload columns against
the registered schema, and escape embedded quotes before using them as SQL
identifiers. Apply this validation before building statements that use
primaryKeyColumn and the operation-selected identifiers. Make lastWriteWins use
the same tie-breaking rule for delete, insert, and update, including equal
updatedAt values.

In `@cpp/sync/SyncOrchestrator.cpp`:
- Around line 55-91: Decouple the retry counter in sendPageWithRetryAnd401 from
the 401 refresh cycle so refreshing credentials and reissuing the same page does
not consume a genuine network-send attempt. Preserve the single-refresh behavior
and ensure a post-refresh network failure still receives the full kMaxAttempts
budget; add coverage for the combined 401-then-network-failure sequence if the
surrounding tests support it.

---

Nitpick comments:
In `@cpp/sync/SyncQueueReader.cpp`:
- Around line 41-45: Update the MigrationEngine schema definition for sync_queue
to add a composite index on (entity, id), supporting the entity filter and
ascending id ordering used by SyncQueueReader::readPage. Keep the existing
primary key and queue behavior unchanged.
- Around line 9-34: Extract the duplicated row-to-operation mapping from
SyncQueueReader::readOperations and readPage into a shared private helper that
accepts the row and column offset. Have both methods call this helper, using
offset 0 for readOperations and the offset needed for readPage’s leading id
column, while preserving the existing JSON field names and value conversions.

In `@cpp/tests/sync/SyncOperationApplierTests.cpp`:
- Around line 86-99: Add a tie-case test alongside the existing
SyncOperationApplier delete test, using equal local and remote updatedAt values
while retaining the delete operation and asserting stats.deleted is 1 and the
row is absent. Keep the existing strictly-newer test unchanged.
🪄 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: da676c21-0849-41a5-bf2d-defc275c93f6

📥 Commits

Reviewing files that changed from the base of the PR and between 339dce8 and 988e131.

📒 Files selected for processing (30)
  • android/CMakeLists.txt
  • cpp/database/DatabaseManager.cpp
  • cpp/database/MigrationEngine.cpp
  • cpp/database/MigrationEngine.hpp
  • cpp/http/SyncHttpCaller.cpp
  • cpp/http/SyncHttpCaller.hpp
  • cpp/sync/SyncCursorStore.cpp
  • cpp/sync/SyncCursorStore.hpp
  • cpp/sync/SyncDefinitionRegistry.cpp
  • cpp/sync/SyncDefinitionRegistry.hpp
  • cpp/sync/SyncOperationApplier.cpp
  • cpp/sync/SyncOperationApplier.hpp
  • cpp/sync/SyncOrchestrator.cpp
  • cpp/sync/SyncOrchestrator.hpp
  • cpp/sync/SyncQueueReader.cpp
  • cpp/sync/SyncQueueReader.hpp
  • cpp/tests/CMakeLists.txt
  • cpp/tests/database/HybridSalveDatabaseSyncTests.cpp
  • cpp/tests/database/MigrationEngineTests.cpp
  • cpp/tests/expression/RequestExpressionEvaluatorTests.cpp
  • cpp/tests/http/SyncHttpCallerTests.cpp
  • cpp/tests/query/QueryExecutorTests.cpp
  • cpp/tests/sync/README.md
  • cpp/tests/sync/SyncApplyGuardTests.cpp
  • cpp/tests/sync/SyncCursorStoreTests.cpp
  • cpp/tests/sync/SyncDefinitionRegistryTests.cpp
  • cpp/tests/sync/SyncOperationApplierTests.cpp
  • cpp/tests/sync/SyncOrchestratorTests.cpp
  • cpp/tests/sync/SyncQueueReaderTests.cpp
  • docs/project.md

Comment thread cpp/database/MigrationEngine.cpp
Comment thread cpp/http/SyncHttpCaller.cpp
Comment thread cpp/sync/SyncOperationApplier.cpp Outdated
Comment thread cpp/sync/SyncOperationApplier.cpp
Comment thread cpp/sync/SyncOrchestrator.cpp
Gabriel-Pereira1788 and others added 2 commits July 13, 2026 14:43
SyncDefinitionRegistry only lived in memory, populated solely by JS
calling Database.register() — meaning a native process woken up
without JS ever running (the whole point of the Background Scheduler,
TASK-011) would find it empty and triggerSync would throw. Replaces
it with SyncDefinitionStore, backed by a new _salve_sync_definitions
table, mirroring SyncCursorStore's pattern.

- registerSchema now writes/removes the contract inside the same
  transaction as the rest of the migration, instead of after commit()
  — a failed migration can no longer leave behind a stale contract.
- save() is INSERT OR REPLACE keyed by schema name: re-registering
  fully replaces the prior contract, it never merges fields. Covered
  by a dedicated test (endpoint path changes, an old pagination field
  does not survive).
- DatabaseManager::open() no longer needs to clear this registration
  — the state now lives in the db file itself, not the process.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- SyncOperationApplier: reject any operation whose entity doesn't
  match the schema being synced, and any payload column not present
  on the real table — closes a path where a buggy/compromised
  response could write to an unrelated (or internal) table via
  unvalidated SQL identifiers.
- SyncOperationApplier: delete now uses the same lastWriteWins
  tie-break as insert/update (local wins on equal updatedAt) —
  was previously inconsistent (remote won ties on delete only).
- SyncOrchestrator: decouple the network-retry counter from the
  401-refresh continue. CodeRabbit's own suggested diff for this
  had an off-by-one (would cut a plain 3-retry run down to 2 sends);
  this uses a separate counter starting at 0 so exactly kMaxAttempts
  genuine network attempts happen regardless of a 401 refresh along
  the way. Added a test combining 401-then-network-failure, which
  the existing suite didn't cover.
- SyncHttpCaller: only require valid JSON for 2xx responses — a
  non-2xx response with an empty/HTML/plain-text body (common on
  real backends) now returns the real statusCode with a null body
  instead of throwing before SyncOrchestrator can see it.
- MigrationEngine: sync.enabled now also requires updatedAt to be
  NOT NULL, not just datetime-typed — a nullable updatedAt could
  reach SyncOperationApplier's std::get<double> and crash instead
  of failing at schema-registration time.
- Added an index on sync_queue(entity, id) — readPage's per-page
  filter+order was an unindexed scan.
- SyncQueueReader: de-duplicated the row-to-operation conversion
  shared by readOperations and readPage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread cpp/sync/SyncOrchestrator.hpp Outdated
sync_test::setRetryDelay lived in SyncOrchestrator.hpp/.cpp, which are
compiled into every build (Android/iOS/tests) — unlike platform::test's
seam, there's no per-platform swap point for this file, so the hook
shipped as a real, callable function in the production binary.

Flagged in PR #61 review. Removes the override entirely; the two
tests exercising real retries (retry-exhaustion, 401-then-network-
failure) now sleep for real (~10s each) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Gabriel-Pereira1788 Gabriel-Pereira1788 merged commit fae3859 into develop Jul 13, 2026
3 checks passed
@Gabriel-Pereira1788 Gabriel-Pereira1788 deleted the feat/sync-orchestrator branch July 13, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants