TASK-012: Implement Sync Orchestrator#61
Conversation
…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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
cpp/tests/sync/SyncOperationApplierTests.cpp (1)
86-99: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd 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 == localboundary 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 | 🔵 TrivialConsider an index on
sync_queue(entity, id).
readPagenow filters byentityand orders byidon every page fetch, butsync_queue(per the MigrationEngine schema) only has a primary key onid. 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 winExtract shared row→operation conversion to reduce duplication.
readOperationsandreadPagebuild near-identical JSON operation objects, differing only by a one-column offset (readPage addsidat 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
📒 Files selected for processing (30)
android/CMakeLists.txtcpp/database/DatabaseManager.cppcpp/database/MigrationEngine.cppcpp/database/MigrationEngine.hppcpp/http/SyncHttpCaller.cppcpp/http/SyncHttpCaller.hppcpp/sync/SyncCursorStore.cppcpp/sync/SyncCursorStore.hppcpp/sync/SyncDefinitionRegistry.cppcpp/sync/SyncDefinitionRegistry.hppcpp/sync/SyncOperationApplier.cppcpp/sync/SyncOperationApplier.hppcpp/sync/SyncOrchestrator.cppcpp/sync/SyncOrchestrator.hppcpp/sync/SyncQueueReader.cppcpp/sync/SyncQueueReader.hppcpp/tests/CMakeLists.txtcpp/tests/database/HybridSalveDatabaseSyncTests.cppcpp/tests/database/MigrationEngineTests.cppcpp/tests/expression/RequestExpressionEvaluatorTests.cppcpp/tests/http/SyncHttpCallerTests.cppcpp/tests/query/QueryExecutorTests.cppcpp/tests/sync/README.mdcpp/tests/sync/SyncApplyGuardTests.cppcpp/tests/sync/SyncCursorStoreTests.cppcpp/tests/sync/SyncDefinitionRegistryTests.cppcpp/tests/sync/SyncOperationApplierTests.cppcpp/tests/sync/SyncOrchestratorTests.cppcpp/tests/sync/SyncQueueReaderTests.cppdocs/project.md
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>
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>
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:MigrationEngine::parseSchemaJsononly readsync.enabled, discardingendpoint/pagination/request/response. AddedSyncDefinitionRegistryto keep the full contract available to the orchestrator._salve_sync_cursors+SyncCursorStore.updatedAtrequirement:registerSchemanow requires adatetimecolumn namedupdatedAton any sync-enabled schema, used forlastWriteWins.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 existingplatform::httpExecutetransport — no injectableIHttpClient(that design was abandoned on the unmergedfeat/http-clientbranch).SyncOperationApplier: applies server operations withlastWriteWins, always insideSyncApplyGuard'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 atmaxPagesPerSessionand 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 passingSyncDefinitionRegistry,SyncCursorStore,SyncQueueReader.readPage,SyncHttpCaller,SyncOperationApplier)SyncOrchestratorTests.cppmapped 1:1 to the issue's 7 acceptance criteria (full cycle, multi-page,maxPagesPerSessioncutoff + resume, network retry exhaustion, 401 refresh, no queue re-entry, mid-apply rollback)HybridSalveDatabaseSyncTests.cpp— same flow end-to-end through the real JSI bridgeupdatedAtrequirement affected them🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
updatedAtdatetime column.Bug Fixes
Documentation
updatedAtfield and synchronization guidance.