@fluentcommerce/fc-connect-sdk 0.1.48 → 0.1.52

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 (47) hide show
  1. package/CHANGELOG.md +506 -379
  2. package/README.md +343 -0
  3. package/dist/cjs/clients/fluent-client.js +110 -14
  4. package/dist/cjs/data-sources/s3-data-source.js +1 -1
  5. package/dist/cjs/data-sources/sftp-data-source.js +1 -1
  6. package/dist/cjs/index.d.ts +1 -1
  7. package/dist/cjs/services/extraction/extraction-orchestrator.d.ts +4 -1
  8. package/dist/cjs/services/extraction/extraction-orchestrator.js +84 -11
  9. package/dist/cjs/types/index.d.ts +79 -10
  10. package/dist/cjs/versori/fluent-versori-client.d.ts +4 -1
  11. package/dist/cjs/versori/fluent-versori-client.js +131 -13
  12. package/dist/esm/clients/fluent-client.js +110 -14
  13. package/dist/esm/data-sources/s3-data-source.js +1 -1
  14. package/dist/esm/data-sources/sftp-data-source.js +1 -1
  15. package/dist/esm/index.d.ts +1 -1
  16. package/dist/esm/services/extraction/extraction-orchestrator.d.ts +4 -1
  17. package/dist/esm/services/extraction/extraction-orchestrator.js +84 -11
  18. package/dist/esm/types/index.d.ts +79 -10
  19. package/dist/esm/versori/fluent-versori-client.d.ts +4 -1
  20. package/dist/esm/versori/fluent-versori-client.js +131 -13
  21. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  22. package/dist/tsconfig.tsbuildinfo +1 -1
  23. package/dist/tsconfig.types.tsbuildinfo +1 -1
  24. package/dist/types/index.d.ts +1 -1
  25. package/dist/types/services/extraction/extraction-orchestrator.d.ts +4 -1
  26. package/dist/types/types/index.d.ts +79 -10
  27. package/dist/types/versori/fluent-versori-client.d.ts +4 -1
  28. package/docs/02-CORE-GUIDES/api-reference/event-api-input-output-reference.md +478 -18
  29. package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-01-client-api.md +83 -0
  30. package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-types.md +52 -0
  31. package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-partial-responses.md +212 -0
  32. package/docs/02-CORE-GUIDES/api-reference/readme.md +1 -1
  33. package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-08-extraction-orchestrator.md +68 -4
  34. package/docs/02-CORE-GUIDES/mapping/modules/mapping-01-foundations.md +450 -448
  35. package/docs/02-CORE-GUIDES/mapping/modules/mapping-02-quick-start.md +476 -474
  36. package/docs/02-CORE-GUIDES/mapping/modules/mapping-03-schema-validation.md +464 -462
  37. package/docs/02-CORE-GUIDES/mapping/modules/mapping-05-advanced-patterns.md +1366 -1364
  38. package/docs/readme.md +245 -245
  39. package/package.json +17 -6
  40. package/docs/versori-apis/ACTIVATIONS-AND-VARIABLES-GUIDE.md +0 -60
  41. package/docs/versori-apis/JWT-GENERATION-GUIDE.md +0 -94
  42. package/docs/versori-apis/QUICK-WORKFLOW.md +0 -293
  43. package/docs/versori-apis/README.md +0 -73
  44. package/docs/versori-apis/VERSORI-PLATFORM-ARCHITECTURE.md +0 -880
  45. package/docs/versori-apis/Versori-Platform-API.postman_collection.json +0 -2925
  46. package/docs/versori-apis/Versori-Platform-API.postman_environment.example.json +0 -62
  47. package/docs/versori-apis/Versori-Platform-API.postman_environment.json +0 -178
