Sync
Sync is declared per schema as data (ISyncDefinition), interpreted entirely by the native engine.
There is no JS sync loop — Database.sync()/Database.syncAll() just ask the native orchestrator to
run a session; the same orchestrator is woken directly from native code on app open and in the
background (see Background Sync).
ISyncDefinition
interface ISyncDefinition {
enabled: boolean;
direction: SyncDirection; // MVP: "bidirectional" only
conflict: ConflictConfig;
transport: Transport; // MVP: "rest" only
endpoint: IEndpointDefinition;
background?: IBackgroundDefinition;
pagination?: IPaginationDefinition;
}
enabled— gates whether this schema participates in sync at all: manualDatabase.sync(),syncOnAppOpen, and the background wake all skip a schema withenabled: false.direction— only"bidirectional"is implemented in the MVP.conflict— how a pulled server row is reconciled against an existing local row. See Conflict strategies below.transport— only"rest"is implemented in the MVP.endpoint— the REST module contract for this entity (base path, path/query templates, cursor field, extra headers). SeeIEndpointDefinition.background—{ enabled: boolean }, intended to gate whether this schema participates in the background wake. Not yet consulted natively:Database.syncAll()'s background counterpart (triggerSyncAll) currently runs everysync.enabledschema regardless of this flag. Global background scheduling (interval, network/charging requirements) is configured once viaDatabase.configure({ background })— see Background Sync.pagination— pull page size and per-session page cap. Optional; falls back to the engine's own defaults when omitted.
sync: {
enabled: true,
direction: 'bidirectional',
conflict: { strategy: 'lastWriteWins' },
transport: 'rest',
endpoint: {
basePath: '/users',
listQueryTemplate: 'updatedAfter={since}&limit={limit}',
},
pagination: { pageSize: 50, maxPagesPerSession: 20 },
}
IEndpointDefinition
interface IEndpointDefinition {
basePath: string;
itemPathTemplate?: string; // default: "{basePath}/{id}"
listQueryTemplate: string;
cursorField?: string; // default: "updatedAt"
headers?: Record<string, string>;
}
basePath— base path of the entity's REST module, e.g./users. Every route the engine calls is relative to it:GET <basePath>(list),POST <basePath>(create), and — by default —PATCH/DELETE <basePath>/:id(update/delete).itemPathTemplate— template for the single-item route used byPATCH/DELETE, e.g."{basePath}({id})"for an OData-style API. Tokens:{basePath},{id}({id}is always percent-encoded;{basePath}is inserted raw). Defaults to"{basePath}/{id}".listQueryTemplate— template for the pull's query string, e.g."updatedAfter={since}&limit={limit}", or a composed filter such as"$filter={cursorField} gt {since}&$top={limit}". Tokens:{since},{limit},{cursorField}(all percent-encoded). Required — there is no fallback; every schema declares its own query shape explicitly. This is a closed{token}vocabulary, deliberately not RFC 6570.cursorField— the field name (in each pulled row's JSON) carrying that row's timestamp, read from the last row of a page to advance the incremental-pull cursor. Required regardless ofsync.conflict.strategy— pagination needs it independent of how conflicts get resolved. Defaults to"updatedAt".headers— extra headers merged into every request for this entity.
IPaginationDefinition
interface IPaginationDefinition {
pageSize: number;
maxPagesPerSession?: number; // default: 20
}
pageSize— items per pull page, rendered intolistQueryTemplate's{limit}token. Push has no batching concept — each queued item is its own HTTP call regardless of this value.maxPagesPerSession— max pull pages per sync session (same connectivity window). Prevents a long loop from draining battery in a single scheduler wake. When the cap is reached with a page still full, the engine stops and resumes from the already-advanced cursor on the next wake. Defaults to20.
Conflict strategies
conflict is a discriminated union on strategy:
-
lastWriteWins(most common) — whichever side has the newer value in a configurable timestamp column wins.conflict: { strategy: 'lastWriteWins', field: 'updatedAt' } // field is optional, defaults to "updatedAt"fieldnames a column declared in the schema's owncolumns(and present in the API payload). Configurable so an API's own naming convention can be used instead of forcingupdatedAt. It must reference a column declared as{ type: "datetime", nullable: false }—Database.register()throws at registration time if it doesn't, since a nullable or non-datetime column can't be compared as a timestamp. -
serverWins— the server's pulled row always overwrites the local row, regardless of any local edit.conflict: { strategy: 'serverWins' } -
clientWins— an existing local row is never overwritten by a pulled server row; a row that doesn't exist locally yet still gets inserted.conflict: { strategy: 'clientWins' } -
manualis not implemented in the MVP.
Note: conflict's strategy is unrelated to endpoint.cursorField — the cursor field is always
required for incremental pagination, independent of which conflict strategy is chosen.
The sync session algorithm
A triggerSync(schemaName) session (invoked by Database.sync(), Database.syncAll(),
syncOnAppOpen, or the background wake) runs two phases, in order. Full detail, including retry and
tombstone semantics, is in docs/sync-rest-contract.md on GitHub.
PHASE 1 — Push (drains the whole sync_queue, sequentially, FIFO)
│
├─ insert → POST <basePath> update → PATCH <basePath>/:id delete → DELETE <basePath>/:id
│
├─ 2xx? → insert/update: response Entity replaces the row (rewrites entityId, cascades FK
│ children, marks metadata SYNCED); delete (204): marks metadata SYNCED
├─ HTTP failure (400/404/409/500)? → item marked FAILED (retryCount++), moves to the next item
└─ network failure? → aborts the rest of PHASE 1 immediately; unprocessed items stay PENDING and
are retried next session
PHASE 2 — Pull (loop of pages; only runs if PHASE 1 did not abort on a network failure)
│
├─ GET <basePath>?<listQueryTemplate rendered>
├─ each row: deletedAt != null → local tombstone; exists locally → update (lastWriteWins by
│ updatedAt, or per the configured conflict strategy); else → insert
├─ advance cursor = deletedAt ?? updatedAt of the last row in the page
└─ page came back full (== pageSize) && pages < maxPagesPerSession?
├─ yes → repeat PHASE 2
└─ no → end session (resumes from the persisted cursor next time)
Push runs before pull: your own local changes are sent before asking "what changed since X" — not strictly required (re-applying your own row via pull is an idempotent no-op), but it is the more intuitive order and avoids a window where a pull could bring back a state the push is about to replace.
Every HTTP call (POST/PATCH/DELETE/GET page) gets its own retry budget: 3 attempts, 5s delay,
fixed in the native engine — not configurable per schema. Every session retries any PENDING or
FAILED queue item, so a transient failure resolves itself on the next session without any user
action.
Triggering sync from JS
await Database.sync('users'); // one schema
await Database.syncAll(); // every sync-enabled schema
Sync also runs automatically:
- On app open — controlled by
Database.configure({ syncOnAppOpen }), which defaults totrue. Set it tofalseto opt out of the automatic sync when the app returns to the foreground. - In the background — see Background Sync for the native
scheduler, its
Database.configure({ background })options, and platform floors.
Authentication for every sync request is handled by the credentials block configured once via
Database.configure — see OAuth2 Credentials.