Sync Component
Introductionβ
The Sync component is a non-visual SDUI component that enables offline-first capabilities for mobile applications. It defines what data to pre-load and cache locally, how to queue operations (workflow executions, file uploads) when offline, and how to synchronize changes when connectivity is restored.
The Sync component acts as a data layer adapter β other SDUI components (datasource, form, button) interact with it transparently, without needing to know whether data comes from the network or local cache.
Key Capabilitiesβ
- Data pre-loading: Cache configurable datasets for offline access
- Operation queuing: Persist workflow executions and mutations when offline
- Background sync: Process queued operations automatically via expo-task-manager
- Attachment uploads: Queue and upload files in the background with compression
- Status visibility: Expose sync state for UI indicators and an operations screen
- Conflict resolution: Last-write-wins strategy for offline changes (see Conflict Resolution)
Design Principlesβ
- Declarative: Sync behavior is defined in YAML, consistent with the SDUI pattern
- Transparent: Consuming components don't distinguish between online and offline
- Resilient: Queue persists across app restarts and survives app kills
- Configurable: Each workflow and model can specify its own sync strategy
YAML Structureβ
component: sync
name: <syncInstanceName>
props:
models:
- name: <string>
query: <graphql>
variables: <object>
keys: [<string>]
syncInterval: <number>
syncOn: [<trigger>]
maxAge: <number>
queue:
maxRetries: <number>
retryInterval: <number>
retryStrategy: <strategy>
persistQueue: <boolean>
backgroundSync: <boolean>
backgroundInterval: <number>
maxQueueSize: <number>
workflows:
default: <mode>
items:
- workflowId: <string>
mode: <mode>
optimisticResult: <object>
rollbackAction: <action[]>
dependsOn: [<string>]
attachments:
strategy: <strategy>
maxConcurrent: <number>
retryOnFailure: <boolean>
compress:
enabled: <boolean>
quality: <number>
maxDimension: <number>
status:
showIndicator: <boolean>
position: <position>
showPendingCount: <boolean>
notifyOnComplete: <boolean>
notifyOnError: <boolean>
Attribute Descriptionβ
Top-Level Propsβ
| Attribute | Type | Required | Description |
|---|---|---|---|
models | array | No | List of data models to pre-load and cache for offline access |
consistencyGroup | boolean | No | All-or-nothing instance refresh β see Consistency Groups. Default false |
queue | object | No | Configuration for the offline operation queue |
workflows | object | No | Workflow execution mode configuration (queue vs optimistic) |
attachments | object | No | File upload queue configuration |
status | object | No | Sync status UI indicator configuration |
Modelsβ
Each model defines a dataset to cache locally for offline access.
| Attribute | Type | Default | Description |
|---|---|---|---|
name | string | β | Required. Unique identifier for this model within the sync instance |
query | string | β | Required. GraphQL query string to fetch the data. Must return a single root field containing an items array (or a root-level array) β see Model Query Shape β unless targets is used |
variables | object | {} | Template-parsed variables passed to the query. Supports {{ }} expressions β global scope only, see Variable Scope |
keys | string[] | β | Required (unless targets is used). Primary key field(s) for cache identity and deduplication |
targets | array | β | Multi-target form: one query (a single request) feeds several cache models β see Multi-Target Models. Mutually exclusive with keys/filter/maxItems |
delta | object | β | Incremental sync: after the first full snapshot, fetch only records modified since the last sync and merge by keys β see Incremental Sync |
syncInterval | number | 300 | Seconds between automatic background refreshes when online. Ignored when syncOn is [manual] |
syncOn | string[] | ["networkReconnect"] | Additional triggers for syncing this model |
maxAge | number | 86400 | Maximum seconds before cached data is considered stale (default: 24h) |
filter | string | β | Optional default filter applied to cached results |
maxItems | number | 1000 | Maximum number of items to cache for this model |
syncOn Triggersβ
| Value | Description |
|---|---|
appForeground | Sync when the app returns to the foreground |
networkReconnect | Sync when network connectivity is restored |
manual | Only sync when explicitly triggered via the syncNow/syncModel adapter actions or the refresh action. Must be the only trigger listed; suppresses syncInterval |
intervalOnly | Only sync on the defined syncInterval, no event-based triggers |
Model Query Shapeβ
The engine extracts cached items from the query result by convention: the
response must contain a single root field whose value either has an
items array (the standard paginated list shape) or is itself an array.
Results that do not match this shape cache nothing and log a warning.
Model Variable Scopeβ
Model variables and the model-level filter are evaluated when the engine
syncs in the background β timers, reconnect, and app-foreground triggers run
with no screen open. Expressions must therefore resolve from the global
scope only: organizationId, user.* / currentUser.*, and global
variables. Screen- or form-scoped expressions in model definitions are
undefined behavior. (Workflow inputs are unaffected β they are fully
resolved at submit time, while the screen still exists.)
Multi-Target Modelsβ
When two cached models reference each other, prefer eliminating the join by
denormalizing β embed what the screen reads directly in the model query
(e.g. a vehicle record that includes its customer name is internally
consistent per record). When the datasets must genuinely stay in step, a
single query can feed several caches: one HTTP request means one fetch
moment, and all targets commit atomically with a shared fetchedAt.
models:
- name: receivingSnapshot
query: |
query($organizationId: Int!) {
orders(organizationId: $organizationId, filter: "orderType:Freight", take: 500) {
items { orderId trackingNumber }
}
warehouseLocations(organizationId: $organizationId) {
items { warehouseLocationId name }
}
}
variables:
organizationId: "{{ number organizationId }}"
targets:
- model: vehicles
path: orders.items
keys: [orderId]
maxItems: 500
- model: warehouseLocations
path: warehouseLocations.items
keys: [warehouseLocationId]
syncInterval: 300
| Target Attribute | Type | Default | Description |
|---|---|---|---|
model | string | β | Required. Cache model name consumers reference via syncAdapter/model |
path | string | β | Required. Path to the item array in the query result (e.g. orders.items) |
keys | string[] | β | Required. Primary key field(s) for this target |
filter | string | β | Optional default filter applied to cached results |
maxItems | number | 1000 | Maximum number of items to cache for this target |
Consumers reference the target model names (vehicles), never the fetch
unit name. Syncing any target (e.g. via syncModel) refreshes the whole
unit, keeping its targets consistent.
Incremental Sync (updatedSince)β
For larger models, refetching the full dataset on every refresh is wasteful.
The delta block switches a model to incremental refreshes: after the first
full snapshot, syncs fetch only records modified since the stored cursor and
merge them by keys into the cache.
models:
- name: vehicles
query: | # full-snapshot query; MUST select the cursor field
query($organizationId: Int!, $filter: String!) {
orders(organizationId: $organizationId, filter: $filter, take: 500) {
items { orderId trackingNumber lastModified }
}
}
variables:
organizationId: "{{ number organizationId }}"
filter: "orderType:Freight"
keys: [orderId]
delta:
filter: 'orderType:Freight AND lastModified:[{{ updatedSince }} TO *]'
| Attribute | Type | Default | Description |
|---|---|---|---|
filter | string | β | The $filter value for delta runs (shorthand for variables: { filter: ... }). Must contain the range term β the engine injects updatedSince (ISO-8601 UTC) into the template scope |
variables | object | β | Advanced form: arbitrary variables merged over the model's base variables for delta runs β for queries with several filter variables (multi-target units) or other per-run overrides. filter wins when both set |
cursorField | string | "lastModified" | Item field that advances the cursor. Must be selected in the query |
overlapSeconds | number | 60 | updatedSince = cursor β overlap; the key-based merge makes re-fetching the overlap idempotent |
fullRefreshInterval | number | 86400 | Seconds between full snapshots reconciling deletions and bulk updates |
Semantics:
- The first sync (and any sync with no stored cursor) runs the full
query and replaces the cache. Any forced sync β
syncNow, thesyncModeladapter action, an explicit datasource refresh β also runs full. Everything else (intervals, reconnect, foreground, stale revalidation, post-queue refresh) runs the delta query. - Delta results merge by
keys: existing records update in place, new records append; if the cache exceedsmaxItems, the oldest snapshot records are dropped first. Exact ordering converges on the next full refresh. - The cursor is the maximum
cursorFieldacross returned records (server clock β immune to device clock skew), advanced monotonically on both full and delta runs. If the query does not select the cursor field, the engine warns and self-heals by running full refreshes. - Filter syntax note: the server supports bracket range syntax only β
lastModified:[{{ updatedSince }} TO *]. Comparison shorthand (lastModified:>X) is not supported.
Hard-deleted records never appear in a modified-since fetch, and bulk
server-side updates may bypass the last-modified stamp. The periodic full
refresh (fullRefreshInterval, default daily) β and any forced sync β
reconciles both. Until then, deleted records remain visible in the cache.
Works with multi-target models (delta is
unit-level; the cursor advances across all targets) and
consistency groups (each unit resolves its own
full/delta mode; publication stays all-or-nothing).
Consistency Groupsβ
By default each model refreshes independently, so two models synced at
different moments can be mutually inconsistent. Setting
consistencyGroup: true on the sync instance makes every refresh
all-or-nothing:
- All model queries are fetched in parallel, and the caches are
published in a single commit with one shared
fetchedAtgeneration β only when every fetch succeeds. - If any fetch fails, no cache is updated (previous data is kept for all models); failed models are marked with an error status.
- Scheduling is group-level: one interval timer at the minimum
syncIntervalamong the models, and any trigger (networkReconnect,appForeground) matched by any model refreshes the whole group.syncOn: [manual]models participate in group refreshes.
The trade-off is deliberate: a consistency group prefers staleness over partial consistency β one flaky query keeps all models on their previous snapshot until the next successful group refresh. Use it only for datasets that genuinely reference each other; for everything else, independent refreshes (the default) and multi-target models provide fresher data.
Queueβ
Configuration for the offline operation queue that persists and processes mutations/workflows.
| Attribute | Type | Default | Description |
|---|---|---|---|
maxRetries | number | 5 | Maximum retry attempts for a failed operation |
retryInterval | number | 30 | Base retry delay in seconds |
retryStrategy | string | "exponential" | Backoff strategy: "exponential" or "fixed" |
persistQueue | boolean | true | Whether the queue persists to device storage (survives app restart) |
backgroundSync | boolean | true | Whether to use expo-task-manager for background queue processing. Currently a no-op β see note below |
backgroundInterval | number | 60 | Minimum seconds between background sync attempts |
True background execution (app killed or backgrounded) requires
expo-task-manager, which is not yet enabled in the mobile app.
backgroundSync: true is accepted but currently a no-op: the queue processes
on network reconnect, app foreground, interval timers, and manual syncNow β
all while the app is running. Queued operations always survive restarts and
process on the next launch.
| maxQueueSize | number | 100 | Maximum pending operations. When the queue is full, new offline operations fail immediately and the action's onError fires |
Operation Identity and Replay Safetyβ
Every operation is stamped with a client-generated operation ID (GUID) when it enters the queue. Workflow operations send this ID as the executionId of the executeWorkflow call on every attempt, and the server deduplicates on it: if an earlier attempt actually succeeded β for example, the request timed out after the server had already accepted it β the retry returns the original execution instead of running the workflow again. This is what makes the retry policy above safe: an ambiguous network failure can never double-execute a business operation.
Failure Classificationβ
Not every failure is retried. The queue classifies errors as:
| Class | Examples | Behavior |
|---|---|---|
| Transient | Network errors, timeouts, HTTP 5xx | Retried per retryStrategy up to maxRetries, then treated as permanent |
| Permanent | Workflow validation errors, business rule rejections, HTTP 4xx | Fails immediately β no retries. Fires onSyncFailed, then rollbackAction |
| Authentication | Expired or invalid token (HTTP 401) | Queue pauses until the user re-authenticates; does not consume retry attempts |
Permanently failed operations remain visible in the Sync Operations screen, where the user can inspect, retry, or discard them.
Workflowsβ
Defines how each workflow action behaves when executed offline.
| Attribute | Type | Default | Description |
|---|---|---|---|
default | string | "queue" | Default mode for workflows not explicitly listed. Only "queue" may be used as the default β "optimistic" requires per-workflow targetModel/targetKey |
items | array | [] | Per-workflow configuration overrides |
Workflow Itemβ
| Attribute | Type | Default | Description |
|---|---|---|---|
workflowId | string | β | Required. The workflow identifier to configure |
mode | string | inherited | Execution mode: "queue" or "optimistic" |
targetModel | string | β | Required in optimistic mode. The cached model this workflow updates |
targetKey | string | β | Required in optimistic mode. Template expression, evaluated against the workflow inputs, that resolves the key of the cached record to patch (e.g., "{{ inputs.orderId }}") |
optimisticResult | object | β | Fields shallow-merged into the targeted cached record when in optimistic mode. Must match the cached record's shape |
rollbackAction | action[] | β | Actions executed if the operation fails permanently after all retries |
dependsOn | string[] | β | Queue item types that must complete before this workflow executes. upload waits for the attachments referenced in this operation's inputs, not all pending uploads |
Execution Modesβ
queue: The workflow call (workflowId + inputs) is stored in the queue. It executes server-side when connectivity is available. The user receives a "queued" confirmation but no immediate result.optimistic: The engine locates the record intargetModelwhose key matches the evaluatedtargetKey, snapshots it, and shallow-mergesoptimisticResultinto it immediately β the user sees the change instantly. The actual workflow executes when connectivity is available. If it fails permanently, the snapshot is restored androllbackActionruns. If no cached record matchestargetKey, the operation falls back toqueuemode.
Optimistic mode only updates existing cached records. Workflows that create new entities must use queue mode β the server assigns identity and computed fields that the client cannot predict.
Attachmentsβ
Configuration for the file upload queue.
| Attribute | Type | Default | Description |
|---|---|---|---|
strategy | string | "background" | Upload strategy: "background" (queue for later) or "immediate" (block until done) |
maxConcurrent | number | 2 | Maximum simultaneous upload operations |
retryOnFailure | boolean | true | Whether failed uploads are retried automatically |
compress.enabled | boolean | false | Whether to compress images before upload |
compress.quality | number | 0.8 | Image compression quality (0.0β1.0) |
compress.maxDimension | number | 2048 | Maximum width or height in pixels (aspect ratio preserved) |
Offline Uploads and Attachment IDsβ
Attachment fields opt into the sync queue explicitly via options.syncAdapter:
- component: field
name: photos
props:
type: attachment
options:
syncAdapter: inspectionSync
allowMultiple: true
Attachment identity is client-generated: when a file is selected, the field immediately generates a GUID that becomes the permanent attachmentId β whether the upload happens now or hours later. The GUID is returned to the form right away, so workflow inputs can reference it before the file has ever reached the server; no placeholder rewriting is needed. When the queued upload completes, the server stores the attachment record under the same client-supplied GUID.
Because the GUID only becomes resolvable once the file exists server-side, workflows whose inputs reference attachments must declare dependsOn: [upload] so they execute only after their own files have finished uploading.
Server-side, an attachment referenced by GUID before its file has arrived is stored with status PendingUpload; it becomes Active when the queued upload completes, or UploadFailed if the upload fails permanently. Pending attachments are excluded from normal attachment queries, so half-synced files never appear in document listings. The Sync Operations screen surfaces these statuses alongside the queue items.
Statusβ
Configuration for the sync status UI indicator.
| Attribute | Type | Default | Description |
|---|---|---|---|
showIndicator | boolean | true | Whether to show a sync status badge/icon |
position | string | "header" | Where to display the indicator: "top", "bottom", or "header" |
showPendingCount | boolean | true | Whether to show the count of pending operations as a badge |
notifyOnComplete | boolean | true | Show notification when all queued operations sync successfully |
notifyOnError | boolean | true | Show notification when an operation fails permanently |
defaultMessages | object | β | Instance-wide fallback notifications for syncAdapter actions that do not define their own handler: queued (info), synced (success), syncFailed (error). An explicit onQueued/onSynced/onSyncFailed on the action suppresses the corresponding default. Messages are localized strings, template-parsed with operation in scope |
status:
defaultMessages:
queued:
en-US: "Saved offline β it will sync when connected."
synced:
en-US: "{{ operation.inputs.freightInfo.trackingNumber }} synced."
syncFailed:
en-US: "Sync failed β check Sync Operations."
With defaults in place, most buttons need no notification blocks at all β only actions with custom behavior (extra data in the message, navigation) define their own handlers.
Lifecycle and Placementβ
A sync instance is registered globally on first render and lives for the rest of the app session β it is not tied to the screen that declared it:
- Place the sync component in the root screen of the module whose data it manages (or in a shared module for global data). It registers when the user first opens that screen.
- Navigating away does not stop syncing, clear the cache, or pause the queue. The sync engine, queue processor, and background worker are app-wide singletons serving all registered instances.
- Re-rendering a sync component whose
nameis already registered updates that instance's configuration in place (idempotent re-registration). Two different sync instances must not share a name. - Cached data and queues are namespaced per sync instance (
syncName/modelName) and scoped to the current organization. Switching organizations switches to a separate cache namespace; the previous organization's cache and queue are preserved and resume processing when the user switches back. - Queued operations survive app restarts (
persistQueue: true) and are processed even if the originating module's screen is never reopened.
Adapter Interfaceβ
Other SDUI components interact with the Sync component via the syncAdapter prop. This provides transparent offline access without the consuming component needing to handle online/offline logic.
Usage in DataSourceβ
- component: datasource
name: vehicleData
props:
name: vehicleData
queries:
- name: getVehicles
query:
syncAdapter: receivingSync
model: vehicles
filter: "trackingNumber:{{ trackingNumber }}"
When a query specifies syncAdapter, the datasource reads that query's result from the local cache instead of the network. If online and the cached data is stale (or was never fetched), fresh data is fetched in the background and re-delivered to the screen automatically when it lands (stale-while-revalidate). The syncAdapter/model/filter combination is a third query source alongside the existing command and workflow forms, and the result is stored under the query name as usual.
Read Result Shapeβ
A sync-adapter query stores this shape under the query name:
{
"items": [],
"totalCount": 0,
"fetchedAt": "2026-07-03T10:15:00.000Z",
"isStale": false
}
Consuming components bind the item list explicitly β e.g. {{ getVehicles.items }} for a collection, or {{ getVehicles.items.0 }} for a single record looked up by filter. fetchedAt is null and isStale is true when the model has never been synced (prime models online first).
Filter Grammarβ
The filter string is template-resolved first, then evaluated client-side against the cached items:
- Terms have the form
fieldPath:value; the path supports dot notation (orderStatus.orderStatusName:Received) and splits on the first colon only. - Multiple terms combine with
AND(uppercase). There is noORand no comparison operators in this version. - Matching uses loose string equality (
orderId:42matches the number42). - A term whose value resolves to empty (missing template variable) matches no items β predictable behavior while route params are not yet available.
Usage in Workflow Actionsβ
- component: button
props:
label:
en-US: Submit
onClick:
- workflow:
workflowId: "6bda41d8-..."
inputs:
data: "{{ form.values }}"
syncAdapter: receivingSync
onQueued:
- notification:
message:
en-US: "Saved offline. Will sync when connected."
variant: info
onSynced:
- notification:
message:
en-US: "Successfully synced to server."
variant: success
The workflow action also supports the inline queue configuration the
mutation action uses β dependsOn, optimistic { model, key, values } and
rollbackAction directly on the action, next to the button they affect:
- workflow:
workflowId: "6bda41d8-..."
inputs:
orderId: "{{ number orderId }}"
syncAdapter: receivingSync
dependsOn: [upload]
optimistic:
model: vehicles
key: "{{ orderId }}"
values:
orderStatus:
orderStatusName: "Received"
The instance's workflows.items entry (keyed by workflowId) remains the
default; inline settings win when both are present. Prefer the inline
form for behavior specific to one button β it keeps the workflow GUID in one
place.
Usage in Mutation Actionsβ
The mutation action supports the same offline queueing as
workflows, plus an inline optimistic config that patches the cached model
immediately β so screens reading the offline model reflect the mutation
right away. The engine snapshots the patched record and rolls the patch
back automatically if the queued mutation later fails permanently.
- component: button
props:
label:
en-US: Mark Received
onClick:
- mutation:
command: |
mutation($orderId: Int!, $organizationId: Int!) {
updateOrderStatus(orderId: $orderId, organizationId: $organizationId, status: "Received") {
orderId
}
}
variables:
orderId: "{{ number orderId }}"
syncAdapter: receivingSync
optimistic:
model: vehicles
key: "{{ orderId }}"
values:
orderStatus:
orderStatusName: Received
rollbackAction:
- notification:
message:
en-US: "Update failed to sync β change reverted."
variant: error
onQueued:
- notification:
message:
en-US: "Saved offline. Will sync when connected."
variant: info
| Attribute | Description |
|---|---|
syncAdapter | Sync instance handling the offline queueing |
dependsOn | Queue item types this mutation waits for ([upload]) |
optimistic.model | Cached model to patch when queued offline |
optimistic.key | Template resolving the cached record's key (evaluated against the action scope) |
optimistic.values | Fields shallow-merged into the matched record |
rollbackAction | Actions executed after the automatic rollback on permanent failure |
onQueued / onSynced / onSyncFailed | Same semantics as on the workflow action |
If no cached record matches optimistic.key, the mutation falls back to
plain queue mode (no patch, no rollback).
Unlike workflows, raw mutations have no server-side executionId deduplication β a retry after an ambiguous network failure may execute the mutation twice. Only queue mutations that are naturally idempotent (set-a-value updates, upserts); prefer workflow actions for business operations.
Usage in Select Fieldsβ
select-async fields can serve their options from a cached sync model
instead of the network β so pickers (customers, locations, β¦) keep working
offline. Set options.syncAdapter + options.model; they take precedence
over searchQuery/valueQuery:
- component: field
name: freight.billToContactId
props:
type: select-async
label:
en-US: Customer
options:
syncAdapter: receivingOfflineSync
model: customers
valueFieldName: contactId
itemLabelTemplate: "{{ name }}"
itemValueTemplate: "{{ contactId }}"
allowSearch: true
Behavior:
- Options come from the cached model, mapped through
itemLabelTemplate/itemValueTemplate. Typing filters client-side by label/value substring (top 50 shown). An optionaloptions.filter(sync filter grammar, exact-matchANDterms) narrows the cached set first. - The selected value's label resolves from the cached record matched by
itemValueTemplate(orvalueFieldName) β novalueQueryneeded, and it works offline. An uncached value falls back to displaying the raw id. - Freshness: a stale cache revalidates in the background when online, and the option list re-reads automatically when the model syncs.
- Creation flows in
dropDownToolbarstill require connectivity β only reading is offline-capable.
Size the backing model for the picker: reference data is usually a small
capped set (e.g. contacts with filter: "contactType:Customer",
take: 500) on a slow syncInterval with a delta block.
Template Variablesβ
While any sync instance is registered, the engine publishes sync state into the global template scope, usable anywhere in module YAML:
| Variable | Type | Description |
|---|---|---|
isOnline | boolean | Current network reachability |
syncPendingCount | number | Pending operations (queued, waiting for uploads, processing, or retrying) in the active organization's queues |
- component: button
props:
label:
en-US: Search Vehicles
isVisible: "{{ isOnline }}" # online-only feature
- component: text
props:
value:
en-US: "Offline β showing cached data. {{ syncPendingCount }} pending."
isVisible: "{{ !isOnline }}"
Adapter Actionsβ
The sync adapter exposes the following actions that can be triggered from YAML:
| Action | Description |
|---|---|
syncNow | Force immediate sync of all models and process queue |
syncModel | Force sync of a specific model by name |
updateModel | Explicitly patch a cached record (model, key, values) and re-deliver the model to open screens. No snapshot/rollback β for mutation-linked patches prefer the mutation action's optimistic config |
clearCache | Clear all cached data for this sync instance |
clearQueue | Discard all pending queue operations |
retryFailed | Retry all permanently failed operations |
- syncAction:
adapter: receivingSync
action: updateModel
model: vehicles
key: "{{ orderId }}"
values:
orderStatus:
orderStatusName: Received
- component: button
props:
label:
en-US: Refresh Data
onClick:
- syncAction:
adapter: receivingSync
action: syncModel
model: vehicles
New Action Eventsβ
When syncAdapter is specified on a workflow action, additional events become available:
| Event | Description |
|---|---|
onQueued | Fired when the operation is added to the offline queue (offline path) |
onSynced | Fired when a previously queued operation completes successfully |
onSyncFailed | Fired when a queued operation fails permanently after all retries |
Interplay with onSuccess / onErrorβ
The existing onSuccess and onError events of the workflow action keep their current behavior when the device is online. The sync events cover the offline path:
| Scenario | Events fired |
|---|---|
| Online β workflow executes immediately | onSuccess or onError (unchanged) |
Offline β operation added to the queue (queue or optimistic mode) | onQueued, immediately |
| Queued operation later syncs successfully | onSynced |
| Queued operation fails permanently after all retries | onSyncFailed, then rollbackAction (optimistic mode) |
onSuccess and onError never fire for queued executions β by the time the queue is processed, the screen that initiated the action may no longer be open. Use onQueued for immediate user feedback and onSynced/onSyncFailed (or the status indicator and notifications) for post-sync feedback.
Examplesβ
Basic: Cache a Single Modelβ
Pre-load customer contacts for offline access with refresh every 10 minutes:
component: sync
name: contactsSync
props:
models:
- name: customers
query: |
query($organizationId: Int!) {
contacts(organizationId: $organizationId, filter: "contactType:Customer") {
items {
contactId
name
email
phone
}
}
}
variables:
organizationId: "{{ number organizationId }}"
keys: [contactId]
syncInterval: 600
syncOn: [appForeground, networkReconnect]
Intermediate: Multiple Models with Workflow Queueβ
A receiving module that caches vehicles and locations, with offline workflow support:
component: sync
name: receivingSync
props:
models:
- name: vehicles
query: |
query($organizationId: Int!, $filter: String) {
orders(organizationId: $organizationId, filter: $filter, limit: 500) {
items {
orderId
trackingNumber
orderStatus { orderStatusName }
orderCommodities {
commodity { customValues }
}
billToContact { contactId name }
}
}
}
variables:
organizationId: "{{ number organizationId }}"
filter: "orderType:Freight"
keys: [orderId]
syncInterval: 300
syncOn: [appForeground, networkReconnect]
- name: warehouseLocations
query: |
query($organizationId: Int!) {
warehouseLocations(organizationId: $organizationId) {
items {
warehouseLocationId
name
}
}
}
variables:
organizationId: "{{ number organizationId }}"
keys: [warehouseLocationId]
syncInterval: 3600
queue:
maxRetries: 5
retryStrategy: exponential
backgroundSync: true
backgroundInterval: 60
workflows:
default: queue
items:
- workflowId: "6bda41d8-28af-42ac-9975-0f7adc0dc45e"
mode: optimistic
targetModel: vehicles
targetKey: "{{ inputs.orderId }}"
optimisticResult:
orderStatus:
orderStatusName: Received
rollbackAction:
- notification:
message:
en-US: "Receiving failed to sync. Check Sync Operations."
variant: error
dependsOn: [upload]
- workflowId: "2ff3c10b-d2d9-418f-a41a-8834a58abf2e"
mode: queue
Advanced: Full Offline Module with Attachmentsβ
Complete sync configuration for a field inspection module with photo capture and background upload:
component: sync
name: inspectionSync
props:
models:
- name: assignments
query: |
query($organizationId: Int!, $filter: String) {
inspectionAssignments(
organizationId: $organizationId
filter: $filter
) {
items {
assignmentId
location { name address }
scheduledDate
inspectionType
status
notes
}
}
}
variables:
organizationId: "{{ number organizationId }}"
filter: "assignedTo:{{ user.userId }} AND status:Pending"
keys: [assignmentId]
syncInterval: 300
syncOn: [appForeground, networkReconnect]
maxItems: 200
- name: inspectionTemplates
query: |
query($organizationId: Int!) {
inspectionTemplates(organizationId: $organizationId) {
items {
templateId
name
sections { sectionId title fields { fieldId label type required } }
}
}
}
variables:
organizationId: "{{ number organizationId }}"
keys: [templateId]
syncOn: [manual]
queue:
maxRetries: 5
retryStrategy: exponential
retryInterval: 30
backgroundSync: true
backgroundInterval: 60
maxQueueSize: 200
persistQueue: true
workflows:
default: queue
items:
- workflowId: "a1b2c3d4-complete-inspection"
mode: optimistic
targetModel: assignments
targetKey: "{{ inputs.assignmentId }}"
optimisticResult:
status: Completed
rollbackAction:
- notification:
message:
en-US: "Inspection submission failed. Your data is saved locally."
variant: warning
dependsOn: [upload]
attachments:
strategy: background
maxConcurrent: 2
retryOnFailure: true
compress:
enabled: true
quality: 0.8
maxDimension: 2048
status:
showIndicator: true
position: header
showPendingCount: true
notifyOnComplete: true
notifyOnError: true
Using the Adapter in a Formβ
How other components consume the sync adapter for seamless offline operation:
- component: form
name: inspectionForm
props:
validationSchema: {}
children:
# Load assignment data from cache
- component: datasource
name: assignmentData
props:
name: assignmentData
queries:
- name: getAssignment
query:
syncAdapter: inspectionSync
model: assignments
filter: "assignmentId:{{ assignmentId }}"
# Form fields...
- component: field
name: notes
props:
type: text
label:
en-US: Inspection Notes
# Photo attachment with offline queue
- component: field
name: photos
props:
type: attachment
label:
en-US: Take Photos
options:
syncAdapter: inspectionSync
allowMultiple: true
displayAs: image
parentType: Inspection
parentId: "{{ assignmentId }}"
# Submit with offline support
- component: button
name: submitInspection
props:
label:
en-US: Complete Inspection
options:
variant: primary
onClick:
- validateForm:
validationSchema:
notes:
required:
message: Notes are required
- workflow:
workflowId: "a1b2c3d4-complete-inspection"
inputs:
assignmentId: "{{ assignmentId }}"
notes: "{{ inspectionForm.notes }}"
photos: "{{ inspectionForm.photos }}"
syncAdapter: inspectionSync
onQueued:
- notification:
message:
en-US: "Inspection saved. Will sync when online."
variant: info
- navigateBackOrClose:
onSynced:
- notification:
message:
en-US: "Inspection synced successfully."
variant: success
Global Sync (Shared Module)β
A sync component defined in a shared module, caching data used across all modules:
component: sync
name: globalSync
props:
models:
- name: contacts
query: |
query($organizationId: Int!) {
contacts(organizationId: $organizationId, limit: 1000) {
items { contactId name contactType email phone }
}
}
variables:
organizationId: "{{ number organizationId }}"
keys: [contactId]
syncInterval: 600
syncOn: [appForeground, networkReconnect]
- name: warehouseLocations
query: |
query($organizationId: Int!) {
warehouseLocations(organizationId: $organizationId) {
items { warehouseLocationId name code }
}
}
variables:
organizationId: "{{ number organizationId }}"
keys: [warehouseLocationId]
syncInterval: 3600
- name: users
query: |
query($organizationId: Int!) {
users(organizationId: $organizationId) {
items { userId displayName email }
}
}
variables:
organizationId: "{{ number organizationId }}"
keys: [userId]
syncInterval: 3600
syncOn: [appForeground]
status:
showIndicator: true
position: header
showPendingCount: false
Sync Operations Screenβ
The Sync component automatically registers a built-in screen at the route sync-operations that displays all sync instances, their cached models, and the operation queue.
Screen Sectionsβ
- Status Bar: Current network state (Online/Offline), last sync timestamp, manual Sync Now button
- Sync Instances: Grouped by sync name, showing:
- Each cached model with item count and freshness indicator
- Pending queue items with operation type, timestamp, and retry count
- Queue Actions: Per-item Retry and Discard buttons
- History: Completed and failed operations with timestamps
Accessing the Screenβ
- component: button
props:
label:
en-US: View Sync Status
onClick:
- navigate: "sync-operations"
The screen is also accessible via the sync status indicator when tapped.
Architectureβ
Data Flowβ
βββββββββββββββββββββββββββββββββββββββββββ
β YAML Sync Definition β
β (models, queue, workflows, attachments) β
ββββββββββββββββββββ¬βββββββββββββββββββββββ
β parsed at module load
ββββββββββββββββββββΌβββββββββββββββββββββββ
β Sync Engine β
β ββββββββββββββ ββββββββββββββββββββ β
β βModel Cache β β Operation Queue β β
β β(persisted) β β (Zustand+persist)β β
β βββββββ¬βββββββ ββββββββββ¬ββββββββββ β
β β β β
β βββββββΌβββββββ ββββββββββΌββββββββββ β
β β Scheduler β βBackground Worker β β
β β(refetch) β β(expo-task-mgr) β β
β ββββββββββββββ ββββββββββββββββββββ β
ββββββββββββββββββββ¬βββββββββββββββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββ ββββββ
β Sync Adapter Interface β
β read() | queue() | upload() | status() β
ββββββββββββββββββββ¬βββββββββββββββββββββββ
β consumed by
ββββββββββββββββββββΌβββββββββββββββββββββββ
β SDUI Components β
β datasource | form | button | dataGrid β
βββββββββββββββββββββββββββββββββββββββββββ
Storage Strategyβ
| Data Type | Storage | Persistence |
|---|---|---|
| Cached model data | SQLite (per-record rows) with an in-memory mirror for synchronous reads, namespaced per organization | Survives app restart and re-authentication |
| Operation queue | Zustand store + AsyncStorage | Survives app restart |
| Attachment queue | Zustand store + file system | Survives app restart |
| Sync configuration | In-memory (parsed from YAML) | Re-parsed on module load |
| Network state | In-memory (NetInfo) | Real-time only |
The model cache deliberately does not use the app's TanStack Query persister: that cache is domain-scoped and reset on authentication changes, while sync data must survive re-login and be partitioned per organization. Cached models are stored in SQLite as one row per record, so writes stay proportional to what changed, there is no practical size ceiling, and a future incremental (delta) sync only needs row upserts. The engine hydrates the in-memory mirror at startup and re-delivers restored models to open datasources; a previously persisted AsyncStorage cache is migrated automatically on first launch.
Queue Processing Orderβ
When connectivity is restored, the background worker processes operations in this order:
- Attachment uploads (files persisted locally, identified by their client-generated GUIDs)
- Workflows with
dependsOn: [upload](execute once the attachments referenced in their own inputs are uploaded) - Independent workflows (no dependencies, processed in FIFO order)
- Model refresh (pull latest data after mutations complete)
Conflict Resolutionβ
The sync engine uses a last-write-wins strategy, with the server as the single source of truth:
- Cached model data is a read-only replica. The client never merges entity state locally β all writes go through workflow executions.
- Queued operations replay in their original order (subject to the processing order above) once connectivity is restored. Each replayed workflow runs full server-side validation and business logic, exactly as if it had been executed online.
- If the same entity was modified on the server while the device was offline, the replayed workflow's changes overwrite those fields β the engine does not detect or surface concurrent-edit conflicts. This is the "last write" in last-write-wins: the queued operation is the later write.
- If the server rejects a replayed workflow (entity deleted, invalid state transition, validation failure), the operation fails permanently per Failure Classification:
onSyncFailedfires,rollbackActionreverts any optimistic patch, and the operation lands in the Sync Operations screen for the user to resolve. - After the queue drains, all models refresh (step 4 above). The refreshed server state replaces the local cache, including any remaining optimistic patches β the cache always converges on the server.
Choose queue mode plus clear "saved offline" messaging when concurrent edits or server-side rejections are likely; last-write-wins is safest for field-worker flows where each entity is effectively owned by one user at a time.
Best Practicesβ
- Denormalize instead of joining caches: Cached models are documents, not tables. Embed what the screen reads in the model query (customer name on the vehicle record) rather than joining two cached models in YAML β per-record consistency is then guaranteed by the server at fetch time. Reach for multi-target models or a consistency group only when datasets must genuinely stay in step.
- Scope sync instances narrowly: Define per-module sync with only the data that module needs, rather than caching everything globally. This reduces storage usage and sync time.
- Use
dependsOnfor attachment workflows: Always declaredependsOn: [upload]for workflows that reference uploaded file IDs. This ensures the server never receives an attachment reference before the file itself has arrived. - Set appropriate
syncInterval: Frequently changing data (vehicles, orders) benefits from shorter intervals (300s). Reference data (locations, templates) can use longer intervals (3600s+). - Prefer
queuemode for data-creation workflows: Use optimistic mode only when you can reliably predict the server result. For complex workflows with server-side validation,queuemode with clear "saved offline" messaging is safer. - Configure
maxItemsfor large datasets: Prevent excessive storage usage by limiting cached items to what a field worker reasonably needs in a session. - Use
syncOn: [manual]for large reference data: Inspection templates or configuration data that rarely changes should only sync on demand to avoid unnecessary network traffic. - Handle
onSyncFailedgracefully: Provide clear user messaging and a path to the Sync Operations screen where they can retry or discard failed operations. - Test with airplane mode: Validate the complete offline flow by enabling airplane mode, performing all operations, then re-enabling connectivity to verify queue processing.
Related Topicsβ
- DataSource Component β Primary consumer of sync adapter for data loading
- Button Component β Workflow actions with
syncAdapterprop - Form Component β Form submissions with offline queue support
- Field Component (Attachment) β File upload integration with sync queue