@@ -1,1364 +1,1366 @@
1
- # Module 5: Advanced Patterns
2
-
3
- **Master complex transformations**
4
-
5
- **Level:** Advanced
6
- **Time:** 40 minutes
7
- **Prerequisites:** [Module 4: Use Cases](./mapping-04-use-cases.md)
8
-
9
- ---
10
-
11
- ## Learning Objectives
12
-
13
- After completing this module, you will:
14
- - ✅ Map complex nested object structures
15
- - ✅ Process arrays with `isArray` configuration
16
- - ✅ Implement conditional transformations
17
- - ✅ Use async resolvers for external lookups
18
- - ✅ Chain multiple transformations
19
- - ✅ Handle GraphQL edges/nodes pagination patterns
20
-
21
- ---
22
-
23
- ## Nested Objects
24
-
25
- Map nested structures using dot notation or nested `fields` configuration.
26
-
27
- ### Method 1: Dot Notation (Flat Mapping)
28
-
29
- ```json
30
- {
31
- "fields": {
32
- "customer.id": {
33
- "source": "customerId",
34
- "resolver": "sdk.parseInt"
35
- },
36
- "customer.email": {
37
- "source": "email",
38
- "resolver": "sdk.lowercase"
39
- },
40
- "customer.address.city": {
41
- "source": "city"
42
- },
43
- "customer.address.state": {
44
- "source": "state"
45
- },
46
- "customer.address.zipCode": {
47
- "source": "zip"
48
- }
49
- }
50
- }
51
- ```
52
-
53
- **Output:**
54
- ```json
55
- {
56
- "customer": {
57
- "id": 12345,
58
- "email": "john@example.com",
59
- "address": {
60
- "city": "New York",
61
- "state": "NY",
62
- "zipCode": "10001"
63
- }
64
- }
65
- }
66
- ```
67
-
68
- ### Method 2: Nested Fields (Structured Mapping)
69
-
70
- ```json
71
- {
72
- "fields": {
73
- "customer": {
74
- "fields": {
75
- "id": {
76
- "source": "customerId",
77
- "resolver": "sdk.parseInt"
78
- },
79
- "email": {
80
- "source": "email",
81
- "resolver": "sdk.lowercase"
82
- },
83
- "address": {
84
- "fields": {
85
- "city": {
86
- "source": "city"
87
- },
88
- "state": {
89
- "source": "state"
90
- },
91
- "zipCode": {
92
- "source": "zip"
93
- }
94
- }
95
- }
96
- }
97
- }
98
- }
99
- }
100
- ```
101
-
102
- **Same output** as Method 1, but more structured and easier to read for complex objects.
103
-
104
- ### When to Use Each Method
105
-
106
- | Method | Best For | Advantages | Disadvantages |
107
- |--------|----------|------------|---------------|
108
- | **Dot Notation** | Simple nesting (2-3 levels) | Concise, flat structure | Hard to read for deep nesting |
109
- | **Nested Fields** | Complex objects, reusable components | Clear hierarchy, organized | More verbose |
110
-
111
- ### Deep Nesting Example
112
-
113
- ```json
114
- {
115
- "fields": {
116
- "order": {
117
- "fields": {
118
- "ref": {
119
- "source": "orderRef"
120
- },
121
- "customer": {
122
- "fields": {
123
- "id": {
124
- "source": "customerId"
125
- },
126
- "profile": {
127
- "fields": {
128
- "firstName": {
129
- "source": "firstName"
130
- },
131
- "lastName": {
132
- "source": "lastName"
133
- },
134
- "preferences": {
135
- "fields": {
136
- "language": {
137
- "source": "lang",
138
- "defaultValue": "en"
139
- },
140
- "currency": {
141
- "source": "currency",
142
- "defaultValue": "USD"
143
- }
144
- }
145
- }
146
- }
147
- }
148
- }
149
- }
150
- }
151
- }
152
- }
153
- }
154
- ```
155
-
156
- ---
157
-
158
- ## Static Values
159
-
160
- Provide constant values at any nesting level.
161
-
162
- ### Simple Static Values
163
-
164
- ```json
165
- {
166
- "fields": {
167
- "type": {
168
- "value": "STANDARD"
169
- },
170
- "retailerId": {
171
- "value": 1
172
- },
173
- "source": {
174
- "value": "CSV_IMPORT"
175
- },
176
- "status": {
177
- "value": "ACTIVE"
178
- }
179
- }
180
- }
181
- ```
182
-
183
- ### Static Values in Nested Objects
184
-
185
- ```json
186
- {
187
- "fields": {
188
- "retailer": {
189
- "fields": {
190
- "id": {
191
- "value": "1",
192
- "comment": "Static retailer ID"
193
- }
194
- }
195
- },
196
- "metadata": {
197
- "fields": {
198
- "importSource": {
199
- "value": "SFCC"
200
- },
201
- "importVersion": {
202
- "value": "2.0"
203
- },
204
- "importTimestamp": {
205
- "resolver": "custom.currentTimestamp"
206
- }
207
- }
208
- }
209
- }
210
- }
211
- ```
212
-
213
- ---
214
-
215
- ## Conditional Transformations
216
-
217
- Use custom resolvers for complex business logic and conditional mapping.
218
-
219
- ### Simple Conditional Mapping
220
-
221
- ```json
222
- {
223
- "fields": {
224
- "shippingMethod": {
225
- "source": "deliverySpeed",
226
- "resolver": "custom.mapShipping"
227
- },
228
- "priority": {
229
- "source": "orderType",
230
- "resolver": "custom.calculatePriority"
231
- }
232
- }
233
- }
234
- ```
235
-
236
- **Custom Resolvers:**
237
- ```typescript
238
- const customResolvers = {
239
- 'custom.mapShipping': (value: string) => {
240
- // Map delivery speed to shipping method
241
- const shippingMap: Record<string, string> = {
242
- 'express': 'OVERNIGHT',
243
- 'fast': 'TWO_DAY',
244
- 'standard': 'GROUND',
245
- 'economy': 'STANDARD'
246
- };
247
- return shippingMap[value.toLowerCase()] || 'STANDARD';
248
- },
249
-
250
- 'custom.calculatePriority': (value: string, sourceData: any) => {
251
- // Calculate priority based on order type and total
252
- const isPremium = sourceData.customerTier === 'PREMIUM';
253
- const isUrgent = value === 'EXPRESS';
254
- const isLargeOrder = sourceData.total > 1000;
255
-
256
- if (isUrgent || isPremium) return 'HIGH';
257
- if (isLargeOrder) return 'MEDIUM';
258
- return 'LOW';
259
- }
260
- };
261
- ```
262
-
263
- ### Multi-Condition Resolver
264
-
265
- ```typescript
266
- const customResolvers = {
267
- 'custom.determineOrderType': (value: any, sourceData: any, config: any, helpers: any) => {
268
- const total = helpers.parseFloatSafe(sourceData.total, 0);
269
- const itemCount = helpers.parseIntSafe(sourceData.itemCount, 0);
270
- const isInternational = sourceData.country !== 'US';
271
-
272
- // Complex business rules
273
- if (isInternational && total > 500) {
274
- return 'INTERNATIONAL_PREMIUM';
275
- }
276
-
277
- if (itemCount > 20 || total > 1000) {
278
- return 'BULK';
279
- }
280
-
281
- if (sourceData.expedited === true) {
282
- return 'EXPRESS';
283
- }
284
-
285
- return 'STANDARD';
286
- }
287
- };
288
- ```
289
-
290
- ---
291
-
292
- ## Arrays
293
-
294
- Map arrays of items using the `isArray` field configuration.
295
-
296
- ### Array Mapping with `isArray`
297
-
298
- ```json
299
- {
300
- "fields": {
301
- "items": {
302
- "source": "orderItems",
303
- "isArray": true,
304
- "fields": {
305
- "ref": {
306
- "source": "id"
307
- },
308
- "productRef": {
309
- "source": "sku"
310
- },
311
- "quantity": {
312
- "source": "qty",
313
- "resolver": "sdk.parseInt"
314
- },
315
- "price": {
316
- "source": "unitPrice",
317
- "resolver": "sdk.parseFloat"
318
- },
319
- "totalPrice": {
320
- "resolver": "custom.calculateItemTotal"
321
- }
322
- }
323
- }
324
- }
325
- }
326
- ```
327
-
328
- ### How `isArray` Works
329
-
330
- 1. **Extract Array:** `source: "orderItems"` extracts the array from source data
331
- 2. **Iterate:** `isArray: true` tells mapper to iterate over array elements
332
- 3. **Map Each Item:** `fields: {...}` defines mappings for each array element
333
- 4. **Context Switch:** ⚠️ **THIS IS THE KEY CONCEPT**
334
-
335
- Once inside `fields` of an `isArray` mapping, the context changes:
336
-
337
- ```mermaid
338
- flowchart TD
339
- A[Root Context<br/>orderItems: [...],<br/>customerId: 123] -->|Iterate Array| B[Array Item Context<br/>id: 'ITEM-1'<br/>sku: 'PROD-001'<br/>qty: '2']
340
- B -->|Map Fields| C[Field Mappings<br/>source: 'id'<br/>→ Reads from item,<br/>NOT root]
341
-
342
- style A fill:#e1f5ff
343
- style B fill:#fff4e1
344
- style C fill:#e8f5e9
345
- ```
346
-
347
- **Text Version:**
348
- ```
349
- Root Context: { orderItems: [...], customerId: 123 }
350
- ↓ iterate
351
- Array Item: { id: "ITEM-1", sku: "PROD-001", qty: "2" } ← NEW CONTEXT!
352
- ↓ map fields
353
- Field Mappings: "source": "id" reads from THIS object, not root
354
- ```
355
-
356
- **In other words:**
357
- - `"source": "id"` → reads `currentArrayItem.id`
358
- - `"source": "sku"` → reads `currentArrayItem.sku`
359
- - NOT `sourceData.id` or `sourceData.sku`
360
-
361
- **Source Data:**
362
- ```json
363
- {
364
- "orderItems": [
365
- { "id": "ITEM-1", "sku": "PROD-001", "qty": "2", "unitPrice": "49.99" },
366
- { "id": "ITEM-2", "sku": "PROD-002", "qty": "1", "unitPrice": "99.99" }
367
- ]
368
- }
369
- ```
370
-
371
- **Mapped Output:**
372
- ```json
373
- {
374
- "items": [
375
- { "ref": "ITEM-1", "productRef": "PROD-001", "quantity": 2, "price": 49.99, "totalPrice": 99.98 },
376
- { "ref": "ITEM-2", "productRef": "PROD-002", "quantity": 1, "price": 99.99, "totalPrice": 99.99 }
377
- ]
378
- }
379
- ```
380
-
381
- ### Common Mistake: Don't Repeat Array Path
382
-
383
- **❌ WRONG - Repeating array path in nested fields:**
384
- ```json
385
- {
386
- "items": {
387
- "source": "orderItems",
388
- "isArray": true,
389
- "fields": {
390
- "ref": {
391
- "source": "orderItems[0].id" // ❌ Already inside array context!
392
- }
393
- }
394
- }
395
- }
396
- ```
397
-
398
- **✅ CORRECT - Use paths relative to array item:**
399
- ```json
400
- {
401
- "items": {
402
- "source": "orderItems",
403
- "isArray": true,
404
- "fields": {
405
- "ref": {
406
- "source": "id" // ✅ Reads from current array item
407
- }
408
- }
409
- }
410
- }
411
- ```
412
-
413
- ### Accessing Array Item Context
414
-
415
- Within array field mappings, use `source` paths relative to the array item:
416
-
417
- ```json
418
- {
419
- "items": {
420
- "source": "orderItems",
421
- "isArray": true,
422
- "fields": {
423
- "ref": {
424
- "source": "id" // ← Reads from current array item's 'id' field
425
- },
426
- "productRef": {
427
- "source": "sku" // ← Reads from current array item's 'sku' field
428
- }
429
- }
430
- }
431
- }
432
- ```
433
-
434
- ### GraphQL Edges/Nodes Pattern
435
-
436
- **IMPORTANT:** The asterisk `[*]` is optional. Both patterns work:
437
-
438
- **Option 1: With wildcard (extracts nodes directly)**
439
- ```json
440
- {
441
- "fields": {
442
- "products": {
443
- "source": "products.edges[*].node", // Wildcard extracts nodes
444
- "isArray": true,
445
- "fields": {
446
- "id": { "source": "id" }, // Direct access (no "node." prefix)
447
- "ref": { "source": "ref" },
448
- "name": { "source": "name" },
449
- "price": { "source": "price", "resolver": "sdk.parseFloat" }
450
- }
451
- }
452
- }
453
- }
454
- ```
455
-
456
- **Option 2: Without wildcard (extracts edges)**
457
- ```json
458
- {
459
- "fields": {
460
- "products": {
461
- "source": "products.edges", // Direct array access
462
- "isArray": true,
463
- "fields": {
464
- "id": { "source": "node.id" }, // Need "node." prefix
465
- "ref": { "source": "node.ref" },
466
- "name": { "source": "node.name" },
467
- "price": { "source": "node.price", "resolver": "sdk.parseFloat" }
468
- }
469
- }
470
- }
471
- }
472
- ```
473
-
474
- **Option 3: Auto-detection (SDK detects GraphQL structure)**
475
- ```json
476
- {
477
- "fields": {
478
- "products": {
479
- "source": "products", // SDK auto-detects { edges: [...] }
480
- "isArray": true,
481
- "fields": {
482
- "id": { "source": "id" }, // Direct access (nodes auto-extracted)
483
- "ref": { "source": "ref" },
484
- "name": { "source": "name" },
485
- "price": { "source": "price", "resolver": "sdk.parseFloat" }
486
- }
487
- }
488
- }
489
- }
490
- ```
491
-
492
- **All three produce the same result** - choose based on clarity for your use case
493
-
494
- **Source Data (GraphQL):**
495
- ```json
496
- {
497
- "products": {
498
- "edges": [
499
- { "cursor": "abc123", "node": { "id": "1", "ref": "PROD-001", "name": "Widget", "price": 19.99 } },
500
- { "cursor": "def456", "node": { "id": "2", "ref": "PROD-002", "name": "Gadget", "price": 29.99 } }
501
- ]
502
- }
503
- }
504
- ```
505
-
506
- **Mapped Output:**
507
- ```json
508
- {
509
- "products": [
510
- { "id": "1", "ref": "PROD-001", "name": "Widget", "price": 19.99 },
511
- { "id": "2", "ref": "PROD-002", "name": "Gadget", "price": 29.99 }
512
- ]
513
- }
514
- ```
515
-
516
- ### Nested Arrays
517
-
518
- Arrays within arrays require nested `isArray` configuration. This is common when parsing XML with complex nested structures.
519
-
520
- #### XML Example with Nested Arrays
521
-
522
- **Sample XML:**
523
- ```xml
524
- <?xml version="1.0" encoding="UTF-8"?>
525
- <Orders>
526
- <Order id="ORD-001" orderDate="2025-01-22">
527
- <customer>
528
- <name>John Doe</name>
529
- <email>John@Example.com</email>
530
- </customer>
531
- <items>
532
- <item sku="PROD-001" qty="2">
533
- <price>29.99</price>
534
- <attributes>
535
- <attribute name="color" value="Blue"/>
536
- <attribute name="size" value="M"/>
537
- </attributes>
538
- </item>
539
- <item sku="PROD-002" qty="1">
540
- <price>49.99</price>
541
- <attributes>
542
- <attribute name="color" value="Red"/>
543
- <attribute name="size" value="L"/>
544
- </attributes>
545
- </item>
546
- </items>
547
- </Order>
548
- <Order id="ORD-002" orderDate="2025-01-23">
549
- <customer>
550
- <name>Jane Smith</name>
551
- <email>jane@example.com</email>
552
- </customer>
553
- <items>
554
- <item sku="PROD-003" qty="3">
555
- <price>19.99</price>
556
- <attributes>
557
- <attribute name="color" value="Green"/>
558
- </attributes>
559
- </item>
560
- </items>
561
- </Order>
562
- </Orders>
563
- ```
564
-
565
- **After XML Parsing (fast-xml-parser):**
566
- ```json
567
- {
568
- "Orders": {
569
- "Order": [
570
- {
571
- "@id": "ORD-001",
572
- "@orderDate": "2025-01-22",
573
- "customer": {
574
- "name": "John Doe",
575
- "email": "John@Example.com"
576
- },
577
- "items": {
578
- "item": [
579
- {
580
- "@sku": "PROD-001",
581
- "@qty": "2",
582
- "price": "29.99",
583
- "attributes": {
584
- "attribute": [
585
- { "@name": "color", "@value": "Blue" },
586
- { "@name": "size", "@value": "M" }
587
- ]
588
- }
589
- },
590
- {
591
- "@sku": "PROD-002",
592
- "@qty": "1",
593
- "price": "49.99",
594
- "attributes": {
595
- "attribute": [
596
- { "@name": "color", "@value": "Red" },
597
- { "@name": "size", "@value": "L" }
598
- ]
599
- }
600
- }
601
- ]
602
- }
603
- }
604
- ]
605
- }
606
- }
607
- ```
608
-
609
- **Mapping Configuration:**
610
- ```json
611
- {
612
- "fields": {
613
- "orders": {
614
- "source": "Orders.Order",
615
- "isArray": true,
616
- "fields": {
617
- "orderRef": {
618
- "source": "@id",
619
- "required": true
620
- },
621
- "orderDate": {
622
- "source": "@orderDate",
623
- "resolver": "sdk.parseDate"
624
- },
625
- "customerName": {
626
- "source": "customer.name",
627
- "resolver": "sdk.trim"
628
- },
629
- "customerEmail": {
630
- "source": "customer.email",
631
- "resolver": "sdk.lowercase"
632
- },
633
- "items": {
634
- "source": "items.item",
635
- "isArray": true,
636
- "comment": "Nested array: items within orders",
637
- "fields": {
638
- "productRef": {
639
- "source": "@sku",
640
- "required": true
641
- },
642
- "quantity": {
643
- "source": "@qty",
644
- "resolver": "sdk.parseInt"
645
- },
646
- "price": {
647
- "source": "price",
648
- "resolver": "sdk.parseFloat"
649
- },
650
- "attributes": {
651
- "source": "attributes.attribute",
652
- "isArray": true,
653
- "comment": "Nested array: attributes within items",
654
- "fields": {
655
- "name": {
656
- "source": "@name",
657
- "resolver": "sdk.trim"
658
- },
659
- "value": {
660
- "source": "@value",
661
- "resolver": "sdk.trim"
662
- }
663
- }
664
- }
665
- }
666
- }
667
- }
668
- }
669
- }
670
- }
671
- ```
672
-
673
- **TypeScript Implementation:**
674
- ```typescript
675
- import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
676
- import { XMLParserService } from '@fluentcommerce/fc-connect-sdk';
677
-
678
- // Parse XML
679
- const parser = new XMLParserService();
680
- const parsedXml = await parser.parse(xmlContent);
681
-
682
- // Normalize array (XML parser returns object for single element, array for multiple)
683
- const ordersData = Array.isArray(parsedXml?.Orders?.Order)
684
- ? parsedXml.Orders.Order
685
- : parsedXml?.Orders?.Order
686
- ? [parsedXml.Orders.Order]
687
- : [];
688
-
689
- // Create mapper
690
- const mapper = new UniversalMapper(mappingConfig);
691
-
692
- // Map each order
693
- const mappedOrders = [];
694
- for (const order of ordersData) {
695
- const result = await mapper.map({ Order: order });
696
- mappedOrders.push(result.data);
697
- }
698
- ```
699
-
700
- **Mapped Output:**
701
- ```json
702
- {
703
- "orders": [
704
- {
705
- "orderRef": "ORD-001",
706
- "orderDate": "2025-01-22T00:00:00.000Z",
707
- "customerName": "John Doe",
708
- "customerEmail": "john@example.com",
709
- "items": [
710
- {
711
- "productRef": "PROD-001",
712
- "quantity": 2,
713
- "price": 29.99,
714
- "attributes": [
715
- { "name": "color", "value": "Blue" },
716
- { "name": "size", "value": "M" }
717
- ]
718
- },
719
- {
720
- "productRef": "PROD-002",
721
- "quantity": 1,
722
- "price": 49.99,
723
- "attributes": [
724
- { "name": "color", "value": "Red" },
725
- { "name": "size", "value": "L" }
726
- ]
727
- }
728
- ]
729
- },
730
- {
731
- "orderRef": "ORD-002",
732
- "orderDate": "2025-01-23T00:00:00.000Z",
733
- "customerName": "Jane Smith",
734
- "customerEmail": "jane@example.com",
735
- "items": [
736
- {
737
- "productRef": "PROD-003",
738
- "quantity": 3,
739
- "price": 19.99,
740
- "attributes": [
741
- { "name": "color", "value": "Green" }
742
- ]
743
- }
744
- ]
745
- }
746
- ]
747
- }
748
- ```
749
-
750
- **Key Points:**
751
- 1. **XML Attributes**: Use `@` prefix (`@id`, `@sku`, `@name`, `@value`)
752
- 2. **Nested Arrays**: Each level requires `isArray: true` with nested `fields`
753
- 3. **Resolvers**: Apply appropriate resolvers (`sdk.parseInt`, `sdk.parseFloat`, `sdk.parseDate`, `sdk.lowercase`, `sdk.trim`)
754
- 4. **Context Switching**: Within array `fields`, `source` paths are relative to each array item
755
- 5. **Array Normalization**: Always normalize XML arrays before mapping (single element → object, multiple → array)
756
-
757
- ---
758
-
759
- ## Async Resolvers
760
-
761
- Resolvers can be async for external lookups and API calls.
762
-
763
- ### Customer Lookup Example
764
-
765
- ```typescript
766
- const customResolvers = {
767
- 'custom.lookupCustomer': async (email: string, sourceData: any, config: any, helpers: any) => {
768
- // Ensure fluentClient is available
769
- if (!helpers.fluentClient) {
770
- throw new Error('fluentClient not available - pass via UniversalMapper options');
771
- }
772
-
773
- // Query Fluent API to find customer by email
774
- const query = `
775
- query GetCustomerByEmail($email: String!) {
776
- customers(email: $email) {
777
- edges {
778
- node {
779
- id
780
- }
781
- }
782
- }
783
- }
784
- `;
785
-
786
- const result = await helpers.fluentClient.graphql({
787
- query,
788
- variables: { email }
789
- });
790
-
791
- const customerId = result.data.customers.edges[0]?.node.id;
792
-
793
- if (!customerId) {
794
- throw new Error(`Customer not found for email: ${email}`);
795
- }
796
-
797
- return customerId;
798
- }
799
- };
800
-
801
- // Create mapper with fluentClient option
802
- const mapper = new UniversalMapper(mappingConfig, {
803
- customResolvers,
804
- fluentClient: fluentClient // ✅ Makes client available in helpers
805
- });
806
- ```
807
-
808
- ### Product Enrichment Example
809
-
810
- ```typescript
811
- const customResolvers = {
812
- 'custom.enrichProduct': async (sku: string, sourceData: any, config: any, helpers: any) => {
813
- // Fetch product details from external API
814
- const response = await fetch(`https://api.example.com/products/${sku}`);
815
- const product = await response.json();
816
-
817
- return {
818
- sku: product.sku,
819
- name: product.name,
820
- description: product.description,
821
- category: product.category,
822
- price: product.price
823
- };
824
- }
825
- };
826
- ```
827
-
828
- ### Memoized Async Resolver
829
-
830
- For repeated lookups, use memoization to cache results:
831
-
832
- ```typescript
833
- const customResolvers = {
834
- 'custom.getCategoryName': helpers.memoize(async (categoryId: string) => {
835
- // This will only be called once per unique categoryId
836
- const response = await fetch(`https://api.example.com/categories/${categoryId}`);
837
- const category = await response.json();
838
- return category.name;
839
- })
840
- };
841
- ```
842
-
843
- ### Performance Consideration
844
-
845
- **❌ Avoid:** Individual async calls in batch processing loops
846
-
847
- ```typescript
848
- // ❌ Bad - Makes N API calls for N records
849
- for (const record of records) {
850
- const result = await mapper.map(record); // Each calls async resolver
851
- }
852
- ```
853
-
854
- **✅ Better:** Pre-fetch data, then map synchronously
855
-
856
- ```typescript
857
- // ✅ Good - Single batch API call, then fast mapping
858
- const customerIds = records.map(r => r.customerId);
859
- const customers = await api.getCustomersBatch(customerIds);
860
- const customersMap = new Map(customers.map(c => [c.id, c]));
861
-
862
- const customResolvers = {
863
- 'custom.getCustomer': (id: string) => customersMap.get(id) // Synchronous lookup
864
- };
865
-
866
- for (const record of records) {
867
- const result = await mapper.map(record); // Fast - no API calls
868
- }
869
- ```
870
-
871
- ---
872
-
873
- ## Chaining Transformations
874
-
875
- Combine multiple transformations via custom resolvers.
876
-
877
- ### Method 1: Manual Chaining
878
-
879
- ```typescript
880
- const customResolvers = {
881
- 'custom.cleanAndFormat': (value: string, sourceData: any, config: any, helpers: any) => {
882
- // Chain: trim → lowercase → capitalize first letter
883
- const trimmed = helpers.normalizeWhitespace(value);
884
- const lower = trimmed.toLowerCase();
885
- return lower.charAt(0).toUpperCase() + lower.slice(1);
886
- }
887
- };
888
- ```
889
-
890
- ### Method 2: Using SDK Resolvers Within Custom
891
-
892
- ```typescript
893
- import { getSdkResolver } from '@fluentcommerce/fc-connect-sdk';
894
-
895
- const trim = getSdkResolver('sdk.trim');
896
- const uppercase = getSdkResolver('sdk.uppercase');
897
-
898
- const customResolvers = {
899
- 'custom.cleanAndUpper': (value: any, sourceData: any, config: any, helpers: any) => {
900
- // Chain SDK resolvers
901
- const trimmed = trim(value, sourceData, config, helpers);
902
- return uppercase(trimmed, sourceData, config, helpers);
903
- }
904
- };
905
- ```
906
-
907
- ### Complex Chaining Example
908
-
909
- ```typescript
910
- const customResolvers = {
911
- 'custom.processOrderRef': (value: string, sourceData: any, config: any, helpers: any) => {
912
- // Step 1: Trim whitespace
913
- const cleaned = helpers.normalizeWhitespace(value);
914
-
915
- // Step 2: Uppercase
916
- const upper = cleaned.toUpperCase();
917
-
918
- // Step 3: Add prefix if not present
919
- const withPrefix = upper.startsWith('ORD-') ? upper : `ORD-${upper}`;
920
-
921
- // Step 4: Validate format
922
- if (!/^ORD-[A-Z0-9]+$/.test(withPrefix)) {
923
- throw new Error(`Invalid order ref format: ${withPrefix}`);
924
- }
925
-
926
- return withPrefix;
927
- }
928
- };
929
- ```
930
-
931
- ### Resolver Composition
932
-
933
- Create reusable resolver building blocks:
934
-
935
- ```typescript
936
- // Utility functions
937
- const cleanString = (value: string) => value.trim().replace(/\s+/g, ' ');
938
- const normalizeEmail = (value: string) => value.toLowerCase().trim();
939
- const validateEmail = (value: string) => {
940
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
941
- if (!emailRegex.test(value)) {
942
- throw new Error(`Invalid email: ${value}`);
943
- }
944
- return value;
945
- };
946
-
947
- // Composed resolvers
948
- const customResolvers = {
949
- 'custom.processEmail': (value: string) => {
950
- return validateEmail(normalizeEmail(cleanString(value)));
951
- },
952
-
953
- 'custom.processPhone': (value: string) => {
954
- const cleaned = cleanString(value);
955
- const digitsOnly = cleaned.replace(/\D/g, '');
956
- return `(${digitsOnly.slice(0,3)}) ${digitsOnly.slice(3,6)}-${digitsOnly.slice(6)}`;
957
- }
958
- };
959
- ```
960
-
961
- ---
962
-
963
- ## Advanced Context Usage
964
-
965
- Access workflow-specific data via `helpers.context`.
966
-
967
- ### Passing Context to Mapper
968
-
969
- ```typescript
970
- // Create mapper with context
971
- const mapper = new UniversalMapper(mappingConfig, {
972
- customResolvers,
973
- context: {
974
- orderId: 'ORD-2025-001',
975
- userId: '999',
976
- importDate: new Date().toISOString(),
977
- retailerId: '1'
978
- }
979
- });
980
- ```
981
-
982
- ### Using Context in Resolvers
983
-
984
- ```typescript
985
- const customResolvers = {
986
- 'custom.addOrderContext': (value: any, sourceData: any, config: any, helpers: any) => {
987
- const orderId = helpers.context?.orderId;
988
- return `${value}_${orderId}`;
989
- },
990
-
991
- 'custom.setRetailer': (value: any, sourceData: any, config: any, helpers: any) => {
992
- return helpers.context?.retailerId || '1';
993
- },
994
-
995
- 'custom.auditTrail': (value: any, sourceData: any, config: any, helpers: any) => {
996
- return {
997
- value,
998
- importedBy: helpers.context?.userId,
999
- importedAt: helpers.context?.importDate
1000
- };
1001
- }
1002
- };
1003
- ```
1004
-
1005
- ---
1006
-
1007
- ## Versori Platform: File Processing with KV State Tracking
1008
-
1009
- **Problem:** Prevent duplicate file processing in scheduled workflows
1010
-
1011
- **Solution:** Use VersoriKVAdapter for state management
1012
-
1013
- ### Complete Implementation
1014
-
1015
- ```typescript
1016
- import { schedule } from '@versori/run';
1017
- import {
1018
- SftpDataSource,
1019
- UniversalMapper,
1020
- VersoriKVAdapter,
1021
- XMLParserService,
1022
- createClient
1023
- } from '@fluentcommerce/fc-connect-sdk';
1024
-
1025
- export const inventorySync = schedule('inventory-sync', '0 */6 * * *', async (ctx) => {
1026
- const { log, openKv, activation, credentials } = ctx;
1027
-
1028
- // 1. Initialize KV state tracking
1029
- const kv = new VersoriKVAdapter(openKv(':project:'));
1030
- const processedFilesKey = ['inventory-sync', 'processed-files'];
1031
-
1032
- // Get list of previously processed files
1033
- const processed = (await kv.get(processedFilesKey))?.value || [];
1034
- log.info('Previously processed files', { count: processed.length });
1035
-
1036
- // 2. Connect to SFTP
1037
- const sftpCreds = await credentials().getAccessToken('SFTP');
1038
- const [username, password] = Buffer.from(sftpCreds.accessToken, 'base64')
1039
- .toString('utf-8')
1040
- .split(':');
1041
-
1042
- const sftp = new SftpDataSource({
1043
- type: 'SFTP_XML',
1044
- sftpConfig: {
1045
- host: activation.getVariable('SFTP_HOST'),
1046
- port: 22,
1047
- username,
1048
- password,
1049
- remotePath: '/inbound/inventory/',
1050
- filePattern: '*.xml'
1051
- }
1052
- }, log);
1053
-
1054
- await sftp.connect();
1055
-
1056
- // 3. List files and filter out already processed
1057
- const allFiles = await sftp.listFiles({
1058
- remotePath: '/inbound/inventory/',
1059
- filePattern: '*.xml'
1060
- });
1061
-
1062
- const newFiles = allFiles.filter(f => !processed.includes(f.name));
1063
- log.info('New files to process', {
1064
- total: allFiles.length,
1065
- new: newFiles.length
1066
- });
1067
-
1068
- // 4. Process each new file
1069
- const mapper = new UniversalMapper({
1070
- name: 'inventory.xml.mapping',
1071
- fields: {
1072
- retailerId: { source: '$context.retailerId', resolver: 'sdk.parseInt' },
1073
- locationRef: { source: 'facilityId', resolver: 'sdk.trim' },
1074
- productRef: { source: 'sku', resolver: 'sdk.trim' },
1075
- quantity: { source: 'qty', resolver: 'sdk.parseInt' }
1076
- }
1077
- }, log);
1078
-
1079
- const client = await createClient({ ...ctx, log });
1080
- const retailerId = activation.getVariable('fluentRetailerId');
1081
- if (!retailerId) {
1082
- throw new Error('fluentRetailerId required in activation variables');
1083
- }
1084
- client.setRetailerId(retailerId);
1085
-
1086
- const results = [];
1087
-
1088
- for (const file of newFiles) {
1089
- try {
1090
- // Download and parse
1091
- const content = await sftp.downloadFile(file.path, { encoding: 'utf8' });
1092
- const parser = new XMLParserService(log);
1093
- const doc = await parser.parse(content);
1094
-
1095
- // Map records
1096
- const records = Array.isArray(doc.inventory.item)
1097
- ? doc.inventory.item
1098
- : [doc.inventory.item];
1099
-
1100
- const mappedRecords = [];
1101
- for (const rec of records) {
1102
- const result = await mapper.map({
1103
- ...rec,
1104
- $context: { retailerId }
1105
- });
1106
- if (result.success) {
1107
- mappedRecords.push(result.data);
1108
- }
1109
- }
1110
-
1111
- // Send to Fluent Batch API
1112
- await client.batch.submit('INVENTORY_QUANTITY', mappedRecords);
1113
-
1114
- // Mark as processed
1115
- processed.push(file.name);
1116
- await kv.set(processedFilesKey, processed);
1117
-
1118
- // Archive file
1119
- await sftp.moveFile(
1120
- file.path,
1121
- `/archive/inventory/${file.name}`,
1122
- true
1123
- );
1124
-
1125
- results.push({ file: file.name, status: 'success', records: mappedRecords.length });
1126
- log.info('File processed successfully', { file: file.name });
1127
-
1128
- } catch (error) {
1129
- log.error('File processing failed', { file: file.name, error });
1130
- results.push({ file: file.name, status: 'failed', error: error.message });
1131
- }
1132
- }
1133
-
1134
- await sftp.disconnect();
1135
-
1136
- return {
1137
- success: true,
1138
- filesProcessed: newFiles.length,
1139
- results
1140
- };
1141
- });
1142
- ```
1143
-
1144
- ### Key Points
1145
-
1146
- - **VersoriKVAdapter:** Persistent state management across workflow runs
1147
- - **Credential Access:** Use `ctx.credentials()` for secure credential retrieval
1148
- - **Environment Variables:** Access via `activation.getVariable()`
1149
- - **File Tracking:** Prevent duplicate processing with KV storage
1150
- - **Error Handling:** Per-file error handling prevents workflow failure
1151
- - **Resource Cleanup:** Always call `sftp.disconnect()` to prevent leaks
1152
- - **Schedule Timeout:** 5 minutes default (adjustable up to 30 minutes)
1153
-
1154
- ### Platform Constraints
1155
-
1156
- | Resource | Limit | Impact | Mitigation |
1157
- |----------|-------|--------|------------|
1158
- | **Schedule Timeout** | 5 minutes (default) | Large file batches may timeout | Process in chunks, use continuation |
1159
- | **Memory** | ~512MB | Large files may cause OOM | Use streaming parsers, batch processing |
1160
- | **Concurrent Requests** | ~10 | Parallel API calls limited | Use SDK batch methods, queue processing |
1161
-
1162
- ### State Management Pattern
1163
-
1164
- ```typescript
1165
- // KV Key Structure
1166
- ['workflow-name', 'state-type']
1167
- // Examples:
1168
- ['inventory-sync', 'processed-files'] // File tracking
1169
- ['order-import', 'last-cursor'] // Pagination cursor
1170
- ['daily-extract', 'last-run-timestamp'] // Execution tracking
1171
- ```
1172
-
1173
- ### Testing in Versori
1174
-
1175
- ```bash
1176
- # Trigger schedule manually in Versori UI
1177
- # OR use CLI:
1178
- versori schedule trigger inventory-sync
1179
-
1180
- # Check logs for:
1181
- # - Previously processed files count
1182
- # - New files detected
1183
- # - Processing results
1184
- # - KV state updates
1185
- ```
1186
-
1187
- ---
1188
-
1189
- ## Key Takeaways
1190
-
1191
- 1. **Nested Objects:** Use dot notation for simple nesting, nested `fields` for complex structures
1192
- 2. **Static Values:** Use `value` property for constants at any nesting level
1193
- 3. **Conditional Logic:** Custom resolvers enable complex business rules
1194
- 4. **Arrays:** Use `isArray: true` to process array elements
1195
- 5. **Async Resolvers:** Enable external lookups but use sparingly for performance
1196
- 6. **Chaining:** Combine transformations via custom resolvers or SDK resolver composition
1197
- 7. **Context:** Pass workflow-specific data via `context` option
1198
- 8. **Versori State Management:** Use VersoriKVAdapter for persistent state across runs
1199
- 9. **Versori Credentials:** Use `ctx.credentials()` for secure access to connection credentials
1200
- 10. **Resource Cleanup:** Always dispose SFTP/database connections to prevent leaks
1201
-
1202
- ---
1203
-
1204
- ## Practice Exercise
1205
-
1206
- **Task:** Create a mapping for complex order data with nested customer, multiple addresses, and line items.
1207
-
1208
- **Source Data:**
1209
- ```json
1210
- {
1211
- "order_id": "12345",
1212
- "customer_email": " JOHN@EXAMPLE.COM ",
1213
- "customer_tier": "premium",
1214
- "billing_address": {
1215
- "street": "123 Main St",
1216
- "city": "New York",
1217
- "state": "NY",
1218
- "zip": "10001"
1219
- },
1220
- "shipping_address": {
1221
- "street": "456 Oak Ave",
1222
- "city": "Boston",
1223
- "state": "MA",
1224
- "zip": "02101"
1225
- },
1226
- "line_items": [
1227
- { "sku": "PROD-001", "qty": "2", "price": "49.99" },
1228
- { "sku": "PROD-002", "qty": "1", "price": "99.99" }
1229
- ]
1230
- }
1231
- ```
1232
-
1233
- **Requirements:**
1234
- 1. Clean and normalize customer email
1235
- 2. Map both billing and shipping addresses
1236
- 3. Process line items array
1237
- 4. Calculate item totals
1238
- 5. Determine priority based on customer tier
1239
-
1240
- <details>
1241
- <summary>Click to see solution</summary>
1242
-
1243
- ```json
1244
- {
1245
- "fields": {
1246
- "ref": {
1247
- "source": "order_id"
1248
- },
1249
- "customer": {
1250
- "fields": {
1251
- "email": {
1252
- "source": "customer_email",
1253
- "resolver": "custom.cleanEmail"
1254
- }
1255
- }
1256
- },
1257
- "priority": {
1258
- "source": "customer_tier",
1259
- "resolver": "custom.calculatePriority"
1260
- },
1261
- "billingAddress": {
1262
- "fields": {
1263
- "street": {
1264
- "source": "billing_address.street"
1265
- },
1266
- "city": {
1267
- "source": "billing_address.city"
1268
- },
1269
- "state": {
1270
- "source": "billing_address.state"
1271
- },
1272
- "zipCode": {
1273
- "source": "billing_address.zip"
1274
- }
1275
- }
1276
- },
1277
- "shippingAddress": {
1278
- "fields": {
1279
- "street": {
1280
- "source": "shipping_address.street"
1281
- },
1282
- "city": {
1283
- "source": "shipping_address.city"
1284
- },
1285
- "state": {
1286
- "source": "shipping_address.state"
1287
- },
1288
- "zipCode": {
1289
- "source": "shipping_address.zip"
1290
- }
1291
- }
1292
- },
1293
- "items": {
1294
- "source": "line_items",
1295
- "isArray": true,
1296
- "fields": {
1297
- "productRef": {
1298
- "source": "sku"
1299
- },
1300
- "quantity": {
1301
- "source": "qty",
1302
- "resolver": "sdk.parseInt"
1303
- },
1304
- "price": {
1305
- "source": "price",
1306
- "resolver": "sdk.parseFloat"
1307
- },
1308
- "totalPrice": {
1309
- "resolver": "custom.calculateItemTotal"
1310
- }
1311
- }
1312
- }
1313
- }
1314
- }
1315
- ```
1316
-
1317
- **Custom Resolvers:**
1318
- ```typescript
1319
- const customResolvers = {
1320
- 'custom.cleanEmail': (value: string, sourceData: any, config: any, helpers: any) => {
1321
- return helpers.normalizeWhitespace(value).toLowerCase();
1322
- },
1323
-
1324
- 'custom.calculatePriority': (tier: string) => {
1325
- return tier === 'premium' ? 'HIGH' : 'NORMAL';
1326
- },
1327
-
1328
- 'custom.calculateItemTotal': (value: any, sourceData: any, config: any, helpers: any) => {
1329
- const qty = helpers.parseIntSafe(sourceData.qty, 0);
1330
- const price = helpers.parseFloatSafe(sourceData.price, 0);
1331
- return qty * price;
1332
- }
1333
- };
1334
- ```
1335
-
1336
- </details>
1337
-
1338
- ---
1339
-
1340
- ## Next Steps
1341
-
1342
- Now that you've mastered advanced patterns, explore the helper functions available:
1343
-
1344
- → Continue to [Module 6: Helpers & Resolvers](./mapping-06-helpers-resolvers.md)
1345
-
1346
- **Alternative paths:**
1347
- - Jump to [API Reference](../../auto-pagination/modules/auto-pagination-07-api-reference.md) for TypeScript usage
1348
- - Review [GraphQL Mutation Mapping Quick Reference](../graphql-mutation-mapping/graphql-mutation-mapping-quick-reference.md) for a comprehensive cheat sheet
1349
-
1350
- ---
1351
-
1352
- [← Back to Use Cases](./mapping-04-use-cases.md) | [Next: Helpers & Resolvers →](./mapping-06-helpers-resolvers.md)
1353
-
1354
-
1355
-
1356
-
1357
-
1358
-
1359
-
1360
-
1361
-
1362
-
1363
-
1364
-
1
+ # Module 5: Advanced Patterns
2
+
3
+ **Master complex transformations**
4
+
5
+ **Level:** Advanced
6
+ **Time:** 40 minutes
7
+ **Prerequisites:** [Module 4: Use Cases](./mapping-04-use-cases.md)
8
+
9
+ ---
10
+
11
+ ## Learning Objectives
12
+
13
+ After completing this module, you will:
14
+ - ✅ Map complex nested object structures
15
+ - ✅ Process arrays with `isArray` configuration
16
+ - ✅ Implement conditional transformations
17
+ - ✅ Use async resolvers for external lookups
18
+ - ✅ Chain multiple transformations
19
+ - ✅ Handle GraphQL edges/nodes pagination patterns
20
+
21
+ ---
22
+
23
+ ## Nested Objects
24
+
25
+ Map nested structures using dot notation or nested `fields` configuration.
26
+
27
+ ### Method 1: Dot Notation (Flat Mapping)
28
+
29
+ ```json
30
+ {
31
+ "fields": {
32
+ "customer.id": {
33
+ "source": "customerId",
34
+ "resolver": "sdk.parseInt"
35
+ },
36
+ "customer.email": {
37
+ "source": "email",
38
+ "resolver": "sdk.lowercase"
39
+ },
40
+ "customer.address.city": {
41
+ "source": "city"
42
+ },
43
+ "customer.address.state": {
44
+ "source": "state"
45
+ },
46
+ "customer.address.zipCode": {
47
+ "source": "zip"
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ **Output:**
54
+ ```json
55
+ {
56
+ "customer": {
57
+ "id": 12345,
58
+ "email": "john@example.com",
59
+ "address": {
60
+ "city": "New York",
61
+ "state": "NY",
62
+ "zipCode": "10001"
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ ### Method 2: Nested Fields (Structured Mapping)
69
+
70
+ ```json
71
+ {
72
+ "fields": {
73
+ "customer": {
74
+ "fields": {
75
+ "id": {
76
+ "source": "customerId",
77
+ "resolver": "sdk.parseInt"
78
+ },
79
+ "email": {
80
+ "source": "email",
81
+ "resolver": "sdk.lowercase"
82
+ },
83
+ "address": {
84
+ "fields": {
85
+ "city": {
86
+ "source": "city"
87
+ },
88
+ "state": {
89
+ "source": "state"
90
+ },
91
+ "zipCode": {
92
+ "source": "zip"
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ **Same output** as Method 1, but more structured and easier to read for complex objects.
103
+
104
+ ### When to Use Each Method
105
+
106
+ | Method | Best For | Advantages | Disadvantages |
107
+ |--------|----------|------------|---------------|
108
+ | **Dot Notation** | Simple nesting (2-3 levels) | Concise, flat structure | Hard to read for deep nesting |
109
+ | **Nested Fields** | Complex objects, reusable components | Clear hierarchy, organized | More verbose |
110
+
111
+ ### Deep Nesting Example
112
+
113
+ ```json
114
+ {
115
+ "fields": {
116
+ "order": {
117
+ "fields": {
118
+ "ref": {
119
+ "source": "orderRef"
120
+ },
121
+ "customer": {
122
+ "fields": {
123
+ "id": {
124
+ "source": "customerId"
125
+ },
126
+ "profile": {
127
+ "fields": {
128
+ "firstName": {
129
+ "source": "firstName"
130
+ },
131
+ "lastName": {
132
+ "source": "lastName"
133
+ },
134
+ "preferences": {
135
+ "fields": {
136
+ "language": {
137
+ "source": "lang",
138
+ "defaultValue": "en"
139
+ },
140
+ "currency": {
141
+ "source": "currency",
142
+ "defaultValue": "USD"
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Static Values
159
+
160
+ Provide constant values at any nesting level.
161
+
162
+ ### Simple Static Values
163
+
164
+ ```json
165
+ {
166
+ "fields": {
167
+ "type": {
168
+ "value": "STANDARD"
169
+ },
170
+ "retailerId": {
171
+ "value": 1
172
+ },
173
+ "source": {
174
+ "value": "CSV_IMPORT"
175
+ },
176
+ "status": {
177
+ "value": "ACTIVE"
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ ### Static Values in Nested Objects
184
+
185
+ ```json
186
+ {
187
+ "fields": {
188
+ "retailer": {
189
+ "fields": {
190
+ "id": {
191
+ "value": "1",
192
+ "comment": "Static retailer ID"
193
+ }
194
+ }
195
+ },
196
+ "metadata": {
197
+ "fields": {
198
+ "importSource": {
199
+ "value": "SFCC"
200
+ },
201
+ "importVersion": {
202
+ "value": "2.0"
203
+ },
204
+ "importTimestamp": {
205
+ "resolver": "custom.currentTimestamp"
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ ```
212
+
213
+ ---
214
+
215
+ ## Conditional Transformations
216
+
217
+ Use custom resolvers for complex business logic and conditional mapping.
218
+
219
+ ### Simple Conditional Mapping
220
+
221
+ ```json
222
+ {
223
+ "fields": {
224
+ "shippingMethod": {
225
+ "source": "deliverySpeed",
226
+ "resolver": "custom.mapShipping"
227
+ },
228
+ "priority": {
229
+ "source": "orderType",
230
+ "resolver": "custom.calculatePriority"
231
+ }
232
+ }
233
+ }
234
+ ```
235
+
236
+ **Custom Resolvers:**
237
+ ```typescript
238
+ const customResolvers = {
239
+ 'custom.mapShipping': (value: string) => {
240
+ // Map delivery speed to shipping method
241
+ const shippingMap: Record<string, string> = {
242
+ 'express': 'OVERNIGHT',
243
+ 'fast': 'TWO_DAY',
244
+ 'standard': 'GROUND',
245
+ 'economy': 'STANDARD'
246
+ };
247
+ return shippingMap[value.toLowerCase()] || 'STANDARD';
248
+ },
249
+
250
+ 'custom.calculatePriority': (value: string, sourceData: any) => {
251
+ // Calculate priority based on order type and total
252
+ const isPremium = sourceData.customerTier === 'PREMIUM';
253
+ const isUrgent = value === 'EXPRESS';
254
+ const isLargeOrder = sourceData.total > 1000;
255
+
256
+ if (isUrgent || isPremium) return 'HIGH';
257
+ if (isLargeOrder) return 'MEDIUM';
258
+ return 'LOW';
259
+ }
260
+ };
261
+ ```
262
+
263
+ ### Multi-Condition Resolver
264
+
265
+ ```typescript
266
+ const customResolvers = {
267
+ 'custom.determineOrderType': (value: any, sourceData: any, config: any, helpers: any) => {
268
+ const total = helpers.parseFloatSafe(sourceData.total, 0);
269
+ const itemCount = helpers.parseIntSafe(sourceData.itemCount, 0);
270
+ const isInternational = sourceData.country !== 'US';
271
+
272
+ // Complex business rules
273
+ if (isInternational && total > 500) {
274
+ return 'INTERNATIONAL_PREMIUM';
275
+ }
276
+
277
+ if (itemCount > 20 || total > 1000) {
278
+ return 'BULK';
279
+ }
280
+
281
+ if (sourceData.expedited === true) {
282
+ return 'EXPRESS';
283
+ }
284
+
285
+ return 'STANDARD';
286
+ }
287
+ };
288
+ ```
289
+
290
+ ---
291
+
292
+ ## Arrays
293
+
294
+ Map arrays of items using the `isArray` field configuration.
295
+
296
+ ### Array Mapping with `isArray`
297
+
298
+ ```json
299
+ {
300
+ "fields": {
301
+ "items": {
302
+ "source": "orderItems",
303
+ "isArray": true,
304
+ "fields": {
305
+ "ref": {
306
+ "source": "id"
307
+ },
308
+ "productRef": {
309
+ "source": "sku"
310
+ },
311
+ "quantity": {
312
+ "source": "qty",
313
+ "resolver": "sdk.parseInt"
314
+ },
315
+ "price": {
316
+ "source": "unitPrice",
317
+ "resolver": "sdk.parseFloat"
318
+ },
319
+ "totalPrice": {
320
+ "resolver": "custom.calculateItemTotal"
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ ```
327
+
328
+ ### How `isArray` Works
329
+
330
+ 1. **Extract Array:** `source: "orderItems"` extracts the array from source data
331
+ 2. **Iterate:** `isArray: true` tells mapper to iterate over array elements
332
+ 3. **Map Each Item:** `fields: {...}` defines mappings for each array element
333
+ 4. **Context Switch:** ⚠️ **THIS IS THE KEY CONCEPT**
334
+
335
+ Once inside `fields` of an `isArray` mapping, the context changes:
336
+
337
+ ```mermaid
338
+ flowchart TD
339
+ A[Root Context<br/>orderItems: [...],<br/>customerId: 123] -->|Iterate Array| B[Array Item Context<br/>id: 'ITEM-1'<br/>sku: 'PROD-001'<br/>qty: '2']
340
+ B -->|Map Fields| C[Field Mappings<br/>source: 'id'<br/>→ Reads from item,<br/>NOT root]
341
+
342
+ style A fill:#e1f5ff
343
+ style B fill:#fff4e1
344
+ style C fill:#e8f5e9
345
+ ```
346
+
347
+ **Text Version:**
348
+ ```
349
+ Root Context: { orderItems: [...], customerId: 123 }
350
+ ↓ iterate
351
+ Array Item: { id: "ITEM-1", sku: "PROD-001", qty: "2" } ← NEW CONTEXT!
352
+ ↓ map fields
353
+ Field Mappings: "source": "id" reads from THIS object, not root
354
+ ```
355
+
356
+ **In other words:**
357
+ - `"source": "id"` → reads `currentArrayItem.id`
358
+ - `"source": "sku"` → reads `currentArrayItem.sku`
359
+ - NOT `sourceData.id` or `sourceData.sku`
360
+
361
+ **Source Data:**
362
+ ```json
363
+ {
364
+ "orderItems": [
365
+ { "id": "ITEM-1", "sku": "PROD-001", "qty": "2", "unitPrice": "49.99" },
366
+ { "id": "ITEM-2", "sku": "PROD-002", "qty": "1", "unitPrice": "99.99" }
367
+ ]
368
+ }
369
+ ```
370
+
371
+ **Mapped Output:**
372
+ ```json
373
+ {
374
+ "items": [
375
+ { "ref": "ITEM-1", "productRef": "PROD-001", "quantity": 2, "price": 49.99, "totalPrice": 99.98 },
376
+ { "ref": "ITEM-2", "productRef": "PROD-002", "quantity": 1, "price": 99.99, "totalPrice": 99.99 }
377
+ ]
378
+ }
379
+ ```
380
+
381
+ ### Common Mistake: Don't Repeat Array Path
382
+
383
+ **❌ WRONG - Repeating array path in nested fields:**
384
+ ```json
385
+ {
386
+ "items": {
387
+ "source": "orderItems",
388
+ "isArray": true,
389
+ "fields": {
390
+ "ref": {
391
+ "source": "orderItems[0].id" // ❌ Already inside array context!
392
+ }
393
+ }
394
+ }
395
+ }
396
+ ```
397
+
398
+ **✅ CORRECT - Use paths relative to array item:**
399
+ ```json
400
+ {
401
+ "items": {
402
+ "source": "orderItems",
403
+ "isArray": true,
404
+ "fields": {
405
+ "ref": {
406
+ "source": "id" // ✅ Reads from current array item
407
+ }
408
+ }
409
+ }
410
+ }
411
+ ```
412
+
413
+ ### Accessing Array Item Context
414
+
415
+ Within array field mappings, use `source` paths relative to the array item:
416
+
417
+ ```json
418
+ {
419
+ "items": {
420
+ "source": "orderItems",
421
+ "isArray": true,
422
+ "fields": {
423
+ "ref": {
424
+ "source": "id" // ← Reads from current array item's 'id' field
425
+ },
426
+ "productRef": {
427
+ "source": "sku" // ← Reads from current array item's 'sku' field
428
+ }
429
+ }
430
+ }
431
+ }
432
+ ```
433
+
434
+ ### GraphQL Edges/Nodes Pattern
435
+
436
+ **IMPORTANT:** The asterisk `[*]` is optional. Both patterns work:
437
+
438
+ **Option 1: With wildcard (extracts nodes directly)**
439
+ ```json
440
+ {
441
+ "fields": {
442
+ "products": {
443
+ "source": "products.edges[*].node", // Wildcard extracts nodes
444
+ "isArray": true,
445
+ "fields": {
446
+ "id": { "source": "id" }, // Direct access (no "node." prefix)
447
+ "ref": { "source": "ref" },
448
+ "name": { "source": "name" },
449
+ "price": { "source": "price", "resolver": "sdk.parseFloat" }
450
+ }
451
+ }
452
+ }
453
+ }
454
+ ```
455
+
456
+ **Option 2: Without wildcard (extracts edges)**
457
+ ```json
458
+ {
459
+ "fields": {
460
+ "products": {
461
+ "source": "products.edges", // Direct array access
462
+ "isArray": true,
463
+ "fields": {
464
+ "id": { "source": "node.id" }, // Need "node." prefix
465
+ "ref": { "source": "node.ref" },
466
+ "name": { "source": "node.name" },
467
+ "price": { "source": "node.price", "resolver": "sdk.parseFloat" }
468
+ }
469
+ }
470
+ }
471
+ }
472
+ ```
473
+
474
+ **Option 3: Auto-detection (SDK detects GraphQL structure)**
475
+ ```json
476
+ {
477
+ "fields": {
478
+ "products": {
479
+ "source": "products", // SDK auto-detects { edges: [...] }
480
+ "isArray": true,
481
+ "fields": {
482
+ "id": { "source": "id" }, // Direct access (nodes auto-extracted)
483
+ "ref": { "source": "ref" },
484
+ "name": { "source": "name" },
485
+ "price": { "source": "price", "resolver": "sdk.parseFloat" }
486
+ }
487
+ }
488
+ }
489
+ }
490
+ ```
491
+
492
+ **All three produce the same result** - choose based on clarity for your use case
493
+
494
+ **Source Data (GraphQL):**
495
+ ```json
496
+ {
497
+ "products": {
498
+ "edges": [
499
+ { "cursor": "abc123", "node": { "id": "1", "ref": "PROD-001", "name": "Widget", "price": 19.99 } },
500
+ { "cursor": "def456", "node": { "id": "2", "ref": "PROD-002", "name": "Gadget", "price": 29.99 } }
501
+ ]
502
+ }
503
+ }
504
+ ```
505
+
506
+ **Mapped Output:**
507
+ ```json
508
+ {
509
+ "products": [
510
+ { "id": "1", "ref": "PROD-001", "name": "Widget", "price": 19.99 },
511
+ { "id": "2", "ref": "PROD-002", "name": "Gadget", "price": 29.99 }
512
+ ]
513
+ }
514
+ ```
515
+
516
+ ### Nested Arrays
517
+
518
+ Arrays within arrays require nested `isArray` configuration. This is common when parsing XML with complex nested structures.
519
+
520
+ #### XML Example with Nested Arrays
521
+
522
+ **Sample XML:**
523
+ ```xml
524
+ <?xml version="1.0" encoding="UTF-8"?>
525
+ <Orders>
526
+ <Order id="ORD-001" orderDate="2025-01-22">
527
+ <customer>
528
+ <name>John Doe</name>
529
+ <email>John@Example.com</email>
530
+ </customer>
531
+ <items>
532
+ <item sku="PROD-001" qty="2">
533
+ <price>29.99</price>
534
+ <attributes>
535
+ <attribute name="color" value="Blue"/>
536
+ <attribute name="size" value="M"/>
537
+ </attributes>
538
+ </item>
539
+ <item sku="PROD-002" qty="1">
540
+ <price>49.99</price>
541
+ <attributes>
542
+ <attribute name="color" value="Red"/>
543
+ <attribute name="size" value="L"/>
544
+ </attributes>
545
+ </item>
546
+ </items>
547
+ </Order>
548
+ <Order id="ORD-002" orderDate="2025-01-23">
549
+ <customer>
550
+ <name>Jane Smith</name>
551
+ <email>jane@example.com</email>
552
+ </customer>
553
+ <items>
554
+ <item sku="PROD-003" qty="3">
555
+ <price>19.99</price>
556
+ <attributes>
557
+ <attribute name="color" value="Green"/>
558
+ </attributes>
559
+ </item>
560
+ </items>
561
+ </Order>
562
+ </Orders>
563
+ ```
564
+
565
+ **After XML Parsing (fast-xml-parser):**
566
+ ```json
567
+ {
568
+ "Orders": {
569
+ "Order": [
570
+ {
571
+ "@id": "ORD-001",
572
+ "@orderDate": "2025-01-22",
573
+ "customer": {
574
+ "name": "John Doe",
575
+ "email": "John@Example.com"
576
+ },
577
+ "items": {
578
+ "item": [
579
+ {
580
+ "@sku": "PROD-001",
581
+ "@qty": "2",
582
+ "price": "29.99",
583
+ "attributes": {
584
+ "attribute": [
585
+ { "@name": "color", "@value": "Blue" },
586
+ { "@name": "size", "@value": "M" }
587
+ ]
588
+ }
589
+ },
590
+ {
591
+ "@sku": "PROD-002",
592
+ "@qty": "1",
593
+ "price": "49.99",
594
+ "attributes": {
595
+ "attribute": [
596
+ { "@name": "color", "@value": "Red" },
597
+ { "@name": "size", "@value": "L" }
598
+ ]
599
+ }
600
+ }
601
+ ]
602
+ }
603
+ }
604
+ ]
605
+ }
606
+ }
607
+ ```
608
+
609
+ **Mapping Configuration:**
610
+ ```json
611
+ {
612
+ "fields": {
613
+ "orders": {
614
+ "source": "Orders.Order",
615
+ "isArray": true,
616
+ "fields": {
617
+ "orderRef": {
618
+ "source": "@id",
619
+ "required": true
620
+ },
621
+ "orderDate": {
622
+ "source": "@orderDate",
623
+ "resolver": "sdk.parseDate"
624
+ },
625
+ "customerName": {
626
+ "source": "customer.name",
627
+ "resolver": "sdk.trim"
628
+ },
629
+ "customerEmail": {
630
+ "source": "customer.email",
631
+ "resolver": "sdk.lowercase"
632
+ },
633
+ "items": {
634
+ "source": "items.item",
635
+ "isArray": true,
636
+ "comment": "Nested array: items within orders",
637
+ "fields": {
638
+ "productRef": {
639
+ "source": "@sku",
640
+ "required": true
641
+ },
642
+ "quantity": {
643
+ "source": "@qty",
644
+ "resolver": "sdk.parseInt"
645
+ },
646
+ "price": {
647
+ "source": "price",
648
+ "resolver": "sdk.parseFloat"
649
+ },
650
+ "attributes": {
651
+ "source": "attributes.attribute",
652
+ "isArray": true,
653
+ "comment": "Nested array: attributes within items",
654
+ "fields": {
655
+ "name": {
656
+ "source": "@name",
657
+ "resolver": "sdk.trim"
658
+ },
659
+ "value": {
660
+ "source": "@value",
661
+ "resolver": "sdk.trim"
662
+ }
663
+ }
664
+ }
665
+ }
666
+ }
667
+ }
668
+ }
669
+ }
670
+ }
671
+ ```
672
+
673
+ **TypeScript Implementation:**
674
+ ```typescript
675
+ import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
676
+ import { XMLParserService } from '@fluentcommerce/fc-connect-sdk';
677
+
678
+ // Parse XML
679
+ const parser = new XMLParserService();
680
+ const parsedXml = await parser.parse(xmlContent);
681
+
682
+ // Normalize array (XML parser returns object for single element, array for multiple)
683
+ const ordersData = Array.isArray(parsedXml?.Orders?.Order)
684
+ ? parsedXml.Orders.Order
685
+ : parsedXml?.Orders?.Order
686
+ ? [parsedXml.Orders.Order]
687
+ : [];
688
+
689
+ // Create mapper
690
+ const mapper = new UniversalMapper(mappingConfig);
691
+
692
+ // Map each order
693
+ const mappedOrders = [];
694
+ for (const order of ordersData) {
695
+ const result = await mapper.map({ Order: order });
696
+ mappedOrders.push(result.data);
697
+ }
698
+ ```
699
+
700
+ **Mapped Output:**
701
+ ```json
702
+ {
703
+ "orders": [
704
+ {
705
+ "orderRef": "ORD-001",
706
+ "orderDate": "2025-01-22T00:00:00.000Z",
707
+ "customerName": "John Doe",
708
+ "customerEmail": "john@example.com",
709
+ "items": [
710
+ {
711
+ "productRef": "PROD-001",
712
+ "quantity": 2,
713
+ "price": 29.99,
714
+ "attributes": [
715
+ { "name": "color", "value": "Blue" },
716
+ { "name": "size", "value": "M" }
717
+ ]
718
+ },
719
+ {
720
+ "productRef": "PROD-002",
721
+ "quantity": 1,
722
+ "price": 49.99,
723
+ "attributes": [
724
+ { "name": "color", "value": "Red" },
725
+ { "name": "size", "value": "L" }
726
+ ]
727
+ }
728
+ ]
729
+ },
730
+ {
731
+ "orderRef": "ORD-002",
732
+ "orderDate": "2025-01-23T00:00:00.000Z",
733
+ "customerName": "Jane Smith",
734
+ "customerEmail": "jane@example.com",
735
+ "items": [
736
+ {
737
+ "productRef": "PROD-003",
738
+ "quantity": 3,
739
+ "price": 19.99,
740
+ "attributes": [
741
+ { "name": "color", "value": "Green" }
742
+ ]
743
+ }
744
+ ]
745
+ }
746
+ ]
747
+ }
748
+ ```
749
+
750
+ **Key Points:**
751
+ 1. **XML Attributes**: Use `@` prefix (`@id`, `@sku`, `@name`, `@value`)
752
+ 2. **Nested Arrays**: Each level requires `isArray: true` with nested `fields`
753
+ 3. **Resolvers**: Apply appropriate resolvers (`sdk.parseInt`, `sdk.parseFloat`, `sdk.parseDate`, `sdk.lowercase`, `sdk.trim`)
754
+ 4. **Context Switching**: Within array `fields`, `source` paths are relative to each array item
755
+ 5. **Array Normalization**: Always normalize XML arrays before mapping (single element → object, multiple → array)
756
+
757
+ ---
758
+
759
+ ## Async Resolvers
760
+
761
+ Resolvers can be async for external lookups and API calls.
762
+
763
+ ### Customer Lookup Example
764
+
765
+ ```typescript
766
+ const customResolvers = {
767
+ 'custom.lookupCustomer': async (email: string, sourceData: any, config: any, helpers: any) => {
768
+ // Ensure fluentClient is available
769
+ if (!helpers.fluentClient) {
770
+ throw new Error('fluentClient not available - pass via UniversalMapper options');
771
+ }
772
+
773
+ // Query Fluent API to find customer by email
774
+ const query = `
775
+ query GetCustomerByEmail($email: String!) {
776
+ customers(email: $email) {
777
+ edges {
778
+ node {
779
+ id
780
+ }
781
+ }
782
+ }
783
+ }
784
+ `;
785
+
786
+ const result = await helpers.fluentClient.graphql({
787
+ query,
788
+ variables: { email }
789
+ });
790
+
791
+ const customerId = result.data.customers.edges[0]?.node.id;
792
+
793
+ if (!customerId) {
794
+ throw new Error(`Customer not found for email: ${email}`);
795
+ }
796
+
797
+ return customerId;
798
+ }
799
+ };
800
+
801
+ // Create mapper with fluentClient option
802
+ const mapper = new UniversalMapper(mappingConfig, {
803
+ customResolvers,
804
+ fluentClient: fluentClient // ✅ Makes client available in helpers
805
+ });
806
+ ```
807
+
808
+ ### Product Enrichment Example
809
+
810
+ ```typescript
811
+ const customResolvers = {
812
+ 'custom.enrichProduct': async (sku: string, sourceData: any, config: any, helpers: any) => {
813
+ // Fetch product details from external API
814
+ const response = await fetch(`https://api.example.com/products/${sku}`);
815
+ const product = await response.json();
816
+
817
+ return {
818
+ sku: product.sku,
819
+ name: product.name,
820
+ description: product.description,
821
+ category: product.category,
822
+ price: product.price
823
+ };
824
+ }
825
+ };
826
+ ```
827
+
828
+ ### Memoized Async Resolver
829
+
830
+ For repeated lookups, use memoization to cache results:
831
+
832
+ ```typescript
833
+ const customResolvers = {
834
+ 'custom.getCategoryName': helpers.memoize(async (categoryId: string) => {
835
+ // This will only be called once per unique categoryId
836
+ const response = await fetch(`https://api.example.com/categories/${categoryId}`);
837
+ const category = await response.json();
838
+ return category.name;
839
+ })
840
+ };
841
+ ```
842
+
843
+ ### Performance Consideration
844
+
845
+ **❌ Avoid:** Individual async calls in batch processing loops
846
+
847
+ ```typescript
848
+ // ❌ Bad - Makes N API calls for N records
849
+ for (const record of records) {
850
+ const result = await mapper.map(record); // Each calls async resolver
851
+ }
852
+ ```
853
+
854
+ **✅ Better:** Pre-fetch data, then map synchronously
855
+
856
+ ```typescript
857
+ // ✅ Good - Single batch API call, then fast mapping
858
+ const customerIds = records.map(r => r.customerId);
859
+ const customers = await api.getCustomersBatch(customerIds);
860
+ const customersMap = new Map(customers.map(c => [c.id, c]));
861
+
862
+ const customResolvers = {
863
+ 'custom.getCustomer': (id: string) => customersMap.get(id) // Synchronous lookup
864
+ };
865
+
866
+ for (const record of records) {
867
+ const result = await mapper.map(record); // Fast - no API calls
868
+ }
869
+ ```
870
+
871
+ ---
872
+
873
+ ## Chaining Transformations
874
+
875
+ Combine multiple transformations via custom resolvers.
876
+
877
+ ### Method 1: Manual Chaining
878
+
879
+ ```typescript
880
+ const customResolvers = {
881
+ 'custom.cleanAndFormat': (value: string, sourceData: any, config: any, helpers: any) => {
882
+ // Chain: trim → lowercase → capitalize first letter
883
+ const trimmed = helpers.normalizeWhitespace(value);
884
+ const lower = trimmed.toLowerCase();
885
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
886
+ }
887
+ };
888
+ ```
889
+
890
+ ### Method 2: Using SDK Resolvers Within Custom
891
+
892
+ ```typescript
893
+ import { getSdkResolver } from '@fluentcommerce/fc-connect-sdk';
894
+
895
+ const trim = getSdkResolver('sdk.trim');
896
+ const uppercase = getSdkResolver('sdk.uppercase');
897
+
898
+ const customResolvers = {
899
+ 'custom.cleanAndUpper': (value: any, sourceData: any, config: any, helpers: any) => {
900
+ // Chain SDK resolvers
901
+ const trimmed = trim(value, sourceData, config, helpers);
902
+ return uppercase(trimmed, sourceData, config, helpers);
903
+ }
904
+ };
905
+ ```
906
+
907
+ ### Complex Chaining Example
908
+
909
+ ```typescript
910
+ const customResolvers = {
911
+ 'custom.processOrderRef': (value: string, sourceData: any, config: any, helpers: any) => {
912
+ // Step 1: Trim whitespace
913
+ const cleaned = helpers.normalizeWhitespace(value);
914
+
915
+ // Step 2: Uppercase
916
+ const upper = cleaned.toUpperCase();
917
+
918
+ // Step 3: Add prefix if not present
919
+ const withPrefix = upper.startsWith('ORD-') ? upper : `ORD-${upper}`;
920
+
921
+ // Step 4: Validate format
922
+ if (!/^ORD-[A-Z0-9]+$/.test(withPrefix)) {
923
+ throw new Error(`Invalid order ref format: ${withPrefix}`);
924
+ }
925
+
926
+ return withPrefix;
927
+ }
928
+ };
929
+ ```
930
+
931
+ ### Resolver Composition
932
+
933
+ Create reusable resolver building blocks:
934
+
935
+ ```typescript
936
+ // Utility functions
937
+ const cleanString = (value: string) => value.trim().replace(/\s+/g, ' ');
938
+ const normalizeEmail = (value: string) => value.toLowerCase().trim();
939
+ const validateEmail = (value: string) => {
940
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
941
+ if (!emailRegex.test(value)) {
942
+ throw new Error(`Invalid email: ${value}`);
943
+ }
944
+ return value;
945
+ };
946
+
947
+ // Composed resolvers
948
+ const customResolvers = {
949
+ 'custom.processEmail': (value: string) => {
950
+ return validateEmail(normalizeEmail(cleanString(value)));
951
+ },
952
+
953
+ 'custom.processPhone': (value: string) => {
954
+ const cleaned = cleanString(value);
955
+ const digitsOnly = cleaned.replace(/\D/g, '');
956
+ return `(${digitsOnly.slice(0,3)}) ${digitsOnly.slice(3,6)}-${digitsOnly.slice(6)}`;
957
+ }
958
+ };
959
+ ```
960
+
961
+ ---
962
+
963
+ ## Advanced Context Usage
964
+
965
+ Access workflow-specific data via `helpers.context`.
966
+
967
+ ### Passing Context to Mapper
968
+
969
+ ```typescript
970
+ // Create mapper with context
971
+ const mapper = new UniversalMapper(mappingConfig, {
972
+ customResolvers,
973
+ context: {
974
+ orderId: 'ORD-2025-001',
975
+ userId: '999',
976
+ importDate: new Date().toISOString(),
977
+ retailerId: '1'
978
+ }
979
+ });
980
+ ```
981
+
982
+ ### Using Context in Resolvers
983
+
984
+ ```typescript
985
+ const customResolvers = {
986
+ 'custom.addOrderContext': (value: any, sourceData: any, config: any, helpers: any) => {
987
+ const orderId = helpers.context?.orderId;
988
+ return `${value}_${orderId}`;
989
+ },
990
+
991
+ 'custom.setRetailer': (value: any, sourceData: any, config: any, helpers: any) => {
992
+ return helpers.context?.retailerId || '1';
993
+ },
994
+
995
+ 'custom.auditTrail': (value: any, sourceData: any, config: any, helpers: any) => {
996
+ return {
997
+ value,
998
+ importedBy: helpers.context?.userId,
999
+ importedAt: helpers.context?.importDate
1000
+ };
1001
+ }
1002
+ };
1003
+ ```
1004
+
1005
+ ---
1006
+
1007
+ ## Versori Platform: File Processing with KV State Tracking
1008
+
1009
+ **Problem:** Prevent duplicate file processing in scheduled workflows
1010
+
1011
+ **Solution:** Use VersoriKVAdapter for state management
1012
+
1013
+ ### Complete Implementation
1014
+
1015
+ ```typescript
1016
+ import { schedule } from '@versori/run';
1017
+ import {
1018
+ SftpDataSource,
1019
+ UniversalMapper,
1020
+ VersoriKVAdapter,
1021
+ XMLParserService,
1022
+ createClient
1023
+ } from '@fluentcommerce/fc-connect-sdk';
1024
+
1025
+ export const inventorySync = schedule('inventory-sync', '0 */6 * * *', async (ctx) => {
1026
+ const { log, openKv, activation, credentials } = ctx;
1027
+
1028
+ // 1. Initialize KV state tracking
1029
+ const kv = new VersoriKVAdapter(openKv(':project:'));
1030
+ const processedFilesKey = ['inventory-sync', 'processed-files'];
1031
+
1032
+ // Get list of previously processed files
1033
+ const processed = (await kv.get(processedFilesKey))?.value || [];
1034
+ log.info('Previously processed files', { count: processed.length });
1035
+
1036
+ // 2. Connect to SFTP
1037
+ const sftpCreds = await credentials().getAccessToken('SFTP');
1038
+ const [username, password] = Buffer.from(sftpCreds.accessToken, 'base64')
1039
+ .toString('utf-8')
1040
+ .split(':');
1041
+
1042
+ const sftp = new SftpDataSource({
1043
+ type: 'SFTP_XML',
1044
+ sftpConfig: {
1045
+ host: activation.getVariable('SFTP_HOST'),
1046
+ port: 22,
1047
+ username,
1048
+ password,
1049
+ remotePath: '/inbound/inventory/',
1050
+ filePattern: '*.xml'
1051
+ }
1052
+ }, log);
1053
+
1054
+ await sftp.connect();
1055
+
1056
+ // 3. List files and filter out already processed
1057
+ const allFiles = await sftp.listFiles({
1058
+ remotePath: '/inbound/inventory/',
1059
+ filePattern: '*.xml'
1060
+ });
1061
+
1062
+ const newFiles = allFiles.filter(f => !processed.includes(f.name));
1063
+ log.info('New files to process', {
1064
+ total: allFiles.length,
1065
+ new: newFiles.length
1066
+ });
1067
+
1068
+ // 4. Process each new file
1069
+ const mapper = new UniversalMapper({
1070
+ name: 'inventory.xml.mapping',
1071
+ fields: {
1072
+ retailerId: { source: '$context.retailerId', resolver: 'sdk.parseInt' },
1073
+ locationRef: { source: 'facilityId', resolver: 'sdk.trim' },
1074
+ productRef: { source: 'sku', resolver: 'sdk.trim' },
1075
+ quantity: { source: 'qty', resolver: 'sdk.parseInt' }
1076
+ }
1077
+ }, log);
1078
+
1079
+ const client = await createClient({ ...ctx, log });
1080
+ const retailerId = activation.getVariable('fluentRetailerId');
1081
+ if (!retailerId) {
1082
+ throw new Error('fluentRetailerId required in activation variables');
1083
+ }
1084
+ client.setRetailerId(retailerId);
1085
+
1086
+ const results = [];
1087
+
1088
+ for (const file of newFiles) {
1089
+ try {
1090
+ // Download and parse
1091
+ const content = await sftp.downloadFile(file.path, { encoding: 'utf8' });
1092
+ const parser = new XMLParserService(log);
1093
+ const doc = await parser.parse(content);
1094
+
1095
+ // Map records
1096
+ const records = Array.isArray(doc.inventory.item)
1097
+ ? doc.inventory.item
1098
+ : [doc.inventory.item];
1099
+
1100
+ const mappedRecords = [];
1101
+ for (const rec of records) {
1102
+ const result = await mapper.map({
1103
+ ...rec,
1104
+ $context: { retailerId }
1105
+ });
1106
+ if (result.success) {
1107
+ mappedRecords.push(result.data);
1108
+ }
1109
+ }
1110
+
1111
+ // Send to Fluent Batch API
1112
+ await client.batch.submit('INVENTORY_QUANTITY', mappedRecords);
1113
+
1114
+ // Mark as processed
1115
+ processed.push(file.name);
1116
+ await kv.set(processedFilesKey, processed);
1117
+
1118
+ // Archive file
1119
+ await sftp.moveFile(
1120
+ file.path,
1121
+ `/archive/inventory/${file.name}`,
1122
+ true
1123
+ );
1124
+
1125
+ results.push({ file: file.name, status: 'success', records: mappedRecords.length });
1126
+ log.info('File processed successfully', { file: file.name });
1127
+
1128
+ } catch (error) {
1129
+ log.error('File processing failed', { file: file.name, error });
1130
+ results.push({ file: file.name, status: 'failed', error: error.message });
1131
+ }
1132
+ }
1133
+
1134
+ await sftp.disconnect();
1135
+
1136
+ return {
1137
+ success: true,
1138
+ filesProcessed: newFiles.length,
1139
+ results
1140
+ };
1141
+ });
1142
+ ```
1143
+
1144
+ ### Key Points
1145
+
1146
+ - **VersoriKVAdapter:** Persistent state management across workflow runs
1147
+ - **Credential Access:** Use `ctx.credentials()` for secure credential retrieval
1148
+ - **Environment Variables:** Access via `activation.getVariable()`
1149
+ - **File Tracking:** Prevent duplicate processing with KV storage
1150
+ - **Error Handling:** Per-file error handling prevents workflow failure
1151
+ - **Resource Cleanup:** Always call `sftp.disconnect()` to prevent leaks
1152
+ - **Schedule Timeout:** 5 minutes default (adjustable up to 30 minutes)
1153
+
1154
+ ### Platform Constraints
1155
+
1156
+ | Resource | Limit | Impact | Mitigation |
1157
+ |----------|-------|--------|------------|
1158
+ | **Schedule Timeout** | 5 minutes (default) | Large file batches may timeout | Process in chunks, use continuation |
1159
+ | **Memory** | ~512MB | Large files may cause OOM | Use streaming parsers, batch processing |
1160
+ | **Concurrent Requests** | ~10 | Parallel API calls limited | Use SDK batch methods, queue processing |
1161
+
1162
+ ### State Management Pattern
1163
+
1164
+ ```typescript
1165
+ // KV Key Structure
1166
+ ['workflow-name', 'state-type']
1167
+ // Examples:
1168
+ ['inventory-sync', 'processed-files'] // File tracking
1169
+ ['order-import', 'last-cursor'] // Pagination cursor
1170
+ ['daily-extract', 'last-run-timestamp'] // Execution tracking
1171
+ ```
1172
+
1173
+ ### Testing in Versori
1174
+
1175
+ ```bash
1176
+ # Trigger schedule manually in Versori UI
1177
+ # OR use CLI:
1178
+ versori schedule trigger inventory-sync
1179
+
1180
+ # Check logs for:
1181
+ # - Previously processed files count
1182
+ # - New files detected
1183
+ # - Processing results
1184
+ # - KV state updates
1185
+ ```
1186
+
1187
+ ---
1188
+
1189
+ ## Key Takeaways
1190
+
1191
+ 1. **Nested Objects:** Use dot notation for simple nesting, nested `fields` for complex structures
1192
+ 2. **Static Values:** Use `value` property for constants at any nesting level
1193
+ 3. **Conditional Logic:** Custom resolvers enable complex business rules
1194
+ 4. **Arrays:** Use `isArray: true` to process array elements
1195
+ 5. **Async Resolvers:** Enable external lookups but use sparingly for performance
1196
+ 6. **Chaining:** Combine transformations via custom resolvers or SDK resolver composition
1197
+ 7. **Context:** Pass workflow-specific data via `context` option
1198
+ 8. **Versori State Management:** Use VersoriKVAdapter for persistent state across runs
1199
+ 9. **Versori Credentials:** Use `ctx.credentials()` for secure access to connection credentials
1200
+ 10. **Resource Cleanup:** Always dispose SFTP/database connections to prevent leaks
1201
+
1202
+ ---
1203
+
1204
+ ## Practice Exercise
1205
+
1206
+ **Task:** Create a mapping for complex order data with nested customer, multiple addresses, and line items.
1207
+
1208
+ **Source Data:**
1209
+ ```json
1210
+ {
1211
+ "order_id": "12345",
1212
+ "customer_email": " JOHN@EXAMPLE.COM ",
1213
+ "customer_tier": "premium",
1214
+ "billing_address": {
1215
+ "street": "123 Main St",
1216
+ "city": "New York",
1217
+ "state": "NY",
1218
+ "zip": "10001"
1219
+ },
1220
+ "shipping_address": {
1221
+ "street": "456 Oak Ave",
1222
+ "city": "Boston",
1223
+ "state": "MA",
1224
+ "zip": "02101"
1225
+ },
1226
+ "line_items": [
1227
+ { "sku": "PROD-001", "qty": "2", "price": "49.99" },
1228
+ { "sku": "PROD-002", "qty": "1", "price": "99.99" }
1229
+ ]
1230
+ }
1231
+ ```
1232
+
1233
+ **Requirements:**
1234
+ 1. Clean and normalize customer email
1235
+ 2. Map both billing and shipping addresses
1236
+ 3. Process line items array
1237
+ 4. Calculate item totals
1238
+ 5. Determine priority based on customer tier
1239
+
1240
+ <details>
1241
+ <summary>Click to see solution</summary>
1242
+
1243
+ ```json
1244
+ {
1245
+ "fields": {
1246
+ "ref": {
1247
+ "source": "order_id"
1248
+ },
1249
+ "customer": {
1250
+ "fields": {
1251
+ "email": {
1252
+ "source": "customer_email",
1253
+ "resolver": "custom.cleanEmail"
1254
+ }
1255
+ }
1256
+ },
1257
+ "priority": {
1258
+ "source": "customer_tier",
1259
+ "resolver": "custom.calculatePriority"
1260
+ },
1261
+ "billingAddress": {
1262
+ "fields": {
1263
+ "street": {
1264
+ "source": "billing_address.street"
1265
+ },
1266
+ "city": {
1267
+ "source": "billing_address.city"
1268
+ },
1269
+ "state": {
1270
+ "source": "billing_address.state"
1271
+ },
1272
+ "zipCode": {
1273
+ "source": "billing_address.zip"
1274
+ }
1275
+ }
1276
+ },
1277
+ "shippingAddress": {
1278
+ "fields": {
1279
+ "street": {
1280
+ "source": "shipping_address.street"
1281
+ },
1282
+ "city": {
1283
+ "source": "shipping_address.city"
1284
+ },
1285
+ "state": {
1286
+ "source": "shipping_address.state"
1287
+ },
1288
+ "zipCode": {
1289
+ "source": "shipping_address.zip"
1290
+ }
1291
+ }
1292
+ },
1293
+ "items": {
1294
+ "source": "line_items",
1295
+ "isArray": true,
1296
+ "fields": {
1297
+ "productRef": {
1298
+ "source": "sku"
1299
+ },
1300
+ "quantity": {
1301
+ "source": "qty",
1302
+ "resolver": "sdk.parseInt"
1303
+ },
1304
+ "price": {
1305
+ "source": "price",
1306
+ "resolver": "sdk.parseFloat"
1307
+ },
1308
+ "totalPrice": {
1309
+ "resolver": "custom.calculateItemTotal"
1310
+ }
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ ```
1316
+
1317
+ **Custom Resolvers:**
1318
+ ```typescript
1319
+ const customResolvers = {
1320
+ 'custom.cleanEmail': (value: string, sourceData: any, config: any, helpers: any) => {
1321
+ return helpers.normalizeWhitespace(value).toLowerCase();
1322
+ },
1323
+
1324
+ 'custom.calculatePriority': (tier: string) => {
1325
+ return tier === 'premium' ? 'HIGH' : 'NORMAL';
1326
+ },
1327
+
1328
+ 'custom.calculateItemTotal': (value: any, sourceData: any, config: any, helpers: any) => {
1329
+ const qty = helpers.parseIntSafe(sourceData.qty, 0);
1330
+ const price = helpers.parseFloatSafe(sourceData.price, 0);
1331
+ return qty * price;
1332
+ }
1333
+ };
1334
+ ```
1335
+
1336
+ </details>
1337
+
1338
+ ---
1339
+
1340
+ ## Next Steps
1341
+
1342
+ Now that you've mastered advanced patterns, explore the helper functions available:
1343
+
1344
+ → Continue to [Module 6: Helpers & Resolvers](./mapping-06-helpers-resolvers.md)
1345
+
1346
+ **Alternative paths:**
1347
+ - Jump to [API Reference](../../auto-pagination/modules/auto-pagination-07-api-reference.md) for TypeScript usage
1348
+ - Review [GraphQL Mutation Mapping Quick Reference](../graphql-mutation-mapping/graphql-mutation-mapping-quick-reference.md) for a comprehensive cheat sheet
1349
+
1350
+ ---
1351
+
1352
+ [← Back to Use Cases](./mapping-04-use-cases.md) | [Next: Helpers & Resolvers →](./mapping-06-helpers-resolvers.md)
1353
+
1354
+
1355
+
1356
+
1357
+
1358
+
1359
+
1360
+
1361
+
1362
+
1363
+
1364
+
1365
+
1366
+