@jigx/core-sdk 1.0.0 → 1.2.0-rc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/action/ja.generate-pdf.d.ts +17 -1
- package/dist/action/ja.generate-pdf.d.ts.map +1 -1
- package/dist/action/ja.generate-pdf.js +4 -1
- package/dist/action/ja.in-background.d.ts +3 -2
- package/dist/action/ja.in-background.d.ts.map +1 -1
- package/dist/action/ja.in-background.js +1 -1
- package/dist/assets/example-extraction-cache.json +3 -3
- package/dist/assets/extracted-core-sdk-examples.yaml +18 -0
- package/dist/assets/extracted-core-sdk-types.yaml +58 -0
- package/dist/assets/type-extraction-cache.json +3 -3
- package/docs/array-fields.md +371 -0
- package/docs/conditional-logic.md +178 -0
- package/docs/convention-naming.md +102 -0
- package/docs/date-field.md +92 -0
- package/docs/dropdown-fields.md +879 -0
- package/docs/field-state.md +131 -0
- package/docs/field-types-overview.md +132 -0
- package/docs/formatting.md +421 -0
- package/docs/icons.md +142 -0
- package/docs/index.md +23 -0
- package/docs/jsonata-expressions.md +200 -0
- package/docs/media-fields.md +107 -0
- package/docs/overview.md +467 -0
- package/docs/pattern-build-deploy.md +91 -0
- package/docs/pattern-datasources.md +459 -0
- package/docs/pattern-forms.md +528 -0
- package/docs/pattern-global-actions.md +92 -0
- package/docs/pattern-javascript-functions.md +452 -0
- package/docs/pattern-navigation.md +304 -0
- package/docs/pattern-pdf-generation.md +391 -0
- package/docs/pattern-rest-acumatica.md +660 -0
- package/docs/pattern-sync-progress.md +96 -0
- package/docs/pattern-sync.md +653 -0
- package/docs/pattern-tabs-form.md +293 -0
- package/docs/recipe-index.md +64 -0
- package/docs/runtime-variables.md +127 -0
- package/docs/sections.md +81 -0
- package/docs/validation-patterns.md +150 -0
- package/package.json +5 -4
- package/CHANGELOG.md +0 -95
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
# Pattern: Data Sync (Acumatica)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Syncing data from Acumatica involves:
|
|
6
|
+
1. An **initial sync** action called from `index.onLoad`
|
|
7
|
+
2. Individual **sync entity** calls per table (REST GET functions)
|
|
8
|
+
3. **Continuation** for large datasets ($top/$skip pagination)
|
|
9
|
+
4. **Diff sync** for subsequent syncs (only fetch changes since last modified)
|
|
10
|
+
5. **Sync status tracking** via local tables and Jigx sync scopes
|
|
11
|
+
6. **Progress display** via UI components bound to sync state
|
|
12
|
+
|
|
13
|
+
This file covers both:
|
|
14
|
+
|
|
15
|
+
- **Inbound sync**: Acumatica -> local tables
|
|
16
|
+
- **Outbound sync orchestration**: local changes -> Acumatica
|
|
17
|
+
|
|
18
|
+
The default architecture is local-first. User flows save locally and update sync state. A centralized sync workflow is responsible for pushing outbound changes to Acumatica.
|
|
19
|
+
|
|
20
|
+
## Local-first outbound sync state
|
|
21
|
+
|
|
22
|
+
For outbound business records, distinguish two concerns:
|
|
23
|
+
|
|
24
|
+
- **Sync state** in `Remote`
|
|
25
|
+
- **Local readiness state** such as `draft`
|
|
26
|
+
|
|
27
|
+
### `Remote` values
|
|
28
|
+
|
|
29
|
+
Use `Remote` only for sync tracking:
|
|
30
|
+
|
|
31
|
+
- `new`: created locally, never synced
|
|
32
|
+
- `dirty`: previously synced, changed locally since last successful sync
|
|
33
|
+
- `remote`: currently in sync with Acumatica
|
|
34
|
+
|
|
35
|
+
### Local draft/readiness state
|
|
36
|
+
|
|
37
|
+
Incomplete records should remain local-only and must not be selected for outbound sync.
|
|
38
|
+
Use a separate local state such as `LocalState = "draft"` when a record is not yet
|
|
39
|
+
complete enough to send to Acumatica.
|
|
40
|
+
|
|
41
|
+
For example, a service order can remain a local draft until:
|
|
42
|
+
|
|
43
|
+
- minimum required header fields are present
|
|
44
|
+
- at least one detail line exists
|
|
45
|
+
|
|
46
|
+
Do not overload `Remote` to mean "not ready yet". `Remote` is for sync history, not
|
|
47
|
+
business completeness.
|
|
48
|
+
|
|
49
|
+
## Core rule: never make Acumatica the primary save path
|
|
50
|
+
|
|
51
|
+
For app data entry and edit flows:
|
|
52
|
+
|
|
53
|
+
- The form saves to local tables first.
|
|
54
|
+
- The save action sets sync state (`Remote = "new"` or `"dirty"`).
|
|
55
|
+
- The user flow completes without depending on an immediate Acumatica round-trip.
|
|
56
|
+
|
|
57
|
+
Remote writes happen later through a centralized sync action, queue, or orchestrator that scans for unsynced records and processes them consistently.
|
|
58
|
+
|
|
59
|
+
Do not use an inline "save form -> immediately PUT to Acumatica" flow as the default recipe pattern.
|
|
60
|
+
|
|
61
|
+
## Batch sync from local datasources
|
|
62
|
+
|
|
63
|
+
The preferred outbound pattern is:
|
|
64
|
+
|
|
65
|
+
1. Save locally from the form or tabs flow.
|
|
66
|
+
2. Mark the row `new` or `dirty`.
|
|
67
|
+
3. Select syncable rows from local SQLite with a datasource.
|
|
68
|
+
4. Trigger a global sync action from the UI.
|
|
69
|
+
5. Use `executeEntities(...).rest('none', 'functionCall')` with a parameter map so one
|
|
70
|
+
function call is executed per selected local row.
|
|
71
|
+
|
|
72
|
+
Important:
|
|
73
|
+
|
|
74
|
+
- The sync selection comes from local datasources, not live form state.
|
|
75
|
+
- The sync button location is a UI choice. A header button is often the best UX for list
|
|
76
|
+
screens, but that does not change the underlying local-first pattern.
|
|
77
|
+
- For lookup/reference entities, decide the required nested shape before wiring the sync.
|
|
78
|
+
Use the schemas and UI fields to propose the likely `$expand` list early, and confirm it if
|
|
79
|
+
there is ambiguity.
|
|
80
|
+
- If you change the sync shape for an entity, for example by adding a new `$expand` child object,
|
|
81
|
+
unchanged remote rows will not be returned by a pure `LastModifiedDateTime` diff filter.
|
|
82
|
+
In that case either clear/rebuild the local cache for that entity, or force the sync for that
|
|
83
|
+
entity until local rows have been refreshed into the new shape.
|
|
84
|
+
- During testing, prefer a separate explicit `Force Sync` UI action rather than permanently forcing
|
|
85
|
+
that entity in the normal diff-sync path. That keeps production sync incremental while still
|
|
86
|
+
giving you a controlled way to refresh cached lookup rows after shape changes.
|
|
87
|
+
|
|
88
|
+
## Force Sync for lookup/reference data
|
|
89
|
+
|
|
90
|
+
This is a repeatable best practice, not a one-off workaround:
|
|
91
|
+
|
|
92
|
+
1. Keep the normal lookup sync incremental and diff-based.
|
|
93
|
+
2. If a lookup's local shape changes, for example a new `$expand` child object is now required,
|
|
94
|
+
do not permanently set the normal sync to `force: true`.
|
|
95
|
+
3. Add a separate explicit `Force Sync` action in the UI, usually in the list header.
|
|
96
|
+
4. That action should re-run the lookup syncs with:
|
|
97
|
+
- `force: true`
|
|
98
|
+
- empty diff filter / full fetch semantics
|
|
99
|
+
5. Use `Force Sync` during testing, rollout, or recovery when cached local rows must be rebuilt.
|
|
100
|
+
|
|
101
|
+
Why:
|
|
102
|
+
|
|
103
|
+
- Existing local rows may be structurally stale even when their `LastModifiedDateTime` has not changed.
|
|
104
|
+
- A normal diff sync will skip those rows, so newly required nested data like expanded child objects
|
|
105
|
+
will never appear locally.
|
|
106
|
+
- Permanently forcing the normal sync hides the real issue and makes production sync heavier than it
|
|
107
|
+
needs to be.
|
|
108
|
+
|
|
109
|
+
Use this pattern for any cached reference/lookup entity, not just Acumatica customer locations.
|
|
110
|
+
|
|
111
|
+
Example shape:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
header.actions.executeAction({
|
|
115
|
+
title: 'Sync',
|
|
116
|
+
action: 'act-sync-all',
|
|
117
|
+
icon: 'cloud-sync',
|
|
118
|
+
parameters: {
|
|
119
|
+
records: '=@ctx.datasources.syncable-records',
|
|
120
|
+
},
|
|
121
|
+
when: '=$count(@ctx.datasources.syncable-records) > 0',
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
workflow.actions
|
|
127
|
+
.executeEntities({
|
|
128
|
+
instanceId: 'sync-records',
|
|
129
|
+
function: 'rest-put-record',
|
|
130
|
+
parameters: `=@ctx.action.parameters.records.{
|
|
131
|
+
"acumaticaURL": @ctx.datasources.data-select-config.data.acumaticaURL,
|
|
132
|
+
"accessToken": "acuerp",
|
|
133
|
+
"id": $.id,
|
|
134
|
+
"remote": $.data.Remote
|
|
135
|
+
}`,
|
|
136
|
+
})
|
|
137
|
+
.rest('none', 'functionCall')
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Query-time payload construction (required default)
|
|
141
|
+
|
|
142
|
+
Remote functions should not depend on stale screen snapshots. The default recipe is:
|
|
143
|
+
|
|
144
|
+
1. Pass identifiers and sync metadata through `parameters`
|
|
145
|
+
2. Use `fn.query(...)` to fetch the latest local row at execution time
|
|
146
|
+
3. Join to other local tables if necessary
|
|
147
|
+
4. Use `inputTransform` to construct the exact Acumatica JSON body
|
|
148
|
+
|
|
149
|
+
This is the recommended default for:
|
|
150
|
+
|
|
151
|
+
- single-record sync
|
|
152
|
+
- batch sync
|
|
153
|
+
- parent-child aggregate sync
|
|
154
|
+
|
|
155
|
+
If the Acumatica endpoint supports inline child arrays, the function can join/query the
|
|
156
|
+
child rows and emit one payload. If it does not, use the parent-then-child pattern below.
|
|
157
|
+
|
|
158
|
+
## Parent-child sync ordering
|
|
159
|
+
|
|
160
|
+
When child records depend on a parent key assigned by Acumatica:
|
|
161
|
+
|
|
162
|
+
1. Sync parent records first
|
|
163
|
+
2. In the parent function, use `findReplace` / search-and-replace operations to update
|
|
164
|
+
local child references from the temporary local id/key to the Acumatica-assigned key
|
|
165
|
+
3. Sync child records after the parent function completes
|
|
166
|
+
|
|
167
|
+
This is the standard multi-step pattern for cases like customer -> contacts.
|
|
168
|
+
|
|
169
|
+
If the endpoint supports inline child sync in one payload, that can replace the
|
|
170
|
+
two-step flow. But do not assume inline child persistence just because the child array
|
|
171
|
+
appears in the swagger.
|
|
172
|
+
|
|
173
|
+
## Parent-first media/file upload pattern
|
|
174
|
+
|
|
175
|
+
Optional attachments still follow the same local-first rule:
|
|
176
|
+
|
|
177
|
+
1. Save the parent record locally.
|
|
178
|
+
2. Save photo/file rows locally in a separate child table.
|
|
179
|
+
3. For unsynced parents, store a temporary remote target reference on the child rows such as
|
|
180
|
+
`tmp:<localParentId>`.
|
|
181
|
+
4. Sync the parent first.
|
|
182
|
+
5. In the parent REST function, use `findReplace` to replace the temporary remote target on the
|
|
183
|
+
child table with the real Acumatica record id / NoteID returned by the response.
|
|
184
|
+
6. Sync file rows after the parent completes.
|
|
185
|
+
7. Upload files one-by-one with `executeEntities(...).rest('none', 'functionCall')`.
|
|
186
|
+
|
|
187
|
+
Why:
|
|
188
|
+
|
|
189
|
+
- Acumatica file upload endpoints usually require the remote parent `id` / `NoteID` in the URL.
|
|
190
|
+
- There is no batch media upload pattern to rely on.
|
|
191
|
+
- The file rows can remain fully local and offline-capable until the parent is remote.
|
|
192
|
+
|
|
193
|
+
For sync selection, keep photo/file uploads separate from the parent datasource:
|
|
194
|
+
|
|
195
|
+
- parent datasource: `Remote in ('new', 'dirty')` and business state is syncable
|
|
196
|
+
- file datasource: `Remote in ('new', 'dirty')` and remote target no longer points to the
|
|
197
|
+
temporary placeholder
|
|
198
|
+
|
|
199
|
+
This keeps parent record sync and child file upload retries independent.
|
|
200
|
+
|
|
201
|
+
## Legacy example warning
|
|
202
|
+
|
|
203
|
+
Some older or cloned projects contain direct Acumatica write calls inside business flows. Treat those as project-specific legacy implementations, not best practice.
|
|
204
|
+
|
|
205
|
+
In particular, DoorPro is a useful reference for Core SDK structure, screens, local modeling, and reference-data sync, but its outbound Acumatica write pattern is not the recipe standard.
|
|
206
|
+
|
|
207
|
+
### Project-specific submit actions still need explicit guards
|
|
208
|
+
|
|
209
|
+
When a project intentionally keeps a direct "Send to Acumatica" submit action, keep
|
|
210
|
+
the action visible and validate on press instead of silently disabling it. The user
|
|
211
|
+
should get a diagnostic modal that lists every actionable blocker, for example:
|
|
212
|
+
|
|
213
|
+
- the quote is not marked as `Order`
|
|
214
|
+
- another quote has already been selected as the order for the appointment
|
|
215
|
+
- required line items are missing
|
|
216
|
+
- the PDF has never been generated
|
|
217
|
+
|
|
218
|
+
If the quote is otherwise sendable but is not marked as `Order`, use a confirm
|
|
219
|
+
flow instead of a hard-stop modal:
|
|
220
|
+
|
|
221
|
+
1. Explain that only the active `Order` quote can be sent.
|
|
222
|
+
2. Ask whether to make this quote the active order and send it now.
|
|
223
|
+
3. If confirmed, set any other quote for the same appointment back to `Quote`.
|
|
224
|
+
4. Set the current quote to `Order`.
|
|
225
|
+
5. Regenerate the PDF before submitting, because the order type changed and the
|
|
226
|
+
previously generated PDF is now stale.
|
|
227
|
+
6. If canceled, do not change quote state and do not submit anything.
|
|
228
|
+
|
|
229
|
+
If the only blocker is a stale generated artifact, prefer a confirm flow:
|
|
230
|
+
|
|
231
|
+
1. Warn that the PDF is outdated.
|
|
232
|
+
2. Ask whether to regenerate and continue.
|
|
233
|
+
3. If confirmed, regenerate inside the same sequential action list and pass the
|
|
234
|
+
fresh output URI to the submit/upload step.
|
|
235
|
+
4. If canceled, do not submit anything.
|
|
236
|
+
|
|
237
|
+
For future edit-lock or re-enable-editing rules, write a first-class dynamic status
|
|
238
|
+
record after successful submit. At minimum store the quote id, service order id or
|
|
239
|
+
number, status (`sent`), submitted timestamp, PDF URI, and quote total. Do not infer
|
|
240
|
+
submission state only from the existence of a remote command queue item or PDF.
|
|
241
|
+
|
|
242
|
+
### Project-specific submit retries
|
|
243
|
+
|
|
244
|
+
For direct submit flows that intentionally call Acumatica from a business action,
|
|
245
|
+
do not invent a parallel retry stage table if the failed calls are already in
|
|
246
|
+
`_commandQueue`.
|
|
247
|
+
|
|
248
|
+
Use `_commandQueue` as the retry source of truth:
|
|
249
|
+
|
|
250
|
+
- Query `_commandQueue` for failed REST function commands related to the current
|
|
251
|
+
business record.
|
|
252
|
+
- Include a stable business id such as `opportunityID`, quote id, or parent id in
|
|
253
|
+
the function call `parameters` so future failed command rows can be filtered back
|
|
254
|
+
to the correct screen. Extra parameters can be useful queue metadata even when the
|
|
255
|
+
REST function does not send them to the remote endpoint.
|
|
256
|
+
- Display those failed rows in a dedicated error/retry tab.
|
|
257
|
+
- Retry failed commands with `action.retry-queue-command`, in ascending queue `id`
|
|
258
|
+
order, so only failed calls are requeued.
|
|
259
|
+
- Do not rerun successful remote steps during targeted retry. For example, if service
|
|
260
|
+
order lines succeeded and PDF upload failed, retry only the failed PDF upload command.
|
|
261
|
+
- When the user starts a full submit cycle again from the main submit action, delete
|
|
262
|
+
the current quote's failed queue rows first because the user is intentionally
|
|
263
|
+
restarting the cycle. Do this after the user confirms the submit, not on validation
|
|
264
|
+
failures or canceled confirms.
|
|
265
|
+
|
|
266
|
+
This keeps targeted retry and full restart separate:
|
|
267
|
+
|
|
268
|
+
- **Retry tab**: requeue failed commands only, in queue order.
|
|
269
|
+
- **Send action**: clear previous failed commands and start a new full send cycle.
|
|
270
|
+
|
|
271
|
+
## Initial sync action
|
|
272
|
+
|
|
273
|
+
A global action (`act-initial-sync`) is called from `index.onLoad`. It contains a sequential action list of `executeAction` calls to individual sync actions. This runs when the app loads for the first time.
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
// app.ts — triggers on first app load
|
|
277
|
+
app.onLoad.executeAction({
|
|
278
|
+
action: 'act-initial-sync',
|
|
279
|
+
})
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The initial sync action orchestrates all individual sync calls:
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
const action = app.addAction({ actionId: 'act-initial-sync' })
|
|
286
|
+
const workflow = action.action.list({ concurrency: 'sequential' })
|
|
287
|
+
|
|
288
|
+
// Create sync scope to track all sync entities
|
|
289
|
+
workflow.actions.startSyncScope({
|
|
290
|
+
instanceId: 'sync-scope-initial',
|
|
291
|
+
syncEntities: ['get-customers-diff', 'get-contacts-diff'],
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// Individual sync calls
|
|
295
|
+
workflow.actions.executeAction({ action: 'act-customers-sync' })
|
|
296
|
+
workflow.actions.executeAction({ action: 'act-contacts-sync' })
|
|
297
|
+
|
|
298
|
+
// Mark initial sync complete
|
|
299
|
+
workflow.actions.executeEntity({ instanceId: 'mark-sync-complete' })
|
|
300
|
+
.local('SyncStatus', 'save')
|
|
301
|
+
.data({ id: 'initialSync', status: 'complete', completedAt: '=$now()' })
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
This initial sync pattern is for inbound/reference-data sync. Do not mix outbound business-record submission into this app-start flow.
|
|
305
|
+
|
|
306
|
+
For standalone test apps, it is useful to wire the same initial-sync action to a root screen's pull-to-refresh event so testers can retry reference-data sync without killing and reopening the app:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
const refreshActions = screen.onRefresh.list({
|
|
310
|
+
instanceId: 'refresh-reference-data',
|
|
311
|
+
concurrency: 'sequential',
|
|
312
|
+
}).actions
|
|
313
|
+
|
|
314
|
+
refreshActions.executeAction({
|
|
315
|
+
instanceId: 'refresh-run-initial-sync',
|
|
316
|
+
action: ACTION.INITIAL_SYNC,
|
|
317
|
+
})
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
If the app seeds local mock data during `app.onLoad`, repeat that seed step before the refresh sync. Keep this to test/mock entry screens unless the production app intentionally supports manual reference-data refresh from that screen.
|
|
321
|
+
|
|
322
|
+
### REST-backed local reference tables
|
|
323
|
+
|
|
324
|
+
Large reference tables or matrix tables can be synced into local SQLite tables through REST instead of Dynamic Data. Use this when the table is too large for comfortable Dynamic Data imports or when you want a backend to own compression, seeding, and paging.
|
|
325
|
+
|
|
326
|
+
Recommended pattern:
|
|
327
|
+
|
|
328
|
+
- Define the table as a local entity, for example `doorMatrixItems`, not `default/doorMatrixItems`.
|
|
329
|
+
- Expose a REST endpoint that accepts `$top` and `$skip` and returns rows with stable `id` fields.
|
|
330
|
+
- Keep the row shape identical to the Dynamic Data shape so datasources can switch between local REST and Dynamic Data later.
|
|
331
|
+
- Authenticate the REST endpoint with the Jigx token by adding an `accessToken` header parameter with `type: jigx` and `value: jigx`.
|
|
332
|
+
- Validate that token in the backend against Jigx Strata before returning rows.
|
|
333
|
+
- Accept both `Authorization: Bearer <token>` and raw `Authorization: <token>` on custom backends. Jigx mobile can send the token as the `Authorization` header value without the `Bearer` prefix even when the function parameter is named `accessToken`.
|
|
334
|
+
- Add the REST sync action to the global initial-sync action before any dropdowns or indexes that depend on the table.
|
|
335
|
+
- Create SQLite expression indexes after sync for every JSON field used by high-cardinality filters, joins, or sorted dropdowns.
|
|
336
|
+
|
|
337
|
+
Use continuation for paging:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
fn.continuation.when('=$count(@ctx.output.data) = $number(@ctx.output.top)')
|
|
341
|
+
fn.continuation.parameters.query('$skip', {
|
|
342
|
+
value: '=$number(@ctx.output.skip) + 5000',
|
|
343
|
+
type: 'number',
|
|
344
|
+
required: true,
|
|
345
|
+
})
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Keep the backend and mobile batch sizes aligned. A batch size of 5000 works well for large local SQLite matrix sync when the payload remains within practical mobile memory and network limits.
|
|
349
|
+
|
|
350
|
+
## Sync scopes — tracking sync progress
|
|
351
|
+
|
|
352
|
+
Jigx tracks sync entity calls via **sync scopes**. Each sync entity gets an `instanceId`. A sync scope groups them and tracks their state.
|
|
353
|
+
|
|
354
|
+
### _syncScope system table
|
|
355
|
+
|
|
356
|
+
The `_syncScope` table is a system table with actual columns (not id/data pattern):
|
|
357
|
+
|
|
358
|
+
| Column | Type | Description |
|
|
359
|
+
| --- | --- | --- |
|
|
360
|
+
| `id` | string | Sync scope identifier |
|
|
361
|
+
| `state` | string | `synced`, `syncing`, `failed`, `notStarted` |
|
|
362
|
+
| `synced` | number | Count of synced entities |
|
|
363
|
+
| `syncing` | number | Count of currently syncing entities |
|
|
364
|
+
| `failed` | number | Count of failed entities |
|
|
365
|
+
| `notStarted` | number | Count of not-started entities |
|
|
366
|
+
| `lastStartedAt` | number | Timestamp of last start |
|
|
367
|
+
| `lastSyncedAt` | number | Timestamp of last successful sync |
|
|
368
|
+
| `lastFailedAt` | number | Timestamp of last failure (null if none) |
|
|
369
|
+
| `details` | string | JSON object with per-entity state: `{"get-customers-diff": {"state": "synced"}}` |
|
|
370
|
+
|
|
371
|
+
### Using sync scopes for integrity
|
|
372
|
+
|
|
373
|
+
- Check if initial sync completed: query SyncStatus table for `initialSync` record
|
|
374
|
+
- Check if sync was interrupted: `_syncScope.state = 'syncing'` with `syncing > 0`
|
|
375
|
+
- Resume interrupted sync: re-call the initial sync action on next app load
|
|
376
|
+
- Track per-entity failures: parse `details` JSON for individual entity states
|
|
377
|
+
|
|
378
|
+
## Sync status tracking
|
|
379
|
+
|
|
380
|
+
Track sync progress in a local `SyncStatus` table:
|
|
381
|
+
|
|
382
|
+
```yaml
|
|
383
|
+
# SyncStatus record
|
|
384
|
+
id: 'initialSync'
|
|
385
|
+
status: 'syncing' | 'complete' | 'failed'
|
|
386
|
+
currentStep: 'Syncing Customers...'
|
|
387
|
+
currentProgress: 2 # current step number
|
|
388
|
+
totalSteps: 5 # total steps
|
|
389
|
+
completedAt: '2026-04-06T...'
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
The initial sync action updates this record at each step — see `act-initial-sync` in the reference implementation.
|
|
393
|
+
|
|
394
|
+
### Sync integrity ideas to consider
|
|
395
|
+
|
|
396
|
+
- **Detect interrupted sync**: On app load, check if `_syncScope.state = 'syncing'` with `syncing > 0` — means a previous sync didn't finish. Re-trigger the sync.
|
|
397
|
+
- **Detect failed sync**: Check `_syncScope.failed > 0` or `SyncStatus.status = 'failed'`. Show a retry option to the user.
|
|
398
|
+
- **Resume vs restart**: If only some entities failed (check `details` JSON per-entity state), consider resuming from the failed entity rather than restarting the full sync.
|
|
399
|
+
- **First-time detection**: Check if `SyncStatus` has an `initialSync` record with `status = 'complete'`. If not, the app has never completed initial sync — block UI or show onboarding.
|
|
400
|
+
|
|
401
|
+
See [pattern-sync-progress.md](pattern-sync-progress.md) for UI patterns to display sync progress.
|
|
402
|
+
|
|
403
|
+
## Outbound sync orchestration
|
|
404
|
+
|
|
405
|
+
For records created or edited in the app:
|
|
406
|
+
|
|
407
|
+
1. Save the record locally.
|
|
408
|
+
2. Mark `Remote` as `"new"` or `"dirty"`.
|
|
409
|
+
3. Let a centralized sync action select unsynced records.
|
|
410
|
+
4. Call the Acumatica REST function from that centralized sync workflow.
|
|
411
|
+
5. On success, merge the response locally and set `Remote` to `"remote"`.
|
|
412
|
+
6. On failure, keep the local record and leave it unsynced for retry/diagnostics.
|
|
413
|
+
|
|
414
|
+
Recommended separation of concerns:
|
|
415
|
+
|
|
416
|
+
- **Forms/screens** own local validation and local persistence.
|
|
417
|
+
- **REST functions** own request/response shaping.
|
|
418
|
+
- **Central sync actions** own record selection, ordering, retries, and failure handling.
|
|
419
|
+
|
|
420
|
+
This separation is the reason the Acumatica REST recipe uses `id` + query-time fetch rather than passing the entire record payload from the jig.
|
|
421
|
+
|
|
422
|
+
## REST GET functions for sync
|
|
423
|
+
|
|
424
|
+
Define a REST GET function for each entity to sync. The function fetches records from the remote system and stores them locally via `forRowsWithMatchingIds` + `records`.
|
|
425
|
+
|
|
426
|
+
### Acumatica-specific: REST vs OData differences
|
|
427
|
+
|
|
428
|
+
Acumatica exposes two API styles. Use REST endpoints where possible — the response is already nested and matches the local schema.
|
|
429
|
+
|
|
430
|
+
| Aspect | REST endpoint | OData endpoint |
|
|
431
|
+
| --- | --- | --- |
|
|
432
|
+
| URL config field | `acumaticaURL` | `acumaticaOdataURL` |
|
|
433
|
+
| Response format | Nested JSON with `.value` wrappers | Flat fields |
|
|
434
|
+
| Records location | Top-level array (`@ctx.response.body`) | Under `value` array (`@ctx.response.body.value`) |
|
|
435
|
+
| outputTransform | Simple — add `id` + `Remote` | Complex — reshape flat → nested with `.value` wrappers |
|
|
436
|
+
| Date handling | ISO format with timezone | Needs timezone conversion (hard-code or from config) |
|
|
437
|
+
| Counting for continuation | `$count(@ctx.output.data)` | `$count(@ctx.output.data)` (after transform) |
|
|
438
|
+
|
|
439
|
+
For non-Acumatica systems, the same patterns apply — just adapt the URL, auth, response format, and outputTransform to match the API.
|
|
440
|
+
|
|
441
|
+
### REST GET function pattern
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
const fn = app.addFunction.rest({
|
|
445
|
+
functionId: 'rest-get-contacts',
|
|
446
|
+
method: 'GET',
|
|
447
|
+
url: 'https://{acumaticaURL}Contact?$filter=(BusinessAccount ne null) and (BusinessAccount ne \'\')',
|
|
448
|
+
useLocalCall: true,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
fn.params.path('acumaticaURL', { required: true })
|
|
452
|
+
fn.params.header('accessToken', 'acuerp', { required: true, type: 'acuerp' }) // Acumatica-specific: acuerp OAuth
|
|
453
|
+
fn.params.query('$top', { value: '1000', type: 'string', required: true })
|
|
454
|
+
fn.params.query('$skip', { value: '0', type: 'string', required: true })
|
|
455
|
+
fn.params.query('$filter', { type: 'string', required: false }) // Used for diff sync
|
|
456
|
+
fn.params.query('$expand', { type: 'string', required: false }) // Acumatica-specific: expand child objects
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
**Acumatica-specific**: `accessToken` with type `acuerp` triggers Jigx OAuth for Acumatica. `$expand` requests child objects in the response. For other systems, replace with the appropriate auth and query parameters.
|
|
460
|
+
|
|
461
|
+
### outputTransform
|
|
462
|
+
|
|
463
|
+
The outputTransform normalizes the API response for local storage and wraps it for continuation tracking. It must produce `{ top, skip, data }` where `data` is the record array.
|
|
464
|
+
|
|
465
|
+
```yaml
|
|
466
|
+
outputTransform: |
|
|
467
|
+
={
|
|
468
|
+
"top": @ctx.parameters."$top",
|
|
469
|
+
"skip": @ctx.parameters."$skip",
|
|
470
|
+
"data": $map(@ctx.response.body, function($item){
|
|
471
|
+
$merge([$item, {"id": $item.CustomerID.value}, {"Remote": "remote"}])
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
The `$merge` adds an `id` field (mapped from the API's key field) and `Remote: "remote"` to mark records as synced.
|
|
477
|
+
|
|
478
|
+
**Acumatica-specific**: For REST endpoints, the response is already nested with `.value` wrappers — the transform is simple. For OData endpoints, the response is flat and needs complex reshaping to match the schema. OData dates also need timezone conversion (see OData section below). The `id` is mapped from `CustomerID.value` (Customer) or `ContactID.value` (Contact) — each Acumatica entity has its own key field.
|
|
479
|
+
|
|
480
|
+
### records and forRowsWithMatchingIds
|
|
481
|
+
|
|
482
|
+
```yaml
|
|
483
|
+
records: =$.data
|
|
484
|
+
forRowsWithMatchingIds: true
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
`forRowsWithMatchingIds: true` updates existing rows matched by `id` instead of replacing all rows. Essential for diff sync — only changed records are updated.
|
|
488
|
+
|
|
489
|
+
`records` points to the array of records from the outputTransform.
|
|
490
|
+
|
|
491
|
+
## Continuation — paginating large datasets
|
|
492
|
+
|
|
493
|
+
For large datasets, Jigx supports automatic pagination via the `continuation` block on REST functions.
|
|
494
|
+
|
|
495
|
+
### How it works
|
|
496
|
+
|
|
497
|
+
1. First call uses initial `$top` and `$skip` values (e.g., 1000 and 0)
|
|
498
|
+
2. After response, the `when` condition checks if the returned count equals `$top`
|
|
499
|
+
3. If true, Jigx makes another call with updated parameters (incrementing `$skip`)
|
|
500
|
+
4. Repeats until `when` is false (fewer records returned than `$top`)
|
|
501
|
+
|
|
502
|
+
### REST continuation pattern
|
|
503
|
+
|
|
504
|
+
```yaml
|
|
505
|
+
continuation:
|
|
506
|
+
parameters:
|
|
507
|
+
$filter:
|
|
508
|
+
location: query
|
|
509
|
+
required: false
|
|
510
|
+
type: string
|
|
511
|
+
$skip:
|
|
512
|
+
location: query
|
|
513
|
+
required: true
|
|
514
|
+
type: number
|
|
515
|
+
value: =$number(@ctx.output.skip) + 1000
|
|
516
|
+
$top:
|
|
517
|
+
location: query
|
|
518
|
+
required: true
|
|
519
|
+
type: number
|
|
520
|
+
value: 1000
|
|
521
|
+
accessToken:
|
|
522
|
+
location: header
|
|
523
|
+
required: true
|
|
524
|
+
type: acuerp
|
|
525
|
+
value: acuerp
|
|
526
|
+
acumaticaURL:
|
|
527
|
+
location: path
|
|
528
|
+
required: true
|
|
529
|
+
type: string
|
|
530
|
+
url: https://{acumaticaURL}Contact?$filter=(BusinessAccount ne null) and (BusinessAccount ne '')
|
|
531
|
+
when: =$count(@ctx.output.data) = $number(@ctx.output.top)
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Key points:
|
|
535
|
+
- All original parameters are redeclared in the continuation block
|
|
536
|
+
- `$skip` increments by the `$top` value each iteration
|
|
537
|
+
- `$filter` preserves its original value from the first call
|
|
538
|
+
- `when` compares output record count to requested top — stops when fewer records returned
|
|
539
|
+
- `url` must be repeated in the continuation block
|
|
540
|
+
- Use `@ctx.output` (not `@ctx.response.body`) to access the transformed output
|
|
541
|
+
|
|
542
|
+
### Recommended batch sizes
|
|
543
|
+
|
|
544
|
+
~1000 records per batch works well. **Acumatica-specific**: Acumatica performs well with 1000-record batches for REST and up to 10000 for OData. Adjust based on the system's rate limits and payload sizes.
|
|
545
|
+
|
|
546
|
+
## Diff sync — fetching only changes
|
|
547
|
+
|
|
548
|
+
After initial sync, subsequent syncs should only fetch records modified since the last sync. The approach: find the latest timestamp in local data, then ask the remote system to return only records changed after that time.
|
|
549
|
+
|
|
550
|
+
### Max last-modified datasource
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
app.addDatasource
|
|
554
|
+
.sqlite({ datasourceId: 'data-select-customers-max-last-modified', provider: 'local' })
|
|
555
|
+
.entity('Customers')
|
|
556
|
+
.query("SELECT max(json_extract(data, '$.LastModifiedDateTime.value')) as LastModifiedDateTime FROM [Customers]")
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
Note: this datasource does NOT use `jsonProperties` — it returns a computed scalar, not an `id`/`data` row.
|
|
560
|
+
|
|
561
|
+
### Passing the filter to the sync action
|
|
562
|
+
|
|
563
|
+
The filter is built conditionally — empty on initial sync (no local data), populated on subsequent syncs:
|
|
564
|
+
|
|
565
|
+
```yaml
|
|
566
|
+
# In the sync action's parameters:
|
|
567
|
+
$filter: >
|
|
568
|
+
=@ctx.datasources.data-select-customers-max-last-modified.LastModifiedDateTime
|
|
569
|
+
?
|
|
570
|
+
"(LastModifiedDateTime gt datetime'" & @ctx.datasources.data-select-customers-max-last-modified.LastModifiedDateTime & "')"
|
|
571
|
+
:
|
|
572
|
+
""
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
If no records exist yet (initial sync), the filter is empty — fetches all records. On subsequent syncs, only records modified after the latest local timestamp are fetched.
|
|
576
|
+
|
|
577
|
+
**Acumatica-specific**: Both REST and OData endpoints accept the `$filter` query parameter with OData filter syntax. Important difference in date type keyword:
|
|
578
|
+
|
|
579
|
+
- **REST endpoints**: Use `datetimeoffset` — fields are `Edm.DateTimeOffset`
|
|
580
|
+
```
|
|
581
|
+
(LastModifiedDateTime gt datetimeoffset'2026-04-06T05:07:00.397+00:00')
|
|
582
|
+
```
|
|
583
|
+
- **OData endpoints**: Use `datetime` — fields are `Edm.DateTime`
|
|
584
|
+
```
|
|
585
|
+
(LastModifiedDateTime gt datetime'2026-04-06T05:07:00')
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Using the wrong keyword causes a 500 error: `"A binary operator with incompatible types was detected"`. The field name may vary by endpoint — check the schema.
|
|
589
|
+
|
|
590
|
+
For other systems, adapt the filter format to match their API (e.g., `?since=2026-04-05T10:30:00Z`, `?modified_after=...`).
|
|
591
|
+
|
|
592
|
+
### Custom REST diff sync
|
|
593
|
+
|
|
594
|
+
For custom REST services that are not OData, keep the same local-max pattern but pass the
|
|
595
|
+
timestamp as the API's expected query parameter instead of building an OData `$filter`.
|
|
596
|
+
|
|
597
|
+
Example:
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
app.addDatasource
|
|
601
|
+
.sqlite({ datasourceId: 'data-select-matrix-max-last-modified', provider: 'local' })
|
|
602
|
+
.entity('matrixItems')
|
|
603
|
+
.query(
|
|
604
|
+
`SELECT max(coalesce(
|
|
605
|
+
json_extract(data, '$.lastModifiedDateTime'),
|
|
606
|
+
json_extract(data, '$.createdDateAndTime')
|
|
607
|
+
)) as lastModifiedDateTime FROM [matrixItems]`,
|
|
608
|
+
)
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
Then pass that value into the REST sync function:
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
parameters: {
|
|
615
|
+
lastModifiedDateTime:
|
|
616
|
+
'=@ctx.datasources.data-select-matrix-max-last-modified.lastModifiedDateTime ? @ctx.datasources.data-select-matrix-max-last-modified.lastModifiedDateTime : ""',
|
|
617
|
+
$top: 5000,
|
|
618
|
+
$skip: 0,
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
The custom endpoint should return only rows where its modified timestamp is greater than
|
|
623
|
+
the supplied value, and should include the row's `lastModifiedDateTime` in each returned
|
|
624
|
+
record so the next sync can compute a new local max. Preserve the same
|
|
625
|
+
`lastModifiedDateTime` parameter in continuation requests while incrementing `$skip`.
|
|
626
|
+
|
|
627
|
+
## Acumatica-specific: OData timezone handling
|
|
628
|
+
|
|
629
|
+
OData responses from Acumatica use a different datetime format than REST. A timezone conversion function is needed in the outputTransform:
|
|
630
|
+
|
|
631
|
+
```jsonata
|
|
632
|
+
$tz := "+02:00";
|
|
633
|
+
$tzSign := $substring($tz, 0, 1) = "-" ? -1 : 1;
|
|
634
|
+
$tzH := $number($substring($tz, 1, 2));
|
|
635
|
+
$tzM := $number($substring($tz, 4, 2));
|
|
636
|
+
$tzOffsetMs := $tzSign * ($tzH * 60 + $tzM) * 60 * 1000;
|
|
637
|
+
$pic := "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01].[f001]";
|
|
638
|
+
$fmtDT := function($d) {
|
|
639
|
+
$d ? $fromMillis($toMillis($d & "Z") + $tzOffsetMs, $pic) & $tz : undefined
|
|
640
|
+
};
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Ideally, store the timezone value in the config table and fetch it via a query in the function, rather than hardcoding.
|
|
644
|
+
|
|
645
|
+
## Error handlers for GET functions
|
|
646
|
+
|
|
647
|
+
GET sync functions use a similar error handler set as PUT functions but with additional handling:
|
|
648
|
+
- **Status 0**: Connection error — device is offline
|
|
649
|
+
- **Status 400**: Bad request — invalid filter syntax
|
|
650
|
+
- **Status 500**: Server error — Acumatica internal error
|
|
651
|
+
- Longer retry delays for 429/504 (16s vs 5s) since sync is background work
|
|
652
|
+
|
|
653
|
+
See [pattern-rest-acumatica.md](pattern-rest-acumatica.md#error-handlers-for-rest-put-functions) for the shared error handler pattern.
|