@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.
Files changed (41) hide show
  1. package/README.md +2 -0
  2. package/dist/action/ja.generate-pdf.d.ts +17 -1
  3. package/dist/action/ja.generate-pdf.d.ts.map +1 -1
  4. package/dist/action/ja.generate-pdf.js +4 -1
  5. package/dist/action/ja.in-background.d.ts +3 -2
  6. package/dist/action/ja.in-background.d.ts.map +1 -1
  7. package/dist/action/ja.in-background.js +1 -1
  8. package/dist/assets/example-extraction-cache.json +3 -3
  9. package/dist/assets/extracted-core-sdk-examples.yaml +18 -0
  10. package/dist/assets/extracted-core-sdk-types.yaml +58 -0
  11. package/dist/assets/type-extraction-cache.json +3 -3
  12. package/docs/array-fields.md +371 -0
  13. package/docs/conditional-logic.md +178 -0
  14. package/docs/convention-naming.md +102 -0
  15. package/docs/date-field.md +92 -0
  16. package/docs/dropdown-fields.md +879 -0
  17. package/docs/field-state.md +131 -0
  18. package/docs/field-types-overview.md +132 -0
  19. package/docs/formatting.md +421 -0
  20. package/docs/icons.md +142 -0
  21. package/docs/index.md +23 -0
  22. package/docs/jsonata-expressions.md +200 -0
  23. package/docs/media-fields.md +107 -0
  24. package/docs/overview.md +467 -0
  25. package/docs/pattern-build-deploy.md +91 -0
  26. package/docs/pattern-datasources.md +459 -0
  27. package/docs/pattern-forms.md +528 -0
  28. package/docs/pattern-global-actions.md +92 -0
  29. package/docs/pattern-javascript-functions.md +452 -0
  30. package/docs/pattern-navigation.md +304 -0
  31. package/docs/pattern-pdf-generation.md +391 -0
  32. package/docs/pattern-rest-acumatica.md +660 -0
  33. package/docs/pattern-sync-progress.md +96 -0
  34. package/docs/pattern-sync.md +653 -0
  35. package/docs/pattern-tabs-form.md +293 -0
  36. package/docs/recipe-index.md +64 -0
  37. package/docs/runtime-variables.md +127 -0
  38. package/docs/sections.md +81 -0
  39. package/docs/validation-patterns.md +150 -0
  40. package/package.json +5 -4
  41. 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.