The app-side entity synchronization system is a foundational component of the local-first architecture. It enables users to interact with their data instantly, even offline, with changes durably stored in SQLite. This document explains how local mutations are propagated to the backend, how server changes are applied, how data consistency is maintained through conflict resolution, and how relational integrity is validated after sync operations. It is designed as a first-class platform concern, showcasing a sophisticated, robust, and observable synchronization architecture.
Scope: This document focuses on app-side entity synchronization (consumptions, sessions, products, journals, devices) between local SQLite and the backend API. It does not cover health data synchronization (
HealthSyncService, see health-ingestion.md), backend sync internals, or UI/presentation logic. For broader context, see architecture.md and data-flow.md. Failure scenarios are elaborated in failure-modes.md.
- Instant Local Responsiveness — UI updates immediately from local SQLite, providing an uninterrupted user experience.
- Durable Local Mutations — All offline changes are durably stored in a transactional outbox, protected against app crashes and network outages.
- Eventual Consistency — Local data reliably converges with the server's authoritative state over time.
- Deterministic Conflict Handling — Divergent changes are reconciled predictably through a configurable, entity-specific merge strategy.
- Forward-Only Sync Progress — Cursors advance monotonically, ensuring no data is accidentally re-processed or skipped.
- Post-Sync Relational Integrity — Local foreign key relationships are validated after server changes are applied, preventing data corruption.
- Bounded Retries & Recoverability — Sync operations are resilient to transient failures with exponential backoff and explicit recovery mechanisms.
The synchronization architecture follows a layered, composable design, orchestrated by the DataSyncService facade:
- UI/Domain Services — The application's frontend and business logic, initiating local data mutations.
DataSyncService— The primary facade, orchestrating sync and invalidating theReact Query Cache.- Coordination Layer — Shared components (
SyncScheduler,SyncLeaseManager,SyncCoordinationState) managing tasks, resource access, and global sync state across multiple potential instances. - Engine Stack — The core logic units:
SyncCoordinatororchestratesPushEngine,PullEngine, andApplyEngine. - Local Persistence — Dedicated SQLite repositories (
OutboxRepository,CursorRepository,IdMapRepository,TombstoneRepository,Local Entity Repositories) for offline data storage and sync metadata. - External Boundaries — Interaction with the
Backend APIvia theBackend API Client, guided byShared Sync Config / Relation Graph, and validated byIntegrityGate.
Detailed Component Interaction (Mermaid)
graph TD
subgraph ClientApp ["Client Application"]
UI["UI / Domain Services"]
DSS("DataSyncService")
RQ["React Query Cache"]
Mon["Monitoring Service"]
end
subgraph CoordLayer ["Coordination Layer"]
Sched["SyncScheduler (Shared)"]
Lease["SyncLeaseManager (Shared)"]
State["SyncCoordinationState (Shared)"]
end
subgraph EngineStack ["Engine Stack"]
SC("SyncCoordinator")
PushE["PushEngine"]
PullE["PullEngine"]
ApplyE["ApplyEngine"]
IG["IntegrityGate"]
end
subgraph LocalPersist ["Local Persistence"]
Outbox["OutboxRepository"]
Cursor["CursorRepository"]
IdMap["IdMapRepository"]
Tombstone["TombstoneRepository"]
LocalRepos["Local Entity Repositories"]
end
subgraph ExtBoundaries ["External Boundaries"]
APIClient["Backend API Client"]
API["Backend API"]
SharedCfg[/"Shared Sync Config / Relation Graph"\]
end
%% Wiring inside Client App
UI -->|Local Mutations| DSS
DSS -->|Invalidation| RQ
DSS -->|Telemetry & Status| Mon
%% Wiring to Coordination
DSS -->|Schedule Tasks| Sched
Sched -->|Acquire Lease| Lease
Sched -->|Track State| State
%% Wiring to Engines
DSS --> SC
SC -->|Orchestrates| PushE
SC -->|Orchestrates| PullE
SC -->|Orchestrates| ApplyE
SC --- IG
%% Persistence flows
PushE --> Outbox
PullE --> Cursor
ApplyE --> IdMap
ApplyE --> Tombstone
ApplyE --> LocalRepos
Outbox -->|Commands| PushE
Cursor -->|Cursor State| PullE
IdMap -->|Mappings| PushE
IdMap -->|Mappings| ApplyE
Tombstone -->|Deletions| PushE
LocalRepos -->|Data Mutate/Read| ApplyE
%% External boundaries
PushE <--> APIClient
PullE <--> APIClient
APIClient <--> API
IG -.-> SharedCfg
ApplyE -.-> SharedCfg
The sync subsystem is built upon a set of well-defined components, each with a clear responsibility.
These repositories (code-snippets/repositories/offline/index.ts) manage the local SQLite database for transactional outbox patterns, cursor-based synchronization, and deletion tracking.
OutboxRepository— Manages theoutbox_eventstable. This is the queue for all local mutations (CREATE, UPDATE, DELETE). It handles in-memory deduplication of commands for the same entity and manages retry states. Outbox event IDs are UUIDs.CursorRepository— Manages thecursor_statetable. It tracks the last successfully processed record for eachEntityTypeusing alastCreatedAttimestamp andlastIdUUID. Crucial for incremental pull operations and enforcing monotonic cursor advancement.IdMapRepository— Manages theid_map(orsync_metadata) table. Stores the essential mapping between client-generated UUIDs (clientId) and server-assigned UUIDs (serverId), vital for resolving foreign keys and managing ID replacements.TombstoneRepository— Manages thetombstonestable. Records soft-deleted entities that need to be propagated asDELETEoperations to the backend, ensuring eventual consistency for removals.
Located at code-snippets/services/sync/DataSyncService.ts, this class is the primary entry point for the sync subsystem. It orchestrates the high-level sync flow, manages global sync state, integrates with AuthTokens (via BackendAPIClient) and DeviceIdManager, and delegates the core sync logic to the SyncCoordinator engine stack. It also includes robust mechanisms for handling network connectivity, app lifecycle changes, and triggering syncs based on local data mutations.
SyncScheduler(code-snippets/services/sync/SyncScheduler.ts) — A shared, global scheduler that prioritizes and queues various sync-related tasks (data_sync,health_ingest,catalog_sync). It manages concurrency limits for resources like network and SQLite access, integrating withCooperativeYieldControllerfor main thread responsiveness.SyncLeaseManager(code-snippets/services/sync/SyncLeaseManager.ts) — Responsible for acquiring and managingSyncLeases from the backend (/sync/leaseendpoint). Leases provide admission control for bulk operations (e.g., large catalog syncs, health data uploads), preventing server overload and ensuring fair resource usage.
The core sync logic is implemented as a composable engine stack (code-snippets/services/sync/engines/index.ts), orchestrated by the SyncCoordinator. This architecture (defined in code-snippets/services/sync/engines/compositionRoot.ts) promotes modularity and testability.
SyncCoordinator(code-snippets/services/sync/engines/SyncCoordinator.ts) — The main orchestrator of the entire sync lifecycle. Acquires global locks, initializes theSyncBatchContext, sequentially executes thePushEngine,PullEngine, andIntegrityGatephases, and commits deferred cursors upon successful completion.PushEngine(code-snippets/services/sync/engines/PushEngine.ts) — Responsible for the push phase. Dequeues local changes fromOutboxRepositoryandTombstoneRepository, resolves foreign keys, orders commands, constructsPushCommandsRequestpayloads, sends them to the backend API, and processes thePushCommandsResponse.PullEngine(code-snippets/services/sync/engines/PullEngine.ts) — Manages the pull phase. Builds aCompositeCursorfrom individual entity cursors, fetches changes from the backend's/sync/changesendpoint, and delegates the application of these changes to theApplyEngine.ApplyEngine(code-snippets/services/sync/engines/ApplyEngine.ts) — Executes the apply phase. ReceivesPullChangeItems (CREATE, UPDATE, DELETE) and applies them to the local SQLite database. LeveragesFrontendSyncHandlerRegistryfor entity-specific logic or falls back to generic SQL builders.
FrontendSyncHandlerRegistry(code-snippets/services/sync/handlers/FrontendSyncHandlerRegistry.ts) — A central registry that maps eachEntityTypeto its correspondingFrontendSyncEntityHandlerimplementation, enabling the engine stack to dynamically select the correct handler for each entity.GenericSyncHandler(code-snippets/services/sync/handlers/GenericSyncHandler.ts) — A highly configurable handler implementing theFrontendSyncEntityHandlerinterface. Uses shared conflict configurations (conflict-configs.ts) and pure custom merge functions (fromcode-snippets/services/sync/utils/custom-merges/) to manage local entity persistence, conflict resolution, and ID replacement cascades (CascadeExecutor). Used for most entity types, with specific merge logic delegated to pure functions.- Shared Sync Config / Relation Graph — Canonical definitions (
packages/shared/src/sync-config/) specifying entity relationships (RELATION_GRAPH.ts), conflict resolution policies (ENTITY_CONFLICT_CONFIG.ts), and cursor semantics (cursor.ts). These shared contracts ensure consistent behavior across frontend and backend.
The sync subsystem relies on dedicated local metadata tables in SQLite (code-snippets/db/schema.ts) to manage its state and guarantee correctness. This metadata is distinct from the primary domain data.
| Metadata Artifact | Purpose | Key Fields | Updated By | Consumed By | Failure Role |
|---|---|---|---|---|---|
| Outbox Events | Queue local mutations for push to backend | id (UUID), aggregateId, payload, status |
UI/Domain Services (atomic tx) | PushEngine | Retry / Dead Letter |
| Cursor State | Track progress for incremental pull | entityType, lastCreatedAt, lastId |
PullEngine (deferred commit) | PullEngine, Backend API (/sync/changes) |
Monotonicity Failure |
| ID Map | Map clientId to serverId for FK resolution |
clientId, serverId |
PushEngine | PushEngine (FK resolution), ApplyEngine | FK Resolution Error |
| Tombstones | Queue soft-deleted entities for DELETE propagation |
entityId, deletedAt, syncStatus |
Local Repositories (soft-delete) | PushEngine | Deletion Confirmation |
SyncBatchContext |
In-memory, per-sync-run context for integrity checking | batchId, touchedSourceIds, deferredCursors |
Push/Pull/Apply Engines | SyncCoordinator, IntegrityGate | Scoping / Coordination |
The push path guarantees that local mutations made while offline or online are durably committed locally and eventually synchronized to the backend.
-
Local Mutation & Outbox Enqueue — When a user creates, updates, or deletes an entity, the UI/domain service calls the local entity repository. This operation is performed atomically within a single SQLite transaction that both updates the local domain table and enqueues a corresponding command (CREATE, UPDATE, or DELETE) into the
OutboxRepository(code-snippets/repositories/offline/OutboxRepository.ts). This ensures that a local change is never committed without a corresponding outbox entry. -
Command Dequeue & Deduplication — The
PushEngine(code-snippets/services/sync/engines/PushEngine.ts) periodically (or on demand) dequeues a batch of actionable commands from theOutboxRepository. This includes commands with aPENDINGstatus (new mutations) and retryableFAILEDcommands (previous transient failures). The dequeue process performs in-memory deduplication:- Multiple
UPDATEs for the same entity are resolved to the latest version. - A
CREATEfollowed by aDELETEfor an entity not yet synced is cancelled (net no-op).
Critical: Superseded or cancelled commands are immediately marked
COMPLETEDin theOutboxRepositoryto prevent infinite loops and stale replays. - Multiple
-
FK Resolution & Command Ordering:
- Foreign Key Resolution —
PushEngine'sresolveForeignKeysfunction (which leverages purePushEngineCorefunctions andIdMapRepository) resolves client-generated UUIDs within command payloads (e.g.,consumption.productId) to server-assigned UUIDs. If an FK cannot be resolved (e.g., the target entity is also client-generated and not yet synced), it may be nullified (if optional) or kept (if mandatory, leading to backend rejection). - Dependency-Based Ordering — Commands are sorted to respect foreign key dependencies:
CREATEoperations are prioritized overUPDATEs andDELETEs. WithinCREATEs, parent entities (e.g.,products,devices) are ordered before their children (e.g.,consumptions,sessions) using theENTITY_SYNC_ORDERfrom@shared/contracts. This prevents FK constraint violations at the backend.
- Foreign Key Resolution —
-
Push Request Construction —
PushEngineconstructs aPushCommandsRequestpayload. This includes thedeviceId(fromDeviceIdManager) and a deterministicsyncOperationId(a SHA-256 hash of the outbox event IDs in the batch) for backend idempotency. -
Backend Submission — The
BackendAPIClientsends thePushCommandsRequestpayload to the backend's/sync/pushendpoint. -
Response Processing & Local Updates — Upon receiving the
PushCommandsResponsefrom the backend,PushEngineprocesses the results:- Successful Items — For each successfully pushed item,
IdMapRepositorystores theclientId->serverIdmapping.GenericSyncHandler.handleIdReplacement(for Model A entities) performs transactional FK cascades in the local SQLite database, replacing the client-generated primary ID with the newserverIdand updating all references. The original outbox command is then markedCOMPLETED. - Failed Items — Commands that failed at the backend are marked
FAILED(if retryable) orDEAD_LETTER(if non-retryable) in theOutboxRepository, entering their respective retry/quarantine lifecycles. - Conflicts — Handled by
GenericSyncHandler.handleConflictV2, which returns an explicitConflictResolutionOutcome(e.g.,ADOPT_SERVER,REBASE_AND_RETRY,SKIPPED,MANUAL_REQUIRED) that dictates subsequent outbox actions.
- Successful Items — For each successfully pushed item,
-
Tombstone Propagation — In parallel or as a subsequent step,
PushEnginealso pushes soft-deleted entities tracked inTombstoneRepositoryto the backend asDELETEcommands, marking themSYNCEDorFAILEDbased on the response. -
Cache Invalidation — After successful pushes,
DataSyncServicetriggersreact-querycache invalidation for relevant entity types, ensuring the UI reflects the latest state.
Guarantee: A local change is never committed without a corresponding outbox entry. No mutation is silently lost.
Detailed Push Sequence (Mermaid)
sequenceDiagram
participant UI as UI/Domain Service
participant LER as Local Entity Repo
participant OR as Outbox Repo
participant PE as PushEngine
participant IMR as IdMap Repo
participant BAC as Backend API Client
participant BA as Backend API
UI->>LER: Mutate Data (e.g., Create Consumption)
LER->>OR: Atomic Tx: Store Local Data + Enqueue Command (PENDING)
Note over PE,OR: SyncScheduler triggers PushEngine.push()
PE->>OR: dequeueDeduplicatedByEntity(limit, userId)
OR-->>PE: Commands (PENDING/FAILED) + Superseded/Cancelled IDs
PE->>OR: markCompletedByIds(Superseded/Cancelled IDs)
loop For each Command
PE->>IMR: getServerId(FKClientId)
IMR-->>PE: ServerId (or null)
PE->>PE: Resolve FKs in Payload
end
PE->>PE: Order Commands (CREATE, UPDATE, DELETE)
PE->>BA: POST /sync/push (PushCommandsRequest)
BA-->>BAC: PushCommandsResponse (Successful, Failed, Conflicts)
BAC-->>PE: PushCommandsResponse
loop For each Successful Item
PE->>IMR: saveMapping(clientId, serverId)
PE->>LER: handleIdReplacement(clientId, serverId)
PE->>OR: markSynced(outboxEventId)
PE->>PE: Record Touched IDs in SyncBatchContext
end
loop For each Failed Item
PE->>OR: markFailed/markDeadLetter(outboxEventId)
end
loop For each Conflict Item
PE->>LER: findById(entityId)
PE->>PE: Merge Conflict (GenericSyncHandler)
PE->>OR: updatePayloadAndVersion/markSynced/markDeadLetter
PE->>PE: Record Touched IDs in SyncBatchContext
end
PE->>PE: Invalidate React Query Cache
PE-->>UI: Sync Completed/Failed
The pull and apply path ensures that backend-originated changes are safely incorporated into the local database, maintaining consistency and enabling eventual convergence.
-
Cursor-Based Delta Fetch —
PullEngineinitiates the process by retrieving the latestCompositeCursorfromCursorRepository. This composite cursor encapsulates the individualEntityCursors for eachEntityType, representing the earliest sync position across all entities.PullEnginethen sends a GET request to the backend's/sync/changesendpoint, including this composite cursor and thedeviceId. -
Pull Response Structure — The backend responds with a
PullChangesResponsecontainingPullChangeItems (CREATE, UPDATE, DELETE) representing changes since the provided cursor, a newCompositeCursorfor the next page (ornullif no more changes), and per-entityEntityCursors (entityCursors). -
Per-Entity Application —
PullEngineiteratively processes thePullChangeItems, delegating each to theApplyEngine.ApplyEnginedispatches the change to theFrontendSyncHandlerregistered for thatEntityType(typicallyGenericSyncHandler).- Handlers
create,update, ordeletethe corresponding local SQLite record. ForCREATE/UPDATEoperations, theForeignKeyResolvermaps any server-provided FK UUIDs to local client UUIDs before the data is persisted, preserving local referential integrity. - Public Catalog Skip —
ApplyEnginecontains logic to skipCREATE/UPDATEoperations for public catalogproducts(isPublic = true), as these are managed exclusively by a local bundled snapshot and should not be overwritten by sync.
-
ID Replacement & Touched-ID Tracking — As changes are applied,
ApplyEngine(via the handlers) meticulously tracks which local entity IDs were modified (touchedSourceIds) and which parent entity IDs were referenced or affected (touchedTargetIds) by these changes. This information is stored in theSyncBatchContextand is crucial for the post-syncIntegrityGatechecks. -
Deferred Cursor Advancement —
PullEnginedoes not immediately commit the new cursors received from the backend. Instead, it defers them toSyncBatchContext. A critical invariant is enforced: cursors for anyEntityTypethat encounteredApplyfailures in the current batch are not deferred, guaranteeing that affected changes are re-fetched in the next sync cycle. -
Pagination — If the backend response indicates
hasMore,PullEnginefetches subsequent pages using thenextCursoruntil all changes are retrieved or a maximum number of pull iterations (MAX_PULL_ITERATIONS) is reached, preventing unbounded loops. -
Sync Report Aggregation — Results from all pull iterations and apply operations are aggregated into a
PullReport, providing a comprehensive summary of the pull phase's outcome.
Guarantee: Cursors never advance past corrupted state. Data integrity is enforced structurally, not by convention.
Detailed Pull / Apply / Integrity Sequence (Mermaid)
sequenceDiagram
participant SC as SyncCoordinator
participant PE as PullEngine
participant CR as CursorRepository
participant BAC as Backend API Client
participant BA as Backend API
participant AE as ApplyEngine
participant FR as FrontendSyncHandlerRegistry
participant LER as Local Entity Repo
participant IMR as IdMap Repository
participant IG as IntegrityGate
SC->>PE: pull(ctx)
PE->>CR: getCursor(entityType)
CR-->>PE: CompositeCursor
loop While hasMore and iterations < MAX_PULL_ITERATIONS
PE->>BA: GET /sync/changes?cursor=...
BA-->>BAC: PullChangesResponse
BAC-->>PE: PullChangesResponse
PE->>AE: applyBatch(changes, ctx)
loop For each Change (CREATE/UPDATE/DELETE)
AE->>FR: getHandler(entityType)
FR-->>AE: GenericSyncHandler
AE->>IMR: getClientId(serverFKId)
IMR-->>AE: ClientFKId (or null)
AE->>LER: create/update/delete(localData)
AE->>AE: Record Touched IDs in ctx.batchContext
end
AE-->>PE: ApplyReport (applied, failed, failedByType)
PE->>PE: Check ApplyReport.failedByType
Note over PE,CR: If entityType has failures, DO NOT defer its cursor.
PE->>SC: ctx.batchContext.deferCursorUpdate(entityCursor)
end
PE-->>SC: PullReport
SC->>IG: checkIntegrity(ctx.batchContext.getTouchedIds())
IG->>LER: Query for orphaned FKs (using RELATION_GRAPH)
LER-->>IG: Violation Rows (if any)
IG-->>SC: IntegrityReport (violations, status)
alt If IntegrityReport.status = 'ok' or 'violations'
SC->>CR: commitDeferredCursors(ctx.batchContext.getDeferredCursorUpdates())
SC->>SC: Update SyncCoordinatorState
else If IntegrityReport.status = 'failed' or 'partial'
SC->>SC: Update SyncCoordinatorState (error)
end
SC-->>UI: SyncReport
The app's conflict resolution model leverages a config-driven approach for deterministic and consistent merging of divergent local and server changes.
-
Generic Handler Model — The
GenericSyncHandler(code-snippets/services/sync/handlers/GenericSyncHandler.ts) acts as the unified conflict resolution engine for most entity types. This replaces a proliferation of entity-specific merge logic with a single, highly configurable implementation. -
Shared Conflict Configuration — The core merge logic is driven by
ENTITY_CONFLICT_CONFIG(from@shared/contracts/sync-config/conflict-configs.ts). This immutable configuration definesfieldPoliciesfor each entity (e.g.,LOCAL_WINS,SERVER_WINS,MERGE_ARRAYS,MONOTONIC) andserverDerivedFieldsthat are always authoritative. This shared contract ensures consistent merge behavior across frontend and backend. -
Deterministic Merge Behavior — The pure
mergeEntity()function (from@shared/contracts/sync-config/entity-merger.ts), or specificcustomMergefunctions, are used for conflict resolution. These functions are stateless, side-effect-free, and leverage aMergeContext(which includes a deterministic timestampnow) to ensure that the same inputs always produce identical merged outputs. -
Custom Merge Escape Hatches — For entities with particularly complex merge logic that cannot be expressed purely through field policies (e.g.,
sessionswith server-derived aggregates,consumptionswith idempotency keys,productswith public/private splits,deviceswith ephemeral data handling,journal_entrieswith array unions and deep object merges),GenericSyncHandlerdelegates to specific purecustomMergefunctions (defined incode-snippets/services/sync/utils/custom-merges/). This provides flexibility without sacrificing the generic handler model. -
Conflict Outcomes — Resolution during the push phase yields an explicit
ConflictResolutionOutcome(ADOPT_SERVER,REBASE_AND_RETRY,MANUAL_REQUIRED,SKIPPED), dictating precise actions on the outbox command (e.g., markCOMPLETED, update payload for retry, move toDEAD_LETTER). -
Conflict-Free Entities — Certain entities (e.g.,
ai_usage_records) are designatedconflictFreeinENTITY_CONFLICT_CONFIG. These are typically append-only logs where each record is unique, structurally preventing conflicts. They bypass complex merge logic.
Guarantee: Every conflict outcome is reproducible, auditable, and testable in isolation. No ad-hoc merge logic exists outside the shared configuration.
Detailed Conflict Resolution Flow (Mermaid)
graph TD
A[Push Conflict Detected] --> B{"Server Data Provided?"}
B -->|No| C[Outcome: SKIPPED]
B -->|Yes| D{"Local Entity Found?"}
D -->|No| C
D -->|Yes| E{"Entity is Conflict-Free?"}
E -->|Yes| F["Outcome: ADOPT_SERVER (Server Wins)"]
E -->|No| G{"Requires Custom Merge?"}
G -->|Yes| H["Call customMerge(local, server, context)"]
G -->|No| I["Apply Config-Driven mergeEntity(...)"]
H --> J{"Custom Merge Result"}
I --> J
J -->|Returns Merged Data| K["Outcome: REBASE_AND_RETRY"]
J -->|Returns Error| L["Outcome: MANUAL_REQUIRED"]
style C fill:#f0f0f0,stroke:#333,stroke-width:2px
style F fill:#e0ffe0,stroke:#3c3,stroke-width:2px
style K fill:#e0e0ff,stroke:#33c,stroke-width:2px
style L fill:#ffe0e0,stroke:#c33,stroke-width:2px
Cursors are fundamental to the incremental synchronization model, providing strict guarantees about data processing and progress.
-
What Cursors Are — Lightweight, opaque markers (
lastCreatedAttimestamp,lastIdUUID) that represent the last successfully processed record for a givenEntityType, stored inCursorRepository. -
Per-Entity Progress — The system tracks an independent
EntityCursorfor eachEntityType. This allows different entities to synchronize at their own pace, crucial for large datasets where not all data types change simultaneously. -
Composite Cursor — For API communication,
EntityCursors are combined into aCompositeCursor. This composite cursor is typically set to the earliest position across all entities (MINstrategy) to ensure no changes are missed for any entity type. -
Forward-Only / Monotonic Advancement — Cursors must always advance monotonically (forward in time/ID). Any attempt to move a cursor backward (
CursorBackwardError) is treated as a critical error, signaling data corruption or a sync logic bug. This invariant prevents re-processing already-synced data or skipping future data. -
Failure Deferral — Cursors are only committed to storage after the
Applyphase has successfully processed all changes for that entity type and theIntegrityGatehas passed. IfApplyfails for an entity, its cursor is not advanced, guaranteeing that affected changes are re-fetched in the next sync cycle. -
Idempotency — Cursors integrate with idempotent backend API endpoints. Replaying a sync request with the same cursor multiple times will not lead to duplicate data or incorrect state changes.
Guarantee: Cursors advance monotonically and are never committed past corrupted state. At-least-once delivery of server changes is structurally enforced.
The IntegrityGate (code-snippets/services/sync/IntegrityGate.ts) is a post-sync validation step designed to detect and prevent data corruption in the local SQLite database.
-
Why it Runs After Sync — The
IntegrityGateoperates after thePullphase has applied all server changes (and potentially afterPushhas completed its ID replacements and cascades), but before cursors are committed. Any referential integrity violations caused by the sync operation are caught before the sync is declared successful and cursors are advanced. -
RELATION_GRAPHas Source of Truth —IntegrityGateleverages the canonicalRELATION_GRAPH(@shared/contracts/sync-config/relation-graph.ts) as its authoritative model for all foreign key relationships between entities. -
Targeted Orphan Detection — To optimize performance, the
IntegrityGateperforms scoped checks usingtouchedIds(IDs of local source rows modified in the current sync batch) andtouchedTargetIds(IDs of parent rows that were replaced or deleted) fromSyncBatchContext. This focuses queries on potentially affected relationships. -
SQL Query Building — Pure functions like
buildOrphanDetectionQuery()andbuildTargetSideOrphanQuery()(code-snippets/services/sync/IntegrityGate.ts) generate parameterized SQL queries to efficiently findIntegrityViolations (orphaned FK references). -
Operational Meaning of Failures:
IntegrityViolationError— Thrown infailFastModewhen orphaned FK references are detected for required FKs. Indicates actual data corruption that must be addressed immediately.IntegrityCheckExecutionError— Thrown if the integrity check itself cannot complete (e.g., a database query fails). Local integrity isUNKNOWNand the sync is aborted.- Optional FK Violations — For nullable FKs, violations are logged as warnings but do not trigger
failFastMode, allowing the sync to complete (degraded but functional).
-
Fail-Fast Policy — The
IntegrityGatedefaults tofailFastMode = true, meaning it throwsIntegrityViolationErroron required FK violations. This prevents corrupted data from being silently committed and ensures data integrity is upheld as a critical system invariant.
Guarantee: No corrupted relational state is silently committed. Required FK violations abort the sync; cursors do not advance past integrity failures.
Detailed Integrity Gate Flow (Mermaid)
graph TD
A[SyncCoordinator Triggers IntegrityGate] --> B[Get Touched IDs from SyncBatchContext]
B --> C{"Determine Relations to Check<br/>(from RELATION_GRAPH)"}
C --> D[Initialize IntegrityReport]
loop For each Relation
D --> E{"Determine Scoping<br/>(touchedSourceIds, etc)"}
E --> F{"Execute Orphan Detection Query"}
F -->|Query SQL| DB[(Local SQLite DB)]
DB -->|Orphan Rows| F
F --> G[Collect Violations in IntegrityReport]
G --> H{"Query Failed?"}
H -->|Yes| I["Mark Relation Status: FAILED"]
H -->|No| J{"Violations Found?"}
J -->|Yes| K["Mark Relation Status: SUCCESS + VIOLATIONS"]
J -->|No| L["Mark Relation Status: SUCCESS"]
end
H -.-> M
K -.-> M
L -.-> M[Finalize IntegrityReport]
M --> N{"failFastMode = true<br/>AND Required FK Violations?"}
N -->|Yes| O[Throw IntegrityViolationError]
N -->|No| P[Return IntegrityReport]
style DB fill:#e0e0ff,stroke:#33c,stroke-width:2px
style O fill:#ffe0e0,stroke:#c33,stroke-width:2px
The sync subsystem incorporates sophisticated coordination mechanisms to ensure stability, efficiency, and correct behavior under various runtime conditions.
-
Shared Scheduler (
SyncScheduler) —DataSyncServiceintegrates with a globalSyncSchedulerto prioritize and queue sync tasks (data_sync,health_ingest,catalog_sync). This prevents resource contention and ensures high-priority tasks (e.g., user-initiated sync) are executed before low-priority background tasks. It also usesCooperativeYieldControllerfor time-budgeted work, preventing UI freezes on the main thread during heavy sync operations. -
Shared Lease Management (
SyncLeaseManager) —SyncLeaseManageracquiresSyncLeases from the backend for bulk operations. This acts as an admission control mechanism, preventing multiple clients from simultaneously overloading the backend and ensuring fair resource allocation. -
Debounced Local-Change Sync —
DataSyncService.setupEventListenersuseslodash.debounceto batch multiple rapid local mutations (dbEvents.DATA_CHANGEDfromFrontendConsumptionService) into a singleperformFullSynccall, preventing sync storms from frequent user interactions. -
Startup Gating —
DataSyncService.configureStartupGateallows delaying heavy sync initialization tasks until after the application's first paint, ensuring the UI becomes interactive quickly without being blocked by background sync setup. -
Duplicate-Instance / Hot-Reload Protections — The
SyncCoordinationState(a static singleton) is a critical component for handling React Native's Hot Reload/Fast Refresh challenges. It manages mutexes and shared timing state (_lastSyncSourceTime,_backoffMs) to prevent:- Race conditions where multiple
DataSyncServiceinstances (created during Hot Reload) try to initialize or run sync simultaneously. - State loss or corruption due to
staticvariables being reset on module reload.
- Race conditions where multiple
-
Network & AppState Awareness —
DataSyncServiceusesNetInfoto monitor network connectivity, triggering syncs automatically upon reconnection. AnAppStatelistener adjusts the periodic sync interval (faster in foreground, slower in background for battery saving) and triggers syncs when the app returns to the foreground.
The sync subsystem is designed for resilience, with explicit failure handling and recovery mechanisms at multiple layers.
-
Typed Errors —
SyncEngineError(fromcode-snippets/services/sync/engines/types.ts) provides structured, machine-readable error codes (NETWORK_ERROR,RATE_LIMITED,FK_RESOLUTION_FAILED,INTEGRITY_VIOLATION, etc.) and aretryableflag, enabling programmatic error handling and targeted retry strategies. -
Retryable vs. Non-Retryable Failures:
- Retryable — Transient errors (network outages, temporary server errors, rate limits) are retried with exponential backoff and jitter (
BackendAPIClient). - Non-Retryable — Terminal errors (schema validation failures, authentication errors, non-retryable backend rejections) cause the affected commands to be moved to the
DEAD_LETTERqueue, preventing infinite retry loops.
- Retryable — Transient errors (network outages, temporary server errors, rate limits) are retried with exponential backoff and jitter (
-
Partial Push / Partial Pull Behavior:
- Push — If some commands in a push batch fail, only those failing commands are marked
FAILED(orDEAD_LETTER). Successfully pushed commands are processed normally. - Pull — If
ApplyEnginefails to apply aPullChangeItem, thePullEnginedoes not advance the cursor for that entity type. This ensures the failed change is re-fetched in the next sync cycle.
- Push — If some commands in a push batch fail, only those failing commands are marked
-
Rate Limiting & Backoff —
BackendAPIClientandSyncCoordinationStateimplement exponential backoff on 429 (RATE_LIMITED) errors, with server-providedRetry-Afterheaders taking precedence. This prevents overwhelming the backend during high-traffic periods. -
Corrupted Payload / Dead-Letter / Quarantine —
OutboxRepositorystrictly validates command payloads. Corrupted commands are moved to theDEAD_LETTERqueue (OutboxRepository.markDeadLetter) rather than silently causingApplyfailures. Commands exhausting their retry attempts (PushEngine.markRetryExhaustedAsDeadLetter) are also moved toDEAD_LETTER, making terminal failures explicit for monitoring and manual intervention. -
Data Integrity Failure Handling —
IntegrityGateerrors (violations or execution failures) are handled based on the configuredfailFastMode. In production, required FK violations causeIntegrityViolationErrorto be thrown, ensuring sync is aborted until integrity is restored. -
Crash-Safety — The sync metadata model is designed for crash recovery. Mechanisms like lease-based expiry (
stateUpdatedAtMs) for staged items ensure that commands or samples stuck mid-process due to app crashes are reset to aPENDINGstate for retry.
Entity synchronization interacts with, but maintains clear boundaries from, other critical app subsystems.
-
Backend API Boundary — Interaction is strictly via the
BackendAPIClientusing defined API contracts (e.g.,PushCommandsRequest,PullChangesResponsefromhealth.contract.ts,sync.schemas.ts). This ensures loose coupling and allows independent evolution of client and server. -
Realtime Notification Boundary (
WebSocketClient) — Provides immediate event notifications (e.g.,consumption.created). These act as freshness hints or triggers forDataSyncServiceto run an expedited sync, enhancing UI reactivity. WebSockets do not replace the transactional guarantees of the HTTP-based push/pull sync. -
Health Sync Boundary (
HealthSyncService) — TheHealthSyncServicehandles synchronization of health data (from HealthKit/Health Connect to the backend). It is a parallel subsystem that shares theSyncSchedulerandSyncLeaseManagerfor coordination but manages its own repositories (HealthSampleRepository), ingestion logic (HealthIngestCore), and payload formats (BatchUpsertSamplesRequest). -
Query Cache / UI Invalidation Boundary (
QueryClient) —DataSyncServiceintegrates withreact-queryby invalidating caches (queryClient.invalidateQueries) after successfulPushandApplyphases. This triggers UI components (via hooks) to refetch fresh data, maintaining reactive UI. -
App Startup Boundary —
AppSetupServiceensures the core database initialization, migrations, and local catalog seeding are complete beforeDataSyncServicebegins its sync operations, providing a stable foundation.
The sync subsystem is rigorously tested to ensure correctness, reliability, and data integrity.
-
Unit Tests — Pure functions (e.g.,
canonicalizePayload,mergeEntity,buildIdCascadeStatements,compareEntityCursors) are thoroughly unit-tested in isolation, ensuring their deterministic behavior. -
Repository Integration Tests — Each repository (
OutboxRepository,CursorRepository,IdMapRepository,TombstoneRepository) has dedicated integration tests verifying its CRUD operations, query logic, and specific sync-related behaviors (e.g.,dequeueDeduplicatedByEntity,markSynced,advanceEntityCursor). -
Handler Integration Tests —
GenericSyncHandlerand custom merge functions (e.g.,mergeConsumption,mergeSession) are tested to ensure conflict resolution policies are correctly applied, andhandleIdReplacementcascades function as expected. -
Engine Integration Tests — The
PushEngine,PullEngine, andApplyEnginecomponents are tested in isolation and in concert to verify their orchestration logic, idempotency, and error handling. -
IntegrityGateTests — Dedicated integration tests forIntegrityGate.checkIntegritycover various scenarios of orphaned FKs, ID replacements, and complex relational graphs, verifying its detection capabilities in bothfailFastModeand warn-only modes. -
End-to-End Sync Integration Tests — Comprehensive integration tests simulate offline mutations, network reconnections, concurrent changes, and various failure scenarios, validating the entire
SyncCoordinator.performFullSynclifecycle. -
Schema Verification —
code-snippets/db/test-mappers.tsand validation utilities incode-snippets/services/sync/config/entity-mappings/schema.tsexplicitly verify Drizzle schema mappings and consistency with_ENTITY_COLUMN_MAPPINGSandRELATION_GRAPH.
| Document | Focus Area |
|---|---|
| System Architecture | High-level system overview and service boundaries. |
| Data Flow Map | End-to-end data movement across layers and pipelines. |
| Health Ingestion Pipeline | HealthKit / Health Connect integration and data ingestion. |
| Failure Modes | System failure modes and recovery strategies. |