@fluentcommerce/fc-connect-sdk 0.1.54 → 0.1.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/cjs/clients/fluent-client.js +13 -6
- package/dist/cjs/utils/pagination-helpers.js +38 -2
- package/dist/cjs/versori/fluent-versori-client.js +11 -5
- package/dist/esm/clients/fluent-client.js +13 -6
- package/dist/esm/utils/pagination-helpers.js +38 -2
- package/dist/esm/versori/fluent-versori-client.js +11 -5
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/tsconfig.types.tsbuildinfo +1 -1
- package/docs/00-START-HERE/EXPORT-VALIDATION.md +158 -158
- package/docs/00-START-HERE/cli-analyze-source-structure-guide.md +655 -655
- package/docs/00-START-HERE/cli-documentation-index.md +202 -202
- package/docs/00-START-HERE/cli-quick-reference.md +252 -252
- package/docs/00-START-HERE/decision-tree.md +552 -552
- package/docs/00-START-HERE/getting-started.md +1070 -1070
- package/docs/00-START-HERE/mapper-quick-decision-guide.md +235 -235
- package/docs/00-START-HERE/readme.md +237 -237
- package/docs/00-START-HERE/retailerid-configuration.md +404 -404
- package/docs/00-START-HERE/sdk-philosophy.md +794 -794
- package/docs/00-START-HERE/troubleshooting-quick-reference.md +1086 -1086
- package/docs/01-TEMPLATES/faq.md +686 -686
- package/docs/01-TEMPLATES/patterns/pattern-templates-guide.md +68 -68
- package/docs/01-TEMPLATES/patterns/patterns-csv-schema-validation-and-rejection-report.md +233 -233
- package/docs/01-TEMPLATES/patterns/patterns-custom-resolvers.md +407 -407
- package/docs/01-TEMPLATES/patterns/patterns-error-handling-retry.md +511 -511
- package/docs/01-TEMPLATES/patterns/patterns-field-mapping-universal.md +701 -701
- package/docs/01-TEMPLATES/patterns/patterns-large-file-splitting.md +1430 -1430
- package/docs/01-TEMPLATES/patterns/patterns-master-data-etl.md +2399 -2399
- package/docs/01-TEMPLATES/patterns/patterns-pagination-streaming.md +447 -447
- package/docs/01-TEMPLATES/patterns/patterns-state-duplicate-prevention.md +385 -385
- package/docs/01-TEMPLATES/readme.md +957 -957
- package/docs/01-TEMPLATES/standalone/standalone-asn-inbound-processing.md +1209 -1209
- package/docs/01-TEMPLATES/standalone/standalone-graphql-query-export.md +1140 -1140
- package/docs/01-TEMPLATES/standalone/standalone-graphql-to-parquet-partitioned-s3.md +432 -432
- package/docs/01-TEMPLATES/standalone/standalone-multi-channel-inventory-sync.md +1185 -1185
- package/docs/01-TEMPLATES/standalone/standalone-multi-source-aggregation.md +1462 -1462
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-batch-api.md +1390 -1390
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-inventory-to-batch.md +330 -330
- package/docs/01-TEMPLATES/standalone/standalone-scripts-guide.md +87 -87
- package/docs/01-TEMPLATES/standalone/standalone-sftp-xml-graphql.md +1444 -1444
- package/docs/01-TEMPLATES/standalone/standalone-webhook-payload-processing.md +688 -688
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-dropship-order-routing.md +193 -193
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-graphql-parquet-extraction.md +518 -518
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-inter-location-transfers.md +2162 -2162
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-pre-order-allocation.md +2226 -2226
- package/docs/01-TEMPLATES/versori/business-examples/business-scenarios-guide.md +87 -87
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-connection-validation-pattern.md +656 -656
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-dual-workflow-connector.md +835 -835
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-guide.md +108 -108
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-kv-state-management.md +1533 -1533
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-xml-response-patterns.md +1160 -1160
- package/docs/01-TEMPLATES/versori/versori-platform-guide.md +201 -201
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-asn-purchase-order.md +1906 -1906
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-dropship-routing.md +1074 -1074
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-flash-sale-reserve.md +1395 -1395
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-generic-xml-order.md +888 -888
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-payment-gateway-integration.md +2478 -2478
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-rma-returns-comprehensive.md +2240 -2240
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-xml-order-ingestion.md +2029 -2029
- package/docs/01-TEMPLATES/versori/webhooks/webhook-templates-guide.md +140 -140
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/inventory-mapping.json +20 -20
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/products_2025-01-22.csv +11 -11
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/sample-data-guide.md +34 -34
- package/docs/01-TEMPLATES/versori/workflows/_examples/workflow-examples-guide.md +36 -36
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-modes-guide.md +1038 -1038
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-workflows-guide.md +138 -138
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/graphql-extraction-guide.md +63 -63
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-csv.md +2062 -2062
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-xml.md +2294 -2294
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-s3-csv.md +2461 -2461
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-sftp-xml.md +2529 -2529
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-csv.md +2464 -2464
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-json.md +1959 -1959
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-s3-csv.md +1953 -1953
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-sftp-xml.md +2541 -2541
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-s3-json.md +2384 -2384
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-sftp-xml.md +2445 -2445
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-csv.md +2355 -2355
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-json.md +2042 -2042
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-sftp-xml.md +2726 -2726
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/batch-api-guide.md +206 -206
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-cycle-count-reconciliation.md +2030 -2030
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-multi-channel-inventory-sync.md +1882 -1882
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-csv-inventory-batch.md +2827 -2827
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-json-inventory-batch.md +1952 -1952
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-xml-inventory-batch.md +3289 -3289
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-csv-inventory-batch.md +3064 -3064
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-json-inventory-batch.md +3238 -3238
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-xml-inventory-batch.md +2977 -2977
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/event-api-guide.md +321 -321
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-json-order-cancel-event.md +959 -959
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-xml-order-cancel-event.md +1170 -1170
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-csv-product-event.md +2312 -2312
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-json-product-event.md +2999 -2999
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-parquet-product-event.md +2836 -2836
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-xml-product-event.md +2395 -2395
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-csv-product-event.md +2295 -2295
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-json-product-event.md +2602 -2602
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-parquet-product-event.md +2589 -2589
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-xml-product-event.md +3578 -3578
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/graphql-mutations-guide.md +93 -93
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-json-order-update-graphql.md +1260 -1260
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-xml-order-update-graphql.md +1472 -1472
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-control-graphql.md +2417 -2417
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-location-graphql.md +2811 -2811
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-price-graphql.md +2619 -2619
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-json-location-graphql.md +2807 -2807
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-xml-location-graphql.md +2373 -2373
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-control-graphql.md +2740 -2740
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-location-graphql.md +2760 -2760
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-json-location-graphql.md +1710 -1710
- package/docs/01-TEMPLATES/versori/workflows/ingestion/ingestion-workflows-guide.md +136 -136
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/rubix-webhooks-guide.md +520 -520
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-inline.md +1418 -1418
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-universal-mapper.md +1785 -1785
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-order-attribute-update.md +824 -824
- package/docs/01-TEMPLATES/versori/workflows/workflows-overview-guide.md +646 -646
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-batch-archival.md +724 -724
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-job-tracker.md +627 -627
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-partial-batch-recovery.md +561 -561
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-quick-reference.md +367 -367
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-readme.md +407 -407
- package/docs/02-CORE-GUIDES/advanced-services/readme.md +49 -49
- package/docs/02-CORE-GUIDES/api-reference/api-reference-quick-reference.md +548 -548
- package/docs/02-CORE-GUIDES/api-reference/event-api-input-output-reference.md +702 -1171
- package/docs/02-CORE-GUIDES/api-reference/examples/client-initialization.ts +286 -286
- package/docs/02-CORE-GUIDES/api-reference/graphql-error-classification.md +337 -337
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-01-client-api.md +399 -520
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-03-authentication.md +199 -199
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-04-graphql-mapping.md +925 -925
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-05-services.md +1198 -1198
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-06-data-sources.md +1083 -1083
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-07-parsers.md +1097 -1097
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-pagination.md +513 -513
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-types.md +545 -597
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-error-handling.md +527 -527
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-webhook-validation.md +514 -514
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-extraction.md +557 -557
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-utilities.md +412 -412
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-cli-tools.md +423 -423
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-error-handling.md +716 -716
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-analyze-source-structure.md +518 -518
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-partial-responses.md +212 -212
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-testing.md +300 -300
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-13-resolver-builder.md +322 -322
- package/docs/02-CORE-GUIDES/api-reference/readme.md +279 -279
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-quick-reference.md +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-readme.md +277 -277
- package/docs/02-CORE-GUIDES/auto-pagination/examples/auto-pagination-readme.md +178 -178
- package/docs/02-CORE-GUIDES/auto-pagination/examples/common-patterns.ts +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-products.ts +384 -384
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-virtual-positions.ts +308 -308
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-01-foundations.md +470 -470
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-02-quick-start.md +713 -713
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-03-configuration.md +754 -754
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-04-advanced-patterns.md +732 -732
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-05-sdk-integration.md +847 -847
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-06-troubleshooting.md +359 -359
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-07-api-reference.md +462 -462
- package/docs/02-CORE-GUIDES/auto-pagination/readme.md +54 -54
- package/docs/02-CORE-GUIDES/data-sources/data-sources-file-operations-error-handling.md +1487 -1487
- package/docs/02-CORE-GUIDES/data-sources/data-sources-quick-reference.md +836 -836
- package/docs/02-CORE-GUIDES/data-sources/data-sources-readme.md +276 -276
- package/docs/02-CORE-GUIDES/data-sources/data-sources-sftp-credential-access-security.md +553 -553
- package/docs/02-CORE-GUIDES/data-sources/examples/common-patterns.ts +409 -409
- package/docs/02-CORE-GUIDES/data-sources/examples/data-sources-readme.md +178 -178
- package/docs/02-CORE-GUIDES/data-sources/examples/s3-operations.ts +308 -308
- package/docs/02-CORE-GUIDES/data-sources/examples/sftp-operations.ts +371 -371
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-01-foundations.md +735 -735
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-02-s3-operations.md +1302 -1302
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-03-sftp-operations.md +1379 -1379
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-04-file-patterns.md +941 -941
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-05-advanced-topics.md +813 -813
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-06-integration-patterns.md +486 -486
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-07-troubleshooting.md +387 -387
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-08-api-reference.md +417 -417
- package/docs/02-CORE-GUIDES/data-sources/readme.md +77 -77
- package/docs/02-CORE-GUIDES/error-handling-guide.md +936 -936
- package/docs/02-CORE-GUIDES/extraction/examples/02-core-guides-extraction-readme.md +116 -116
- package/docs/02-CORE-GUIDES/extraction/examples/common-patterns.ts +428 -428
- package/docs/02-CORE-GUIDES/extraction/examples/extract-inventory-basic.ts +187 -187
- package/docs/02-CORE-GUIDES/extraction/extraction-quick-reference.md +596 -596
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-01-foundations.md +514 -514
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-02-basic-extraction.md +823 -823
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-03-parquet-processing.md +507 -507
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-04-data-enrichment.md +546 -546
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-05-transformation.md +494 -494
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-export-formats.md +458 -458
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-performance.md +138 -138
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-api-reference.md +148 -148
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-optimization.md +692 -692
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-08-extraction-orchestrator.md +1008 -1008
- package/docs/02-CORE-GUIDES/extraction/readme.md +151 -151
- package/docs/02-CORE-GUIDES/ingestion/examples/_simple-kv-store.ts +40 -40
- package/docs/02-CORE-GUIDES/ingestion/examples/error-recovery.ts +728 -728
- package/docs/02-CORE-GUIDES/ingestion/examples/event-driven.ts +501 -501
- package/docs/02-CORE-GUIDES/ingestion/examples/local-file-ingestion.ts +88 -88
- package/docs/02-CORE-GUIDES/ingestion/examples/parquet-ingestion.ts +117 -117
- package/docs/02-CORE-GUIDES/ingestion/examples/performance-optimized.ts +647 -647
- package/docs/02-CORE-GUIDES/ingestion/examples/s3-csv-ingestion.ts +169 -169
- package/docs/02-CORE-GUIDES/ingestion/examples/sftp-csv-ingestion.ts +134 -134
- package/docs/02-CORE-GUIDES/ingestion/ingestion-quick-reference.md +546 -546
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-01-introduction.md +626 -626
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-02-quick-start.md +658 -658
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-03-data-sources.md +1052 -1052
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-04-field-mapping.md +763 -763
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-05-advanced-parsers.md +676 -676
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-06-batch-api.md +1295 -1295
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-api-reference.md +138 -138
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-state-management.md +1037 -1037
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-08-performance-optimization.md +1349 -1349
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-09-best-practices.md +1893 -1893
- package/docs/02-CORE-GUIDES/ingestion/readme.md +160 -160
- package/docs/02-CORE-GUIDES/logging-guide.md +585 -585
- package/docs/02-CORE-GUIDES/mapping/error-handling-patterns.md +401 -401
- package/docs/02-CORE-GUIDES/mapping/examples/02-core-guides-mapping-readme.md +128 -128
- package/docs/02-CORE-GUIDES/mapping/examples/common-patterns.ts +273 -273
- package/docs/02-CORE-GUIDES/mapping/examples/csv-location-ingestion.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/csv-mapping.ts +242 -242
- package/docs/02-CORE-GUIDES/mapping/examples/graphql-to-parquet-extraction.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/json-mapping.ts +213 -213
- package/docs/02-CORE-GUIDES/mapping/examples/json-product-to-mutation.json +48 -48
- package/docs/02-CORE-GUIDES/mapping/examples/xml-mapping.ts +291 -291
- package/docs/02-CORE-GUIDES/mapping/examples/xml-order-to-mutation.json +45 -45
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-quick-reference.md +463 -463
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-readme.md +227 -227
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-01-introduction.md +222 -222
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-02-quick-start.md +351 -351
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-03-schema-validation.md +569 -569
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-04-mapping-patterns.md +471 -471
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-05-configuration-reference.md +611 -611
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-advanced-xpath.md +148 -148
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-path-syntax.md +464 -464
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-api-reference.md +94 -94
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-array-handling.md +307 -307
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-08-custom-resolvers.md +544 -544
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-09-advanced-patterns.md +427 -427
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-10-hooks-and-variables.md +336 -336
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-11-error-handling.md +488 -488
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-12-arguments-vs-nodes.md +383 -383
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-13-best-practices.md +477 -477
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/readme.md +62 -62
- package/docs/02-CORE-GUIDES/mapping/mapping-format-decision-tree.md +480 -480
- package/docs/02-CORE-GUIDES/mapping/mapping-graphql-alias-batching-guide.md +820 -820
- package/docs/02-CORE-GUIDES/mapping/mapping-javascript-objects.md +2369 -2369
- package/docs/02-CORE-GUIDES/mapping/mapping-mapper-comparison-guide.md +682 -682
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-07-api-reference.md +1327 -1327
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-08-error-handling.md +1142 -1142
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-04-use-cases.md +891 -891
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-helpers-resolvers.md +1126 -1126
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-sdk-resolvers.md +199 -199
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-07-api-reference.md +1319 -1319
- package/docs/02-CORE-GUIDES/mapping/readme.md +178 -178
- package/docs/02-CORE-GUIDES/mapping/resolver-registration.md +410 -410
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/common-patterns.ts +226 -226
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/custom-resolvers.ts +227 -227
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/sdk-resolvers-usage.ts +203 -203
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-readme.md +274 -274
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-api-reference.md +679 -679
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-cookbook.md +826 -826
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-guide.md +1330 -1330
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-helpers-reference.md +1437 -1437
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-parameters-reference.md +553 -553
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-troubleshooting.md +854 -854
- package/docs/02-CORE-GUIDES/mapping/resolvers/readme.md +75 -75
- package/docs/02-CORE-GUIDES/parsers/examples/02-core-guides-parsers-readme.md +161 -161
- package/docs/02-CORE-GUIDES/parsers/examples/csv-parser-examples.ts +110 -110
- package/docs/02-CORE-GUIDES/parsers/examples/json-parser-examples.ts +33 -33
- package/docs/02-CORE-GUIDES/parsers/examples/parquet-parser-examples.ts +47 -47
- package/docs/02-CORE-GUIDES/parsers/examples/xml-parser-examples.ts +38 -38
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-01-foundations.md +355 -355
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-02-csv-parser.md +772 -772
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-03-json-parser.md +789 -789
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-04-xml-parser.md +857 -857
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-05-parquet-parser.md +603 -603
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-integration-patterns.md +702 -702
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-streaming.md +121 -121
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-api-reference.md +89 -89
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-troubleshooting.md +727 -727
- package/docs/02-CORE-GUIDES/parsers/parsers-quick-reference.md +482 -482
- package/docs/02-CORE-GUIDES/parsers/parsers-readme.md +258 -258
- package/docs/02-CORE-GUIDES/parsers/readme.md +65 -65
- package/docs/02-CORE-GUIDES/readme.md +194 -194
- package/docs/02-CORE-GUIDES/webhook-validation/examples/basic-validation.ts +108 -108
- package/docs/02-CORE-GUIDES/webhook-validation/examples/common-patterns.ts +316 -316
- package/docs/02-CORE-GUIDES/webhook-validation/examples/webhook-validation-readme.md +61 -61
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-01-foundations.md +440 -440
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-02-quick-start.md +525 -525
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-03-versori-integration.md +741 -741
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-04-platform-integration.md +629 -629
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-05-configuration.md +535 -535
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-error-handling.md +611 -611
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-troubleshooting.md +124 -124
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-07-api-reference.md +511 -511
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-08-rubix-webhooks.md +590 -590
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-09-rubix-event-vs-http-call.md +432 -432
- package/docs/02-CORE-GUIDES/webhook-validation/readme.md +239 -239
- package/docs/02-CORE-GUIDES/webhook-validation/webhook-validation-quick-reference.md +392 -392
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-quick-reference.md +498 -498
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-readme.md +313 -313
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/common-patterns.ts +612 -612
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/connector-scenarios-readme.md +253 -253
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-01-foundations.md +452 -452
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-02-simple-scenarios.md +681 -681
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-03-intermediate-scenarios.md +637 -637
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-04-advanced-scenarios.md +650 -650
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-05-bidirectional-sync.md +233 -233
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-06-production-patterns.md +442 -442
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-07-reference.md +445 -445
- package/docs/03-PATTERN-GUIDES/connector-scenarios/readme.md +31 -31
- package/docs/03-PATTERN-GUIDES/enterprise-integration-patterns.md +1528 -1528
- package/docs/03-PATTERN-GUIDES/error-handling/comprehensive-error-handling-guide.md +1437 -1437
- package/docs/03-PATTERN-GUIDES/error-handling/error-handling-quick-reference.md +390 -390
- package/docs/03-PATTERN-GUIDES/error-handling/examples/common-patterns.ts +438 -438
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-01-foundations.md +362 -362
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-02-error-types.md +850 -850
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-03-utf8-handling.md +456 -456
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-04-error-scenarios.md +658 -658
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-05-calling-patterns.md +671 -671
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-06-retry-strategies.md +1034 -1034
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-07-monitoring.md +653 -653
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-08-api-reference.md +847 -847
- package/docs/03-PATTERN-GUIDES/error-handling/readme.md +36 -36
- package/docs/03-PATTERN-GUIDES/examples/__tests__/readme.md +40 -40
- package/docs/03-PATTERN-GUIDES/examples/__tests__/resolver-examples.test.js +282 -282
- package/docs/03-PATTERN-GUIDES/examples/test-data/03-pattern-guides-readme.md +110 -110
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-inventory.json +123 -123
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-order.json +171 -171
- package/docs/03-PATTERN-GUIDES/examples/test-data/readme.md +28 -28
- package/docs/03-PATTERN-GUIDES/extraction/extraction-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/extraction/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/file-operations/examples/common-patterns.ts +407 -407
- package/docs/03-PATTERN-GUIDES/file-operations/examples/file-operations-readme.md +142 -142
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-quick-reference.md +462 -462
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-readme.md +379 -379
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-01-foundations.md +430 -430
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-02-quick-start.md +484 -484
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-03-s3-operations.md +507 -507
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-04-sftp-operations.md +963 -963
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-05-streaming-performance.md +503 -503
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-archive-patterns.md +386 -386
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-error-handling.md +117 -117
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-api-reference.md +78 -78
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-testing-troubleshooting.md +567 -567
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-08-api-reference.md +1055 -1055
- package/docs/03-PATTERN-GUIDES/file-operations/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/ingestion/ingestion-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/ingestion/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/batch-processing.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/common-patterns.ts +360 -360
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/delta-sync.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/integration-patterns-readme.md +100 -100
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/real-time-webhook.ts +398 -398
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-quick-reference.md +962 -962
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-readme.md +134 -134
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-01-real-time-processing.md +991 -991
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-02-batch-processing.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-03-delta-sync.md +1108 -1108
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-04-webhook-patterns.md +1181 -1181
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-05-error-handling.md +1061 -1061
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-advanced-integration-services.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-performance.md +109 -109
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-07-api-reference.md +34 -34
- package/docs/03-PATTERN-GUIDES/integration-patterns/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/logging-minimal-mode.md +128 -128
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/common-patterns.ts +380 -380
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/multiple-connections-readme.md +139 -139
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/parallel-root-connections.ts +149 -149
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/real-world-scenarios.ts +405 -405
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-01-foundations.md +378 -378
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-02-quick-start.md +566 -566
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-03-targeting-connections.md +659 -659
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-04-parallel-queries.md +656 -656
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-05-best-practices.md +624 -624
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-api-reference.md +824 -824
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-versori.md +119 -119
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-07-api-reference.md +87 -87
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-quick-reference.md +353 -353
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-readme.md +270 -270
- package/docs/03-PATTERN-GUIDES/multiple-connections/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/pagination/pagination-readme.md +14 -14
- package/docs/03-PATTERN-GUIDES/pagination/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/parquet/examples/common-patterns.ts +180 -180
- package/docs/03-PATTERN-GUIDES/parquet/examples/read-parquet.ts +48 -48
- package/docs/03-PATTERN-GUIDES/parquet/examples/write-parquet.ts +65 -65
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-01-introduction.md +393 -393
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-02-quick-start.md +572 -572
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-03-reading-parquet.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-04-writing-parquet.md +554 -554
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-05-graphql-extraction.md +405 -405
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-performance.md +104 -104
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-s3-integration.md +511 -511
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-api-reference.md +90 -90
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-performance-optimization.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-08-best-practices.md +712 -712
- package/docs/03-PATTERN-GUIDES/parquet/parquet-quick-reference.md +683 -683
- package/docs/03-PATTERN-GUIDES/parquet/parquet-readme.md +248 -248
- package/docs/03-PATTERN-GUIDES/parquet/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/parsers/parsers-readme.md +12 -12
- package/docs/03-PATTERN-GUIDES/parsers/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/readme.md +159 -159
- package/docs/03-PATTERN-GUIDES/webhooks/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/webhooks/webhooks-readme.md +8 -8
- package/docs/04-REFERENCE/architecture/architecture-01-overview.md +427 -427
- package/docs/04-REFERENCE/architecture/architecture-02-client-architecture.md +424 -424
- package/docs/04-REFERENCE/architecture/architecture-03-data-flow.md +690 -690
- package/docs/04-REFERENCE/architecture/architecture-04-service-layer.md +834 -834
- package/docs/04-REFERENCE/architecture/architecture-05-integration-architecture.md +655 -655
- package/docs/04-REFERENCE/architecture/architecture-06-state-management.md +653 -653
- package/docs/04-REFERENCE/architecture/architecture-adding-new-data-sources.md +686 -686
- package/docs/04-REFERENCE/architecture/readme.md +279 -279
- package/docs/04-REFERENCE/platforms/deno/readme.md +117 -117
- package/docs/04-REFERENCE/platforms/nodejs/readme.md +146 -146
- package/docs/04-REFERENCE/platforms/readme.md +135 -135
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-01-introduction.md +398 -398
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-02-quick-start.md +560 -560
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-03-authentication.md +757 -757
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-04-workflows.md +2476 -2476
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-05-connections.md +1167 -1167
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-kv-storage.md +990 -990
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-state-management.md +121 -121
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-api-reference.md +68 -68
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-deployment.md +731 -731
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-08-best-practices.md +1111 -1111
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-09-signature-reference.md +766 -766
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-readme.md +299 -299
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-s3-sftp-configuration-guide.md +1425 -1425
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-api-key-security.md +816 -816
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-connection-security.md +681 -681
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-workflow-task-types.md +708 -708
- package/docs/04-REFERENCE/platforms/versori/readme.md +108 -108
- package/docs/04-REFERENCE/readme.md +148 -148
- package/docs/04-REFERENCE/resolver-signature/examples/advanced-resolvers.ts +482 -482
- package/docs/04-REFERENCE/resolver-signature/examples/async-resolvers.ts +496 -496
- package/docs/04-REFERENCE/resolver-signature/examples/basic-resolvers.ts +343 -343
- package/docs/04-REFERENCE/resolver-signature/examples/resolver-signature-readme.md +188 -188
- package/docs/04-REFERENCE/resolver-signature/examples/testing-resolvers.ts +463 -463
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-01-foundations.md +286 -286
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-02-parameter-reference.md +643 -643
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-03-basic-examples.md +521 -521
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-04-advanced-patterns.md +739 -739
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-05-sdk-resolvers.md +531 -531
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-migration-guide.md +650 -650
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-testing.md +125 -125
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-07-api-reference.md +794 -794
- package/docs/04-REFERENCE/resolver-signature/readme.md +64 -64
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-quick-reference.md +270 -270
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-readme.md +351 -351
- package/docs/04-REFERENCE/schema/fluent-commerce-schema.json +764 -764
- package/docs/04-REFERENCE/schema/readme.md +141 -141
- package/docs/04-REFERENCE/testing/examples/04-reference-testing-readme.md +158 -158
- package/docs/04-REFERENCE/testing/examples/fluent-testing.ts +62 -62
- package/docs/04-REFERENCE/testing/examples/health-check.ts +155 -155
- package/docs/04-REFERENCE/testing/examples/integration-test.ts +119 -119
- package/docs/04-REFERENCE/testing/examples/performance-test.ts +183 -183
- package/docs/04-REFERENCE/testing/examples/s3-testing.ts +127 -127
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-01-foundations.md +267 -267
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-02-s3-testing.md +599 -599
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-03-fluent-testing.md +589 -589
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-04-integration-testing.md +699 -699
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-05-debugging.md +478 -478
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-cicd-integration.md +463 -463
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-preflight-validation.md +131 -131
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-best-practices.md +499 -499
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-coverage-ci.md +165 -165
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-08-api-reference.md +634 -634
- package/docs/04-REFERENCE/testing/readme.md +86 -86
- package/docs/04-REFERENCE/testing/testing-quick-reference.md +667 -667
- package/docs/04-REFERENCE/testing/testing-readme.md +286 -286
- package/docs/04-REFERENCE/troubleshooting/readme.md +144 -144
- package/docs/04-REFERENCE/troubleshooting/troubleshooting-deno-sftp-compatibility.md +392 -392
- package/docs/template-loading-matrix.md +242 -242
- package/package.json +5 -3
- package/docs/02-CORE-GUIDES/api-reference/cli-profile-integration.md +0 -377
|
@@ -1,2529 +1,2529 @@
|
|
|
1
|
-
---
|
|
2
|
-
template_id: tpl-extract-inventory-positions-to-sftp-xml
|
|
3
|
-
canonical_filename: template-extraction-inventory-positions-to-sftp-xml.md
|
|
4
|
-
version: 2.0.0
|
|
5
|
-
sdk_version: ^0.1.39
|
|
6
|
-
runtime: versori
|
|
7
|
-
direction: extraction
|
|
8
|
-
source: fluent-graphql
|
|
9
|
-
destination: sftp-xml
|
|
10
|
-
entity: inventoryPositions
|
|
11
|
-
format: xml
|
|
12
|
-
logging: versori
|
|
13
|
-
status: stable
|
|
14
|
-
features:
|
|
15
|
-
- memory-management
|
|
16
|
-
- enhanced-logging
|
|
17
|
-
- pagination-progress
|
|
18
|
-
- dispose-finally
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
# Template: Extraction - Inventory Positions to SFTP XML
|
|
22
|
-
|
|
23
|
-
**Template Version:** 2.0.0
|
|
24
|
-
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
25
|
-
**Last Updated:** 2025-01-24
|
|
26
|
-
**Deployment Target:** Versori Platform
|
|
27
|
-
|
|
28
|
-
**🆕 Version 2.0.0 Enhancements:**
|
|
29
|
-
- ✅ **Memory Management** - Clear large result sets after processing batches
|
|
30
|
-
- ✅ **Enhanced Logging** - Pagination progress tracking with emoji indicators (📊, 📥, ✅)
|
|
31
|
-
- ✅ **Pagination Progress** - Real-time page-by-page progress logging with metrics
|
|
32
|
-
- ✅ **Resource Cleanup** - SFTP dispose in finally blocks prevents connection leaks
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
## ?? STEP 1: Load These Docs Into Your AI (Human Checklist)
|
|
37
|
-
|
|
38
|
-
1. REQUIRED (load all)
|
|
39
|
-
- [ ] fc-connect-sdk/docs/02-CORE-GUIDES/api-reference/
|
|
40
|
-
- [ ] fc-connect-sdk/docs/02-CORE-GUIDES/mapping/
|
|
41
|
-
- [ ] fc-connect-sdk/docs/03-PATTERN-GUIDES/data-sources/
|
|
42
|
-
- [ ] fc-connect-sdk/docs/03-PATTERN-GUIDES/parsers/
|
|
43
|
-
- [ ] fc-connect-sdk/docs/03-PATTERN-GUIDES/extraction/
|
|
44
|
-
- [ ] fc-connect-sdk/docs/04-REFERENCE/platforms/versori/
|
|
45
|
-
|
|
46
|
-
Copy-paste list (open these):
|
|
47
|
-
fc-connect-sdk/docs/02-CORE-GUIDES/api-reference/
|
|
48
|
-
fc-connect-sdk/docs/02-CORE-GUIDES/mapping/
|
|
49
|
-
fc-connect-sdk/docs/03-PATTERN-GUIDES/data-sources/
|
|
50
|
-
fc-connect-sdk/docs/03-PATTERN-GUIDES/parsers/
|
|
51
|
-
fc-connect-sdk/docs/03-PATTERN-GUIDES/extraction/
|
|
52
|
-
fc-connect-sdk/docs/04-REFERENCE/platforms/versori/
|
|
53
|
-
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
## 📋 STEP 2: Tell Your AI (Prompt)
|
|
57
|
-
|
|
58
|
-
Copy/paste this prompt into your AI tool after loading the documentation above:
|
|
59
|
-
|
|
60
|
-
```
|
|
61
|
-
I need a Versori scheduled extractor that:
|
|
62
|
-
|
|
63
|
-
1) Queries Fluent Commerce GraphQL for Inventory Positions with auto-pagination
|
|
64
|
-
2) Supports incremental runs via KV state (with an overlap buffer)
|
|
65
|
-
3) Transforms results using UniversalMapper per mapping JSON
|
|
66
|
-
4) Generates pretty-printed XML and uploads to SFTP
|
|
67
|
-
5) Tracks progress with JobTracker and exposes a job-status webhook
|
|
68
|
-
6) Uses native Versori log (LoggingService removed - use native log) and disposes SFTP
|
|
69
|
-
|
|
70
|
-
Use the loaded docs to fill in SDK specifics and best practices.
|
|
71
|
-
Keep the structure identical to the template; only adapt where needed.
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
---
|
|
75
|
-
|
|
76
|
-
## 📋 Template Overview
|
|
77
|
-
|
|
78
|
-
This connector runs on the Versori platform. Most operational settings (Fluent account/connection, SFTP connection, schedule, page size/limits) are configured via activation variables. Data shape and logic (mapping JSON, XML structure, GraphQL selection set/filters, validators/resolvers) are adjusted in code as needed. It extracts inventory positions from Fluent Commerce via GraphQL, transforms the data into XML, and uploads the result to SFTP.
|
|
79
|
-
|
|
80
|
-
### What This Template Does
|
|
81
|
-
|
|
82
|
-
```
|
|
83
|
-
�������������������������������������������������������������������
|
|
84
|
-
� EXTRACTION WORKFLOW �
|
|
85
|
-
�������������������������������������������������������������������
|
|
86
|
-
|
|
87
|
-
1. TRIGGER
|
|
88
|
-
�� Scheduled (Cron): Runs automatically every hour
|
|
89
|
-
�� Ad hoc (Webhook): Manual trigger with optional date override
|
|
90
|
-
�� Status Query (Webhook): Check job progress
|
|
91
|
-
|
|
92
|
-
2. EXTRACT (ExtractionOrchestrator)
|
|
93
|
-
�� Query Fluent GraphQL API for inventory positions
|
|
94
|
-
�� Auto-pagination (handles large datasets)
|
|
95
|
-
�� Apply date filters (incremental or manual range)
|
|
96
|
-
�� Validate each record (optional)
|
|
97
|
-
|
|
98
|
-
3. TRANSFORM (UniversalMapper)
|
|
99
|
-
�� Map GraphQL fields to XML schema
|
|
100
|
-
�� Apply SDK resolvers (trim, uppercase, parseInt, etc.)
|
|
101
|
-
�� Extract nested data (catalogue)
|
|
102
|
-
�� Handle transformation errors
|
|
103
|
-
|
|
104
|
-
4. GENERATE XML (XMLBuilder)
|
|
105
|
-
�� Convert transformed records to XML
|
|
106
|
-
�� Auto-escape special characters
|
|
107
|
-
�� Apply pretty-print formatting
|
|
108
|
-
�� Generate timestamped filename
|
|
109
|
-
|
|
110
|
-
5. UPLOAD (SftpDataSource)
|
|
111
|
-
�� Connect to SFTP server
|
|
112
|
-
�� Upload XML file with retry logic
|
|
113
|
-
�� Verify upload success
|
|
114
|
-
�� Always dispose connection (finally block)
|
|
115
|
-
|
|
116
|
-
6. TRACK JOB (JobTracker)
|
|
117
|
-
�� Create job with unique ID
|
|
118
|
-
�� Update status at each step
|
|
119
|
-
�� Store job result in KV
|
|
120
|
-
�� Enable status queries via webhook
|
|
121
|
-
|
|
122
|
-
7. UPDATE STATE (VersoriKVAdapter)
|
|
123
|
-
�� Calculate max updatedOn from records
|
|
124
|
-
�� Store timestamp for next incremental run
|
|
125
|
-
�� Apply overlap buffer (prevent missed records)
|
|
126
|
-
�� Skip update if manual date override
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
### Key Features
|
|
130
|
-
|
|
131
|
-
- Job tracking with status queries
|
|
132
|
-
- Execution modes: scheduled, ad hoc, status query
|
|
133
|
-
- Uses ExtractionOrchestrator, UniversalMapper, JobTracker
|
|
134
|
-
- Error handling, retry logic, and SFTP cleanup
|
|
135
|
-
- Reusable services suitable for similar use cases
|
|
136
|
-
|
|
137
|
-
Note: JobTracker persists stage/status to Versori KV for visibility, job-status webhooks, and auditing. Recommended for production multi-step flows; can be skipped for trivial single-step utilities.
|
|
138
|
-
|
|
139
|
-
### 📦 Package Information
|
|
140
|
-
|
|
141
|
-
**SDK:** [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk)
|
|
142
|
-
|
|
143
|
-
**Version:** Check [npm](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk) for latest version
|
|
144
|
-
|
|
145
|
-
```bash
|
|
146
|
-
npm install @fluentcommerce/fc-connect-sdk
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
---
|
|
150
|
-
|
|
151
|
-
**Templates are designed for direct deployment; customize via activation variables.**
|
|
152
|
-
|
|
153
|
-
---
|
|
154
|
-
|
|
155
|
-
## 📦 SDK Imports (Verified - Versori Optimized)
|
|
156
|
-
|
|
157
|
-
```typescript
|
|
158
|
-
// ? VERIFIED IMPORTS - These match actual SDK exports
|
|
159
|
-
import { Buffer } from 'node:buffer';
|
|
160
|
-
import {
|
|
161
|
-
createClient, // Universal client factory (auto-detects Versori/Node/Deno)
|
|
162
|
-
ExtractionOrchestrator, // High-level extraction with auto-pagination
|
|
163
|
-
JobTracker, // Job status tracking in KV store
|
|
164
|
-
UniversalMapper, // Field mapping with SDK resolvers
|
|
165
|
-
XMLBuilder, // XML generation with auto-escaping (from SDK)
|
|
166
|
-
SftpDataSource, // SFTP operations with retry logic
|
|
167
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
168
|
-
|
|
169
|
-
// Versori platform imports
|
|
170
|
-
import { schedule, webhook, http, fn } from '@versori/run';
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
**CRITICAL:** `openKv` is NOT imported directly - it's accessed from the context parameter:
|
|
174
|
-
```typescript
|
|
175
|
-
// ✅ CORRECT - From context parameter
|
|
176
|
-
export const myWorkflow = schedule('task', '0 * * * *', async (ctx) => {
|
|
177
|
-
const { openKv, log } = ctx; // Access from context
|
|
178
|
-
const kv = openKv(':project:');
|
|
179
|
-
});
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
**Note:** `openKv` is NOT exported from `@versori/run` - attempting to import it directly will cause a runtime error.
|
|
183
|
-
|
|
184
|
-
**Note:** All imports are from actual SDK exports - this code compiles and runs as-is.
|
|
185
|
-
|
|
186
|
-
**? VERSORI PLATFORM - Use Native Logs:**
|
|
187
|
-
|
|
188
|
-
- Use `log` from context: `const { log } = ctx;`
|
|
189
|
-
- Don't import or use LoggingService for Versori connectors
|
|
190
|
-
- Native Versori logs are simpler and automatically integrated with platform monitoring
|
|
191
|
-
|
|
192
|
-
---
|
|
193
|
-
|
|
194
|
-
## ??� Configuration
|
|
195
|
-
|
|
196
|
-
### SFTP Connection Setup
|
|
197
|
-
|
|
198
|
-
**? RECOMMENDED APPROACH:** Store SFTP credentials in a Versori connection for security and reusability.
|
|
199
|
-
|
|
200
|
-
Versori provides **three methods** to access connection credentials. Choose based on your use case:
|
|
201
|
-
|
|
202
|
-
#### Method 1: `activation.connections` (Recommended - Simplest)
|
|
203
|
-
|
|
204
|
-
**When to use:** Most workflows (scheduled, webhooks without connection param)
|
|
205
|
-
|
|
206
|
-
**Setup:**
|
|
207
|
-
|
|
208
|
-
1. Create connection named `versori_ftp_server` with Basic Auth
|
|
209
|
-
2. Access via `activation.connections`:
|
|
210
|
-
|
|
211
|
-
```typescript
|
|
212
|
-
// ========================================
|
|
213
|
-
// SFTP CREDENTIAL RETRIEVAL
|
|
214
|
-
// ========================================
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Retrieve SFTP credentials from connection configuration
|
|
218
|
-
*
|
|
219
|
-
* This approach retrieves credentials stored in the Versori connection settings.
|
|
220
|
-
* The connection must be configured in the UI with Basic Authentication.
|
|
221
|
-
*
|
|
222
|
-
* Steps:
|
|
223
|
-
* 1. Call ctx.credentials().getAccessToken('SFTP') to get base64-encoded credentials
|
|
224
|
-
* 2. Decode the accessToken from base64 to get "username:password" string
|
|
225
|
-
* 3. Split on ':' to extract username and password
|
|
226
|
-
*
|
|
227
|
-
* Connection Name: 'SFTP' (must match the connection name in Versori UI)
|
|
228
|
-
* Auth Type: Basic Authentication (username + password)
|
|
229
|
-
*
|
|
230
|
-
* This method provides:
|
|
231
|
-
* - Centralized credential management through Versori UI
|
|
232
|
-
* - Better security (credentials not stored in integration variables)
|
|
233
|
-
* - Easier credential rotation and updates
|
|
234
|
-
*/
|
|
235
|
-
log.info('Retrieving SFTP credentials from connection configuration');
|
|
236
|
-
|
|
237
|
-
let sftpUsername: string;
|
|
238
|
-
let sftpPassword: string;
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
// Retrieve credentials from the 'SFTP' connection
|
|
242
|
-
const sftpCred = await ctx.credentials().getAccessToken('SFTP');
|
|
243
|
-
|
|
244
|
-
if (!sftpCred?.accessToken) {
|
|
245
|
-
throw new Error('No SFTP credentials found in connection configuration');
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Decode base64 accessToken to get "username:password"
|
|
249
|
-
const rawBasicAuth = Buffer.from(sftpCred.accessToken, 'base64').toString('utf-8');
|
|
250
|
-
|
|
251
|
-
// Split on ':' to extract username and password
|
|
252
|
-
const parts = rawBasicAuth.split(':');
|
|
253
|
-
|
|
254
|
-
if (parts.length !== 2) {
|
|
255
|
-
throw new Error('Invalid SFTP credential format - expected username:password');
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
sftpUsername = parts[0];
|
|
259
|
-
sftpPassword = parts[1];
|
|
260
|
-
|
|
261
|
-
log.info('SFTP credentials retrieved successfully', {
|
|
262
|
-
hasUsername: !!sftpUsername,
|
|
263
|
-
hasPassword: !!sftpPassword,
|
|
264
|
-
usernameLength: sftpUsername.length,
|
|
265
|
-
passwordLength: sftpPassword.length,
|
|
266
|
-
});
|
|
267
|
-
} catch (error: any) {
|
|
268
|
-
log.error('Failed to retrieve SFTP credentials', {
|
|
269
|
-
message: error instanceof Error ? error.message : String(error),
|
|
270
|
-
|
|
271
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
272
|
-
|
|
273
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',,
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
return {
|
|
277
|
-
success: false,
|
|
278
|
-
error: 'Failed to retrieve SFTP credentials from connection configuration',
|
|
279
|
-
details: error?.message,
|
|
280
|
-
recommendation: 'Please ensure the SFTP connection is configured in the Connections section with Basic Authentication (username and password)',
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
**Pros:**
|
|
286
|
-
|
|
287
|
-
- ✅ Dynamic connection name from config/environment
|
|
288
|
-
- ✅ Explicit error handling for missing connections
|
|
289
|
-
- ✅ Works in all workflow types
|
|
290
|
-
|
|
291
|
-
**Cons:**
|
|
292
|
-
|
|
293
|
-
- ⚠️ Async (requires `await`)
|
|
294
|
-
- ⚠️ More verbose than `activation.connections`
|
|
295
|
-
|
|
296
|
-
#### Method 3: `connections` from Context (Workflow-Scoped)
|
|
297
|
-
|
|
298
|
-
**When to use:** Webhook with `connection` parameter (connection passed to specific step)
|
|
299
|
-
|
|
300
|
-
**Note:** This method is different from `activation.connections` - it only works when the workflow step explicitly receives a `connection` parameter.
|
|
301
|
-
|
|
302
|
-
**Setup:**
|
|
303
|
-
|
|
304
|
-
1. Create connection named `versori_ftp_server`
|
|
305
|
-
2. Pass to workflow step:
|
|
306
|
-
|
|
307
|
-
```typescript
|
|
308
|
-
export const myWebhook = webhook('my-webhook', { connection: 'versori_ftp_server' }).then(
|
|
309
|
-
http('process', { connection: 'versori_ftp_server' }, async ctx => {
|
|
310
|
-
// Get SFTP credentials from Versori connection (Basic Auth)
|
|
311
|
-
// RECOMMENDED: Use activation.connections (already decoded)
|
|
312
|
-
const allConnections = ctx.activation.connections || [];
|
|
313
|
-
const sftpConn = allConnections.find(c => c.name === 'versori_ftp_server');
|
|
314
|
-
|
|
315
|
-
if (!sftpConn) {
|
|
316
|
-
throw new Error('SFTP connection "versori_ftp_server" not found');
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const credential = sftpConn.credentials[0]?.credential;
|
|
320
|
-
if (!credential?.data?.basicAuth) {
|
|
321
|
-
throw new Error('SFTP connection not configured with Basic Authentication');
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const { username, password } = credential.data.basicAuth;
|
|
325
|
-
// ? Already decoded - no Buffer.from() needed!
|
|
326
|
-
})
|
|
327
|
-
);
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
**Pros:**
|
|
331
|
-
|
|
332
|
-
- ✅ Explicit connection passing to specific steps
|
|
333
|
-
- ✅ Connection validation at workflow definition
|
|
334
|
-
|
|
335
|
-
**Cons:**
|
|
336
|
-
|
|
337
|
-
- ⚠️ Only works with `connection` parameter in workflow definition
|
|
338
|
-
- ⚠️ Not available in scheduled workflows or webhooks without connection param
|
|
339
|
-
|
|
340
|
-
#### Quick Decision Guide
|
|
341
|
-
|
|
342
|
-
| Scenario | Method | Example |
|
|
343
|
-
| ----------------------------------- | -------------------------- | ------------------------------------------------------------------- |
|
|
344
|
-
| **Scheduled workflow** | `activation.connections` | `schedule().then(http(...))` |
|
|
345
|
-
| **Webhook (no connection param)** | `activation.connections` | `webhook().then(fn(...))` |
|
|
346
|
-
| **Webhook (with connection param)** | `connections` from context | `webhook({ connection: 'x' }).then(http({ connection: 'x' }, ...))` |
|
|
347
|
-
| **Dynamic connection name** | `credentials().get()` | `await credentials().get(connName)` |
|
|
348
|
-
| **Need error handling** | `credentials().get()` | Explicit null check |
|
|
349
|
-
|
|
350
|
-
**Connection Configuration:**
|
|
351
|
-
|
|
352
|
-
1. In Versori platform, create connection named `versori_ftp_server`
|
|
353
|
-
2. Set **Authentication Type**: `Basic Auth`
|
|
354
|
-
3. Enter **Username**: Your SFTP username
|
|
355
|
-
4. Enter **Password**: Your SFTP password
|
|
356
|
-
5. Connection auto-encodes credentials as base64 Basic Auth
|
|
357
|
-
|
|
358
|
-
**Why use connections instead of activation variables?**
|
|
359
|
-
|
|
360
|
-
- ✅ Credentials stored securely in Versori vault
|
|
361
|
-
- ✅ Connection can be reused across workflows
|
|
362
|
-
- ✅ No need to manage sensitive data in activation variables
|
|
363
|
-
- ✅ Easier credential rotation
|
|
364
|
-
- ✅ Better separation of concerns (config vs secrets)
|
|
365
|
-
- ✅ Follows industry security best practices
|
|
366
|
-
|
|
367
|
-
**?? Complete Guide:** See `docs/02-CORE-GUIDES/data-sources/sftp-credential-access-security.md` for:
|
|
368
|
-
|
|
369
|
-
- Detailed comparison of all three methods
|
|
370
|
-
- Security best practices
|
|
371
|
-
- Common pitfalls and solutions
|
|
372
|
-
- Migration examples
|
|
373
|
-
- Troubleshooting connection issues
|
|
374
|
-
|
|
375
|
-
### Activation Variables
|
|
376
|
-
|
|
377
|
-
**Configuration is driven by activation variables - modify these instead of code:**
|
|
378
|
-
|
|
379
|
-
```json
|
|
380
|
-
{
|
|
381
|
-
"sftpHost": "sftp.partner.com",
|
|
382
|
-
"sftpPort": 22,
|
|
383
|
-
"sftpPrivateKey": "",
|
|
384
|
-
"sftpPath": "/incoming/inventory/",
|
|
385
|
-
"fileNamePrefix": "inventorypositions",
|
|
386
|
-
"pageSize": 200,
|
|
387
|
-
"maxRecords": 100000,
|
|
388
|
-
"overlapBufferSeconds": 60,
|
|
389
|
-
"requireAbsolutePaths": "true"
|
|
390
|
-
}
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
> **Note:** `sftpUsername` and `sftpPassword` are fetched from the `versori_ftp_server` connection (see above).
|
|
394
|
-
|
|
395
|
-
Note: For webhook security, set `webhookApiKey` (activation variable) and provide it via `X-API-Key` header on webhook calls.
|
|
396
|
-
|
|
397
|
-
### Variable Explanations
|
|
398
|
-
|
|
399
|
-
| Variable | Purpose | Default | Customization Hints |
|
|
400
|
-
| --------------------------- | ---------------------------- | ---------------------- | ---------------------------------------------------------------- |
|
|
401
|
-
| **SFTP Credentials** | _From Connection_ | | _See connection setup above_ |
|
|
402
|
-
| `sftpHost` | SFTP server hostname | - | Required - your SFTP server |
|
|
403
|
-
| `sftpPort` | SFTP server port | `22` | Usually 22, sometimes 2222 |
|
|
404
|
-
| `sftpPrivateKey` | SSH private key (optional) | - | Alternative to password auth |
|
|
405
|
-
| `sftpPath` | Remote directory path | `/incoming/inventory/` | Where to upload XML files |
|
|
406
|
-
| `fileNamePrefix` | XML filename prefix | `inventorypositions` | Customize naming convention |
|
|
407
|
-
| `pageSize` | Records per GraphQL page | `200` | Increase for fewer API calls |
|
|
408
|
-
| `maxRecords` | Total extraction limit | `100000` | Safety limit - adjust for volume |
|
|
409
|
-
| `overlapBufferSeconds` | Incremental safety window | `60` | Prevents missed records |
|
|
410
|
-
| `requireAbsolutePaths` | Require absolute SFTP paths | `"true"` | `"true"` for AWS Transfer Family, `"false"` for standard OpenSSH |
|
|
411
|
-
| `validateConnectionOnStart` | Fail-fast connection testing | `"false"` | `"true"` to validate auth before extraction (optional) |
|
|
412
|
-
|
|
413
|
-
**🆕 New Activation Variables (v1.1.0):**
|
|
414
|
-
|
|
415
|
-
- **`validateConnectionOnStart`**: When set to `"true"`, validates Fluent Commerce connection before extraction starts. Executes `query { me { ref } }` to verify authentication. Default: `"false"` (validation happens on first API call). Use for fail-fast behavior in production environments.
|
|
416
|
-
|
|
417
|
-
---
|
|
418
|
-
|
|
419
|
-
### ?? State Management & Incremental Sync
|
|
420
|
-
|
|
421
|
-
**How incremental sync works:**
|
|
422
|
-
|
|
423
|
-
1. **First Run:** Uses `DEFAULT_FALLBACK` date (2024-01-01T00:00:00Z)
|
|
424
|
-
2. **Subsequent Runs:** Uses `lastInventoryPositionSync` timestamp from KV store
|
|
425
|
-
3. **Overlap Buffer:** Subtracts 60 seconds to catch late-arriving records
|
|
426
|
-
4. **State Update:** After successful upload, stores max `updatedOn` for next run
|
|
427
|
-
|
|
428
|
-
**Incremental vs Manual Modes:**
|
|
429
|
-
|
|
430
|
-
| Mode | When to Use | State Update | Payload Example |
|
|
431
|
-
| --------------------------- | -------------------- | ------------ | -------------------------------------------------------------- |
|
|
432
|
-
| **Incremental** | Daily scheduled sync | ? Yes | `{}` (empty - uses last sync) |
|
|
433
|
-
| **Manual Range** | Historical backfill | No | `{ "fromDate": "2024-01-01T00:00:00Z", "updateState": false }` |
|
|
434
|
-
| **Manual Range with State** | One-time catch-up | ? Yes | `{ "fromDate": "2024-01-01T00:00:00Z", "updateState": true }` |
|
|
435
|
-
|
|
436
|
-
**Why overlap buffer?**
|
|
437
|
-
|
|
438
|
-
Records updated near the sync time might not appear in the query due to:
|
|
439
|
-
|
|
440
|
-
- Clock drift between systems
|
|
441
|
-
- Transaction timing in the database
|
|
442
|
-
- GraphQL query execution timing
|
|
443
|
-
|
|
444
|
-
The 60-second buffer ensures these edge-case records are captured in the next run, preventing data loss.
|
|
445
|
-
|
|
446
|
-
**When to skip state update (`updateState: false`):**
|
|
447
|
-
|
|
448
|
-
- Historical backfills (don't affect ongoing incremental sync)
|
|
449
|
-
- Testing/debugging specific date ranges
|
|
450
|
-
- Reprocessing old data without changing the sync pointer
|
|
451
|
-
|
|
452
|
-
---
|
|
453
|
-
|
|
454
|
-
### 📁 SFTP Path Configuration
|
|
455
|
-
|
|
456
|
-
**Note:** Folder paths and filename patterns are configured separately.
|
|
457
|
-
|
|
458
|
-
- **Folder Path:** Configured via `sftpPath` activation variable (e.g., `/incoming/inventory/`)
|
|
459
|
-
- **Filename Pattern:** Configured via `fileNamePrefix` activation variable (e.g., `inventorypositions`)
|
|
460
|
-
|
|
461
|
-
**The workflow generates files like:** `{sftpPath}/{fileNamePrefix}-{timestamp}.xml`
|
|
462
|
-
|
|
463
|
-
**Example:** With `sftpPath="/incoming/inventory/"` and `fileNamePrefix="inventorypositions"`, a generated file will look like:
|
|
464
|
-
|
|
465
|
-
```
|
|
466
|
-
/incoming/inventory/inventorypositions-2025-10-27T18-30-45Z.xml
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
Format may vary slightly (colons replaced; includes Z).
|
|
470
|
-
|
|
471
|
-
**Note:** To change the upload folder, modify the `sftpPath` activation variable (not in code).
|
|
472
|
-
|
|
473
|
-
---
|
|
474
|
-
|
|
475
|
-
### Auto-pagination and limits (ExtractionOrchestrator)
|
|
476
|
-
|
|
477
|
-
**What:** ExtractionOrchestrator handles GraphQL Relay cursor-based pagination automatically.
|
|
478
|
-
|
|
479
|
-
**Why:** Prevents manual pagination loop code, handles large datasets efficiently.
|
|
480
|
-
|
|
481
|
-
**How:** You configure `pageSize` and `maxRecords`; the orchestrator injects `$first` and `$after` variables automatically and loops until `pageInfo.hasNextPage === false` or `maxRecords` is reached.
|
|
482
|
-
**Critical:** Your query MUST include `edges { cursor }` and `pageInfo { hasNextPage }` fields, or pagination will fail.
|
|
483
|
-
|
|
484
|
-
#### How Auto-Pagination Works (Step-by-Step)
|
|
485
|
-
|
|
486
|
-
Note: Orchestrator injects $first/$after automatically, flattens via resultPath, and continues while pageInfo.hasNextPage and total < maxRecords.
|
|
487
|
-
|
|
488
|
-
#### Configuration Parameters
|
|
489
|
-
|
|
490
|
-
| Parameter | Purpose | Example | Effect |
|
|
491
|
-
| ---------------- | ------------------------------ | --------------------------------- | -------------------------- |
|
|
492
|
-
| `pageSize` | Records per GraphQL request | `200` | Controls `first` variable |
|
|
493
|
-
| `maxRecords` | Total extraction limit | `100000` | Hard stop across all pages |
|
|
494
|
-
| `resultPath` | Where records live in response | `"inventoryPositions.edges.node"` | Flattening path |
|
|
495
|
-
| `validateItem()` | Optional record filter | `(item) => !!item.ref` | Skips invalid records |
|
|
496
|
-
|
|
497
|
-
#### Example Configuration
|
|
498
|
-
|
|
499
|
-
```typescript
|
|
500
|
-
const pageSize = parseInt(activation.getVariable('pageSize') || '200', 10);
|
|
501
|
-
const maxRecords = parseInt(activation.getVariable('maxRecords') || '100000', 10);
|
|
502
|
-
|
|
503
|
-
const extractionResult = await orchestrator.extract({
|
|
504
|
-
query: INVENTORY_POSITIONS_EXTRACTION_QUERY,
|
|
505
|
-
resultPath: 'inventoryPositions.edges.node',
|
|
506
|
-
variables: {
|
|
507
|
-
dateRangeFilter,
|
|
508
|
-
// Note: Don't include 'first' or 'after' here; orchestrator injects them based on pageSize.
|
|
509
|
-
},
|
|
510
|
-
pageSize,
|
|
511
|
-
maxRecords,
|
|
512
|
-
validateItem: item => !!(item.ref && item.productRef && typeof item.onHand === 'number'),
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
// Stats available after extraction (Versori)
|
|
516
|
-
log.info('Extraction stats', {
|
|
517
|
-
totalRecords: extractionResult.stats.totalRecords,
|
|
518
|
-
totalPages: extractionResult.stats.totalPages,
|
|
519
|
-
validRecords: extractionResult.stats.validRecords ?? extractionResult.data.length,
|
|
520
|
-
failedValidations: extractionResult.stats.failedValidations,
|
|
521
|
-
});
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
#### GraphQL Query Requirements
|
|
525
|
-
|
|
526
|
-
**Your query MUST include these pagination fields:**
|
|
527
|
-
|
|
528
|
-
```graphql
|
|
529
|
-
query GetInventoryPositions(
|
|
530
|
-
$dateRangeFilter: DateRange
|
|
531
|
-
$first: Int! # ?� Orchestrator injects this
|
|
532
|
-
$after: String # ?� Orchestrator injects this
|
|
533
|
-
) {
|
|
534
|
-
inventoryPositions(
|
|
535
|
-
updatedOn: $dateRangeFilter
|
|
536
|
-
first: $first # ?� Pagination page size
|
|
537
|
-
after: $after # ?� Pagination cursor
|
|
538
|
-
) {
|
|
539
|
-
edges {
|
|
540
|
-
# ?� REQUIRED: Relay connection structure
|
|
541
|
-
node {
|
|
542
|
-
# ?� REQUIRED: Actual records here
|
|
543
|
-
id
|
|
544
|
-
ref
|
|
545
|
-
# ... your fields
|
|
546
|
-
}
|
|
547
|
-
cursor # ?� REQUIRED: Orchestrator uses this for next page
|
|
548
|
-
}
|
|
549
|
-
pageInfo {
|
|
550
|
-
# ?� REQUIRED: Orchestrator checks this
|
|
551
|
-
hasNextPage # ?� REQUIRED: Tells orchestrator to continue
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
---
|
|
558
|
-
|
|
559
|
-
## 📄 Mapping Configuration
|
|
560
|
-
|
|
561
|
-
**File:** `config/inventory-positions.export.xml.json`
|
|
562
|
-
|
|
563
|
-
```json
|
|
564
|
-
{
|
|
565
|
-
"name": "inventory-positions.export.xml",
|
|
566
|
-
"version": "1.0.0",
|
|
567
|
-
"description": "Inventory Positions ? XML Export Mapping",
|
|
568
|
-
"fields": {
|
|
569
|
-
"position_ref": {
|
|
570
|
-
"source": "ref",
|
|
571
|
-
"required": true,
|
|
572
|
-
"resolver": "sdk.trim"
|
|
573
|
-
},
|
|
574
|
-
"product_ref": {
|
|
575
|
-
"source": "productRef",
|
|
576
|
-
"required": true,
|
|
577
|
-
"resolver": "sdk.trim"
|
|
578
|
-
},
|
|
579
|
-
"location_ref": {
|
|
580
|
-
"source": "locationRef",
|
|
581
|
-
"required": false,
|
|
582
|
-
"resolver": "sdk.trim"
|
|
583
|
-
},
|
|
584
|
-
"on_hand": {
|
|
585
|
-
"source": "onHand",
|
|
586
|
-
"required": true,
|
|
587
|
-
"resolver": "sdk.parseInt"
|
|
588
|
-
},
|
|
589
|
-
"position_type": {
|
|
590
|
-
"source": "type",
|
|
591
|
-
"required": true,
|
|
592
|
-
"resolver": "sdk.uppercase"
|
|
593
|
-
},
|
|
594
|
-
"status": {
|
|
595
|
-
"source": "status",
|
|
596
|
-
"required": false,
|
|
597
|
-
"resolver": "sdk.uppercase"
|
|
598
|
-
},
|
|
599
|
-
"catalogue_ref": {
|
|
600
|
-
"source": "catalogue.ref",
|
|
601
|
-
"required": false,
|
|
602
|
-
"resolver": "sdk.trim"
|
|
603
|
-
},
|
|
604
|
-
"catalogue_name": {
|
|
605
|
-
"source": "catalogue.name",
|
|
606
|
-
"required": false,
|
|
607
|
-
"resolver": "sdk.trim"
|
|
608
|
-
},
|
|
609
|
-
"created_on": {
|
|
610
|
-
"source": "createdOn",
|
|
611
|
-
"required": true,
|
|
612
|
-
"resolver": "sdk.toString"
|
|
613
|
-
},
|
|
614
|
-
"updated_on": {
|
|
615
|
-
"source": "updatedOn",
|
|
616
|
-
"required": true,
|
|
617
|
-
"resolver": "sdk.toString"
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
```
|
|
622
|
-
|
|
623
|
-
**AI Customization Hints:**
|
|
624
|
-
|
|
625
|
-
- Add fields: Copy existing field config, change `source` path
|
|
626
|
-
- Remove fields: Delete field from config
|
|
627
|
-
- Change resolvers: Replace `sdk.trim` with `sdk.uppercase`, etc.
|
|
628
|
-
- Nested fields: Use dot notation like `catalogue.ref`
|
|
629
|
-
|
|
630
|
-
Note: Customize mapping by editing the JSON above; prefer built-in resolvers. See SDK Universal Mapping guide for advanced usage.
|
|
631
|
-
|
|
632
|
-
---
|
|
633
|
-
|
|
634
|
-
## Versori Workflows Structure
|
|
635
|
-
|
|
636
|
-
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
637
|
-
|
|
638
|
-
**Trigger Types:**
|
|
639
|
-
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
640
|
-
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
641
|
-
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
642
|
-
|
|
643
|
-
**Execution Steps (chained to triggers):**
|
|
644
|
-
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
645
|
-
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
646
|
-
|
|
647
|
-
### Recommended Project Structure
|
|
648
|
-
|
|
649
|
-
```
|
|
650
|
-
inventory-positions-extraction/
|
|
651
|
-
├── index.ts # Entry point - exports all workflows
|
|
652
|
-
└── src/
|
|
653
|
-
├── workflows/
|
|
654
|
-
│ ├── scheduled/
|
|
655
|
-
│ │ └── daily-inventory-positions-extraction.ts # Scheduled workflow
|
|
656
|
-
│ └── webhook/
|
|
657
|
-
│ ├── adhoc-inventory-positions-extraction.ts # Webhook: Manual trigger
|
|
658
|
-
│ └── job-status-check.ts # Webhook: Status query
|
|
659
|
-
│
|
|
660
|
-
├── services/
|
|
661
|
-
│ └── inventory-positions-extraction.service.ts # Shared orchestration logic (reusable)
|
|
662
|
-
│
|
|
663
|
-
└── config/
|
|
664
|
-
└── inventory-positions.export.xml.json # Mapping configuration
|
|
665
|
-
```
|
|
666
|
-
|
|
667
|
-
---
|
|
668
|
-
|
|
669
|
-
### 1. Entry Point (`index.ts`)
|
|
670
|
-
|
|
671
|
-
**Purpose**: Register all workflows with Versori platform
|
|
672
|
-
|
|
673
|
-
**Pattern**: Use simple re-exports (NOT `MemoryInterpreter` pattern)
|
|
674
|
-
|
|
675
|
-
```typescript
|
|
676
|
-
/**
|
|
677
|
-
* Entry point - Export all workflows for Versori platform
|
|
678
|
-
*
|
|
679
|
-
* This file exports all workflows to be registered with Versori.
|
|
680
|
-
* Each workflow is defined in its own file for better organization.
|
|
681
|
-
*/
|
|
682
|
-
|
|
683
|
-
// Scheduled workflows
|
|
684
|
-
export { scheduledInventoryPositionsExtraction } from './src/workflows/scheduled/daily-inventory-positions-extraction';
|
|
685
|
-
|
|
686
|
-
// Webhook workflows
|
|
687
|
-
export { adhocInventoryPositionsExtraction } from './src/workflows/webhook/adhoc-inventory-positions-extraction';
|
|
688
|
-
export { inventoryPositionsJobStatus } from './src/workflows/webhook/job-status-check';
|
|
689
|
-
```
|
|
690
|
-
|
|
691
|
-
**IMPORTANT**: Do NOT use `MemoryInterpreter` or complex patterns in `index.ts`. Simple re-exports are the Versori standard.
|
|
692
|
-
|
|
693
|
-
### 2. Workflow Definitions
|
|
694
|
-
|
|
695
|
-
The code examples below show the three workflow types that go in separate files:
|
|
696
|
-
- `src/workflows/scheduled/daily-inventory-positions-extraction.ts` - Scheduled workflow
|
|
697
|
-
- `src/workflows/webhook/adhoc-inventory-positions-extraction.ts` - Webhook: Manual trigger
|
|
698
|
-
- `src/workflows/webhook/job-status-check.ts` - Webhook: Status query
|
|
699
|
-
|
|
700
|
-
### 3. Main Orchestration Service (`src/services/inventory-positions-extraction.service.ts`)
|
|
701
|
-
|
|
702
|
-
**Purpose**: Shared orchestration logic reused by all workflows
|
|
703
|
-
|
|
704
|
-
**Note:** This service coordinates the complete extraction workflow:
|
|
705
|
-
- Initialize clients and services
|
|
706
|
-
- Determine date range (incremental vs manual)
|
|
707
|
-
- Extract data using ExtractionOrchestrator
|
|
708
|
-
- Transform using UniversalMapper
|
|
709
|
-
- Generate XML using XMLBuilder
|
|
710
|
-
- Upload to SFTP
|
|
711
|
-
- Track job progress with JobTracker
|
|
712
|
-
- Update state for next run
|
|
713
|
-
|
|
714
|
-
**NAMING PATTERN** (consistent across all use cases):
|
|
715
|
-
- Interface: `{Entity}ExtractionParams` (e.g., `InventoryPositionExtractionParams`)
|
|
716
|
-
- Function: `execute{Entity}Extraction` (e.g., `executeInventoryPositionExtraction`)
|
|
717
|
-
- Service file: `{entity}-extraction.service.ts` (e.g., `inventory-positions-extraction.service.ts`)
|
|
718
|
-
|
|
719
|
-
```typescript
|
|
720
|
-
/**
|
|
721
|
-
* Workflows - Defines 3 execution patterns for inventory positions extraction
|
|
722
|
-
*
|
|
723
|
-
* WORKFLOW 1: Scheduled (Cron) - Runs automatically every hour
|
|
724
|
-
* WORKFLOW 2: Ad hoc (Webhook) - Manual trigger with optional date override
|
|
725
|
-
* WORKFLOW 3: Job Status (Webhook) - Query job progress
|
|
726
|
-
*
|
|
727
|
-
* AI CUSTOMIZATION HINTS:
|
|
728
|
-
* - Change schedule: Modify cron expression in schedule()
|
|
729
|
-
* - Add filtering: Pass additional params to executeInventoryPositionExtraction()
|
|
730
|
-
* - Change response format: Modify return object structure
|
|
731
|
-
*/
|
|
732
|
-
|
|
733
|
-
import { schedule, webhook, http, fn } from '@versori/run';
|
|
734
|
-
import { executeInventoryPositionExtraction, getJobStatus } from '../services/extraction-orchestration';
|
|
735
|
-
import { generateJobId, extractFileName } from '../utils/job-id-generator'; // We'll create this
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* WORKFLOW 1: Scheduled Extraction
|
|
739
|
-
*
|
|
740
|
-
* Purpose: Automated hourly extraction for incremental sync
|
|
741
|
-
* Trigger: Cron schedule (every hour at minute 0)
|
|
742
|
-
* State Update: Always updates lastSync timestamp
|
|
743
|
-
*
|
|
744
|
-
* AI CUSTOMIZATION:
|
|
745
|
-
* - Change schedule: Replace '0 * * * *' with your cron expression
|
|
746
|
-
* Examples:
|
|
747
|
-
* - Every 30 min: '*/30 * * * *'
|
|
748
|
-
* - Daily at 2 AM: '0 2 * * *'
|
|
749
|
-
* - Every 15 min: '*/15 * * * *'
|
|
750
|
-
*/
|
|
751
|
-
export const scheduledInventoryPositionsExtraction = schedule('scheduled-inventory-positions-extraction', '0 * * * *') // ? CUSTOMIZE: Cron expression
|
|
752
|
-
.then(
|
|
753
|
-
http('execute-scheduled-extraction', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
754
|
-
const { log } = ctx;
|
|
755
|
-
|
|
756
|
-
// Generate unique job ID for tracking
|
|
757
|
-
// Format: SCHEDULED_IP_YYYYMMDD_HHMMSS_random
|
|
758
|
-
const jobId = generateJobId('SCHEDULED', 'INVENTORY_POSITIONS');
|
|
759
|
-
|
|
760
|
-
log.info('Scheduled extraction triggered', { jobId });
|
|
761
|
-
|
|
762
|
-
try {
|
|
763
|
-
// Execute main workflow (extraction ? transform ? upload)
|
|
764
|
-
const result = await executeInventoryPositionExtraction(ctx, {
|
|
765
|
-
jobId,
|
|
766
|
-
triggeredBy: 'schedule',
|
|
767
|
-
updateState: true, // Always update state for scheduled runs
|
|
768
|
-
// positionTypes: ['DEFAULT', 'SEASONAL'],
|
|
769
|
-
// locationRefs: ['LOC-001'],
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
log.info('Scheduled extraction completed', {
|
|
773
|
-
jobId,
|
|
774
|
-
recordCount: result.recordsExtracted,
|
|
775
|
-
fileName: result.fileName
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
return result;
|
|
779
|
-
|
|
780
|
-
} catch (error: any) {
|
|
781
|
-
log.error('Scheduled extraction failed', {
|
|
782
|
-
jobId,
|
|
783
|
-
message: error instanceof Error ? error.message : String(error),
|
|
784
|
-
|
|
785
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
786
|
-
|
|
787
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
788
|
-
});
|
|
789
|
-
throw error;
|
|
790
|
-
}
|
|
791
|
-
})
|
|
792
|
-
);
|
|
793
|
-
|
|
794
|
-
/**
|
|
795
|
-
* WORKFLOW 2: Ad hoc Extraction (Manual Trigger)
|
|
796
|
-
*
|
|
797
|
-
* Purpose: Manual extraction with optional date range override
|
|
798
|
-
* Trigger: Webhook POST to /webhooks/inventory-positions-adhoc
|
|
799
|
-
* State Update: Optional (controlled by request payload)
|
|
800
|
-
*
|
|
801
|
-
* WEBHOOK PAYLOAD EXAMPLES:
|
|
802
|
-
*
|
|
803
|
-
* 1. Incremental (use last sync timestamp):
|
|
804
|
-
* {}
|
|
805
|
-
*
|
|
806
|
-
* 2. Date range (manual override):
|
|
807
|
-
* {
|
|
808
|
-
* "fromDate": "2025-01-01T00:00:00Z",
|
|
809
|
-
* "toDate": "2025-01-31T23:59:59Z",
|
|
810
|
-
* "updateState": false
|
|
811
|
-
* }
|
|
812
|
-
*
|
|
813
|
-
* 3. Specific time window:
|
|
814
|
-
* {
|
|
815
|
-
* "fromDate": "2025-01-15T08:00:00Z",
|
|
816
|
-
* "toDate": "2025-01-15T17:00:00Z",
|
|
817
|
-
* "updateState": false
|
|
818
|
-
* }
|
|
819
|
-
*
|
|
820
|
-
* AI CUSTOMIZATION:
|
|
821
|
-
* - Add request validation
|
|
822
|
-
* - Add authentication check
|
|
823
|
-
* - Add custom filters from payload
|
|
824
|
-
*/
|
|
825
|
-
export const adhocInventoryPositionsExtraction = webhook(
|
|
826
|
-
'inventory-positions-adhoc',
|
|
827
|
-
{ connection: 'inventory-positions-adhoc', response: { mode: 'sync' } }
|
|
828
|
-
)
|
|
829
|
-
.then(
|
|
830
|
-
http('execute-adhoc-extraction', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
831
|
-
const { data, log } = ctx;
|
|
832
|
-
|
|
833
|
-
// Generate unique job ID
|
|
834
|
-
const jobId = generateJobId('ADHOC', 'INVENTORY_POSITIONS');
|
|
835
|
-
|
|
836
|
-
// Security handled by Versori connection
|
|
837
|
-
|
|
838
|
-
// Extract optional date override from webhook payload
|
|
839
|
-
const fromDate = data.fromDate as string | undefined;
|
|
840
|
-
const toDate = data.toDate as string | undefined;
|
|
841
|
-
const updateState = data.updateState === true; // Default false; advance state only if explicitly true
|
|
842
|
-
|
|
843
|
-
log.info('Ad hoc extraction triggered via webhook', {
|
|
844
|
-
jobId,
|
|
845
|
-
hasDateOverride: !!fromDate,
|
|
846
|
-
fromDate: fromDate || 'not specified',
|
|
847
|
-
toDate: toDate || 'not specified',
|
|
848
|
-
updateState
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
try {
|
|
852
|
-
// Execute main workflow with optional overrides
|
|
853
|
-
const result = await executeInventoryPositionExtraction(ctx, {
|
|
854
|
-
jobId,
|
|
855
|
-
triggeredBy: 'webhook',
|
|
856
|
-
fromDate, // Optional: override start date
|
|
857
|
-
toDate, // Optional: override end date
|
|
858
|
-
updateState, // Optional: skip state update for historical queries
|
|
859
|
-
// positionTypes: data.positionTypes,
|
|
860
|
-
// locationRefs: data.locationRefs,
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
log.info('Ad hoc extraction completed', {
|
|
864
|
-
jobId,
|
|
865
|
-
recordCount: result.recordsExtracted,
|
|
866
|
-
fileName: result.fileName,
|
|
867
|
-
isManualOverride: !!fromDate,
|
|
868
|
-
stateUpdated: result.stateUpdated
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
// Return success with job details
|
|
872
|
-
return {
|
|
873
|
-
success: true,
|
|
874
|
-
jobId,
|
|
875
|
-
recordsExtracted: result.recordsExtracted,
|
|
876
|
-
fileName: result.fileName,
|
|
877
|
-
sftpPath: result.sftpPath,
|
|
878
|
-
statusUrl: `/webhooks/inventory-positions-job-status?jobId=${jobId}`,
|
|
879
|
-
dateRange: fromDate ? {
|
|
880
|
-
from: fromDate,
|
|
881
|
-
to: toDate || 'not specified',
|
|
882
|
-
updateState
|
|
883
|
-
} : undefined
|
|
884
|
-
};
|
|
885
|
-
|
|
886
|
-
} catch (error: any) {
|
|
887
|
-
log.error('Ad hoc extraction failed', {
|
|
888
|
-
jobId,
|
|
889
|
-
message: error instanceof Error ? error.message : String(error),
|
|
890
|
-
|
|
891
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
892
|
-
|
|
893
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
return {
|
|
897
|
-
success: false,
|
|
898
|
-
jobId,
|
|
899
|
-
message: error instanceof Error ? error.message : String(error),
|
|
900
|
-
|
|
901
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
902
|
-
|
|
903
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
904
|
-
};
|
|
905
|
-
}
|
|
906
|
-
})
|
|
907
|
-
);
|
|
908
|
-
|
|
909
|
-
/**
|
|
910
|
-
* WORKFLOW 3: Job Status Query
|
|
911
|
-
*
|
|
912
|
-
* Purpose: Check job progress and status
|
|
913
|
-
* Trigger: Webhook GET/POST to /webhooks/inventory-positions-job-status?jobId=xxx
|
|
914
|
-
* Returns: Current job status, stage, progress
|
|
915
|
-
*
|
|
916
|
-
* QUERY EXAMPLES:
|
|
917
|
-
*
|
|
918
|
-
* 1. HTTP GET:
|
|
919
|
-
* GET /webhooks/inventory-positions-job-status?jobId=ADHOC_IP_20251027_183045_abc123
|
|
920
|
-
*
|
|
921
|
-
* 2. HTTP POST:
|
|
922
|
-
* POST /webhooks/inventory-positions-job-status
|
|
923
|
-
* { "jobId": "ADHOC_IP_20251027_183045_abc123" }
|
|
924
|
-
*
|
|
925
|
-
* RESPONSE EXAMPLE:
|
|
926
|
-
* {
|
|
927
|
-
* "success": true,
|
|
928
|
-
* "jobId": "ADHOC_IP_20251027_183045_abc123",
|
|
929
|
-
* "status": "processing",
|
|
930
|
-
* "stage": "transforming",
|
|
931
|
-
* "message": "Transforming 15000 records",
|
|
932
|
-
* "createdAt": "2025-10-27T18:30:45.000Z",
|
|
933
|
-
* "startedAt": "2025-10-27T18:30:46.000Z"
|
|
934
|
-
* }
|
|
935
|
-
*
|
|
936
|
-
* AI CUSTOMIZATION:
|
|
937
|
-
* - Add detailed progress percentage
|
|
938
|
-
* - Add estimated time remaining
|
|
939
|
-
* - Add historical job queries
|
|
940
|
-
*/
|
|
941
|
-
export const inventoryPositionsJobStatus = webhook(
|
|
942
|
-
'inventory-positions-job-status',
|
|
943
|
-
{ connection: 'inventory-positions-job-status', response: { mode: 'sync' } }
|
|
944
|
-
)
|
|
945
|
-
.then(
|
|
946
|
-
fn('query-job-status', async (ctx) => {
|
|
947
|
-
const { data, log, openKv } = ctx;
|
|
948
|
-
|
|
949
|
-
// Get jobId from query param or POST body
|
|
950
|
-
const jobId = data.jobId as string;
|
|
951
|
-
|
|
952
|
-
if (!jobId) {
|
|
953
|
-
log.error('Job ID not provided in request');
|
|
954
|
-
return {
|
|
955
|
-
success: false,
|
|
956
|
-
error: 'Job ID is required. Provide jobId in query param or request body.'
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
log.info('Querying job status', { jobId });
|
|
961
|
-
|
|
962
|
-
try {
|
|
963
|
-
// Query job status from KV store
|
|
964
|
-
const status = await getJobStatus(openKv(':project:'), jobId, log);
|
|
965
|
-
|
|
966
|
-
if (!status) {
|
|
967
|
-
log.info('Job not found', { jobId });
|
|
968
|
-
return {
|
|
969
|
-
success: false,
|
|
970
|
-
error: 'Job not found',
|
|
971
|
-
jobId
|
|
972
|
-
};
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
log.info('Job status retrieved', { jobId, status: status.status });
|
|
976
|
-
|
|
977
|
-
return {
|
|
978
|
-
success: true,
|
|
979
|
-
jobId,
|
|
980
|
-
...status
|
|
981
|
-
};
|
|
982
|
-
|
|
983
|
-
} catch (error: any) {
|
|
984
|
-
log.error('Failed to query job status', {
|
|
985
|
-
jobId,
|
|
986
|
-
message: error instanceof Error ? error.message : String(error),
|
|
987
|
-
|
|
988
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
989
|
-
|
|
990
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
return {
|
|
994
|
-
success: false,
|
|
995
|
-
jobId,
|
|
996
|
-
message: error instanceof Error ? error.message : String(error),
|
|
997
|
-
|
|
998
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
999
|
-
|
|
1000
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1001
|
-
};
|
|
1002
|
-
}
|
|
1003
|
-
})
|
|
1004
|
-
);
|
|
1005
|
-
```
|
|
1006
|
-
|
|
1007
|
-
---
|
|
1008
|
-
|
|
1009
|
-
# Production Template: Service Implementation
|
|
1010
|
-
|
|
1011
|
-
## 3. Main Orchestration Service (src/services/extraction-orchestration.ts)
|
|
1012
|
-
|
|
1013
|
-
```typescript
|
|
1014
|
-
/**
|
|
1015
|
-
* MAIN ORCHESTRATION SERVICE
|
|
1016
|
-
*
|
|
1017
|
-
* This is the heart of the extraction workflow. It coordinates all steps:
|
|
1018
|
-
* 1. Initialize clients and services
|
|
1019
|
-
* 2. Determine date range (incremental vs manual)
|
|
1020
|
-
* 3. Extract data using ExtractionOrchestrator
|
|
1021
|
-
* 4. Transform using UniversalMapper
|
|
1022
|
-
* 5. Generate XML using XMLBuilder
|
|
1023
|
-
* 6. Upload to SFTP
|
|
1024
|
-
* 7. Track job progress with JobTracker
|
|
1025
|
-
* 8. Update state for next run
|
|
1026
|
-
*
|
|
1027
|
-
* NAMING PATTERN (consistent across all use cases):
|
|
1028
|
-
* - Interface: {Entity}ExtractionParams (e.g., InventoryPositionExtractionParams)
|
|
1029
|
-
* - Result: {Entity}ExtractionResult (e.g., InventoryPositionExtractionResult)
|
|
1030
|
-
* - Main function: execute{Entity}Extraction (e.g., executeInventoryPositionExtraction)
|
|
1031
|
-
*
|
|
1032
|
-
* AI CUSTOMIZATION HINTS:
|
|
1033
|
-
* - Change entity: Replace "InventoryPosition" with "VirtualPosition", "Order", etc.
|
|
1034
|
-
* - Change output: Replace XMLBuilder with CSVParserService.stringify()
|
|
1035
|
-
* - Change destination: Replace SftpDataSource with S3DataSource
|
|
1036
|
-
* - Add steps: Insert new service calls between existing steps
|
|
1037
|
-
*/
|
|
1038
|
-
|
|
1039
|
-
import { Buffer } from 'node:buffer';
|
|
1040
|
-
import {
|
|
1041
|
-
createClient,
|
|
1042
|
-
ExtractionOrchestrator,
|
|
1043
|
-
JobTracker,
|
|
1044
|
-
UniversalMapper,
|
|
1045
|
-
XMLBuilder,
|
|
1046
|
-
SftpDataSource,
|
|
1047
|
-
type ExtractionOptions,
|
|
1048
|
-
type ExtractionResult,
|
|
1049
|
-
type JobStatus,
|
|
1050
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1051
|
-
|
|
1052
|
-
import mappingConfig from '../../config/inventory-positions.export.xml.json' with { type: 'json' };
|
|
1053
|
-
import { extractFileName } from '../../utils/job-id-generator';
|
|
1054
|
-
|
|
1055
|
-
// ? VERSORI PLATFORM: Use native log from context, not LoggingService
|
|
1056
|
-
|
|
1057
|
-
/**
|
|
1058
|
-
* Parameters for extraction workflow
|
|
1059
|
-
*
|
|
1060
|
-
* NAMING: {Entity}ExtractionParams
|
|
1061
|
-
*/
|
|
1062
|
-
export interface InventoryPositionExtractionParams {
|
|
1063
|
-
jobId: string;
|
|
1064
|
-
triggeredBy: 'schedule' | 'webhook';
|
|
1065
|
-
fromDate?: string; // Optional: manual date override
|
|
1066
|
-
toDate?: string; // Optional: manual date override
|
|
1067
|
-
updateState: boolean; // Whether to update lastSync timestamp
|
|
1068
|
-
|
|
1069
|
-
// AI CUSTOMIZATION: Add filters specific to entity
|
|
1070
|
-
positionTypes?: string[]; // e.g., ['DEFAULT', 'SEASONAL']
|
|
1071
|
-
locationRefs?: string[]; // e.g., ['LOC-001']
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/**
|
|
1075
|
-
* Result from extraction workflow
|
|
1076
|
-
*
|
|
1077
|
-
* NAMING: {Entity}ExtractionResult
|
|
1078
|
-
*/
|
|
1079
|
-
export interface InventoryPositionExtractionResult {
|
|
1080
|
-
success: boolean;
|
|
1081
|
-
jobId: string;
|
|
1082
|
-
recordsExtracted: number;
|
|
1083
|
-
fileName?: string;
|
|
1084
|
-
sftpPath?: string;
|
|
1085
|
-
error?: string;
|
|
1086
|
-
errors?: any[];
|
|
1087
|
-
isManualOverride?: boolean;
|
|
1088
|
-
stateUpdated?: boolean;
|
|
1089
|
-
newTimestamp?: string;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
/**
|
|
1093
|
-
* GraphQL Query for Inventory Positions
|
|
1094
|
-
*
|
|
1095
|
-
* NAMING: {ENTITY}_EXTRACTION_QUERY (uppercase constant)
|
|
1096
|
-
*
|
|
1097
|
-
* Replace entity/fields as needed but keep pagination structure (edges, node, pageInfo)
|
|
1098
|
-
*/
|
|
1099
|
-
const INVENTORY_POSITIONS_EXTRACTION_QUERY = `
|
|
1100
|
-
query GetInventoryPositions(
|
|
1101
|
-
$dateRangeFilter: DateRange
|
|
1102
|
-
$productRefs: [String!]
|
|
1103
|
-
$types: [String!]
|
|
1104
|
-
$locationRefs: [String]
|
|
1105
|
-
$first: Int!
|
|
1106
|
-
$after: String
|
|
1107
|
-
) {
|
|
1108
|
-
inventoryPositions(
|
|
1109
|
-
updatedOn: $dateRangeFilter
|
|
1110
|
-
productRef: $productRefs
|
|
1111
|
-
type: $types
|
|
1112
|
-
locationRef: $locationRefs
|
|
1113
|
-
first: $first
|
|
1114
|
-
after: $after
|
|
1115
|
-
) {
|
|
1116
|
-
edges {
|
|
1117
|
-
node {
|
|
1118
|
-
id
|
|
1119
|
-
ref
|
|
1120
|
-
productRef
|
|
1121
|
-
locationRef
|
|
1122
|
-
onHand
|
|
1123
|
-
type
|
|
1124
|
-
status
|
|
1125
|
-
catalogue {
|
|
1126
|
-
ref
|
|
1127
|
-
name
|
|
1128
|
-
}
|
|
1129
|
-
createdOn
|
|
1130
|
-
updatedOn
|
|
1131
|
-
}
|
|
1132
|
-
cursor
|
|
1133
|
-
}
|
|
1134
|
-
pageInfo {
|
|
1135
|
-
hasNextPage
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
`;
|
|
1140
|
-
|
|
1141
|
-
/**
|
|
1142
|
-
* Query job status from KV store
|
|
1143
|
-
*
|
|
1144
|
-
* NAMING: get{Entity}JobStatus or just getJobStatus (generic)
|
|
1145
|
-
*
|
|
1146
|
-
* ? VERSORI PLATFORM: Uses native log from context (passed as parameter)
|
|
1147
|
-
*/
|
|
1148
|
-
export async function getJobStatus(
|
|
1149
|
-
kv: any, // ? Versori KV (compatible with JobTracker's KVAdapter interface)
|
|
1150
|
-
jobId: string,
|
|
1151
|
-
log: any // ? Native Versori log from context
|
|
1152
|
-
): Promise<JobStatus | undefined> {
|
|
1153
|
-
try {
|
|
1154
|
-
const tracker = new JobTracker(kv, log);
|
|
1155
|
-
return await tracker.getJob(jobId); // ? Use getJob() not getJobStatus()
|
|
1156
|
-
} catch (error: any) {
|
|
1157
|
-
log.error('Failed to get job status', { jobId, message: error instanceof Error ? error.message : String(error),
|
|
1158
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1159
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error', });
|
|
1160
|
-
return undefined;
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
/**
|
|
1165
|
-
* MAIN ORCHESTRATION FUNCTION
|
|
1166
|
-
*
|
|
1167
|
-
* NAMING: execute{Entity}Extraction (e.g., executeInventoryPositionExtraction)
|
|
1168
|
-
*
|
|
1169
|
-
* This function implements the complete workflow in steps.
|
|
1170
|
-
* Each step is clearly commented for AI understanding.
|
|
1171
|
-
*/
|
|
1172
|
-
export async function executeInventoryPositionExtraction(
|
|
1173
|
-
ctx: any,
|
|
1174
|
-
params: InventoryPositionExtractionParams
|
|
1175
|
-
): Promise<InventoryPositionExtractionResult> {
|
|
1176
|
-
// ? VERSORI PLATFORM: Extract native log from context
|
|
1177
|
-
const { log, openKv, activation, credentials } = ctx;
|
|
1178
|
-
const { jobId, triggeredBy, fromDate, toDate, updateState } = params;
|
|
1179
|
-
|
|
1180
|
-
// ⏱️ Track total execution time
|
|
1181
|
-
const startTime = Date.now();
|
|
1182
|
-
|
|
1183
|
-
// Open KV store for state management and job tracking
|
|
1184
|
-
// ? Pass raw Versori KV directly - it matches KVAdapter interface
|
|
1185
|
-
// ? Pass native log to JobTracker
|
|
1186
|
-
const kv = openKv(':project:');
|
|
1187
|
-
const tracker = new JobTracker(kv, log);
|
|
1188
|
-
|
|
1189
|
-
try {
|
|
1190
|
-
// �����������������������������������������������������������
|
|
1191
|
-
// STEP 1/8: Initialize Job Tracking
|
|
1192
|
-
// �����������������������������������������������������������
|
|
1193
|
-
log.info('📊 [STEP 1/8] Initializing job tracking', { jobId });
|
|
1194
|
-
|
|
1195
|
-
await tracker.createJob(jobId, {
|
|
1196
|
-
triggeredBy,
|
|
1197
|
-
hasDateOverride: !!fromDate,
|
|
1198
|
-
fromDate,
|
|
1199
|
-
toDate,
|
|
1200
|
-
updateStateAfterRun: updateState,
|
|
1201
|
-
});
|
|
1202
|
-
|
|
1203
|
-
// �����������������������������������������������������������
|
|
1204
|
-
// STEP 2/8: Initialize Fluent Client
|
|
1205
|
-
// �����������������������������������������������������������
|
|
1206
|
-
log.info('🔌 [STEP 2/8] Initializing Fluent Commerce client', { jobId });
|
|
1207
|
-
|
|
1208
|
-
// ✅ Optional: Validate connection immediately (fail-fast mode)
|
|
1209
|
-
// Set activation variable 'validateConnectionOnStart' = 'true' to enable
|
|
1210
|
-
// When enabled: Executes query { me { ref } } to verify authentication
|
|
1211
|
-
// When disabled: Fast creation, validation happens on first API call (default)
|
|
1212
|
-
const validateConnection = activation.getVariable('validateConnectionOnStart') === 'true';
|
|
1213
|
-
const client = await createClient(ctx, validateConnection ? { validateConnection: true } : undefined);
|
|
1214
|
-
|
|
1215
|
-
if (!client) {
|
|
1216
|
-
throw new Error('Failed to create Fluent Commerce client');
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
if (validateConnection) {
|
|
1220
|
-
log.info('✅ Connection validated successfully - authentication confirmed', { jobId });
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// �����������������������������������������������������������
|
|
1224
|
-
// STEP 3/8: Determine Date Range (WITH overlap buffer)
|
|
1225
|
-
// �����������������������������������������������������������
|
|
1226
|
-
log.info('📅 [STEP 3/8] Determining date range for extraction', { jobId });
|
|
1227
|
-
|
|
1228
|
-
// State key for incremental sync tracking
|
|
1229
|
-
// NAMING: last{Entity}Sync (e.g., lastInventoryPositionSync)
|
|
1230
|
-
const STATE_KEY = 'lastInventoryPositionSync';
|
|
1231
|
-
const DEFAULT_FALLBACK = '2024-01-01T00:00:00Z';
|
|
1232
|
-
const OVERLAP_BUFFER_SECONDS = parseInt(
|
|
1233
|
-
activation.getVariable('overlapBufferSeconds') || '60',
|
|
1234
|
-
10
|
|
1235
|
-
);
|
|
1236
|
-
|
|
1237
|
-
let dateRangeFilter: { from?: string; to?: string } | null = null;
|
|
1238
|
-
const isManualOverride = !!fromDate;
|
|
1239
|
-
|
|
1240
|
-
if (isManualOverride) {
|
|
1241
|
-
// Manual date override from webhook
|
|
1242
|
-
dateRangeFilter = { from: fromDate, to: toDate };
|
|
1243
|
-
log.info('Using manual date override', { fromDate, toDate });
|
|
1244
|
-
} else {
|
|
1245
|
-
// Incremental sync - get last sync timestamp
|
|
1246
|
-
const rawLastRunTime = (await kv.get<string>(STATE_KEY)) || DEFAULT_FALLBACK;
|
|
1247
|
-
|
|
1248
|
-
// Apply overlap buffer (prevents missed records)
|
|
1249
|
-
const bufferedLastRunTime = new Date(
|
|
1250
|
-
new Date(rawLastRunTime).getTime() - OVERLAP_BUFFER_SECONDS * 1000
|
|
1251
|
-
).toISOString();
|
|
1252
|
-
|
|
1253
|
-
const effectiveEndTime = toDate || new Date().toISOString();
|
|
1254
|
-
|
|
1255
|
-
dateRangeFilter = {
|
|
1256
|
-
from: bufferedLastRunTime,
|
|
1257
|
-
to: effectiveEndTime, // End of extraction window
|
|
1258
|
-
};
|
|
1259
|
-
|
|
1260
|
-
log.info('Using incremental sync with overlap buffer', {
|
|
1261
|
-
rawLastRunTime,
|
|
1262
|
-
bufferedLastRunTime,
|
|
1263
|
-
effectiveEndTime,
|
|
1264
|
-
overlapBufferSeconds: OVERLAP_BUFFER_SECONDS,
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
// �����������������������������������������������������������
|
|
1269
|
-
// STEP 4/8: Extract Data (ExtractionOrchestrator)
|
|
1270
|
-
// �����������������������������������������������������������
|
|
1271
|
-
log.info('📥 [STEP 4/8] Extracting data from Fluent Commerce', { jobId });
|
|
1272
|
-
|
|
1273
|
-
await tracker.updateJob(jobId, {
|
|
1274
|
-
status: 'processing',
|
|
1275
|
-
stage: 'extraction',
|
|
1276
|
-
message: 'Extracting data with auto-pagination',
|
|
1277
|
-
});
|
|
1278
|
-
|
|
1279
|
-
// Configure extraction
|
|
1280
|
-
const pageSize = parseInt(activation.getVariable('pageSize') || '200', 10);
|
|
1281
|
-
const maxRecords = parseInt(activation.getVariable('maxRecords') || '100000', 10);
|
|
1282
|
-
// Memory management: maxRecords prevents unbounded memory; for very large outputs, split files or use S3.
|
|
1283
|
-
// Performance note: pageSize=200 is a balanced default; split output if records > ~50k for optimal performance.
|
|
1284
|
-
|
|
1285
|
-
// ? Enhanced: Extract context for progress logging
|
|
1286
|
-
const dateRangeInfo = {
|
|
1287
|
-
start: dateRangeFilter?.from || 'N/A',
|
|
1288
|
-
end: dateRangeFilter?.to || 'N/A',
|
|
1289
|
-
types: params.positionTypes?.join(', ') || 'all',
|
|
1290
|
-
locationRefs: params.locationRefs?.slice(0, 3).join(', ') || 'all'
|
|
1291
|
-
};
|
|
1292
|
-
|
|
1293
|
-
// ? Enhanced: Start logging with context
|
|
1294
|
-
log.info(`🔍 [ExtractionOrchestrator] Starting extraction`, {
|
|
1295
|
-
query: 'inventoryPositions',
|
|
1296
|
-
pageSize,
|
|
1297
|
-
maxRecords,
|
|
1298
|
-
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
1299
|
-
positionTypes: dateRangeInfo.types,
|
|
1300
|
-
sampleLocations: dateRangeInfo.locationRefs,
|
|
1301
|
-
jobId
|
|
1302
|
-
});
|
|
1303
|
-
|
|
1304
|
-
// Initialize ExtractionOrchestrator
|
|
1305
|
-
const orchestrator = new ExtractionOrchestrator(client, log);
|
|
1306
|
-
|
|
1307
|
-
// Execute extraction with auto-pagination
|
|
1308
|
-
const extractionResult: ExtractionResult<any> = await orchestrator.extract({
|
|
1309
|
-
query: INVENTORY_POSITIONS_EXTRACTION_QUERY,
|
|
1310
|
-
resultPath: 'inventoryPositions.edges.node',
|
|
1311
|
-
variables: {
|
|
1312
|
-
dateRangeFilter,
|
|
1313
|
-
types: params.positionTypes,
|
|
1314
|
-
locationRefs: params.locationRefs,
|
|
1315
|
-
// Note: Don't include 'first' or 'after' here; orchestrator injects them based on pageSize below
|
|
1316
|
-
},
|
|
1317
|
-
pageSize,
|
|
1318
|
-
maxRecords,
|
|
1319
|
-
// Optional: validate each record
|
|
1320
|
-
validateItem: (item: any) => {
|
|
1321
|
-
return !!(item.ref && item.productRef && typeof item.onHand === 'number');
|
|
1322
|
-
},
|
|
1323
|
-
});
|
|
1324
|
-
|
|
1325
|
-
const records = extractionResult.data || [];
|
|
1326
|
-
|
|
1327
|
-
log.info('Extraction complete', {
|
|
1328
|
-
totalRecords: extractionResult.stats.totalRecords,
|
|
1329
|
-
totalPages: extractionResult.stats.totalPages,
|
|
1330
|
-
validRecords: extractionResult.stats.validRecords ?? records.length,
|
|
1331
|
-
failedValidations: extractionResult.stats.failedValidations,
|
|
1332
|
-
truncated: extractionResult.stats.truncated,
|
|
1333
|
-
truncationReason: extractionResult.stats.truncationReason,
|
|
1334
|
-
errors: extractionResult.errors ? extractionResult.errors.length : 0,
|
|
1335
|
-
});
|
|
1336
|
-
|
|
1337
|
-
// ? Enhanced: Completion logging with summary
|
|
1338
|
-
log.info(`✅ [ExtractionOrchestrator] Extraction completed`, {
|
|
1339
|
-
totalRecords: extractionResult.stats.totalRecords,
|
|
1340
|
-
totalPages: extractionResult.stats.totalPages,
|
|
1341
|
-
validRecords: extractionResult.stats.validRecords ?? records.length,
|
|
1342
|
-
failedValidations: extractionResult.stats.failedValidations,
|
|
1343
|
-
truncated: extractionResult.stats.truncated,
|
|
1344
|
-
truncationReason: extractionResult.stats.truncationReason,
|
|
1345
|
-
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
1346
|
-
jobId
|
|
1347
|
-
});
|
|
1348
|
-
|
|
1349
|
-
if (extractionResult.errors && extractionResult.errors.length > 0) {
|
|
1350
|
-
log.warn('Non-fatal extraction errors encountered', {
|
|
1351
|
-
errorCount: extractionResult.errors.length,
|
|
1352
|
-
sampleErrors: extractionResult.errors.slice(0, 3),
|
|
1353
|
-
});
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
// Handle empty result
|
|
1357
|
-
if (records.length === 0) {
|
|
1358
|
-
log.info('No records to process');
|
|
1359
|
-
|
|
1360
|
-
// Update state even with no records (prevents re-querying empty window)
|
|
1361
|
-
if (updateState && !isManualOverride) {
|
|
1362
|
-
await kv.set(STATE_KEY, new Date().toISOString());
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
await tracker.markCompleted(jobId, {
|
|
1366
|
-
recordCount: 0,
|
|
1367
|
-
message: 'No records to extract',
|
|
1368
|
-
});
|
|
1369
|
-
|
|
1370
|
-
return {
|
|
1371
|
-
success: true,
|
|
1372
|
-
jobId,
|
|
1373
|
-
recordsExtracted: 0,
|
|
1374
|
-
};
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// �����������������������������������������������������������
|
|
1378
|
-
// STEP 5/8: Transform Data (UniversalMapper)
|
|
1379
|
-
// �����������������������������������������������������������
|
|
1380
|
-
log.info('🔄 [STEP 5/8] Transforming data with UniversalMapper', {
|
|
1381
|
-
jobId,
|
|
1382
|
-
recordCount: records.length,
|
|
1383
|
-
});
|
|
1384
|
-
|
|
1385
|
-
await tracker.updateJob(jobId, {
|
|
1386
|
-
status: 'processing',
|
|
1387
|
-
stage: 'transformation',
|
|
1388
|
-
message: `Transforming ${records.length} records`,
|
|
1389
|
-
});
|
|
1390
|
-
|
|
1391
|
-
const mapper = new UniversalMapper(mappingConfig);
|
|
1392
|
-
const mappingResult = await mapper.map(records);
|
|
1393
|
-
const mappingErrors = mappingResult.errors || [];
|
|
1394
|
-
|
|
1395
|
-
if (!mappingResult.success) {
|
|
1396
|
-
log.error('Mapping failed - terminating job', {
|
|
1397
|
-
jobId,
|
|
1398
|
-
errorCount: mappingErrors.length,
|
|
1399
|
-
sampleErrors: mappingErrors.slice(0, 3),
|
|
1400
|
-
});
|
|
1401
|
-
|
|
1402
|
-
await tracker.markFailed(
|
|
1403
|
-
jobId,
|
|
1404
|
-
mappingErrors[0] || 'UniversalMapper returned unsuccessful result',
|
|
1405
|
-
{
|
|
1406
|
-
failedCount: mappingErrors.length,
|
|
1407
|
-
errors: mappingErrors,
|
|
1408
|
-
}
|
|
1409
|
-
);
|
|
1410
|
-
|
|
1411
|
-
return {
|
|
1412
|
-
success: false,
|
|
1413
|
-
jobId,
|
|
1414
|
-
error: `Transformation failed: ${mappingErrors[0] || 'Unknown error'}`,
|
|
1415
|
-
errors: mappingErrors,
|
|
1416
|
-
};
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
const transformedRecords = Array.isArray(mappingResult.data)
|
|
1420
|
-
? (mappingResult.data as any[])
|
|
1421
|
-
: [];
|
|
1422
|
-
|
|
1423
|
-
if (mappingErrors.length > 0) {
|
|
1424
|
-
log.warn('Some records failed transformation', {
|
|
1425
|
-
jobId,
|
|
1426
|
-
errorCount: mappingErrors.length,
|
|
1427
|
-
sampleErrors: mappingErrors.slice(0, 3),
|
|
1428
|
-
});
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
1432
|
-
log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
1433
|
-
jobId,
|
|
1434
|
-
skippedFields: mappingResult.skippedFields,
|
|
1435
|
-
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
1436
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
if (transformedRecords.length === 0) {
|
|
1440
|
-
await tracker.markFailed(jobId, 'All records failed mapping', {
|
|
1441
|
-
failedCount: mappingErrors.length,
|
|
1442
|
-
errors: mappingErrors,
|
|
1443
|
-
});
|
|
1444
|
-
return {
|
|
1445
|
-
success: false,
|
|
1446
|
-
jobId,
|
|
1447
|
-
error: 'All records failed mapping',
|
|
1448
|
-
errors: mappingErrors,
|
|
1449
|
-
};
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
log.info('Transformation complete', {
|
|
1453
|
-
successful: transformedRecords.length,
|
|
1454
|
-
failed: mappingErrors.length,
|
|
1455
|
-
skippedRecords: records.length - transformedRecords.length,
|
|
1456
|
-
});
|
|
1457
|
-
|
|
1458
|
-
// �����������������������������������������������������������
|
|
1459
|
-
// STEP 6/8: Generate XML (XMLBuilder)
|
|
1460
|
-
// �����������������������������������������������������������
|
|
1461
|
-
log.info('📝 [STEP 6/8] Generating XML file', { jobId });
|
|
1462
|
-
|
|
1463
|
-
await tracker.updateJob(jobId, {
|
|
1464
|
-
status: 'processing',
|
|
1465
|
-
stage: 'xml_generation',
|
|
1466
|
-
message: `Generating XML for ${transformedRecords.length} records`,
|
|
1467
|
-
});
|
|
1468
|
-
|
|
1469
|
-
// Initialize XMLBuilder
|
|
1470
|
-
const xmlBuilder = new XMLBuilder({
|
|
1471
|
-
ignoreAttributes: false,
|
|
1472
|
-
format: true,
|
|
1473
|
-
indentBy: ' ',
|
|
1474
|
-
suppressEmptyNode: true,
|
|
1475
|
-
});
|
|
1476
|
-
|
|
1477
|
-
// Build XML structure
|
|
1478
|
-
const xmlData = {
|
|
1479
|
-
'?xml': {
|
|
1480
|
-
'@_version': '1.0',
|
|
1481
|
-
'@_encoding': 'UTF-8',
|
|
1482
|
-
},
|
|
1483
|
-
InventoryPositions: {
|
|
1484
|
-
Position: transformedRecords,
|
|
1485
|
-
},
|
|
1486
|
-
};
|
|
1487
|
-
|
|
1488
|
-
const xmlContent = xmlBuilder.build(xmlData);
|
|
1489
|
-
|
|
1490
|
-
// Generate timestamped filename using extractFileName helper
|
|
1491
|
-
const fileNamePrefix = activation.getVariable('fileNamePrefix') || 'inventorypositions';
|
|
1492
|
-
const fileName = extractFileName(fileNamePrefix, 'xml');
|
|
1493
|
-
|
|
1494
|
-
log.info('XML file generated', {
|
|
1495
|
-
fileName,
|
|
1496
|
-
sizeBytes: xmlContent.length,
|
|
1497
|
-
recordCount: transformedRecords.length,
|
|
1498
|
-
});
|
|
1499
|
-
|
|
1500
|
-
// �����������������������������������������������������������
|
|
1501
|
-
// STEP 7/8: Upload to SFTP (SftpDataSource)
|
|
1502
|
-
// �����������������������������������������������������������
|
|
1503
|
-
log.info('📤 [STEP 7/8] Uploading to SFTP', { jobId, fileName });
|
|
1504
|
-
|
|
1505
|
-
await tracker.updateJob(jobId, {
|
|
1506
|
-
status: 'processing',
|
|
1507
|
-
stage: 'sftp_upload',
|
|
1508
|
-
message: `Uploading ${fileName} to SFTP`,
|
|
1509
|
-
});
|
|
1510
|
-
|
|
1511
|
-
// Get SFTP credentials from Versori connection (Basic Auth)
|
|
1512
|
-
// RECOMMENDED: Use activation.connections (already decoded)
|
|
1513
|
-
const allConnections = ctx.activation.connections || [];
|
|
1514
|
-
const sftpConn = allConnections.find(c => c.name === 'versori_ftp_server');
|
|
1515
|
-
|
|
1516
|
-
if (!sftpConn) {
|
|
1517
|
-
throw new Error('SFTP connection "versori_ftp_server" not found');
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
const credential = sftpConn.credentials[0]?.credential;
|
|
1521
|
-
if (!credential?.data?.basicAuth) {
|
|
1522
|
-
throw new Error('SFTP connection not configured with Basic Authentication');
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
const { username, password } = credential.data.basicAuth;
|
|
1526
|
-
// ? Already decoded - no Buffer.from() needed!
|
|
1527
|
-
|
|
1528
|
-
// Get SFTP configuration from activation variables
|
|
1529
|
-
const sftpConfig = {
|
|
1530
|
-
host: activation.getVariable('sftpHost'),
|
|
1531
|
-
port: parseInt(activation.getVariable('sftpPort') || '22', 10),
|
|
1532
|
-
username: sftpUsername, // From connection
|
|
1533
|
-
password: sftpPassword, // From connection
|
|
1534
|
-
privateKey: activation.getVariable('sftpPrivateKey'),
|
|
1535
|
-
remotePath: activation.getVariable('sftpPath') || '/incoming/',
|
|
1536
|
-
};
|
|
1537
|
-
|
|
1538
|
-
// Validate SFTP config
|
|
1539
|
-
if (!sftpConfig.host || !username) {
|
|
1540
|
-
throw new Error('SFTP configuration incomplete: missing host or username (from connection)');
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
if (!sftpConfig.password && !sftpConfig.privateKey) {
|
|
1544
|
-
throw new Error('SFTP configuration incomplete: missing password or privateKey');
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
// Initialize SFTP data source
|
|
1548
|
-
// ? VERSORI PLATFORM: Pass native log from context
|
|
1549
|
-
const sftp = new SftpDataSource(
|
|
1550
|
-
{
|
|
1551
|
-
type: 'SFTP_XML',
|
|
1552
|
-
connectionId: 'inventory-positions-sftp',
|
|
1553
|
-
name: 'Inventory Positions SFTP Upload',
|
|
1554
|
-
settings: {
|
|
1555
|
-
host: sftpConfig.host,
|
|
1556
|
-
port: sftpConfig.port,
|
|
1557
|
-
username: sftpConfig.username,
|
|
1558
|
-
password: sftpConfig.password,
|
|
1559
|
-
privateKey: sftpConfig.privateKey,
|
|
1560
|
-
remotePath: sftpConfig.remotePath,
|
|
1561
|
-
filePattern: '*.xml',
|
|
1562
|
-
},
|
|
1563
|
-
},
|
|
1564
|
-
log
|
|
1565
|
-
);
|
|
1566
|
-
|
|
1567
|
-
try {
|
|
1568
|
-
// Ensure remote directory exists (recursive)
|
|
1569
|
-
await sftp.createDirectory(sftpConfig.remotePath || '/incoming/', true);
|
|
1570
|
-
|
|
1571
|
-
// Get SFTP path configuration
|
|
1572
|
-
const requireAbsolutePaths = activation.getVariable('requireAbsolutePaths') === 'true';
|
|
1573
|
-
|
|
1574
|
-
/**
|
|
1575
|
-
* Helper: Join SFTP path segments safely
|
|
1576
|
-
*
|
|
1577
|
-
* Handles different SFTP server path requirements:
|
|
1578
|
-
* - AWS Transfer Family: Requires absolute paths (leading /)
|
|
1579
|
-
* - Standard OpenSSH: Supports relative paths
|
|
1580
|
-
*
|
|
1581
|
-
* @param requireAbsolutePath - true for AWS Transfer Family, false for standard OpenSSH
|
|
1582
|
-
* @param parts - Path segments to join
|
|
1583
|
-
* @returns Properly formatted SFTP path
|
|
1584
|
-
*/
|
|
1585
|
-
function joinSftpPath(requireAbsolutePath: boolean, ...parts: string[]): string {
|
|
1586
|
-
// Clean each segment (remove leading/trailing slashes)
|
|
1587
|
-
const cleaned = parts
|
|
1588
|
-
.filter(Boolean)
|
|
1589
|
-
.map(p => String(p).replace(/^\/+|\/+$/g, ''))
|
|
1590
|
-
.join('/');
|
|
1591
|
-
|
|
1592
|
-
// Add leading slash if required (AWS Transfer Family)
|
|
1593
|
-
return requireAbsolutePath && !cleaned.startsWith('/') ? `/${cleaned}` : cleaned;
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
// Construct full SFTP path safely
|
|
1597
|
-
const fullPath = joinSftpPath(
|
|
1598
|
-
requireAbsolutePaths,
|
|
1599
|
-
sftpConfig.remotePath || '/incoming/',
|
|
1600
|
-
fileName
|
|
1601
|
-
);
|
|
1602
|
-
|
|
1603
|
-
// Upload with retry logic (built into SftpDataSource)
|
|
1604
|
-
// ? Use createDirectories: true to ensure remote path exists
|
|
1605
|
-
// Upload failure: If SFTP upload fails, job is failed and state is not advanced; next run retries same window.
|
|
1606
|
-
await sftp.uploadFile(fullPath, Buffer.from(xmlContent, 'utf8'), {
|
|
1607
|
-
createDirectories: true,
|
|
1608
|
-
});
|
|
1609
|
-
|
|
1610
|
-
log.info('SFTP upload successful', { fileName, remotePath: fullPath });
|
|
1611
|
-
|
|
1612
|
-
// Optional: upload a transformation error report alongside the XML
|
|
1613
|
-
if (mappingErrors.length > 0) {
|
|
1614
|
-
const baseName = fileName.replace(/\.xml$/i, '');
|
|
1615
|
-
const report = {
|
|
1616
|
-
fileName,
|
|
1617
|
-
processedAt: new Date().toISOString(),
|
|
1618
|
-
errorStage: 'transform',
|
|
1619
|
-
totals: {
|
|
1620
|
-
totalRecords: records.length,
|
|
1621
|
-
recordsTransformed: transformedRecords.length,
|
|
1622
|
-
transformErrors: mappingErrors.length,
|
|
1623
|
-
},
|
|
1624
|
-
errors: mappingErrors,
|
|
1625
|
-
};
|
|
1626
|
-
|
|
1627
|
-
const reportPath = joinSftpPath(
|
|
1628
|
-
requireAbsolutePaths,
|
|
1629
|
-
sftpConfig.remotePath || '/incoming/',
|
|
1630
|
-
`${baseName}-errors.json`
|
|
1631
|
-
);
|
|
1632
|
-
await sftp.uploadFile(reportPath, Buffer.from(JSON.stringify(report, null, 2), 'utf8'), {
|
|
1633
|
-
createDirectories: true,
|
|
1634
|
-
overwrite: true,
|
|
1635
|
-
});
|
|
1636
|
-
|
|
1637
|
-
log.info('Uploaded transformation error report', { reportPath });
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
// Optional: For very large outputs, split transformedRecords into chunks
|
|
1641
|
-
// and upload multiple XML files to avoid size/memory limits.
|
|
1642
|
-
|
|
1643
|
-
// �����������������������������������������������������������
|
|
1644
|
-
// STEP 8/8: Update State & Complete Job (WITHOUT buffer)
|
|
1645
|
-
// �����������������������������������������������������������
|
|
1646
|
-
log.info('✅ [STEP 8/8] Updating state and completing job', { jobId });
|
|
1647
|
-
|
|
1648
|
-
// Calculate new timestamp for next incremental run
|
|
1649
|
-
let newTimestamp: string | undefined;
|
|
1650
|
-
|
|
1651
|
-
if (updateState && !isManualOverride) {
|
|
1652
|
-
// Find max updatedOn from extracted records
|
|
1653
|
-
const maxUpdatedOn = records.reduce(
|
|
1654
|
-
(max, record) => {
|
|
1655
|
-
const recordTime = new Date(record.updatedOn).getTime();
|
|
1656
|
-
return recordTime > max ? recordTime : max;
|
|
1657
|
-
},
|
|
1658
|
-
new Date(dateRangeFilter?.from || DEFAULT_FALLBACK).getTime()
|
|
1659
|
-
);
|
|
1660
|
-
|
|
1661
|
-
newTimestamp = new Date(maxUpdatedOn).toISOString();
|
|
1662
|
-
|
|
1663
|
-
// Store new timestamp (WITHOUT buffer - buffer only applied on read)
|
|
1664
|
-
await kv.set(STATE_KEY, newTimestamp);
|
|
1665
|
-
|
|
1666
|
-
log.info('State updated', {
|
|
1667
|
-
oldTimestamp: dateRangeFilter?.from,
|
|
1668
|
-
newTimestamp,
|
|
1669
|
-
});
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
// Mark job as completed
|
|
1673
|
-
await tracker.markCompleted(jobId, {
|
|
1674
|
-
recordCount: transformedRecords.length,
|
|
1675
|
-
fileName,
|
|
1676
|
-
remotePath: fullPath,
|
|
1677
|
-
errorCount: mappingErrors.length,
|
|
1678
|
-
errors: mappingErrors,
|
|
1679
|
-
isManualOverride,
|
|
1680
|
-
stateUpdated: updateState && !isManualOverride,
|
|
1681
|
-
newTimestamp,
|
|
1682
|
-
});
|
|
1683
|
-
|
|
1684
|
-
// ⏱️ Calculate total execution time
|
|
1685
|
-
const totalDuration = Date.now() - startTime;
|
|
1686
|
-
|
|
1687
|
-
log.info('✅ Extraction workflow completed successfully', {
|
|
1688
|
-
jobId,
|
|
1689
|
-
totalDuration,
|
|
1690
|
-
recordsExtracted: transformedRecords.length,
|
|
1691
|
-
fileName,
|
|
1692
|
-
});
|
|
1693
|
-
|
|
1694
|
-
return {
|
|
1695
|
-
success: true,
|
|
1696
|
-
jobId,
|
|
1697
|
-
recordsExtracted: transformedRecords.length,
|
|
1698
|
-
fileName,
|
|
1699
|
-
sftpPath: fullPath,
|
|
1700
|
-
isManualOverride,
|
|
1701
|
-
stateUpdated: updateState && !isManualOverride,
|
|
1702
|
-
newTimestamp,
|
|
1703
|
-
duration: totalDuration,
|
|
1704
|
-
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1705
|
-
};
|
|
1706
|
-
} finally {
|
|
1707
|
-
// ?��️ CRITICAL: Always dispose SFTP connection
|
|
1708
|
-
await sftp.dispose();
|
|
1709
|
-
log.info('SFTP connection disposed');
|
|
1710
|
-
}
|
|
1711
|
-
} catch (error: any) {
|
|
1712
|
-
const totalDuration = Date.now() - startTime;
|
|
1713
|
-
|
|
1714
|
-
log.error('❌ Extraction workflow failed', {
|
|
1715
|
-
jobId,
|
|
1716
|
-
duration: totalDuration,
|
|
1717
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1718
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1719
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1720
|
-
recommendation: getErrorRecommendation(error),
|
|
1721
|
-
});
|
|
1722
|
-
|
|
1723
|
-
// Mark job as failed
|
|
1724
|
-
await tracker.markFailed(jobId, error);
|
|
1725
|
-
|
|
1726
|
-
return {
|
|
1727
|
-
success: false,
|
|
1728
|
-
jobId,
|
|
1729
|
-
recordsExtracted: 0,
|
|
1730
|
-
duration: totalDuration,
|
|
1731
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1732
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1733
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1734
|
-
recommendation: getErrorRecommendation(error),
|
|
1735
|
-
};
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
/**
|
|
1740
|
-
* Get actionable recommendation based on error type
|
|
1741
|
-
*/
|
|
1742
|
-
function getErrorRecommendation(error: any): string {
|
|
1743
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1744
|
-
|
|
1745
|
-
if (message.includes('SFTP') || message.includes('connection')) {
|
|
1746
|
-
return 'Check SFTP credentials and network connectivity. Verify the SFTP connection is configured in Versori Connections with Basic Authentication.';
|
|
1747
|
-
}
|
|
1748
|
-
if (message.includes('GraphQL') || message.includes('query')) {
|
|
1749
|
-
return 'Verify GraphQL query syntax and schema compatibility. Check Fluent Commerce connection credentials.';
|
|
1750
|
-
}
|
|
1751
|
-
if (message.includes('mapping') || message.includes('transform')) {
|
|
1752
|
-
return 'Review mapping configuration. Run schema validation: npx fc-connect validate-schema --mapping config.json --schema schema.json';
|
|
1753
|
-
}
|
|
1754
|
-
if (message.includes('authentication') || message.includes('401')) {
|
|
1755
|
-
return 'Check Fluent Commerce OAuth2 credentials. Verify clientId and clientSecret in connection configuration.';
|
|
1756
|
-
}
|
|
1757
|
-
if (message.includes('timeout')) {
|
|
1758
|
-
return 'Reduce maxRecords or pageSize to process smaller batches. Check network latency and API response times.';
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
return 'Review error details above. Check activation variables and connection configuration. Consult documentation for troubleshooting steps.';
|
|
1762
|
-
}
|
|
1763
|
-
```
|
|
1764
|
-
|
|
1765
|
-
Note: Customize mapping by editing the JSON above; prefer built-in resolvers. See SDK Universal Mapping guide for advanced usage.
|
|
1766
|
-
|
|
1767
|
-
---
|
|
1768
|
-
|
|
1769
|
-
## 4. Utility Functions (src/utils/)
|
|
1770
|
-
|
|
1771
|
-
### Job ID Generator (src/utils/job-id-generator.ts)
|
|
1772
|
-
|
|
1773
|
-
```typescript
|
|
1774
|
-
/**
|
|
1775
|
-
* Job ID Generator
|
|
1776
|
-
*
|
|
1777
|
-
* Generates unique job IDs for tracking extraction workflows
|
|
1778
|
-
*
|
|
1779
|
-
* FORMAT: {TYPE}_{ENTITY}_{YYYYMMDD}_{HHMMSS}_{RANDOM}
|
|
1780
|
-
* Example: SCHEDULED_IP_20251027_183045_a1b2c3
|
|
1781
|
-
*
|
|
1782
|
-
* NAMING: generate{Entity}JobId or generateJobId (generic)
|
|
1783
|
-
*/
|
|
1784
|
-
|
|
1785
|
-
/**
|
|
1786
|
-
* Generate unique job ID
|
|
1787
|
-
*
|
|
1788
|
-
* @param type - Job trigger type (SCHEDULED, ADHOC, MANUAL)
|
|
1789
|
-
* @param entity - Entity abbreviation (IP, VP, ORD, PRD)
|
|
1790
|
-
* @returns Unique job ID string
|
|
1791
|
-
*/
|
|
1792
|
-
export function generateJobId(type: string, entity: string): string {
|
|
1793
|
-
const now = new Date();
|
|
1794
|
-
|
|
1795
|
-
// Format: YYYYMMDD
|
|
1796
|
-
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
1797
|
-
|
|
1798
|
-
// Format: HHMMSS
|
|
1799
|
-
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '');
|
|
1800
|
-
|
|
1801
|
-
// Random suffix (6 chars)
|
|
1802
|
-
const randomStr = Math.random().toString(36).substring(2, 8);
|
|
1803
|
-
|
|
1804
|
-
return `${type}_${entity}_${dateStr}_${timeStr}_${randomStr}`;
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
/**
|
|
1808
|
-
* Generate timestamped filename for extraction output
|
|
1809
|
-
*
|
|
1810
|
-
* @param prefix - Filename prefix (from activation variable)
|
|
1811
|
-
* @param extension - File extension (xml, csv, json)
|
|
1812
|
-
* @returns Timestamped filename
|
|
1813
|
-
*
|
|
1814
|
-
* Example: extractFileName('inventorypositions', 'xml')
|
|
1815
|
-
* Returns: 'inventorypositions-2025-11-04T14-30-45-123Z.xml'
|
|
1816
|
-
*/
|
|
1817
|
-
export function extractFileName(prefix: string, extension: string = 'xml'): string {
|
|
1818
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1819
|
-
return `${prefix}-${timestamp}.${extension}`;
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
/**
|
|
1823
|
-
* Parse job ID components
|
|
1824
|
-
*
|
|
1825
|
-
* @param jobId - Job ID to parse
|
|
1826
|
-
* @returns Parsed components or null if invalid
|
|
1827
|
-
*/
|
|
1828
|
-
export function parseJobId(jobId: string): {
|
|
1829
|
-
type: string;
|
|
1830
|
-
entity: string;
|
|
1831
|
-
date: string;
|
|
1832
|
-
time: string;
|
|
1833
|
-
random: string;
|
|
1834
|
-
} | null {
|
|
1835
|
-
const parts = jobId.split('_');
|
|
1836
|
-
|
|
1837
|
-
if (parts.length !== 5) {
|
|
1838
|
-
return null;
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
return {
|
|
1842
|
-
type: parts[0],
|
|
1843
|
-
entity: parts[1],
|
|
1844
|
-
date: parts[2],
|
|
1845
|
-
time: parts[3],
|
|
1846
|
-
random: parts[4],
|
|
1847
|
-
};
|
|
1848
|
-
}
|
|
1849
|
-
```
|
|
1850
|
-
|
|
1851
|
-
---
|
|
1852
|
-
|
|
1853
|
-
## 5. Package Configuration
|
|
1854
|
-
|
|
1855
|
-
### package.json
|
|
1856
|
-
|
|
1857
|
-
```json
|
|
1858
|
-
{
|
|
1859
|
-
"name": "inventory-positions-to-sftp-xml",
|
|
1860
|
-
"version": "1.0.0",
|
|
1861
|
-
"description": "Extract inventory positions from Fluent Commerce and export to SFTP as XML",
|
|
1862
|
-
"type": "module",
|
|
1863
|
-
"main": "src/index.ts",
|
|
1864
|
-
"scripts": {
|
|
1865
|
-
"dev": "versori dev",
|
|
1866
|
-
"build": "versori build",
|
|
1867
|
-
"deploy": "versori deploy"
|
|
1868
|
-
},
|
|
1869
|
-
"dependencies": {
|
|
1870
|
-
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
1871
|
-
"@versori/run": "latest",
|
|
1872
|
-
"fast-xml-parser": "^5.2.5"
|
|
1873
|
-
},
|
|
1874
|
-
"devDependencies": {
|
|
1875
|
-
"@types/node": "^20.0.0",
|
|
1876
|
-
"typescript": "^5.0.0"
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
```
|
|
1880
|
-
|
|
1881
|
-
### tsconfig.json
|
|
1882
|
-
|
|
1883
|
-
```json
|
|
1884
|
-
{
|
|
1885
|
-
"compilerOptions": {
|
|
1886
|
-
"module": "ES2022",
|
|
1887
|
-
"target": "ES2024",
|
|
1888
|
-
"moduleResolution": "node"
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
```
|
|
1892
|
-
|
|
1893
|
-
---
|
|
1894
|
-
|
|
1895
|
-
## 6. Deployment Instructions
|
|
1896
|
-
|
|
1897
|
-
### Deploy to Versori
|
|
1898
|
-
|
|
1899
|
-
```bash
|
|
1900
|
-
# 1. Install dependencies
|
|
1901
|
-
npm install
|
|
1902
|
-
|
|
1903
|
-
# 2. Test locally (if using Versori CLI)
|
|
1904
|
-
npm run dev
|
|
1905
|
-
|
|
1906
|
-
# 3. Deploy to Versori platform
|
|
1907
|
-
npm run deploy
|
|
1908
|
-
```
|
|
1909
|
-
|
|
1910
|
-
### Configure Activation Variables
|
|
1911
|
-
|
|
1912
|
-
In Versori platform settings, configure:
|
|
1913
|
-
|
|
1914
|
-
```json
|
|
1915
|
-
{
|
|
1916
|
-
"sftpHost": "sftp.partner.com",
|
|
1917
|
-
"sftpPort": 22,
|
|
1918
|
-
"sftpUsername": "export_user",
|
|
1919
|
-
"sftpPassword": "********",
|
|
1920
|
-
"sftpPath": "/incoming/inventory/",
|
|
1921
|
-
"fileNamePrefix": "inventorypositions",
|
|
1922
|
-
"pageSize": 200,
|
|
1923
|
-
"maxRecords": 100000,
|
|
1924
|
-
"overlapBufferSeconds": 60
|
|
1925
|
-
}
|
|
1926
|
-
```
|
|
1927
|
-
|
|
1928
|
-
---
|
|
1929
|
-
|
|
1930
|
-
## 7. Performance Notes
|
|
1931
|
-
|
|
1932
|
-
- **Pagination:** Default pageSize=200 is optimized for most use cases; increase to 500 if network latency is high
|
|
1933
|
-
- **Large outputs:** For extractions exceeding ~50k records, consider splitting into multiple files or streaming to S3
|
|
1934
|
-
- **Memory limits:** maxRecords parameter prevents unbounded memory growth; adjust based on available memory
|
|
1935
|
-
|
|
1936
|
-
---
|
|
1937
|
-
|
|
1938
|
-
## 8. Testing
|
|
1939
|
-
|
|
1940
|
-
### Test Scheduled Extraction
|
|
1941
|
-
|
|
1942
|
-
The scheduled workflow runs automatically based on cron schedule.
|
|
1943
|
-
|
|
1944
|
-
**Check logs:**
|
|
1945
|
-
|
|
1946
|
-
```
|
|
1947
|
-
[STEP 1/8] Initializing job tracking
|
|
1948
|
-
[STEP 2/8] Initializing Fluent Commerce client
|
|
1949
|
-
[STEP 3/8] Determining date range for extraction
|
|
1950
|
-
[STEP 4/8] Extracting data from Fluent Commerce
|
|
1951
|
-
[STEP 5/8] Transforming data with UniversalMapper
|
|
1952
|
-
[STEP 6/8] Generating XML file
|
|
1953
|
-
[STEP 7/8] Uploading to SFTP
|
|
1954
|
-
[STEP 8/8] Updating state and completing job
|
|
1955
|
-
```
|
|
1956
|
-
|
|
1957
|
-
### Test Ad hoc Extraction
|
|
1958
|
-
|
|
1959
|
-
```bash
|
|
1960
|
-
# Incremental (uses last sync timestamp)
|
|
1961
|
-
curl -X POST https://api.versori.com/webhooks/inventory-positions-adhoc \
|
|
1962
|
-
-H "Content-Type: application/json" \
|
|
1963
|
-
-H "X-API-Key: your-api-key" \
|
|
1964
|
-
-d '{}'
|
|
1965
|
-
|
|
1966
|
-
# Date range override
|
|
1967
|
-
curl -X POST https://api.versori.com/webhooks/inventory-positions-adhoc \
|
|
1968
|
-
-H "Content-Type: application/json" \
|
|
1969
|
-
-H "X-API-Key: your-api-key" \
|
|
1970
|
-
-d '{
|
|
1971
|
-
"fromDate": "2025-01-01T00:00:00Z",
|
|
1972
|
-
"toDate": "2025-01-31T23:59:59Z",
|
|
1973
|
-
"updateState": false
|
|
1974
|
-
}'
|
|
1975
|
-
```
|
|
1976
|
-
|
|
1977
|
-
### Test Job Status Query
|
|
1978
|
-
|
|
1979
|
-
```bash
|
|
1980
|
-
curl -X POST https://api.versori.com/webhooks/inventory-positions-job-status \
|
|
1981
|
-
-H "Content-Type: application/json" \
|
|
1982
|
-
-H "X-API-Key: your-api-key" \
|
|
1983
|
-
-d '{
|
|
1984
|
-
"jobId": "ADHOC_IP_20251027_183045_abc123"
|
|
1985
|
-
}'
|
|
1986
|
-
```
|
|
1987
|
-
|
|
1988
|
-
**Response:**
|
|
1989
|
-
|
|
1990
|
-
```json
|
|
1991
|
-
{
|
|
1992
|
-
"success": true,
|
|
1993
|
-
"jobId": "ADHOC_IP_20251027_183045_abc123",
|
|
1994
|
-
"status": "processing",
|
|
1995
|
-
"stage": "transformation",
|
|
1996
|
-
"message": "Transforming 15000 records",
|
|
1997
|
-
"createdAt": "2025-10-27T18:30:45.000Z",
|
|
1998
|
-
"startedAt": "2025-10-27T18:30:46.000Z"
|
|
1999
|
-
}
|
|
2000
|
-
```
|
|
2001
|
-
|
|
2002
|
-
### Monitoring
|
|
2003
|
-
|
|
2004
|
-
Use Versori logs filtered by `jobId` or `stage`; optional status webhook for dashboards.
|
|
2005
|
-
|
|
2006
|
-
---
|
|
2007
|
-
|
|
2008
|
-
### Schema requirements
|
|
2009
|
-
|
|
2010
|
-
Ensure your GraphQL query includes Relay pagination fields and variables ($first, $after), and that `resultPath` matches the edges.node path. The orchestrator injects pagination variables automatically (don't include them in your variables object).
|
|
2011
|
-
|
|
2012
|
-
---
|
|
2013
|
-
|
|
2014
|
-
## ?? Troubleshooting (quick)
|
|
2015
|
-
|
|
2016
|
-
- No records extracted: Check dateRange (manual override vs incremental).
|
|
2017
|
-
- SFTP upload failed: Job fails; state not advanced. Next run retries same window.
|
|
2018
|
-
- GraphQL pagination error: Ensure edges.cursor and pageInfo.hasNextPage are in the query.
|
|
2019
|
-
- Memory pressure: Lower pageSize or maxRecords; split output or stream to S3.
|
|
2020
|
-
|
|
2021
|
-
## 🗺️ Mapping tips (compact)
|
|
2022
|
-
|
|
2023
|
-
- Required fields: mark in mapping JSON; invalid records are skipped and reported.
|
|
2024
|
-
- Nested paths: use dot notation (e.g., catalogue.ref).
|
|
2025
|
-
- Arrays: map arrays directly; one record in, one mapped object out.
|
|
2026
|
-
- Resolvers: prefer built-ins (trim, uppercase, parseInt); keep output types aligned.
|
|
2027
|
-
|
|
2028
|
-
---
|
|
2029
|
-
|
|
2030
|
-
## 9. Replication Checklist
|
|
2031
|
-
|
|
2032
|
-
**To replicate this template for other entities/formats:**
|
|
2033
|
-
|
|
2034
|
-
1. **File Naming:** Replace `inventory-positions`, `IP`, `InventoryPosition` with your entity name across all files
|
|
2035
|
-
2. **GraphQL Query:** Update query constant and field selection to match your entity schema
|
|
2036
|
-
3. **Mapping Config:** Create new mapping file in `config/` with correct field paths
|
|
2037
|
-
4. **Workflows:** Rename workflow exports to match entity (e.g., `scheduledVirtualPositionsExtraction`)
|
|
2038
|
-
5. **Service Function:** Rename main function (e.g., `executeVirtualPositionExtraction`)
|
|
2039
|
-
6. **State Key:** Update KV key (e.g., `lastVirtualPositionSync`)
|
|
2040
|
-
7. **Output Format:** For CSV use `CSVParserService`, for JSON use `JSON.stringify()`, for XML use `XMLBuilder`
|
|
2041
|
-
8. **Upload Destination:** For S3 replace `SftpDataSource` with `S3DataSource`
|
|
2042
|
-
|
|
2043
|
-
---
|
|
2044
|
-
|
|
2045
|
-
### Pattern 8: Backward Pagination (Optional - Advanced)
|
|
2046
|
-
|
|
2047
|
-
**Use Case**: Extract data in reverse chronological order (newest to oldest) instead of oldest to newest.
|
|
2048
|
-
|
|
2049
|
-
**When to Use**:
|
|
2050
|
-
|
|
2051
|
-
- ✅ Need most recent records first (e.g., latest orders, recent inventory updates)
|
|
2052
|
-
- ✅ Time-bounded reverse traversal for auditing
|
|
2053
|
-
- ✅ Display newest-first in UI/reports
|
|
2054
|
-
- ❌ **Don't use for standard incremental sync** - use forward pagination (default)
|
|
2055
|
-
|
|
2056
|
-
**GraphQL Query Requirements**:
|
|
2057
|
-
|
|
2058
|
-
Your query must support backward pagination by including `$last` and `$before`:
|
|
2059
|
-
|
|
2060
|
-
```graphql
|
|
2061
|
-
query GetData(
|
|
2062
|
-
$retailerId: ID!
|
|
2063
|
-
$first: Int # For forward pagination
|
|
2064
|
-
$after: String # For forward pagination
|
|
2065
|
-
$last: Int # For backward pagination
|
|
2066
|
-
$before: String # For backward pagination
|
|
2067
|
-
) {
|
|
2068
|
-
data(retailerId: $retailerId, first: $first, after: $after, last: $last, before: $before) {
|
|
2069
|
-
edges {
|
|
2070
|
-
cursor # ? REQUIRED
|
|
2071
|
-
node {
|
|
2072
|
-
id
|
|
2073
|
-
createdAt
|
|
2074
|
-
# ... other fields
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
pageInfo {
|
|
2078
|
-
hasNextPage # For forward
|
|
2079
|
-
hasPreviousPage # ? REQUIRED for backward
|
|
2080
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
```
|
|
2084
|
-
|
|
2085
|
-
**Implementation**:
|
|
2086
|
-
|
|
2087
|
-
```typescript
|
|
2088
|
-
// Backward pagination - newest records first
|
|
2089
|
-
const result = await orchestrator.extract({
|
|
2090
|
-
query: YOUR_QUERY,
|
|
2091
|
-
resultPath: 'data.edges.node',
|
|
2092
|
-
variables: {
|
|
2093
|
-
retailerId,
|
|
2094
|
-
dateRangeFilter: { from: bufferedLastRunTime, to: effectiveEndTime },
|
|
2095
|
-
// Don't include last/before - orchestrator injects them
|
|
2096
|
-
},
|
|
2097
|
-
pageSize: 200,
|
|
2098
|
-
direction: 'backward', // ? Enable reverse pagination
|
|
2099
|
-
maxRecords: 10000,
|
|
2100
|
-
});
|
|
2101
|
-
|
|
2102
|
-
// Records are returned in reverse chronological order
|
|
2103
|
-
console.log(result.data[0].createdAt); // Newest
|
|
2104
|
-
console.log(result.data[result.data.length - 1].createdAt); // Oldest (within range)
|
|
2105
|
-
```
|
|
2106
|
-
|
|
2107
|
-
**Key Differences from Forward Pagination**:
|
|
2108
|
-
|
|
2109
|
-
| Aspect | Forward (Default) | Backward |
|
|
2110
|
-
| ---------------------- | -------------------------------- | ----------------------- |
|
|
2111
|
-
| **Direction** | `direction: 'forward'` (default) | `direction: 'backward'` |
|
|
2112
|
-
| **Variables Injected** | `first`, `after` | `last`, `before` |
|
|
2113
|
-
| **PageInfo Field** | `hasNextPage` | `hasPreviousPage` |
|
|
2114
|
-
| **Cursor Source** | Last edge of page | First edge of page |
|
|
2115
|
-
| **Record Order** | Oldest ? Newest | Newest ? Oldest |
|
|
2116
|
-
|
|
2117
|
-
**Important Notes**:
|
|
2118
|
-
|
|
2119
|
-
1. **Orchestrator injects variables**: Don't pass `last` or `before` in your variables object - the orchestrator injects them based on `pageSize` and cursor tracking.
|
|
2120
|
-
|
|
2121
|
-
2. **Query signature**: Your GraphQL query must declare `$last` and `$before` parameters even if you don't pass them explicitly.
|
|
2122
|
-
|
|
2123
|
-
3. **PageInfo requirement**: Response must include `pageInfo.hasPreviousPage` or the orchestrator will throw an error.
|
|
2124
|
-
|
|
2125
|
-
4. **Cursor requirement**: Each edge must include `cursor` field for pagination to work.
|
|
2126
|
-
|
|
2127
|
-
**Example: Extract Latest 1000 Orders**
|
|
2128
|
-
|
|
2129
|
-
```typescript
|
|
2130
|
-
const latestOrders = await orchestrator.extract({
|
|
2131
|
-
query: ORDERS_QUERY,
|
|
2132
|
-
resultPath: 'orders.edges.node',
|
|
2133
|
-
variables: {
|
|
2134
|
-
retailerId,
|
|
2135
|
-
statuses: ['BOOKED', 'ALLOCATED'],
|
|
2136
|
-
},
|
|
2137
|
-
direction: 'backward', // Start from newest
|
|
2138
|
-
maxRecords: 1000, // Stop after 1000 records
|
|
2139
|
-
pageSize: 100, // 100 per page = 10 pages
|
|
2140
|
-
});
|
|
2141
|
-
|
|
2142
|
-
// latestOrders.data[0] is the newest order
|
|
2143
|
-
// latestOrders.data[999] is the 1000th newest order
|
|
2144
|
-
```
|
|
2145
|
-
|
|
2146
|
-
**When to Use Forward vs Backward**:
|
|
2147
|
-
|
|
2148
|
-
```typescript
|
|
2149
|
-
// ? Forward (default) - For incremental sync
|
|
2150
|
-
const incrementalData = await orchestrator.extract({
|
|
2151
|
-
query: YOUR_QUERY,
|
|
2152
|
-
resultPath: 'data.edges.node',
|
|
2153
|
-
variables: {
|
|
2154
|
-
dateRangeFilter: { from: lastSyncTime, to: now },
|
|
2155
|
-
},
|
|
2156
|
-
// direction defaults to 'forward'
|
|
2157
|
-
// Processes oldest ? newest for proper sequencing
|
|
2158
|
-
});
|
|
2159
|
-
|
|
2160
|
-
// ? Backward - For "latest N records" use cases
|
|
2161
|
-
const latestData = await orchestrator.extract({
|
|
2162
|
-
query: YOUR_QUERY,
|
|
2163
|
-
resultPath: 'data.edges.node',
|
|
2164
|
-
direction: 'backward',
|
|
2165
|
-
maxRecords: 100, // Just get latest 100
|
|
2166
|
-
// Gets newest ? oldest
|
|
2167
|
-
});
|
|
2168
|
-
```
|
|
2169
|
-
|
|
2170
|
-
**Pagination Variables Reference**:
|
|
2171
|
-
|
|
2172
|
-
| Variable | Forward | Backward | Injected By | Notes |
|
|
2173
|
-
| -------- | ------------ | ------------ | ------------ | ------------------------ |
|
|
2174
|
-
| `first` | ? Used | Not used | Orchestrator | From `pageSize` |
|
|
2175
|
-
| `after` | ? Used | Not used | Orchestrator | From cursor (last edge) |
|
|
2176
|
-
| `last` | Not used | ? Used | Orchestrator | From `pageSize` |
|
|
2177
|
-
| `before` | Not used | ? Used | Orchestrator | From cursor (first edge) |
|
|
2178
|
-
|
|
2179
|
-
**Common Mistakes to Avoid**:
|
|
2180
|
-
|
|
2181
|
-
```typescript
|
|
2182
|
-
// WRONG - Don't pass pagination variables
|
|
2183
|
-
const result = await orchestrator.extract({
|
|
2184
|
-
variables: {
|
|
2185
|
-
last: 200, // Orchestrator will override this
|
|
2186
|
-
before: cursor, // Orchestrator manages cursor
|
|
2187
|
-
},
|
|
2188
|
-
direction: 'backward',
|
|
2189
|
-
});
|
|
2190
|
-
|
|
2191
|
-
// ? CORRECT - Let orchestrator inject pagination
|
|
2192
|
-
const result = await orchestrator.extract({
|
|
2193
|
-
variables: {
|
|
2194
|
-
retailerId, // ? Your business variables only
|
|
2195
|
-
},
|
|
2196
|
-
pageSize: 200, // ? Orchestrator uses this for last/before
|
|
2197
|
-
direction: 'backward',
|
|
2198
|
-
});
|
|
2199
|
-
```
|
|
2200
|
-
|
|
2201
|
-
#### Optional: Reverse Pagination
|
|
2202
|
-
|
|
2203
|
-
- Default remains forward pagination ($first/$after) using pageInfo.hasNextPage.
|
|
2204
|
-
- To paginate backward, define $last/$before and add pageInfo.hasPreviousPage; set direction='backward' in the orchestrator.
|
|
2205
|
-
|
|
2206
|
-
Snippet:
|
|
2207
|
-
|
|
2208
|
-
```graphql
|
|
2209
|
-
query GetInventoryPositionsBackward($last: Int!, $before: String) {
|
|
2210
|
-
inventoryPositions(last: $last, before: $before) {
|
|
2211
|
-
edges {
|
|
2212
|
-
cursor
|
|
2213
|
-
node {
|
|
2214
|
-
id
|
|
2215
|
-
ref
|
|
2216
|
-
updatedOn
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
pageInfo {
|
|
2220
|
-
hasPreviousPage
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2223
|
-
}
|
|
2224
|
-
```
|
|
2225
|
-
|
|
2226
|
-
SDK:
|
|
2227
|
-
|
|
2228
|
-
```typescript
|
|
2229
|
-
await orchestrator.extract({
|
|
2230
|
-
query: INVENTORY_POSITIONS_BACKWARD_QUERY,
|
|
2231
|
-
resultPath: 'inventoryPositions.edges.node',
|
|
2232
|
-
variables: { dateRangeFilter },
|
|
2233
|
-
pageSize,
|
|
2234
|
-
direction: 'backward',
|
|
2235
|
-
});
|
|
2236
|
-
```
|
|
2237
|
-
|
|
2238
|
-
---
|
|
2239
|
-
|
|
2240
|
-
## Testing Checklist
|
|
2241
|
-
|
|
2242
|
-
**Before production deployment:**
|
|
2243
|
-
|
|
2244
|
-
### 1. Schema Validation
|
|
2245
|
-
|
|
2246
|
-
- [ ] Run `npx fc-connect introspect-schema --url <your-graphql-url>`
|
|
2247
|
-
- [ ] Run `npx fc-connect validate-schema --mapping ./config/inventory-positions.export.xml.json --schema ./fluent-schema.json`
|
|
2248
|
-
- [ ] Run `npx fc-connect analyze-coverage --mapping ./config/inventory-positions.export.xml.json --schema ./fluent-schema.json`
|
|
2249
|
-
- [ ] Verify all `source` paths in mapping exist in GraphQL schema
|
|
2250
|
-
- [ ] Verify query structure matches schema (fields, types, filters)
|
|
2251
|
-
|
|
2252
|
-
### 2. Extraction Testing
|
|
2253
|
-
|
|
2254
|
-
- [ ] Test with small dataset first (maxRecords=10)
|
|
2255
|
-
- [ ] Verify ExtractionOrchestrator pagination works correctly
|
|
2256
|
-
- [ ] Test with multiple pages of data (verify cursor handling)
|
|
2257
|
-
- [ ] Verify date range filtering (updatedOn filter)
|
|
2258
|
-
- [ ] Test empty result handling (no records in date range)
|
|
2259
|
-
- [ ] Verify extraction stops at maxRecords limit
|
|
2260
|
-
|
|
2261
|
-
### 3. Mapping Testing
|
|
2262
|
-
|
|
2263
|
-
- [ ] Verify required fields are populated
|
|
2264
|
-
- [ ] Verify SDK resolvers work correctly (sdk.trim, sdk.parseInt, sdk.formatDate, etc.)
|
|
2265
|
-
- [ ] Test custom resolvers with edge cases (if any)
|
|
2266
|
-
- [ ] Verify nested field extraction
|
|
2267
|
-
- [ ] Test with null/missing fields
|
|
2268
|
-
- [ ] Verify mapping error collection works
|
|
2269
|
-
|
|
2270
|
-
### 4. XML Generation Testing
|
|
2271
|
-
|
|
2272
|
-
- [ ] Verify XML structure matches expected format
|
|
2273
|
-
- [ ] Test XML validation against XSD schema (if applicable)
|
|
2274
|
-
- [ ] Verify special character escaping in XML
|
|
2275
|
-
- [ ] Test with large datasets (>1000 records)
|
|
2276
|
-
- [ ] Verify UTF-8 encoding
|
|
2277
|
-
- [ ] Test XML namespace handling (if applicable)
|
|
2278
|
-
|
|
2279
|
-
### 5. SFTP Upload Testing
|
|
2280
|
-
|
|
2281
|
-
- [ ] Test SFTP connection and authentication
|
|
2282
|
-
- [ ] Verify file upload to correct path
|
|
2283
|
-
- [ ] Test file naming convention (timestamp format)
|
|
2284
|
-
- [ ] Verify file permissions on SFTP server
|
|
2285
|
-
- [ ] Test upload retry logic (simulate network failure)
|
|
2286
|
-
- [ ] Verify SFTP connection disposal (no connection leaks)
|
|
2287
|
-
|
|
2288
|
-
### 6. State Management Testing
|
|
2289
|
-
|
|
2290
|
-
- [ ] Verify overlap buffer prevents missed records (60-second default)
|
|
2291
|
-
- [ ] Test state recovery after extraction failure
|
|
2292
|
-
- [ ] Verify timestamp saved WITHOUT buffer (MAX(updatedOn))
|
|
2293
|
-
- [ ] Test first run with no previous state (uses fallbackStartDate)
|
|
2294
|
-
- [ ] Verify state update only happens on successful upload
|
|
2295
|
-
- [ ] Test manual date override (doesn't update state)
|
|
2296
|
-
|
|
2297
|
-
### 7. Job Tracking Testing
|
|
2298
|
-
|
|
2299
|
-
- [ ] Test job creation with JobTracker
|
|
2300
|
-
- [ ] Verify job status updates at each stage
|
|
2301
|
-
- [ ] Test job completion with metadata
|
|
2302
|
-
- [ ] Test job failure handling
|
|
2303
|
-
- [ ] Query job status via webhook endpoint
|
|
2304
|
-
- [ ] Verify job status persists in KV store
|
|
2305
|
-
|
|
2306
|
-
### 8. Error Handling Testing
|
|
2307
|
-
|
|
2308
|
-
- [ ] Test with invalid GraphQL query
|
|
2309
|
-
- [ ] Test with mapping errors (invalid field paths)
|
|
2310
|
-
- [ ] Test with SFTP connection failures
|
|
2311
|
-
- [ ] Test with authentication failures
|
|
2312
|
-
- [ ] Test with network timeouts
|
|
2313
|
-
- [ ] Verify error logging includes context (jobId, stage, error details)
|
|
2314
|
-
- [ ] Test error threshold logic (if applicable)
|
|
2315
|
-
|
|
2316
|
-
### 9. Staging Environment Testing
|
|
2317
|
-
|
|
2318
|
-
- [ ] Run full extraction in staging environment
|
|
2319
|
-
- [ ] Verify XML file format with downstream system
|
|
2320
|
-
- [ ] Monitor extraction duration and resource usage
|
|
2321
|
-
- [ ] Test with production-like data volumes
|
|
2322
|
-
- [ ] Verify no performance degradation over time
|
|
2323
|
-
|
|
2324
|
-
### 10. Integration Testing
|
|
2325
|
-
|
|
2326
|
-
- [ ] Test scheduled workflow (cron trigger)
|
|
2327
|
-
- [ ] Test ad hoc webhook trigger
|
|
2328
|
-
- [ ] Test job status query webhook
|
|
2329
|
-
- [ ] Verify activation variables are read correctly
|
|
2330
|
-
- [ ] Test with different extraction modes (incremental, date range)
|
|
2331
|
-
- [ ] End-to-end test: trigger ? extract ? transform ? upload ? verify file
|
|
2332
|
-
|
|
2333
|
-
---
|
|
2334
|
-
## Monitoring & Alerting
|
|
2335
|
-
|
|
2336
|
-
### Success Response Example
|
|
2337
|
-
|
|
2338
|
-
```json
|
|
2339
|
-
{
|
|
2340
|
-
"success": true,
|
|
2341
|
-
"jobId": "SCHEDULED_IP_20251102_140000_abc123",
|
|
2342
|
-
"recordsExtracted": 1523,
|
|
2343
|
-
"fileName": "inventory-positions-2025-11-02T14-00-00-000Z.xml",
|
|
2344
|
-
"sftpPath": "/outbound/inventory-positions/inventory-positions-2025-11-02T14-00-00-000Z.xml",
|
|
2345
|
-
"metrics": {
|
|
2346
|
-
"extractionDurationMs": 12543,
|
|
2347
|
-
"totalPages": 8,
|
|
2348
|
-
"pageSize": 200,
|
|
2349
|
-
"mappingErrors": 0,
|
|
2350
|
-
"fileSizeBytes": 524288,
|
|
2351
|
-
"uploadDurationMs": 1234
|
|
2352
|
-
},
|
|
2353
|
-
"timestamps": {
|
|
2354
|
-
"extractionStart": "2025-11-02T14:00:00.000Z",
|
|
2355
|
-
"extractionEnd": "2025-11-02T14:00:12.543Z",
|
|
2356
|
-
"uploadComplete": "2025-11-02T14:00:13.777Z"
|
|
2357
|
-
},
|
|
2358
|
-
"state": {
|
|
2359
|
-
"previousTimestamp": "2025-11-02T13:00:00.000Z",
|
|
2360
|
-
"newTimestamp": "2025-11-02T13:59:58.123Z",
|
|
2361
|
-
"stateUpdated": true,
|
|
2362
|
-
"overlapBufferSeconds": 60
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
```
|
|
2366
|
-
|
|
2367
|
-
### Error Response Example
|
|
2368
|
-
|
|
2369
|
-
```json
|
|
2370
|
-
{
|
|
2371
|
-
"success": false,
|
|
2372
|
-
"jobId": "ADHOC_IP_20251102_140500_xyz789",
|
|
2373
|
-
"error": "SFTP upload failed: Connection timeout",
|
|
2374
|
-
"errorCategory": "NETWORK",
|
|
2375
|
-
"recordsExtracted": 0,
|
|
2376
|
-
"stage": "sftp_upload",
|
|
2377
|
-
"details": {
|
|
2378
|
-
"message": "Failed to upload file after 3 retry attempts",
|
|
2379
|
-
"retryAttempts": 3,
|
|
2380
|
-
"lastError": "ETIMEDOUT: Connection timed out after 30000ms"
|
|
2381
|
-
},
|
|
2382
|
-
"state": {
|
|
2383
|
-
"stateUpdated": false,
|
|
2384
|
-
"willRetryNextRun": true,
|
|
2385
|
-
"note": "State not advanced - next extraction will retry same time window"
|
|
2386
|
-
}
|
|
2387
|
-
}
|
|
2388
|
-
```
|
|
2389
|
-
|
|
2390
|
-
### Key Metrics to Track
|
|
2391
|
-
|
|
2392
|
-
```typescript
|
|
2393
|
-
const METRICS = {
|
|
2394
|
-
// Extraction Performance
|
|
2395
|
-
extractionDurationMs: Date.now() - extractionStart,
|
|
2396
|
-
recordCount: records.length,
|
|
2397
|
-
pageCount: extractionResult.stats.totalPages,
|
|
2398
|
-
avgRecordsPerPage: records.length / extractionResult.stats.totalPages,
|
|
2399
|
-
|
|
2400
|
-
// Transformation Performance
|
|
2401
|
-
transformedCount: transformedRecords.length,
|
|
2402
|
-
failedCount: mappingErrors.length,
|
|
2403
|
-
errorRate: ((mappingErrors.length / records.length) * 100).toFixed(2) + '%',
|
|
2404
|
-
|
|
2405
|
-
// File Generation
|
|
2406
|
-
fileSizeMB: (xmlContent.length / (1024 * 1024)).toFixed(2),
|
|
2407
|
-
|
|
2408
|
-
// Upload Performance
|
|
2409
|
-
uploadDurationMs: uploadEnd - uploadStart,
|
|
2410
|
-
uploadSpeedMBps: (fileSizeMB / (uploadDurationMs / 1000)).toFixed(2),
|
|
2411
|
-
|
|
2412
|
-
// State Management
|
|
2413
|
-
timeSinceLastRun: Date.now() - new Date(lastTimestamp).getTime(),
|
|
2414
|
-
recordsPerMinute: (records.length / (extractionDurationMs / 60000)).toFixed(0),
|
|
2415
|
-
};
|
|
2416
|
-
|
|
2417
|
-
log.info('Extraction metrics', metrics);
|
|
2418
|
-
```
|
|
2419
|
-
|
|
2420
|
-
### Alert Thresholds
|
|
2421
|
-
|
|
2422
|
-
```typescript
|
|
2423
|
-
const ALERT_THRESHOLDS = {
|
|
2424
|
-
// Duration Alerts
|
|
2425
|
-
EXTRACTION_DURATION_MS: 5 * 60 * 1000, // 5 minutes
|
|
2426
|
-
UPLOAD_DURATION_MS: 2 * 60 * 1000, // 2 minutes
|
|
2427
|
-
TOTAL_DURATION_MS: 10 * 60 * 1000, // 10 minutes
|
|
2428
|
-
|
|
2429
|
-
// Error Rate Alerts
|
|
2430
|
-
MAX_ERROR_RATE: 0.05, // 5% mapping errors
|
|
2431
|
-
MAX_VALIDATION_FAILURES: 0.02, // 2% validation failures
|
|
2432
|
-
|
|
2433
|
-
// Volume Alerts
|
|
2434
|
-
MAX_RECORDS_PER_RUN: 100000,
|
|
2435
|
-
MIN_RECORDS_WARNING: 0, // Alert if no records found
|
|
2436
|
-
MAX_FILE_SIZE_MB: 150, // 150MB
|
|
2437
|
-
|
|
2438
|
-
// State Alerts
|
|
2439
|
-
MAX_TIME_SINCE_LAST_RUN_HOURS: 25, // Alert if >25 hours (should run hourly)
|
|
2440
|
-
MAX_OVERLAP_BUFFER_SECONDS: 300, // Alert if buffer >5 minutes
|
|
2441
|
-
};
|
|
2442
|
-
|
|
2443
|
-
// Check thresholds
|
|
2444
|
-
if (metrics.extractionDurationMs > ALERT_THRESHOLDS.EXTRACTION_DURATION_MS) {
|
|
2445
|
-
log.warn('Extraction duration exceeded threshold', {
|
|
2446
|
-
duration: metrics.extractionDurationMs,
|
|
2447
|
-
threshold: ALERT_THRESHOLDS.EXTRACTION_DURATION_MS,
|
|
2448
|
-
recommendation: 'Consider reducing maxRecords or increasing extraction frequency'
|
|
2449
|
-
});
|
|
2450
|
-
}
|
|
2451
|
-
```
|
|
2452
|
-
|
|
2453
|
-
### Monitoring Dashboard Queries
|
|
2454
|
-
|
|
2455
|
-
**Versori Platform Logs Query:**
|
|
2456
|
-
|
|
2457
|
-
```
|
|
2458
|
-
# Successful extractions
|
|
2459
|
-
log_level:info AND message:"Extraction complete" AND jobId:*
|
|
2460
|
-
|
|
2461
|
-
# Failed extractions
|
|
2462
|
-
log_level:error AND message:"Extraction workflow failed" AND jobId:*
|
|
2463
|
-
|
|
2464
|
-
# Performance issues
|
|
2465
|
-
extractionDurationMs:>300000 OR uploadDurationMs:>120000
|
|
2466
|
-
|
|
2467
|
-
# High error rates
|
|
2468
|
-
errorRate:>5
|
|
2469
|
-
|
|
2470
|
-
# State management issues
|
|
2471
|
-
stateUpdated:false AND success:true
|
|
2472
|
-
```
|
|
2473
|
-
|
|
2474
|
-
### Common Issues and Solutions
|
|
2475
|
-
|
|
2476
|
-
**Issue**: "Extraction timeout after 10 minutes"
|
|
2477
|
-
|
|
2478
|
-
- **Cause**: Too many records in single extraction
|
|
2479
|
-
- **Fix**: Reduce maxRecords, increase extraction frequency, or optimize query filters
|
|
2480
|
-
- **Prevention**: Monitor recordCount trends, set appropriate maxRecords
|
|
2481
|
-
|
|
2482
|
-
**Issue**: "Mapping errors for 50% of records"
|
|
2483
|
-
|
|
2484
|
-
- **Cause**: Schema mismatch between GraphQL response and mapping config
|
|
2485
|
-
- **Fix**: Run schema validation, update mapping config paths
|
|
2486
|
-
- **Prevention**: Use `npx fc-connect validate-schema` before deployment
|
|
2487
|
-
|
|
2488
|
-
**Issue**: "SFTP connection timeout"
|
|
2489
|
-
|
|
2490
|
-
- **Cause**: Network issues, firewall, or connection pool exhaustion
|
|
2491
|
-
- **Fix**: Check SFTP credentials, verify network connectivity
|
|
2492
|
-
- **Prevention**: Implement connection health checks, monitor connection status
|
|
2493
|
-
|
|
2494
|
-
**Issue**: "State not updating after successful extraction"
|
|
2495
|
-
|
|
2496
|
-
- **Cause**: KV write failure or intentional retry logic
|
|
2497
|
-
- **Fix**: Check KV logs, verify state update code executed
|
|
2498
|
-
- **Prevention**: Add KV write verification, log state updates explicitly
|
|
2499
|
-
|
|
2500
|
-
**Issue**: "First run exceeds record limits"
|
|
2501
|
-
|
|
2502
|
-
- **Cause**: No previous timestamp, fetches all historical records
|
|
2503
|
-
- **Fix**: Set fallbackStartDate close to current date, apply additional filters
|
|
2504
|
-
- **Prevention**: Use appropriate fallbackStartDate for initial runs
|
|
2505
|
-
|
|
2506
|
-
**Issue**: "Excessive duplicate records in output"
|
|
2507
|
-
|
|
2508
|
-
- **Cause**: Overlap buffer (expected) or timestamp not saved correctly
|
|
2509
|
-
- **Fix**: Verify newTimestamp saved WITHOUT buffer, check state persistence
|
|
2510
|
-
- **Prevention**: Monitor duplicate rates, verify state update logic
|
|
2511
|
-
|
|
2512
|
-
---
|
|
2513
|
-
|
|
2514
|
-
## Troubleshooting Quick Reference
|
|
2515
|
-
|
|
2516
|
-
| Error Message | Likely Cause | Solution |
|
|
2517
|
-
|--------------|--------------|----------|
|
|
2518
|
-
| "Failed to create Fluent Commerce client" | Authentication failure | Check OAuth2 credentials, verify connection config |
|
|
2519
|
-
| "GraphQL query validation error" | Invalid query syntax | Validate query against schema with introspection tool |
|
|
2520
|
-
| "Pagination cursor invalid" | Stale cursor or query change | Reset extraction, verify cursor handling in query |
|
|
2521
|
-
| "Mapping failed: field not found" | Schema mismatch | Run schema validation, update mapping paths |
|
|
2522
|
-
| "SFTP authentication failed" | Invalid credentials | Verify SFTP credentials in activation variables |
|
|
2523
|
-
| "Connection pool exhausted" | Too many concurrent requests | Reduce concurrency, increase pool size |
|
|
2524
|
-
| "KV operation failed" | Versori KV issue | Check Versori platform status, retry operation |
|
|
2525
|
-
| "Job status not found" | Invalid jobId or expired | Verify jobId format, check KV retention policy |
|
|
2526
|
-
| "Memory limit exceeded" | Dataset too large | Reduce maxRecords, enable streaming mode |
|
|
2527
|
-
| "XML generation failed" | Format-specific error | Check XML generation logic, validate output |
|
|
2528
|
-
|
|
2529
|
-
---
|
|
1
|
+
---
|
|
2
|
+
template_id: tpl-extract-inventory-positions-to-sftp-xml
|
|
3
|
+
canonical_filename: template-extraction-inventory-positions-to-sftp-xml.md
|
|
4
|
+
version: 2.0.0
|
|
5
|
+
sdk_version: ^0.1.39
|
|
6
|
+
runtime: versori
|
|
7
|
+
direction: extraction
|
|
8
|
+
source: fluent-graphql
|
|
9
|
+
destination: sftp-xml
|
|
10
|
+
entity: inventoryPositions
|
|
11
|
+
format: xml
|
|
12
|
+
logging: versori
|
|
13
|
+
status: stable
|
|
14
|
+
features:
|
|
15
|
+
- memory-management
|
|
16
|
+
- enhanced-logging
|
|
17
|
+
- pagination-progress
|
|
18
|
+
- dispose-finally
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# Template: Extraction - Inventory Positions to SFTP XML
|
|
22
|
+
|
|
23
|
+
**Template Version:** 2.0.0
|
|
24
|
+
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
25
|
+
**Last Updated:** 2025-01-24
|
|
26
|
+
**Deployment Target:** Versori Platform
|
|
27
|
+
|
|
28
|
+
**🆕 Version 2.0.0 Enhancements:**
|
|
29
|
+
- ✅ **Memory Management** - Clear large result sets after processing batches
|
|
30
|
+
- ✅ **Enhanced Logging** - Pagination progress tracking with emoji indicators (📊, 📥, ✅)
|
|
31
|
+
- ✅ **Pagination Progress** - Real-time page-by-page progress logging with metrics
|
|
32
|
+
- ✅ **Resource Cleanup** - SFTP dispose in finally blocks prevents connection leaks
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## ?? STEP 1: Load These Docs Into Your AI (Human Checklist)
|
|
37
|
+
|
|
38
|
+
1. REQUIRED (load all)
|
|
39
|
+
- [ ] fc-connect-sdk/docs/02-CORE-GUIDES/api-reference/
|
|
40
|
+
- [ ] fc-connect-sdk/docs/02-CORE-GUIDES/mapping/
|
|
41
|
+
- [ ] fc-connect-sdk/docs/03-PATTERN-GUIDES/data-sources/
|
|
42
|
+
- [ ] fc-connect-sdk/docs/03-PATTERN-GUIDES/parsers/
|
|
43
|
+
- [ ] fc-connect-sdk/docs/03-PATTERN-GUIDES/extraction/
|
|
44
|
+
- [ ] fc-connect-sdk/docs/04-REFERENCE/platforms/versori/
|
|
45
|
+
|
|
46
|
+
Copy-paste list (open these):
|
|
47
|
+
fc-connect-sdk/docs/02-CORE-GUIDES/api-reference/
|
|
48
|
+
fc-connect-sdk/docs/02-CORE-GUIDES/mapping/
|
|
49
|
+
fc-connect-sdk/docs/03-PATTERN-GUIDES/data-sources/
|
|
50
|
+
fc-connect-sdk/docs/03-PATTERN-GUIDES/parsers/
|
|
51
|
+
fc-connect-sdk/docs/03-PATTERN-GUIDES/extraction/
|
|
52
|
+
fc-connect-sdk/docs/04-REFERENCE/platforms/versori/
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 📋 STEP 2: Tell Your AI (Prompt)
|
|
57
|
+
|
|
58
|
+
Copy/paste this prompt into your AI tool after loading the documentation above:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
I need a Versori scheduled extractor that:
|
|
62
|
+
|
|
63
|
+
1) Queries Fluent Commerce GraphQL for Inventory Positions with auto-pagination
|
|
64
|
+
2) Supports incremental runs via KV state (with an overlap buffer)
|
|
65
|
+
3) Transforms results using UniversalMapper per mapping JSON
|
|
66
|
+
4) Generates pretty-printed XML and uploads to SFTP
|
|
67
|
+
5) Tracks progress with JobTracker and exposes a job-status webhook
|
|
68
|
+
6) Uses native Versori log (LoggingService removed - use native log) and disposes SFTP
|
|
69
|
+
|
|
70
|
+
Use the loaded docs to fill in SDK specifics and best practices.
|
|
71
|
+
Keep the structure identical to the template; only adapt where needed.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 📋 Template Overview
|
|
77
|
+
|
|
78
|
+
This connector runs on the Versori platform. Most operational settings (Fluent account/connection, SFTP connection, schedule, page size/limits) are configured via activation variables. Data shape and logic (mapping JSON, XML structure, GraphQL selection set/filters, validators/resolvers) are adjusted in code as needed. It extracts inventory positions from Fluent Commerce via GraphQL, transforms the data into XML, and uploads the result to SFTP.
|
|
79
|
+
|
|
80
|
+
### What This Template Does
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
�������������������������������������������������������������������
|
|
84
|
+
� EXTRACTION WORKFLOW �
|
|
85
|
+
�������������������������������������������������������������������
|
|
86
|
+
|
|
87
|
+
1. TRIGGER
|
|
88
|
+
�� Scheduled (Cron): Runs automatically every hour
|
|
89
|
+
�� Ad hoc (Webhook): Manual trigger with optional date override
|
|
90
|
+
�� Status Query (Webhook): Check job progress
|
|
91
|
+
|
|
92
|
+
2. EXTRACT (ExtractionOrchestrator)
|
|
93
|
+
�� Query Fluent GraphQL API for inventory positions
|
|
94
|
+
�� Auto-pagination (handles large datasets)
|
|
95
|
+
�� Apply date filters (incremental or manual range)
|
|
96
|
+
�� Validate each record (optional)
|
|
97
|
+
|
|
98
|
+
3. TRANSFORM (UniversalMapper)
|
|
99
|
+
�� Map GraphQL fields to XML schema
|
|
100
|
+
�� Apply SDK resolvers (trim, uppercase, parseInt, etc.)
|
|
101
|
+
�� Extract nested data (catalogue)
|
|
102
|
+
�� Handle transformation errors
|
|
103
|
+
|
|
104
|
+
4. GENERATE XML (XMLBuilder)
|
|
105
|
+
�� Convert transformed records to XML
|
|
106
|
+
�� Auto-escape special characters
|
|
107
|
+
�� Apply pretty-print formatting
|
|
108
|
+
�� Generate timestamped filename
|
|
109
|
+
|
|
110
|
+
5. UPLOAD (SftpDataSource)
|
|
111
|
+
�� Connect to SFTP server
|
|
112
|
+
�� Upload XML file with retry logic
|
|
113
|
+
�� Verify upload success
|
|
114
|
+
�� Always dispose connection (finally block)
|
|
115
|
+
|
|
116
|
+
6. TRACK JOB (JobTracker)
|
|
117
|
+
�� Create job with unique ID
|
|
118
|
+
�� Update status at each step
|
|
119
|
+
�� Store job result in KV
|
|
120
|
+
�� Enable status queries via webhook
|
|
121
|
+
|
|
122
|
+
7. UPDATE STATE (VersoriKVAdapter)
|
|
123
|
+
�� Calculate max updatedOn from records
|
|
124
|
+
�� Store timestamp for next incremental run
|
|
125
|
+
�� Apply overlap buffer (prevent missed records)
|
|
126
|
+
�� Skip update if manual date override
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Key Features
|
|
130
|
+
|
|
131
|
+
- Job tracking with status queries
|
|
132
|
+
- Execution modes: scheduled, ad hoc, status query
|
|
133
|
+
- Uses ExtractionOrchestrator, UniversalMapper, JobTracker
|
|
134
|
+
- Error handling, retry logic, and SFTP cleanup
|
|
135
|
+
- Reusable services suitable for similar use cases
|
|
136
|
+
|
|
137
|
+
Note: JobTracker persists stage/status to Versori KV for visibility, job-status webhooks, and auditing. Recommended for production multi-step flows; can be skipped for trivial single-step utilities.
|
|
138
|
+
|
|
139
|
+
### 📦 Package Information
|
|
140
|
+
|
|
141
|
+
**SDK:** [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk)
|
|
142
|
+
|
|
143
|
+
**Version:** Check [npm](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk) for latest version
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm install @fluentcommerce/fc-connect-sdk
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
**Templates are designed for direct deployment; customize via activation variables.**
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 📦 SDK Imports (Verified - Versori Optimized)
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
// ? VERIFIED IMPORTS - These match actual SDK exports
|
|
159
|
+
import { Buffer } from 'node:buffer';
|
|
160
|
+
import {
|
|
161
|
+
createClient, // Universal client factory (auto-detects Versori/Node/Deno)
|
|
162
|
+
ExtractionOrchestrator, // High-level extraction with auto-pagination
|
|
163
|
+
JobTracker, // Job status tracking in KV store
|
|
164
|
+
UniversalMapper, // Field mapping with SDK resolvers
|
|
165
|
+
XMLBuilder, // XML generation with auto-escaping (from SDK)
|
|
166
|
+
SftpDataSource, // SFTP operations with retry logic
|
|
167
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
168
|
+
|
|
169
|
+
// Versori platform imports
|
|
170
|
+
import { schedule, webhook, http, fn } from '@versori/run';
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**CRITICAL:** `openKv` is NOT imported directly - it's accessed from the context parameter:
|
|
174
|
+
```typescript
|
|
175
|
+
// ✅ CORRECT - From context parameter
|
|
176
|
+
export const myWorkflow = schedule('task', '0 * * * *', async (ctx) => {
|
|
177
|
+
const { openKv, log } = ctx; // Access from context
|
|
178
|
+
const kv = openKv(':project:');
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Note:** `openKv` is NOT exported from `@versori/run` - attempting to import it directly will cause a runtime error.
|
|
183
|
+
|
|
184
|
+
**Note:** All imports are from actual SDK exports - this code compiles and runs as-is.
|
|
185
|
+
|
|
186
|
+
**? VERSORI PLATFORM - Use Native Logs:**
|
|
187
|
+
|
|
188
|
+
- Use `log` from context: `const { log } = ctx;`
|
|
189
|
+
- Don't import or use LoggingService for Versori connectors
|
|
190
|
+
- Native Versori logs are simpler and automatically integrated with platform monitoring
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## ??� Configuration
|
|
195
|
+
|
|
196
|
+
### SFTP Connection Setup
|
|
197
|
+
|
|
198
|
+
**? RECOMMENDED APPROACH:** Store SFTP credentials in a Versori connection for security and reusability.
|
|
199
|
+
|
|
200
|
+
Versori provides **three methods** to access connection credentials. Choose based on your use case:
|
|
201
|
+
|
|
202
|
+
#### Method 1: `activation.connections` (Recommended - Simplest)
|
|
203
|
+
|
|
204
|
+
**When to use:** Most workflows (scheduled, webhooks without connection param)
|
|
205
|
+
|
|
206
|
+
**Setup:**
|
|
207
|
+
|
|
208
|
+
1. Create connection named `versori_ftp_server` with Basic Auth
|
|
209
|
+
2. Access via `activation.connections`:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
// ========================================
|
|
213
|
+
// SFTP CREDENTIAL RETRIEVAL
|
|
214
|
+
// ========================================
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Retrieve SFTP credentials from connection configuration
|
|
218
|
+
*
|
|
219
|
+
* This approach retrieves credentials stored in the Versori connection settings.
|
|
220
|
+
* The connection must be configured in the UI with Basic Authentication.
|
|
221
|
+
*
|
|
222
|
+
* Steps:
|
|
223
|
+
* 1. Call ctx.credentials().getAccessToken('SFTP') to get base64-encoded credentials
|
|
224
|
+
* 2. Decode the accessToken from base64 to get "username:password" string
|
|
225
|
+
* 3. Split on ':' to extract username and password
|
|
226
|
+
*
|
|
227
|
+
* Connection Name: 'SFTP' (must match the connection name in Versori UI)
|
|
228
|
+
* Auth Type: Basic Authentication (username + password)
|
|
229
|
+
*
|
|
230
|
+
* This method provides:
|
|
231
|
+
* - Centralized credential management through Versori UI
|
|
232
|
+
* - Better security (credentials not stored in integration variables)
|
|
233
|
+
* - Easier credential rotation and updates
|
|
234
|
+
*/
|
|
235
|
+
log.info('Retrieving SFTP credentials from connection configuration');
|
|
236
|
+
|
|
237
|
+
let sftpUsername: string;
|
|
238
|
+
let sftpPassword: string;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
// Retrieve credentials from the 'SFTP' connection
|
|
242
|
+
const sftpCred = await ctx.credentials().getAccessToken('SFTP');
|
|
243
|
+
|
|
244
|
+
if (!sftpCred?.accessToken) {
|
|
245
|
+
throw new Error('No SFTP credentials found in connection configuration');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Decode base64 accessToken to get "username:password"
|
|
249
|
+
const rawBasicAuth = Buffer.from(sftpCred.accessToken, 'base64').toString('utf-8');
|
|
250
|
+
|
|
251
|
+
// Split on ':' to extract username and password
|
|
252
|
+
const parts = rawBasicAuth.split(':');
|
|
253
|
+
|
|
254
|
+
if (parts.length !== 2) {
|
|
255
|
+
throw new Error('Invalid SFTP credential format - expected username:password');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
sftpUsername = parts[0];
|
|
259
|
+
sftpPassword = parts[1];
|
|
260
|
+
|
|
261
|
+
log.info('SFTP credentials retrieved successfully', {
|
|
262
|
+
hasUsername: !!sftpUsername,
|
|
263
|
+
hasPassword: !!sftpPassword,
|
|
264
|
+
usernameLength: sftpUsername.length,
|
|
265
|
+
passwordLength: sftpPassword.length,
|
|
266
|
+
});
|
|
267
|
+
} catch (error: any) {
|
|
268
|
+
log.error('Failed to retrieve SFTP credentials', {
|
|
269
|
+
message: error instanceof Error ? error.message : String(error),
|
|
270
|
+
|
|
271
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
272
|
+
|
|
273
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
success: false,
|
|
278
|
+
error: 'Failed to retrieve SFTP credentials from connection configuration',
|
|
279
|
+
details: error?.message,
|
|
280
|
+
recommendation: 'Please ensure the SFTP connection is configured in the Connections section with Basic Authentication (username and password)',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Pros:**
|
|
286
|
+
|
|
287
|
+
- ✅ Dynamic connection name from config/environment
|
|
288
|
+
- ✅ Explicit error handling for missing connections
|
|
289
|
+
- ✅ Works in all workflow types
|
|
290
|
+
|
|
291
|
+
**Cons:**
|
|
292
|
+
|
|
293
|
+
- ⚠️ Async (requires `await`)
|
|
294
|
+
- ⚠️ More verbose than `activation.connections`
|
|
295
|
+
|
|
296
|
+
#### Method 3: `connections` from Context (Workflow-Scoped)
|
|
297
|
+
|
|
298
|
+
**When to use:** Webhook with `connection` parameter (connection passed to specific step)
|
|
299
|
+
|
|
300
|
+
**Note:** This method is different from `activation.connections` - it only works when the workflow step explicitly receives a `connection` parameter.
|
|
301
|
+
|
|
302
|
+
**Setup:**
|
|
303
|
+
|
|
304
|
+
1. Create connection named `versori_ftp_server`
|
|
305
|
+
2. Pass to workflow step:
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
export const myWebhook = webhook('my-webhook', { connection: 'versori_ftp_server' }).then(
|
|
309
|
+
http('process', { connection: 'versori_ftp_server' }, async ctx => {
|
|
310
|
+
// Get SFTP credentials from Versori connection (Basic Auth)
|
|
311
|
+
// RECOMMENDED: Use activation.connections (already decoded)
|
|
312
|
+
const allConnections = ctx.activation.connections || [];
|
|
313
|
+
const sftpConn = allConnections.find(c => c.name === 'versori_ftp_server');
|
|
314
|
+
|
|
315
|
+
if (!sftpConn) {
|
|
316
|
+
throw new Error('SFTP connection "versori_ftp_server" not found');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const credential = sftpConn.credentials[0]?.credential;
|
|
320
|
+
if (!credential?.data?.basicAuth) {
|
|
321
|
+
throw new Error('SFTP connection not configured with Basic Authentication');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { username, password } = credential.data.basicAuth;
|
|
325
|
+
// ? Already decoded - no Buffer.from() needed!
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**Pros:**
|
|
331
|
+
|
|
332
|
+
- ✅ Explicit connection passing to specific steps
|
|
333
|
+
- ✅ Connection validation at workflow definition
|
|
334
|
+
|
|
335
|
+
**Cons:**
|
|
336
|
+
|
|
337
|
+
- ⚠️ Only works with `connection` parameter in workflow definition
|
|
338
|
+
- ⚠️ Not available in scheduled workflows or webhooks without connection param
|
|
339
|
+
|
|
340
|
+
#### Quick Decision Guide
|
|
341
|
+
|
|
342
|
+
| Scenario | Method | Example |
|
|
343
|
+
| ----------------------------------- | -------------------------- | ------------------------------------------------------------------- |
|
|
344
|
+
| **Scheduled workflow** | `activation.connections` | `schedule().then(http(...))` |
|
|
345
|
+
| **Webhook (no connection param)** | `activation.connections` | `webhook().then(fn(...))` |
|
|
346
|
+
| **Webhook (with connection param)** | `connections` from context | `webhook({ connection: 'x' }).then(http({ connection: 'x' }, ...))` |
|
|
347
|
+
| **Dynamic connection name** | `credentials().get()` | `await credentials().get(connName)` |
|
|
348
|
+
| **Need error handling** | `credentials().get()` | Explicit null check |
|
|
349
|
+
|
|
350
|
+
**Connection Configuration:**
|
|
351
|
+
|
|
352
|
+
1. In Versori platform, create connection named `versori_ftp_server`
|
|
353
|
+
2. Set **Authentication Type**: `Basic Auth`
|
|
354
|
+
3. Enter **Username**: Your SFTP username
|
|
355
|
+
4. Enter **Password**: Your SFTP password
|
|
356
|
+
5. Connection auto-encodes credentials as base64 Basic Auth
|
|
357
|
+
|
|
358
|
+
**Why use connections instead of activation variables?**
|
|
359
|
+
|
|
360
|
+
- ✅ Credentials stored securely in Versori vault
|
|
361
|
+
- ✅ Connection can be reused across workflows
|
|
362
|
+
- ✅ No need to manage sensitive data in activation variables
|
|
363
|
+
- ✅ Easier credential rotation
|
|
364
|
+
- ✅ Better separation of concerns (config vs secrets)
|
|
365
|
+
- ✅ Follows industry security best practices
|
|
366
|
+
|
|
367
|
+
**?? Complete Guide:** See `docs/02-CORE-GUIDES/data-sources/sftp-credential-access-security.md` for:
|
|
368
|
+
|
|
369
|
+
- Detailed comparison of all three methods
|
|
370
|
+
- Security best practices
|
|
371
|
+
- Common pitfalls and solutions
|
|
372
|
+
- Migration examples
|
|
373
|
+
- Troubleshooting connection issues
|
|
374
|
+
|
|
375
|
+
### Activation Variables
|
|
376
|
+
|
|
377
|
+
**Configuration is driven by activation variables - modify these instead of code:**
|
|
378
|
+
|
|
379
|
+
```json
|
|
380
|
+
{
|
|
381
|
+
"sftpHost": "sftp.partner.com",
|
|
382
|
+
"sftpPort": 22,
|
|
383
|
+
"sftpPrivateKey": "",
|
|
384
|
+
"sftpPath": "/incoming/inventory/",
|
|
385
|
+
"fileNamePrefix": "inventorypositions",
|
|
386
|
+
"pageSize": 200,
|
|
387
|
+
"maxRecords": 100000,
|
|
388
|
+
"overlapBufferSeconds": 60,
|
|
389
|
+
"requireAbsolutePaths": "true"
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
> **Note:** `sftpUsername` and `sftpPassword` are fetched from the `versori_ftp_server` connection (see above).
|
|
394
|
+
|
|
395
|
+
Note: For webhook security, set `webhookApiKey` (activation variable) and provide it via `X-API-Key` header on webhook calls.
|
|
396
|
+
|
|
397
|
+
### Variable Explanations
|
|
398
|
+
|
|
399
|
+
| Variable | Purpose | Default | Customization Hints |
|
|
400
|
+
| --------------------------- | ---------------------------- | ---------------------- | ---------------------------------------------------------------- |
|
|
401
|
+
| **SFTP Credentials** | _From Connection_ | | _See connection setup above_ |
|
|
402
|
+
| `sftpHost` | SFTP server hostname | - | Required - your SFTP server |
|
|
403
|
+
| `sftpPort` | SFTP server port | `22` | Usually 22, sometimes 2222 |
|
|
404
|
+
| `sftpPrivateKey` | SSH private key (optional) | - | Alternative to password auth |
|
|
405
|
+
| `sftpPath` | Remote directory path | `/incoming/inventory/` | Where to upload XML files |
|
|
406
|
+
| `fileNamePrefix` | XML filename prefix | `inventorypositions` | Customize naming convention |
|
|
407
|
+
| `pageSize` | Records per GraphQL page | `200` | Increase for fewer API calls |
|
|
408
|
+
| `maxRecords` | Total extraction limit | `100000` | Safety limit - adjust for volume |
|
|
409
|
+
| `overlapBufferSeconds` | Incremental safety window | `60` | Prevents missed records |
|
|
410
|
+
| `requireAbsolutePaths` | Require absolute SFTP paths | `"true"` | `"true"` for AWS Transfer Family, `"false"` for standard OpenSSH |
|
|
411
|
+
| `validateConnectionOnStart` | Fail-fast connection testing | `"false"` | `"true"` to validate auth before extraction (optional) |
|
|
412
|
+
|
|
413
|
+
**🆕 New Activation Variables (v1.1.0):**
|
|
414
|
+
|
|
415
|
+
- **`validateConnectionOnStart`**: When set to `"true"`, validates Fluent Commerce connection before extraction starts. Executes `query { me { ref } }` to verify authentication. Default: `"false"` (validation happens on first API call). Use for fail-fast behavior in production environments.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
### ?? State Management & Incremental Sync
|
|
420
|
+
|
|
421
|
+
**How incremental sync works:**
|
|
422
|
+
|
|
423
|
+
1. **First Run:** Uses `DEFAULT_FALLBACK` date (2024-01-01T00:00:00Z)
|
|
424
|
+
2. **Subsequent Runs:** Uses `lastInventoryPositionSync` timestamp from KV store
|
|
425
|
+
3. **Overlap Buffer:** Subtracts 60 seconds to catch late-arriving records
|
|
426
|
+
4. **State Update:** After successful upload, stores max `updatedOn` for next run
|
|
427
|
+
|
|
428
|
+
**Incremental vs Manual Modes:**
|
|
429
|
+
|
|
430
|
+
| Mode | When to Use | State Update | Payload Example |
|
|
431
|
+
| --------------------------- | -------------------- | ------------ | -------------------------------------------------------------- |
|
|
432
|
+
| **Incremental** | Daily scheduled sync | ? Yes | `{}` (empty - uses last sync) |
|
|
433
|
+
| **Manual Range** | Historical backfill | No | `{ "fromDate": "2024-01-01T00:00:00Z", "updateState": false }` |
|
|
434
|
+
| **Manual Range with State** | One-time catch-up | ? Yes | `{ "fromDate": "2024-01-01T00:00:00Z", "updateState": true }` |
|
|
435
|
+
|
|
436
|
+
**Why overlap buffer?**
|
|
437
|
+
|
|
438
|
+
Records updated near the sync time might not appear in the query due to:
|
|
439
|
+
|
|
440
|
+
- Clock drift between systems
|
|
441
|
+
- Transaction timing in the database
|
|
442
|
+
- GraphQL query execution timing
|
|
443
|
+
|
|
444
|
+
The 60-second buffer ensures these edge-case records are captured in the next run, preventing data loss.
|
|
445
|
+
|
|
446
|
+
**When to skip state update (`updateState: false`):**
|
|
447
|
+
|
|
448
|
+
- Historical backfills (don't affect ongoing incremental sync)
|
|
449
|
+
- Testing/debugging specific date ranges
|
|
450
|
+
- Reprocessing old data without changing the sync pointer
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
454
|
+
### 📁 SFTP Path Configuration
|
|
455
|
+
|
|
456
|
+
**Note:** Folder paths and filename patterns are configured separately.
|
|
457
|
+
|
|
458
|
+
- **Folder Path:** Configured via `sftpPath` activation variable (e.g., `/incoming/inventory/`)
|
|
459
|
+
- **Filename Pattern:** Configured via `fileNamePrefix` activation variable (e.g., `inventorypositions`)
|
|
460
|
+
|
|
461
|
+
**The workflow generates files like:** `{sftpPath}/{fileNamePrefix}-{timestamp}.xml`
|
|
462
|
+
|
|
463
|
+
**Example:** With `sftpPath="/incoming/inventory/"` and `fileNamePrefix="inventorypositions"`, a generated file will look like:
|
|
464
|
+
|
|
465
|
+
```
|
|
466
|
+
/incoming/inventory/inventorypositions-2025-10-27T18-30-45Z.xml
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Format may vary slightly (colons replaced; includes Z).
|
|
470
|
+
|
|
471
|
+
**Note:** To change the upload folder, modify the `sftpPath` activation variable (not in code).
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
### Auto-pagination and limits (ExtractionOrchestrator)
|
|
476
|
+
|
|
477
|
+
**What:** ExtractionOrchestrator handles GraphQL Relay cursor-based pagination automatically.
|
|
478
|
+
|
|
479
|
+
**Why:** Prevents manual pagination loop code, handles large datasets efficiently.
|
|
480
|
+
|
|
481
|
+
**How:** You configure `pageSize` and `maxRecords`; the orchestrator injects `$first` and `$after` variables automatically and loops until `pageInfo.hasNextPage === false` or `maxRecords` is reached.
|
|
482
|
+
**Critical:** Your query MUST include `edges { cursor }` and `pageInfo { hasNextPage }` fields, or pagination will fail.
|
|
483
|
+
|
|
484
|
+
#### How Auto-Pagination Works (Step-by-Step)
|
|
485
|
+
|
|
486
|
+
Note: Orchestrator injects $first/$after automatically, flattens via resultPath, and continues while pageInfo.hasNextPage and total < maxRecords.
|
|
487
|
+
|
|
488
|
+
#### Configuration Parameters
|
|
489
|
+
|
|
490
|
+
| Parameter | Purpose | Example | Effect |
|
|
491
|
+
| ---------------- | ------------------------------ | --------------------------------- | -------------------------- |
|
|
492
|
+
| `pageSize` | Records per GraphQL request | `200` | Controls `first` variable |
|
|
493
|
+
| `maxRecords` | Total extraction limit | `100000` | Hard stop across all pages |
|
|
494
|
+
| `resultPath` | Where records live in response | `"inventoryPositions.edges.node"` | Flattening path |
|
|
495
|
+
| `validateItem()` | Optional record filter | `(item) => !!item.ref` | Skips invalid records |
|
|
496
|
+
|
|
497
|
+
#### Example Configuration
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
const pageSize = parseInt(activation.getVariable('pageSize') || '200', 10);
|
|
501
|
+
const maxRecords = parseInt(activation.getVariable('maxRecords') || '100000', 10);
|
|
502
|
+
|
|
503
|
+
const extractionResult = await orchestrator.extract({
|
|
504
|
+
query: INVENTORY_POSITIONS_EXTRACTION_QUERY,
|
|
505
|
+
resultPath: 'inventoryPositions.edges.node',
|
|
506
|
+
variables: {
|
|
507
|
+
dateRangeFilter,
|
|
508
|
+
// Note: Don't include 'first' or 'after' here; orchestrator injects them based on pageSize.
|
|
509
|
+
},
|
|
510
|
+
pageSize,
|
|
511
|
+
maxRecords,
|
|
512
|
+
validateItem: item => !!(item.ref && item.productRef && typeof item.onHand === 'number'),
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// Stats available after extraction (Versori)
|
|
516
|
+
log.info('Extraction stats', {
|
|
517
|
+
totalRecords: extractionResult.stats.totalRecords,
|
|
518
|
+
totalPages: extractionResult.stats.totalPages,
|
|
519
|
+
validRecords: extractionResult.stats.validRecords ?? extractionResult.data.length,
|
|
520
|
+
failedValidations: extractionResult.stats.failedValidations,
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
#### GraphQL Query Requirements
|
|
525
|
+
|
|
526
|
+
**Your query MUST include these pagination fields:**
|
|
527
|
+
|
|
528
|
+
```graphql
|
|
529
|
+
query GetInventoryPositions(
|
|
530
|
+
$dateRangeFilter: DateRange
|
|
531
|
+
$first: Int! # ?� Orchestrator injects this
|
|
532
|
+
$after: String # ?� Orchestrator injects this
|
|
533
|
+
) {
|
|
534
|
+
inventoryPositions(
|
|
535
|
+
updatedOn: $dateRangeFilter
|
|
536
|
+
first: $first # ?� Pagination page size
|
|
537
|
+
after: $after # ?� Pagination cursor
|
|
538
|
+
) {
|
|
539
|
+
edges {
|
|
540
|
+
# ?� REQUIRED: Relay connection structure
|
|
541
|
+
node {
|
|
542
|
+
# ?� REQUIRED: Actual records here
|
|
543
|
+
id
|
|
544
|
+
ref
|
|
545
|
+
# ... your fields
|
|
546
|
+
}
|
|
547
|
+
cursor # ?� REQUIRED: Orchestrator uses this for next page
|
|
548
|
+
}
|
|
549
|
+
pageInfo {
|
|
550
|
+
# ?� REQUIRED: Orchestrator checks this
|
|
551
|
+
hasNextPage # ?� REQUIRED: Tells orchestrator to continue
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
## 📄 Mapping Configuration
|
|
560
|
+
|
|
561
|
+
**File:** `config/inventory-positions.export.xml.json`
|
|
562
|
+
|
|
563
|
+
```json
|
|
564
|
+
{
|
|
565
|
+
"name": "inventory-positions.export.xml",
|
|
566
|
+
"version": "1.0.0",
|
|
567
|
+
"description": "Inventory Positions ? XML Export Mapping",
|
|
568
|
+
"fields": {
|
|
569
|
+
"position_ref": {
|
|
570
|
+
"source": "ref",
|
|
571
|
+
"required": true,
|
|
572
|
+
"resolver": "sdk.trim"
|
|
573
|
+
},
|
|
574
|
+
"product_ref": {
|
|
575
|
+
"source": "productRef",
|
|
576
|
+
"required": true,
|
|
577
|
+
"resolver": "sdk.trim"
|
|
578
|
+
},
|
|
579
|
+
"location_ref": {
|
|
580
|
+
"source": "locationRef",
|
|
581
|
+
"required": false,
|
|
582
|
+
"resolver": "sdk.trim"
|
|
583
|
+
},
|
|
584
|
+
"on_hand": {
|
|
585
|
+
"source": "onHand",
|
|
586
|
+
"required": true,
|
|
587
|
+
"resolver": "sdk.parseInt"
|
|
588
|
+
},
|
|
589
|
+
"position_type": {
|
|
590
|
+
"source": "type",
|
|
591
|
+
"required": true,
|
|
592
|
+
"resolver": "sdk.uppercase"
|
|
593
|
+
},
|
|
594
|
+
"status": {
|
|
595
|
+
"source": "status",
|
|
596
|
+
"required": false,
|
|
597
|
+
"resolver": "sdk.uppercase"
|
|
598
|
+
},
|
|
599
|
+
"catalogue_ref": {
|
|
600
|
+
"source": "catalogue.ref",
|
|
601
|
+
"required": false,
|
|
602
|
+
"resolver": "sdk.trim"
|
|
603
|
+
},
|
|
604
|
+
"catalogue_name": {
|
|
605
|
+
"source": "catalogue.name",
|
|
606
|
+
"required": false,
|
|
607
|
+
"resolver": "sdk.trim"
|
|
608
|
+
},
|
|
609
|
+
"created_on": {
|
|
610
|
+
"source": "createdOn",
|
|
611
|
+
"required": true,
|
|
612
|
+
"resolver": "sdk.toString"
|
|
613
|
+
},
|
|
614
|
+
"updated_on": {
|
|
615
|
+
"source": "updatedOn",
|
|
616
|
+
"required": true,
|
|
617
|
+
"resolver": "sdk.toString"
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
**AI Customization Hints:**
|
|
624
|
+
|
|
625
|
+
- Add fields: Copy existing field config, change `source` path
|
|
626
|
+
- Remove fields: Delete field from config
|
|
627
|
+
- Change resolvers: Replace `sdk.trim` with `sdk.uppercase`, etc.
|
|
628
|
+
- Nested fields: Use dot notation like `catalogue.ref`
|
|
629
|
+
|
|
630
|
+
Note: Customize mapping by editing the JSON above; prefer built-in resolvers. See SDK Universal Mapping guide for advanced usage.
|
|
631
|
+
|
|
632
|
+
---
|
|
633
|
+
|
|
634
|
+
## Versori Workflows Structure
|
|
635
|
+
|
|
636
|
+
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
637
|
+
|
|
638
|
+
**Trigger Types:**
|
|
639
|
+
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
640
|
+
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
641
|
+
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
642
|
+
|
|
643
|
+
**Execution Steps (chained to triggers):**
|
|
644
|
+
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
645
|
+
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
646
|
+
|
|
647
|
+
### Recommended Project Structure
|
|
648
|
+
|
|
649
|
+
```
|
|
650
|
+
inventory-positions-extraction/
|
|
651
|
+
├── index.ts # Entry point - exports all workflows
|
|
652
|
+
└── src/
|
|
653
|
+
├── workflows/
|
|
654
|
+
│ ├── scheduled/
|
|
655
|
+
│ │ └── daily-inventory-positions-extraction.ts # Scheduled workflow
|
|
656
|
+
│ └── webhook/
|
|
657
|
+
│ ├── adhoc-inventory-positions-extraction.ts # Webhook: Manual trigger
|
|
658
|
+
│ └── job-status-check.ts # Webhook: Status query
|
|
659
|
+
│
|
|
660
|
+
├── services/
|
|
661
|
+
│ └── inventory-positions-extraction.service.ts # Shared orchestration logic (reusable)
|
|
662
|
+
│
|
|
663
|
+
└── config/
|
|
664
|
+
└── inventory-positions.export.xml.json # Mapping configuration
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
### 1. Entry Point (`index.ts`)
|
|
670
|
+
|
|
671
|
+
**Purpose**: Register all workflows with Versori platform
|
|
672
|
+
|
|
673
|
+
**Pattern**: Use simple re-exports (NOT `MemoryInterpreter` pattern)
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
/**
|
|
677
|
+
* Entry point - Export all workflows for Versori platform
|
|
678
|
+
*
|
|
679
|
+
* This file exports all workflows to be registered with Versori.
|
|
680
|
+
* Each workflow is defined in its own file for better organization.
|
|
681
|
+
*/
|
|
682
|
+
|
|
683
|
+
// Scheduled workflows
|
|
684
|
+
export { scheduledInventoryPositionsExtraction } from './src/workflows/scheduled/daily-inventory-positions-extraction';
|
|
685
|
+
|
|
686
|
+
// Webhook workflows
|
|
687
|
+
export { adhocInventoryPositionsExtraction } from './src/workflows/webhook/adhoc-inventory-positions-extraction';
|
|
688
|
+
export { inventoryPositionsJobStatus } from './src/workflows/webhook/job-status-check';
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
**IMPORTANT**: Do NOT use `MemoryInterpreter` or complex patterns in `index.ts`. Simple re-exports are the Versori standard.
|
|
692
|
+
|
|
693
|
+
### 2. Workflow Definitions
|
|
694
|
+
|
|
695
|
+
The code examples below show the three workflow types that go in separate files:
|
|
696
|
+
- `src/workflows/scheduled/daily-inventory-positions-extraction.ts` - Scheduled workflow
|
|
697
|
+
- `src/workflows/webhook/adhoc-inventory-positions-extraction.ts` - Webhook: Manual trigger
|
|
698
|
+
- `src/workflows/webhook/job-status-check.ts` - Webhook: Status query
|
|
699
|
+
|
|
700
|
+
### 3. Main Orchestration Service (`src/services/inventory-positions-extraction.service.ts`)
|
|
701
|
+
|
|
702
|
+
**Purpose**: Shared orchestration logic reused by all workflows
|
|
703
|
+
|
|
704
|
+
**Note:** This service coordinates the complete extraction workflow:
|
|
705
|
+
- Initialize clients and services
|
|
706
|
+
- Determine date range (incremental vs manual)
|
|
707
|
+
- Extract data using ExtractionOrchestrator
|
|
708
|
+
- Transform using UniversalMapper
|
|
709
|
+
- Generate XML using XMLBuilder
|
|
710
|
+
- Upload to SFTP
|
|
711
|
+
- Track job progress with JobTracker
|
|
712
|
+
- Update state for next run
|
|
713
|
+
|
|
714
|
+
**NAMING PATTERN** (consistent across all use cases):
|
|
715
|
+
- Interface: `{Entity}ExtractionParams` (e.g., `InventoryPositionExtractionParams`)
|
|
716
|
+
- Function: `execute{Entity}Extraction` (e.g., `executeInventoryPositionExtraction`)
|
|
717
|
+
- Service file: `{entity}-extraction.service.ts` (e.g., `inventory-positions-extraction.service.ts`)
|
|
718
|
+
|
|
719
|
+
```typescript
|
|
720
|
+
/**
|
|
721
|
+
* Workflows - Defines 3 execution patterns for inventory positions extraction
|
|
722
|
+
*
|
|
723
|
+
* WORKFLOW 1: Scheduled (Cron) - Runs automatically every hour
|
|
724
|
+
* WORKFLOW 2: Ad hoc (Webhook) - Manual trigger with optional date override
|
|
725
|
+
* WORKFLOW 3: Job Status (Webhook) - Query job progress
|
|
726
|
+
*
|
|
727
|
+
* AI CUSTOMIZATION HINTS:
|
|
728
|
+
* - Change schedule: Modify cron expression in schedule()
|
|
729
|
+
* - Add filtering: Pass additional params to executeInventoryPositionExtraction()
|
|
730
|
+
* - Change response format: Modify return object structure
|
|
731
|
+
*/
|
|
732
|
+
|
|
733
|
+
import { schedule, webhook, http, fn } from '@versori/run';
|
|
734
|
+
import { executeInventoryPositionExtraction, getJobStatus } from '../services/extraction-orchestration';
|
|
735
|
+
import { generateJobId, extractFileName } from '../utils/job-id-generator'; // We'll create this
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* WORKFLOW 1: Scheduled Extraction
|
|
739
|
+
*
|
|
740
|
+
* Purpose: Automated hourly extraction for incremental sync
|
|
741
|
+
* Trigger: Cron schedule (every hour at minute 0)
|
|
742
|
+
* State Update: Always updates lastSync timestamp
|
|
743
|
+
*
|
|
744
|
+
* AI CUSTOMIZATION:
|
|
745
|
+
* - Change schedule: Replace '0 * * * *' with your cron expression
|
|
746
|
+
* Examples:
|
|
747
|
+
* - Every 30 min: '*/30 * * * *'
|
|
748
|
+
* - Daily at 2 AM: '0 2 * * *'
|
|
749
|
+
* - Every 15 min: '*/15 * * * *'
|
|
750
|
+
*/
|
|
751
|
+
export const scheduledInventoryPositionsExtraction = schedule('scheduled-inventory-positions-extraction', '0 * * * *') // ? CUSTOMIZE: Cron expression
|
|
752
|
+
.then(
|
|
753
|
+
http('execute-scheduled-extraction', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
754
|
+
const { log } = ctx;
|
|
755
|
+
|
|
756
|
+
// Generate unique job ID for tracking
|
|
757
|
+
// Format: SCHEDULED_IP_YYYYMMDD_HHMMSS_random
|
|
758
|
+
const jobId = generateJobId('SCHEDULED', 'INVENTORY_POSITIONS');
|
|
759
|
+
|
|
760
|
+
log.info('Scheduled extraction triggered', { jobId });
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
// Execute main workflow (extraction ? transform ? upload)
|
|
764
|
+
const result = await executeInventoryPositionExtraction(ctx, {
|
|
765
|
+
jobId,
|
|
766
|
+
triggeredBy: 'schedule',
|
|
767
|
+
updateState: true, // Always update state for scheduled runs
|
|
768
|
+
// positionTypes: ['DEFAULT', 'SEASONAL'],
|
|
769
|
+
// locationRefs: ['LOC-001'],
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
log.info('Scheduled extraction completed', {
|
|
773
|
+
jobId,
|
|
774
|
+
recordCount: result.recordsExtracted,
|
|
775
|
+
fileName: result.fileName
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
return result;
|
|
779
|
+
|
|
780
|
+
} catch (error: any) {
|
|
781
|
+
log.error('Scheduled extraction failed', {
|
|
782
|
+
jobId,
|
|
783
|
+
message: error instanceof Error ? error.message : String(error),
|
|
784
|
+
|
|
785
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
786
|
+
|
|
787
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
788
|
+
});
|
|
789
|
+
throw error;
|
|
790
|
+
}
|
|
791
|
+
})
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* WORKFLOW 2: Ad hoc Extraction (Manual Trigger)
|
|
796
|
+
*
|
|
797
|
+
* Purpose: Manual extraction with optional date range override
|
|
798
|
+
* Trigger: Webhook POST to /webhooks/inventory-positions-adhoc
|
|
799
|
+
* State Update: Optional (controlled by request payload)
|
|
800
|
+
*
|
|
801
|
+
* WEBHOOK PAYLOAD EXAMPLES:
|
|
802
|
+
*
|
|
803
|
+
* 1. Incremental (use last sync timestamp):
|
|
804
|
+
* {}
|
|
805
|
+
*
|
|
806
|
+
* 2. Date range (manual override):
|
|
807
|
+
* {
|
|
808
|
+
* "fromDate": "2025-01-01T00:00:00Z",
|
|
809
|
+
* "toDate": "2025-01-31T23:59:59Z",
|
|
810
|
+
* "updateState": false
|
|
811
|
+
* }
|
|
812
|
+
*
|
|
813
|
+
* 3. Specific time window:
|
|
814
|
+
* {
|
|
815
|
+
* "fromDate": "2025-01-15T08:00:00Z",
|
|
816
|
+
* "toDate": "2025-01-15T17:00:00Z",
|
|
817
|
+
* "updateState": false
|
|
818
|
+
* }
|
|
819
|
+
*
|
|
820
|
+
* AI CUSTOMIZATION:
|
|
821
|
+
* - Add request validation
|
|
822
|
+
* - Add authentication check
|
|
823
|
+
* - Add custom filters from payload
|
|
824
|
+
*/
|
|
825
|
+
export const adhocInventoryPositionsExtraction = webhook(
|
|
826
|
+
'inventory-positions-adhoc',
|
|
827
|
+
{ connection: 'inventory-positions-adhoc', response: { mode: 'sync' } }
|
|
828
|
+
)
|
|
829
|
+
.then(
|
|
830
|
+
http('execute-adhoc-extraction', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
831
|
+
const { data, log } = ctx;
|
|
832
|
+
|
|
833
|
+
// Generate unique job ID
|
|
834
|
+
const jobId = generateJobId('ADHOC', 'INVENTORY_POSITIONS');
|
|
835
|
+
|
|
836
|
+
// Security handled by Versori connection
|
|
837
|
+
|
|
838
|
+
// Extract optional date override from webhook payload
|
|
839
|
+
const fromDate = data.fromDate as string | undefined;
|
|
840
|
+
const toDate = data.toDate as string | undefined;
|
|
841
|
+
const updateState = data.updateState === true; // Default false; advance state only if explicitly true
|
|
842
|
+
|
|
843
|
+
log.info('Ad hoc extraction triggered via webhook', {
|
|
844
|
+
jobId,
|
|
845
|
+
hasDateOverride: !!fromDate,
|
|
846
|
+
fromDate: fromDate || 'not specified',
|
|
847
|
+
toDate: toDate || 'not specified',
|
|
848
|
+
updateState
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
try {
|
|
852
|
+
// Execute main workflow with optional overrides
|
|
853
|
+
const result = await executeInventoryPositionExtraction(ctx, {
|
|
854
|
+
jobId,
|
|
855
|
+
triggeredBy: 'webhook',
|
|
856
|
+
fromDate, // Optional: override start date
|
|
857
|
+
toDate, // Optional: override end date
|
|
858
|
+
updateState, // Optional: skip state update for historical queries
|
|
859
|
+
// positionTypes: data.positionTypes,
|
|
860
|
+
// locationRefs: data.locationRefs,
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
log.info('Ad hoc extraction completed', {
|
|
864
|
+
jobId,
|
|
865
|
+
recordCount: result.recordsExtracted,
|
|
866
|
+
fileName: result.fileName,
|
|
867
|
+
isManualOverride: !!fromDate,
|
|
868
|
+
stateUpdated: result.stateUpdated
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// Return success with job details
|
|
872
|
+
return {
|
|
873
|
+
success: true,
|
|
874
|
+
jobId,
|
|
875
|
+
recordsExtracted: result.recordsExtracted,
|
|
876
|
+
fileName: result.fileName,
|
|
877
|
+
sftpPath: result.sftpPath,
|
|
878
|
+
statusUrl: `/webhooks/inventory-positions-job-status?jobId=${jobId}`,
|
|
879
|
+
dateRange: fromDate ? {
|
|
880
|
+
from: fromDate,
|
|
881
|
+
to: toDate || 'not specified',
|
|
882
|
+
updateState
|
|
883
|
+
} : undefined
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
} catch (error: any) {
|
|
887
|
+
log.error('Ad hoc extraction failed', {
|
|
888
|
+
jobId,
|
|
889
|
+
message: error instanceof Error ? error.message : String(error),
|
|
890
|
+
|
|
891
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
892
|
+
|
|
893
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
success: false,
|
|
898
|
+
jobId,
|
|
899
|
+
message: error instanceof Error ? error.message : String(error),
|
|
900
|
+
|
|
901
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
902
|
+
|
|
903
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
})
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* WORKFLOW 3: Job Status Query
|
|
911
|
+
*
|
|
912
|
+
* Purpose: Check job progress and status
|
|
913
|
+
* Trigger: Webhook GET/POST to /webhooks/inventory-positions-job-status?jobId=xxx
|
|
914
|
+
* Returns: Current job status, stage, progress
|
|
915
|
+
*
|
|
916
|
+
* QUERY EXAMPLES:
|
|
917
|
+
*
|
|
918
|
+
* 1. HTTP GET:
|
|
919
|
+
* GET /webhooks/inventory-positions-job-status?jobId=ADHOC_IP_20251027_183045_abc123
|
|
920
|
+
*
|
|
921
|
+
* 2. HTTP POST:
|
|
922
|
+
* POST /webhooks/inventory-positions-job-status
|
|
923
|
+
* { "jobId": "ADHOC_IP_20251027_183045_abc123" }
|
|
924
|
+
*
|
|
925
|
+
* RESPONSE EXAMPLE:
|
|
926
|
+
* {
|
|
927
|
+
* "success": true,
|
|
928
|
+
* "jobId": "ADHOC_IP_20251027_183045_abc123",
|
|
929
|
+
* "status": "processing",
|
|
930
|
+
* "stage": "transforming",
|
|
931
|
+
* "message": "Transforming 15000 records",
|
|
932
|
+
* "createdAt": "2025-10-27T18:30:45.000Z",
|
|
933
|
+
* "startedAt": "2025-10-27T18:30:46.000Z"
|
|
934
|
+
* }
|
|
935
|
+
*
|
|
936
|
+
* AI CUSTOMIZATION:
|
|
937
|
+
* - Add detailed progress percentage
|
|
938
|
+
* - Add estimated time remaining
|
|
939
|
+
* - Add historical job queries
|
|
940
|
+
*/
|
|
941
|
+
export const inventoryPositionsJobStatus = webhook(
|
|
942
|
+
'inventory-positions-job-status',
|
|
943
|
+
{ connection: 'inventory-positions-job-status', response: { mode: 'sync' } }
|
|
944
|
+
)
|
|
945
|
+
.then(
|
|
946
|
+
fn('query-job-status', async (ctx) => {
|
|
947
|
+
const { data, log, openKv } = ctx;
|
|
948
|
+
|
|
949
|
+
// Get jobId from query param or POST body
|
|
950
|
+
const jobId = data.jobId as string;
|
|
951
|
+
|
|
952
|
+
if (!jobId) {
|
|
953
|
+
log.error('Job ID not provided in request');
|
|
954
|
+
return {
|
|
955
|
+
success: false,
|
|
956
|
+
error: 'Job ID is required. Provide jobId in query param or request body.'
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
log.info('Querying job status', { jobId });
|
|
961
|
+
|
|
962
|
+
try {
|
|
963
|
+
// Query job status from KV store
|
|
964
|
+
const status = await getJobStatus(openKv(':project:'), jobId, log);
|
|
965
|
+
|
|
966
|
+
if (!status) {
|
|
967
|
+
log.info('Job not found', { jobId });
|
|
968
|
+
return {
|
|
969
|
+
success: false,
|
|
970
|
+
error: 'Job not found',
|
|
971
|
+
jobId
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
log.info('Job status retrieved', { jobId, status: status.status });
|
|
976
|
+
|
|
977
|
+
return {
|
|
978
|
+
success: true,
|
|
979
|
+
jobId,
|
|
980
|
+
...status
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
} catch (error: any) {
|
|
984
|
+
log.error('Failed to query job status', {
|
|
985
|
+
jobId,
|
|
986
|
+
message: error instanceof Error ? error.message : String(error),
|
|
987
|
+
|
|
988
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
989
|
+
|
|
990
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
success: false,
|
|
995
|
+
jobId,
|
|
996
|
+
message: error instanceof Error ? error.message : String(error),
|
|
997
|
+
|
|
998
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
999
|
+
|
|
1000
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
})
|
|
1004
|
+
);
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
---
|
|
1008
|
+
|
|
1009
|
+
# Production Template: Service Implementation
|
|
1010
|
+
|
|
1011
|
+
## 3. Main Orchestration Service (src/services/extraction-orchestration.ts)
|
|
1012
|
+
|
|
1013
|
+
```typescript
|
|
1014
|
+
/**
|
|
1015
|
+
* MAIN ORCHESTRATION SERVICE
|
|
1016
|
+
*
|
|
1017
|
+
* This is the heart of the extraction workflow. It coordinates all steps:
|
|
1018
|
+
* 1. Initialize clients and services
|
|
1019
|
+
* 2. Determine date range (incremental vs manual)
|
|
1020
|
+
* 3. Extract data using ExtractionOrchestrator
|
|
1021
|
+
* 4. Transform using UniversalMapper
|
|
1022
|
+
* 5. Generate XML using XMLBuilder
|
|
1023
|
+
* 6. Upload to SFTP
|
|
1024
|
+
* 7. Track job progress with JobTracker
|
|
1025
|
+
* 8. Update state for next run
|
|
1026
|
+
*
|
|
1027
|
+
* NAMING PATTERN (consistent across all use cases):
|
|
1028
|
+
* - Interface: {Entity}ExtractionParams (e.g., InventoryPositionExtractionParams)
|
|
1029
|
+
* - Result: {Entity}ExtractionResult (e.g., InventoryPositionExtractionResult)
|
|
1030
|
+
* - Main function: execute{Entity}Extraction (e.g., executeInventoryPositionExtraction)
|
|
1031
|
+
*
|
|
1032
|
+
* AI CUSTOMIZATION HINTS:
|
|
1033
|
+
* - Change entity: Replace "InventoryPosition" with "VirtualPosition", "Order", etc.
|
|
1034
|
+
* - Change output: Replace XMLBuilder with CSVParserService.stringify()
|
|
1035
|
+
* - Change destination: Replace SftpDataSource with S3DataSource
|
|
1036
|
+
* - Add steps: Insert new service calls between existing steps
|
|
1037
|
+
*/
|
|
1038
|
+
|
|
1039
|
+
import { Buffer } from 'node:buffer';
|
|
1040
|
+
import {
|
|
1041
|
+
createClient,
|
|
1042
|
+
ExtractionOrchestrator,
|
|
1043
|
+
JobTracker,
|
|
1044
|
+
UniversalMapper,
|
|
1045
|
+
XMLBuilder,
|
|
1046
|
+
SftpDataSource,
|
|
1047
|
+
type ExtractionOptions,
|
|
1048
|
+
type ExtractionResult,
|
|
1049
|
+
type JobStatus,
|
|
1050
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1051
|
+
|
|
1052
|
+
import mappingConfig from '../../config/inventory-positions.export.xml.json' with { type: 'json' };
|
|
1053
|
+
import { extractFileName } from '../../utils/job-id-generator';
|
|
1054
|
+
|
|
1055
|
+
// ? VERSORI PLATFORM: Use native log from context, not LoggingService
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Parameters for extraction workflow
|
|
1059
|
+
*
|
|
1060
|
+
* NAMING: {Entity}ExtractionParams
|
|
1061
|
+
*/
|
|
1062
|
+
export interface InventoryPositionExtractionParams {
|
|
1063
|
+
jobId: string;
|
|
1064
|
+
triggeredBy: 'schedule' | 'webhook';
|
|
1065
|
+
fromDate?: string; // Optional: manual date override
|
|
1066
|
+
toDate?: string; // Optional: manual date override
|
|
1067
|
+
updateState: boolean; // Whether to update lastSync timestamp
|
|
1068
|
+
|
|
1069
|
+
// AI CUSTOMIZATION: Add filters specific to entity
|
|
1070
|
+
positionTypes?: string[]; // e.g., ['DEFAULT', 'SEASONAL']
|
|
1071
|
+
locationRefs?: string[]; // e.g., ['LOC-001']
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Result from extraction workflow
|
|
1076
|
+
*
|
|
1077
|
+
* NAMING: {Entity}ExtractionResult
|
|
1078
|
+
*/
|
|
1079
|
+
export interface InventoryPositionExtractionResult {
|
|
1080
|
+
success: boolean;
|
|
1081
|
+
jobId: string;
|
|
1082
|
+
recordsExtracted: number;
|
|
1083
|
+
fileName?: string;
|
|
1084
|
+
sftpPath?: string;
|
|
1085
|
+
error?: string;
|
|
1086
|
+
errors?: any[];
|
|
1087
|
+
isManualOverride?: boolean;
|
|
1088
|
+
stateUpdated?: boolean;
|
|
1089
|
+
newTimestamp?: string;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* GraphQL Query for Inventory Positions
|
|
1094
|
+
*
|
|
1095
|
+
* NAMING: {ENTITY}_EXTRACTION_QUERY (uppercase constant)
|
|
1096
|
+
*
|
|
1097
|
+
* Replace entity/fields as needed but keep pagination structure (edges, node, pageInfo)
|
|
1098
|
+
*/
|
|
1099
|
+
const INVENTORY_POSITIONS_EXTRACTION_QUERY = `
|
|
1100
|
+
query GetInventoryPositions(
|
|
1101
|
+
$dateRangeFilter: DateRange
|
|
1102
|
+
$productRefs: [String!]
|
|
1103
|
+
$types: [String!]
|
|
1104
|
+
$locationRefs: [String]
|
|
1105
|
+
$first: Int!
|
|
1106
|
+
$after: String
|
|
1107
|
+
) {
|
|
1108
|
+
inventoryPositions(
|
|
1109
|
+
updatedOn: $dateRangeFilter
|
|
1110
|
+
productRef: $productRefs
|
|
1111
|
+
type: $types
|
|
1112
|
+
locationRef: $locationRefs
|
|
1113
|
+
first: $first
|
|
1114
|
+
after: $after
|
|
1115
|
+
) {
|
|
1116
|
+
edges {
|
|
1117
|
+
node {
|
|
1118
|
+
id
|
|
1119
|
+
ref
|
|
1120
|
+
productRef
|
|
1121
|
+
locationRef
|
|
1122
|
+
onHand
|
|
1123
|
+
type
|
|
1124
|
+
status
|
|
1125
|
+
catalogue {
|
|
1126
|
+
ref
|
|
1127
|
+
name
|
|
1128
|
+
}
|
|
1129
|
+
createdOn
|
|
1130
|
+
updatedOn
|
|
1131
|
+
}
|
|
1132
|
+
cursor
|
|
1133
|
+
}
|
|
1134
|
+
pageInfo {
|
|
1135
|
+
hasNextPage
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
`;
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Query job status from KV store
|
|
1143
|
+
*
|
|
1144
|
+
* NAMING: get{Entity}JobStatus or just getJobStatus (generic)
|
|
1145
|
+
*
|
|
1146
|
+
* ? VERSORI PLATFORM: Uses native log from context (passed as parameter)
|
|
1147
|
+
*/
|
|
1148
|
+
export async function getJobStatus(
|
|
1149
|
+
kv: any, // ? Versori KV (compatible with JobTracker's KVAdapter interface)
|
|
1150
|
+
jobId: string,
|
|
1151
|
+
log: any // ? Native Versori log from context
|
|
1152
|
+
): Promise<JobStatus | undefined> {
|
|
1153
|
+
try {
|
|
1154
|
+
const tracker = new JobTracker(kv, log);
|
|
1155
|
+
return await tracker.getJob(jobId); // ? Use getJob() not getJobStatus()
|
|
1156
|
+
} catch (error: any) {
|
|
1157
|
+
log.error('Failed to get job status', { jobId, message: error instanceof Error ? error.message : String(error),
|
|
1158
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1159
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error', });
|
|
1160
|
+
return undefined;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* MAIN ORCHESTRATION FUNCTION
|
|
1166
|
+
*
|
|
1167
|
+
* NAMING: execute{Entity}Extraction (e.g., executeInventoryPositionExtraction)
|
|
1168
|
+
*
|
|
1169
|
+
* This function implements the complete workflow in steps.
|
|
1170
|
+
* Each step is clearly commented for AI understanding.
|
|
1171
|
+
*/
|
|
1172
|
+
export async function executeInventoryPositionExtraction(
|
|
1173
|
+
ctx: any,
|
|
1174
|
+
params: InventoryPositionExtractionParams
|
|
1175
|
+
): Promise<InventoryPositionExtractionResult> {
|
|
1176
|
+
// ? VERSORI PLATFORM: Extract native log from context
|
|
1177
|
+
const { log, openKv, activation, credentials } = ctx;
|
|
1178
|
+
const { jobId, triggeredBy, fromDate, toDate, updateState } = params;
|
|
1179
|
+
|
|
1180
|
+
// ⏱️ Track total execution time
|
|
1181
|
+
const startTime = Date.now();
|
|
1182
|
+
|
|
1183
|
+
// Open KV store for state management and job tracking
|
|
1184
|
+
// ? Pass raw Versori KV directly - it matches KVAdapter interface
|
|
1185
|
+
// ? Pass native log to JobTracker
|
|
1186
|
+
const kv = openKv(':project:');
|
|
1187
|
+
const tracker = new JobTracker(kv, log);
|
|
1188
|
+
|
|
1189
|
+
try {
|
|
1190
|
+
// �����������������������������������������������������������
|
|
1191
|
+
// STEP 1/8: Initialize Job Tracking
|
|
1192
|
+
// �����������������������������������������������������������
|
|
1193
|
+
log.info('📊 [STEP 1/8] Initializing job tracking', { jobId });
|
|
1194
|
+
|
|
1195
|
+
await tracker.createJob(jobId, {
|
|
1196
|
+
triggeredBy,
|
|
1197
|
+
hasDateOverride: !!fromDate,
|
|
1198
|
+
fromDate,
|
|
1199
|
+
toDate,
|
|
1200
|
+
updateStateAfterRun: updateState,
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// �����������������������������������������������������������
|
|
1204
|
+
// STEP 2/8: Initialize Fluent Client
|
|
1205
|
+
// �����������������������������������������������������������
|
|
1206
|
+
log.info('🔌 [STEP 2/8] Initializing Fluent Commerce client', { jobId });
|
|
1207
|
+
|
|
1208
|
+
// ✅ Optional: Validate connection immediately (fail-fast mode)
|
|
1209
|
+
// Set activation variable 'validateConnectionOnStart' = 'true' to enable
|
|
1210
|
+
// When enabled: Executes query { me { ref } } to verify authentication
|
|
1211
|
+
// When disabled: Fast creation, validation happens on first API call (default)
|
|
1212
|
+
const validateConnection = activation.getVariable('validateConnectionOnStart') === 'true';
|
|
1213
|
+
const client = await createClient(ctx, validateConnection ? { validateConnection: true } : undefined);
|
|
1214
|
+
|
|
1215
|
+
if (!client) {
|
|
1216
|
+
throw new Error('Failed to create Fluent Commerce client');
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (validateConnection) {
|
|
1220
|
+
log.info('✅ Connection validated successfully - authentication confirmed', { jobId });
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// �����������������������������������������������������������
|
|
1224
|
+
// STEP 3/8: Determine Date Range (WITH overlap buffer)
|
|
1225
|
+
// �����������������������������������������������������������
|
|
1226
|
+
log.info('📅 [STEP 3/8] Determining date range for extraction', { jobId });
|
|
1227
|
+
|
|
1228
|
+
// State key for incremental sync tracking
|
|
1229
|
+
// NAMING: last{Entity}Sync (e.g., lastInventoryPositionSync)
|
|
1230
|
+
const STATE_KEY = 'lastInventoryPositionSync';
|
|
1231
|
+
const DEFAULT_FALLBACK = '2024-01-01T00:00:00Z';
|
|
1232
|
+
const OVERLAP_BUFFER_SECONDS = parseInt(
|
|
1233
|
+
activation.getVariable('overlapBufferSeconds') || '60',
|
|
1234
|
+
10
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
let dateRangeFilter: { from?: string; to?: string } | null = null;
|
|
1238
|
+
const isManualOverride = !!fromDate;
|
|
1239
|
+
|
|
1240
|
+
if (isManualOverride) {
|
|
1241
|
+
// Manual date override from webhook
|
|
1242
|
+
dateRangeFilter = { from: fromDate, to: toDate };
|
|
1243
|
+
log.info('Using manual date override', { fromDate, toDate });
|
|
1244
|
+
} else {
|
|
1245
|
+
// Incremental sync - get last sync timestamp
|
|
1246
|
+
const rawLastRunTime = (await kv.get<string>(STATE_KEY)) || DEFAULT_FALLBACK;
|
|
1247
|
+
|
|
1248
|
+
// Apply overlap buffer (prevents missed records)
|
|
1249
|
+
const bufferedLastRunTime = new Date(
|
|
1250
|
+
new Date(rawLastRunTime).getTime() - OVERLAP_BUFFER_SECONDS * 1000
|
|
1251
|
+
).toISOString();
|
|
1252
|
+
|
|
1253
|
+
const effectiveEndTime = toDate || new Date().toISOString();
|
|
1254
|
+
|
|
1255
|
+
dateRangeFilter = {
|
|
1256
|
+
from: bufferedLastRunTime,
|
|
1257
|
+
to: effectiveEndTime, // End of extraction window
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
log.info('Using incremental sync with overlap buffer', {
|
|
1261
|
+
rawLastRunTime,
|
|
1262
|
+
bufferedLastRunTime,
|
|
1263
|
+
effectiveEndTime,
|
|
1264
|
+
overlapBufferSeconds: OVERLAP_BUFFER_SECONDS,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// �����������������������������������������������������������
|
|
1269
|
+
// STEP 4/8: Extract Data (ExtractionOrchestrator)
|
|
1270
|
+
// �����������������������������������������������������������
|
|
1271
|
+
log.info('📥 [STEP 4/8] Extracting data from Fluent Commerce', { jobId });
|
|
1272
|
+
|
|
1273
|
+
await tracker.updateJob(jobId, {
|
|
1274
|
+
status: 'processing',
|
|
1275
|
+
stage: 'extraction',
|
|
1276
|
+
message: 'Extracting data with auto-pagination',
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
// Configure extraction
|
|
1280
|
+
const pageSize = parseInt(activation.getVariable('pageSize') || '200', 10);
|
|
1281
|
+
const maxRecords = parseInt(activation.getVariable('maxRecords') || '100000', 10);
|
|
1282
|
+
// Memory management: maxRecords prevents unbounded memory; for very large outputs, split files or use S3.
|
|
1283
|
+
// Performance note: pageSize=200 is a balanced default; split output if records > ~50k for optimal performance.
|
|
1284
|
+
|
|
1285
|
+
// ? Enhanced: Extract context for progress logging
|
|
1286
|
+
const dateRangeInfo = {
|
|
1287
|
+
start: dateRangeFilter?.from || 'N/A',
|
|
1288
|
+
end: dateRangeFilter?.to || 'N/A',
|
|
1289
|
+
types: params.positionTypes?.join(', ') || 'all',
|
|
1290
|
+
locationRefs: params.locationRefs?.slice(0, 3).join(', ') || 'all'
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
// ? Enhanced: Start logging with context
|
|
1294
|
+
log.info(`🔍 [ExtractionOrchestrator] Starting extraction`, {
|
|
1295
|
+
query: 'inventoryPositions',
|
|
1296
|
+
pageSize,
|
|
1297
|
+
maxRecords,
|
|
1298
|
+
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
1299
|
+
positionTypes: dateRangeInfo.types,
|
|
1300
|
+
sampleLocations: dateRangeInfo.locationRefs,
|
|
1301
|
+
jobId
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
// Initialize ExtractionOrchestrator
|
|
1305
|
+
const orchestrator = new ExtractionOrchestrator(client, log);
|
|
1306
|
+
|
|
1307
|
+
// Execute extraction with auto-pagination
|
|
1308
|
+
const extractionResult: ExtractionResult<any> = await orchestrator.extract({
|
|
1309
|
+
query: INVENTORY_POSITIONS_EXTRACTION_QUERY,
|
|
1310
|
+
resultPath: 'inventoryPositions.edges.node',
|
|
1311
|
+
variables: {
|
|
1312
|
+
dateRangeFilter,
|
|
1313
|
+
types: params.positionTypes,
|
|
1314
|
+
locationRefs: params.locationRefs,
|
|
1315
|
+
// Note: Don't include 'first' or 'after' here; orchestrator injects them based on pageSize below
|
|
1316
|
+
},
|
|
1317
|
+
pageSize,
|
|
1318
|
+
maxRecords,
|
|
1319
|
+
// Optional: validate each record
|
|
1320
|
+
validateItem: (item: any) => {
|
|
1321
|
+
return !!(item.ref && item.productRef && typeof item.onHand === 'number');
|
|
1322
|
+
},
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
const records = extractionResult.data || [];
|
|
1326
|
+
|
|
1327
|
+
log.info('Extraction complete', {
|
|
1328
|
+
totalRecords: extractionResult.stats.totalRecords,
|
|
1329
|
+
totalPages: extractionResult.stats.totalPages,
|
|
1330
|
+
validRecords: extractionResult.stats.validRecords ?? records.length,
|
|
1331
|
+
failedValidations: extractionResult.stats.failedValidations,
|
|
1332
|
+
truncated: extractionResult.stats.truncated,
|
|
1333
|
+
truncationReason: extractionResult.stats.truncationReason,
|
|
1334
|
+
errors: extractionResult.errors ? extractionResult.errors.length : 0,
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// ? Enhanced: Completion logging with summary
|
|
1338
|
+
log.info(`✅ [ExtractionOrchestrator] Extraction completed`, {
|
|
1339
|
+
totalRecords: extractionResult.stats.totalRecords,
|
|
1340
|
+
totalPages: extractionResult.stats.totalPages,
|
|
1341
|
+
validRecords: extractionResult.stats.validRecords ?? records.length,
|
|
1342
|
+
failedValidations: extractionResult.stats.failedValidations,
|
|
1343
|
+
truncated: extractionResult.stats.truncated,
|
|
1344
|
+
truncationReason: extractionResult.stats.truncationReason,
|
|
1345
|
+
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
1346
|
+
jobId
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
if (extractionResult.errors && extractionResult.errors.length > 0) {
|
|
1350
|
+
log.warn('Non-fatal extraction errors encountered', {
|
|
1351
|
+
errorCount: extractionResult.errors.length,
|
|
1352
|
+
sampleErrors: extractionResult.errors.slice(0, 3),
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// Handle empty result
|
|
1357
|
+
if (records.length === 0) {
|
|
1358
|
+
log.info('No records to process');
|
|
1359
|
+
|
|
1360
|
+
// Update state even with no records (prevents re-querying empty window)
|
|
1361
|
+
if (updateState && !isManualOverride) {
|
|
1362
|
+
await kv.set(STATE_KEY, new Date().toISOString());
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
await tracker.markCompleted(jobId, {
|
|
1366
|
+
recordCount: 0,
|
|
1367
|
+
message: 'No records to extract',
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
return {
|
|
1371
|
+
success: true,
|
|
1372
|
+
jobId,
|
|
1373
|
+
recordsExtracted: 0,
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// �����������������������������������������������������������
|
|
1378
|
+
// STEP 5/8: Transform Data (UniversalMapper)
|
|
1379
|
+
// �����������������������������������������������������������
|
|
1380
|
+
log.info('🔄 [STEP 5/8] Transforming data with UniversalMapper', {
|
|
1381
|
+
jobId,
|
|
1382
|
+
recordCount: records.length,
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
await tracker.updateJob(jobId, {
|
|
1386
|
+
status: 'processing',
|
|
1387
|
+
stage: 'transformation',
|
|
1388
|
+
message: `Transforming ${records.length} records`,
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
const mapper = new UniversalMapper(mappingConfig);
|
|
1392
|
+
const mappingResult = await mapper.map(records);
|
|
1393
|
+
const mappingErrors = mappingResult.errors || [];
|
|
1394
|
+
|
|
1395
|
+
if (!mappingResult.success) {
|
|
1396
|
+
log.error('Mapping failed - terminating job', {
|
|
1397
|
+
jobId,
|
|
1398
|
+
errorCount: mappingErrors.length,
|
|
1399
|
+
sampleErrors: mappingErrors.slice(0, 3),
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
await tracker.markFailed(
|
|
1403
|
+
jobId,
|
|
1404
|
+
mappingErrors[0] || 'UniversalMapper returned unsuccessful result',
|
|
1405
|
+
{
|
|
1406
|
+
failedCount: mappingErrors.length,
|
|
1407
|
+
errors: mappingErrors,
|
|
1408
|
+
}
|
|
1409
|
+
);
|
|
1410
|
+
|
|
1411
|
+
return {
|
|
1412
|
+
success: false,
|
|
1413
|
+
jobId,
|
|
1414
|
+
error: `Transformation failed: ${mappingErrors[0] || 'Unknown error'}`,
|
|
1415
|
+
errors: mappingErrors,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
const transformedRecords = Array.isArray(mappingResult.data)
|
|
1420
|
+
? (mappingResult.data as any[])
|
|
1421
|
+
: [];
|
|
1422
|
+
|
|
1423
|
+
if (mappingErrors.length > 0) {
|
|
1424
|
+
log.warn('Some records failed transformation', {
|
|
1425
|
+
jobId,
|
|
1426
|
+
errorCount: mappingErrors.length,
|
|
1427
|
+
sampleErrors: mappingErrors.slice(0, 3),
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
1432
|
+
log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
1433
|
+
jobId,
|
|
1434
|
+
skippedFields: mappingResult.skippedFields,
|
|
1435
|
+
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
if (transformedRecords.length === 0) {
|
|
1440
|
+
await tracker.markFailed(jobId, 'All records failed mapping', {
|
|
1441
|
+
failedCount: mappingErrors.length,
|
|
1442
|
+
errors: mappingErrors,
|
|
1443
|
+
});
|
|
1444
|
+
return {
|
|
1445
|
+
success: false,
|
|
1446
|
+
jobId,
|
|
1447
|
+
error: 'All records failed mapping',
|
|
1448
|
+
errors: mappingErrors,
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
log.info('Transformation complete', {
|
|
1453
|
+
successful: transformedRecords.length,
|
|
1454
|
+
failed: mappingErrors.length,
|
|
1455
|
+
skippedRecords: records.length - transformedRecords.length,
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
// �����������������������������������������������������������
|
|
1459
|
+
// STEP 6/8: Generate XML (XMLBuilder)
|
|
1460
|
+
// �����������������������������������������������������������
|
|
1461
|
+
log.info('📝 [STEP 6/8] Generating XML file', { jobId });
|
|
1462
|
+
|
|
1463
|
+
await tracker.updateJob(jobId, {
|
|
1464
|
+
status: 'processing',
|
|
1465
|
+
stage: 'xml_generation',
|
|
1466
|
+
message: `Generating XML for ${transformedRecords.length} records`,
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
// Initialize XMLBuilder
|
|
1470
|
+
const xmlBuilder = new XMLBuilder({
|
|
1471
|
+
ignoreAttributes: false,
|
|
1472
|
+
format: true,
|
|
1473
|
+
indentBy: ' ',
|
|
1474
|
+
suppressEmptyNode: true,
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
// Build XML structure
|
|
1478
|
+
const xmlData = {
|
|
1479
|
+
'?xml': {
|
|
1480
|
+
'@_version': '1.0',
|
|
1481
|
+
'@_encoding': 'UTF-8',
|
|
1482
|
+
},
|
|
1483
|
+
InventoryPositions: {
|
|
1484
|
+
Position: transformedRecords,
|
|
1485
|
+
},
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
const xmlContent = xmlBuilder.build(xmlData);
|
|
1489
|
+
|
|
1490
|
+
// Generate timestamped filename using extractFileName helper
|
|
1491
|
+
const fileNamePrefix = activation.getVariable('fileNamePrefix') || 'inventorypositions';
|
|
1492
|
+
const fileName = extractFileName(fileNamePrefix, 'xml');
|
|
1493
|
+
|
|
1494
|
+
log.info('XML file generated', {
|
|
1495
|
+
fileName,
|
|
1496
|
+
sizeBytes: xmlContent.length,
|
|
1497
|
+
recordCount: transformedRecords.length,
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
// �����������������������������������������������������������
|
|
1501
|
+
// STEP 7/8: Upload to SFTP (SftpDataSource)
|
|
1502
|
+
// �����������������������������������������������������������
|
|
1503
|
+
log.info('📤 [STEP 7/8] Uploading to SFTP', { jobId, fileName });
|
|
1504
|
+
|
|
1505
|
+
await tracker.updateJob(jobId, {
|
|
1506
|
+
status: 'processing',
|
|
1507
|
+
stage: 'sftp_upload',
|
|
1508
|
+
message: `Uploading ${fileName} to SFTP`,
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
// Get SFTP credentials from Versori connection (Basic Auth)
|
|
1512
|
+
// RECOMMENDED: Use activation.connections (already decoded)
|
|
1513
|
+
const allConnections = ctx.activation.connections || [];
|
|
1514
|
+
const sftpConn = allConnections.find(c => c.name === 'versori_ftp_server');
|
|
1515
|
+
|
|
1516
|
+
if (!sftpConn) {
|
|
1517
|
+
throw new Error('SFTP connection "versori_ftp_server" not found');
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const credential = sftpConn.credentials[0]?.credential;
|
|
1521
|
+
if (!credential?.data?.basicAuth) {
|
|
1522
|
+
throw new Error('SFTP connection not configured with Basic Authentication');
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const { username, password } = credential.data.basicAuth;
|
|
1526
|
+
// ? Already decoded - no Buffer.from() needed!
|
|
1527
|
+
|
|
1528
|
+
// Get SFTP configuration from activation variables
|
|
1529
|
+
const sftpConfig = {
|
|
1530
|
+
host: activation.getVariable('sftpHost'),
|
|
1531
|
+
port: parseInt(activation.getVariable('sftpPort') || '22', 10),
|
|
1532
|
+
username: sftpUsername, // From connection
|
|
1533
|
+
password: sftpPassword, // From connection
|
|
1534
|
+
privateKey: activation.getVariable('sftpPrivateKey'),
|
|
1535
|
+
remotePath: activation.getVariable('sftpPath') || '/incoming/',
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
// Validate SFTP config
|
|
1539
|
+
if (!sftpConfig.host || !username) {
|
|
1540
|
+
throw new Error('SFTP configuration incomplete: missing host or username (from connection)');
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (!sftpConfig.password && !sftpConfig.privateKey) {
|
|
1544
|
+
throw new Error('SFTP configuration incomplete: missing password or privateKey');
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// Initialize SFTP data source
|
|
1548
|
+
// ? VERSORI PLATFORM: Pass native log from context
|
|
1549
|
+
const sftp = new SftpDataSource(
|
|
1550
|
+
{
|
|
1551
|
+
type: 'SFTP_XML',
|
|
1552
|
+
connectionId: 'inventory-positions-sftp',
|
|
1553
|
+
name: 'Inventory Positions SFTP Upload',
|
|
1554
|
+
settings: {
|
|
1555
|
+
host: sftpConfig.host,
|
|
1556
|
+
port: sftpConfig.port,
|
|
1557
|
+
username: sftpConfig.username,
|
|
1558
|
+
password: sftpConfig.password,
|
|
1559
|
+
privateKey: sftpConfig.privateKey,
|
|
1560
|
+
remotePath: sftpConfig.remotePath,
|
|
1561
|
+
filePattern: '*.xml',
|
|
1562
|
+
},
|
|
1563
|
+
},
|
|
1564
|
+
log
|
|
1565
|
+
);
|
|
1566
|
+
|
|
1567
|
+
try {
|
|
1568
|
+
// Ensure remote directory exists (recursive)
|
|
1569
|
+
await sftp.createDirectory(sftpConfig.remotePath || '/incoming/', true);
|
|
1570
|
+
|
|
1571
|
+
// Get SFTP path configuration
|
|
1572
|
+
const requireAbsolutePaths = activation.getVariable('requireAbsolutePaths') === 'true';
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* Helper: Join SFTP path segments safely
|
|
1576
|
+
*
|
|
1577
|
+
* Handles different SFTP server path requirements:
|
|
1578
|
+
* - AWS Transfer Family: Requires absolute paths (leading /)
|
|
1579
|
+
* - Standard OpenSSH: Supports relative paths
|
|
1580
|
+
*
|
|
1581
|
+
* @param requireAbsolutePath - true for AWS Transfer Family, false for standard OpenSSH
|
|
1582
|
+
* @param parts - Path segments to join
|
|
1583
|
+
* @returns Properly formatted SFTP path
|
|
1584
|
+
*/
|
|
1585
|
+
function joinSftpPath(requireAbsolutePath: boolean, ...parts: string[]): string {
|
|
1586
|
+
// Clean each segment (remove leading/trailing slashes)
|
|
1587
|
+
const cleaned = parts
|
|
1588
|
+
.filter(Boolean)
|
|
1589
|
+
.map(p => String(p).replace(/^\/+|\/+$/g, ''))
|
|
1590
|
+
.join('/');
|
|
1591
|
+
|
|
1592
|
+
// Add leading slash if required (AWS Transfer Family)
|
|
1593
|
+
return requireAbsolutePath && !cleaned.startsWith('/') ? `/${cleaned}` : cleaned;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// Construct full SFTP path safely
|
|
1597
|
+
const fullPath = joinSftpPath(
|
|
1598
|
+
requireAbsolutePaths,
|
|
1599
|
+
sftpConfig.remotePath || '/incoming/',
|
|
1600
|
+
fileName
|
|
1601
|
+
);
|
|
1602
|
+
|
|
1603
|
+
// Upload with retry logic (built into SftpDataSource)
|
|
1604
|
+
// ? Use createDirectories: true to ensure remote path exists
|
|
1605
|
+
// Upload failure: If SFTP upload fails, job is failed and state is not advanced; next run retries same window.
|
|
1606
|
+
await sftp.uploadFile(fullPath, Buffer.from(xmlContent, 'utf8'), {
|
|
1607
|
+
createDirectories: true,
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
log.info('SFTP upload successful', { fileName, remotePath: fullPath });
|
|
1611
|
+
|
|
1612
|
+
// Optional: upload a transformation error report alongside the XML
|
|
1613
|
+
if (mappingErrors.length > 0) {
|
|
1614
|
+
const baseName = fileName.replace(/\.xml$/i, '');
|
|
1615
|
+
const report = {
|
|
1616
|
+
fileName,
|
|
1617
|
+
processedAt: new Date().toISOString(),
|
|
1618
|
+
errorStage: 'transform',
|
|
1619
|
+
totals: {
|
|
1620
|
+
totalRecords: records.length,
|
|
1621
|
+
recordsTransformed: transformedRecords.length,
|
|
1622
|
+
transformErrors: mappingErrors.length,
|
|
1623
|
+
},
|
|
1624
|
+
errors: mappingErrors,
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
const reportPath = joinSftpPath(
|
|
1628
|
+
requireAbsolutePaths,
|
|
1629
|
+
sftpConfig.remotePath || '/incoming/',
|
|
1630
|
+
`${baseName}-errors.json`
|
|
1631
|
+
);
|
|
1632
|
+
await sftp.uploadFile(reportPath, Buffer.from(JSON.stringify(report, null, 2), 'utf8'), {
|
|
1633
|
+
createDirectories: true,
|
|
1634
|
+
overwrite: true,
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
log.info('Uploaded transformation error report', { reportPath });
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Optional: For very large outputs, split transformedRecords into chunks
|
|
1641
|
+
// and upload multiple XML files to avoid size/memory limits.
|
|
1642
|
+
|
|
1643
|
+
// �����������������������������������������������������������
|
|
1644
|
+
// STEP 8/8: Update State & Complete Job (WITHOUT buffer)
|
|
1645
|
+
// �����������������������������������������������������������
|
|
1646
|
+
log.info('✅ [STEP 8/8] Updating state and completing job', { jobId });
|
|
1647
|
+
|
|
1648
|
+
// Calculate new timestamp for next incremental run
|
|
1649
|
+
let newTimestamp: string | undefined;
|
|
1650
|
+
|
|
1651
|
+
if (updateState && !isManualOverride) {
|
|
1652
|
+
// Find max updatedOn from extracted records
|
|
1653
|
+
const maxUpdatedOn = records.reduce(
|
|
1654
|
+
(max, record) => {
|
|
1655
|
+
const recordTime = new Date(record.updatedOn).getTime();
|
|
1656
|
+
return recordTime > max ? recordTime : max;
|
|
1657
|
+
},
|
|
1658
|
+
new Date(dateRangeFilter?.from || DEFAULT_FALLBACK).getTime()
|
|
1659
|
+
);
|
|
1660
|
+
|
|
1661
|
+
newTimestamp = new Date(maxUpdatedOn).toISOString();
|
|
1662
|
+
|
|
1663
|
+
// Store new timestamp (WITHOUT buffer - buffer only applied on read)
|
|
1664
|
+
await kv.set(STATE_KEY, newTimestamp);
|
|
1665
|
+
|
|
1666
|
+
log.info('State updated', {
|
|
1667
|
+
oldTimestamp: dateRangeFilter?.from,
|
|
1668
|
+
newTimestamp,
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// Mark job as completed
|
|
1673
|
+
await tracker.markCompleted(jobId, {
|
|
1674
|
+
recordCount: transformedRecords.length,
|
|
1675
|
+
fileName,
|
|
1676
|
+
remotePath: fullPath,
|
|
1677
|
+
errorCount: mappingErrors.length,
|
|
1678
|
+
errors: mappingErrors,
|
|
1679
|
+
isManualOverride,
|
|
1680
|
+
stateUpdated: updateState && !isManualOverride,
|
|
1681
|
+
newTimestamp,
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
// ⏱️ Calculate total execution time
|
|
1685
|
+
const totalDuration = Date.now() - startTime;
|
|
1686
|
+
|
|
1687
|
+
log.info('✅ Extraction workflow completed successfully', {
|
|
1688
|
+
jobId,
|
|
1689
|
+
totalDuration,
|
|
1690
|
+
recordsExtracted: transformedRecords.length,
|
|
1691
|
+
fileName,
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
return {
|
|
1695
|
+
success: true,
|
|
1696
|
+
jobId,
|
|
1697
|
+
recordsExtracted: transformedRecords.length,
|
|
1698
|
+
fileName,
|
|
1699
|
+
sftpPath: fullPath,
|
|
1700
|
+
isManualOverride,
|
|
1701
|
+
stateUpdated: updateState && !isManualOverride,
|
|
1702
|
+
newTimestamp,
|
|
1703
|
+
duration: totalDuration,
|
|
1704
|
+
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1705
|
+
};
|
|
1706
|
+
} finally {
|
|
1707
|
+
// ?��️ CRITICAL: Always dispose SFTP connection
|
|
1708
|
+
await sftp.dispose();
|
|
1709
|
+
log.info('SFTP connection disposed');
|
|
1710
|
+
}
|
|
1711
|
+
} catch (error: any) {
|
|
1712
|
+
const totalDuration = Date.now() - startTime;
|
|
1713
|
+
|
|
1714
|
+
log.error('❌ Extraction workflow failed', {
|
|
1715
|
+
jobId,
|
|
1716
|
+
duration: totalDuration,
|
|
1717
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1718
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1719
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1720
|
+
recommendation: getErrorRecommendation(error),
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
// Mark job as failed
|
|
1724
|
+
await tracker.markFailed(jobId, error);
|
|
1725
|
+
|
|
1726
|
+
return {
|
|
1727
|
+
success: false,
|
|
1728
|
+
jobId,
|
|
1729
|
+
recordsExtracted: 0,
|
|
1730
|
+
duration: totalDuration,
|
|
1731
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1732
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1733
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1734
|
+
recommendation: getErrorRecommendation(error),
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Get actionable recommendation based on error type
|
|
1741
|
+
*/
|
|
1742
|
+
function getErrorRecommendation(error: any): string {
|
|
1743
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1744
|
+
|
|
1745
|
+
if (message.includes('SFTP') || message.includes('connection')) {
|
|
1746
|
+
return 'Check SFTP credentials and network connectivity. Verify the SFTP connection is configured in Versori Connections with Basic Authentication.';
|
|
1747
|
+
}
|
|
1748
|
+
if (message.includes('GraphQL') || message.includes('query')) {
|
|
1749
|
+
return 'Verify GraphQL query syntax and schema compatibility. Check Fluent Commerce connection credentials.';
|
|
1750
|
+
}
|
|
1751
|
+
if (message.includes('mapping') || message.includes('transform')) {
|
|
1752
|
+
return 'Review mapping configuration. Run schema validation: npx fc-connect validate-schema --mapping config.json --schema schema.json';
|
|
1753
|
+
}
|
|
1754
|
+
if (message.includes('authentication') || message.includes('401')) {
|
|
1755
|
+
return 'Check Fluent Commerce OAuth2 credentials. Verify clientId and clientSecret in connection configuration.';
|
|
1756
|
+
}
|
|
1757
|
+
if (message.includes('timeout')) {
|
|
1758
|
+
return 'Reduce maxRecords or pageSize to process smaller batches. Check network latency and API response times.';
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
return 'Review error details above. Check activation variables and connection configuration. Consult documentation for troubleshooting steps.';
|
|
1762
|
+
}
|
|
1763
|
+
```
|
|
1764
|
+
|
|
1765
|
+
Note: Customize mapping by editing the JSON above; prefer built-in resolvers. See SDK Universal Mapping guide for advanced usage.
|
|
1766
|
+
|
|
1767
|
+
---
|
|
1768
|
+
|
|
1769
|
+
## 4. Utility Functions (src/utils/)
|
|
1770
|
+
|
|
1771
|
+
### Job ID Generator (src/utils/job-id-generator.ts)
|
|
1772
|
+
|
|
1773
|
+
```typescript
|
|
1774
|
+
/**
|
|
1775
|
+
* Job ID Generator
|
|
1776
|
+
*
|
|
1777
|
+
* Generates unique job IDs for tracking extraction workflows
|
|
1778
|
+
*
|
|
1779
|
+
* FORMAT: {TYPE}_{ENTITY}_{YYYYMMDD}_{HHMMSS}_{RANDOM}
|
|
1780
|
+
* Example: SCHEDULED_IP_20251027_183045_a1b2c3
|
|
1781
|
+
*
|
|
1782
|
+
* NAMING: generate{Entity}JobId or generateJobId (generic)
|
|
1783
|
+
*/
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Generate unique job ID
|
|
1787
|
+
*
|
|
1788
|
+
* @param type - Job trigger type (SCHEDULED, ADHOC, MANUAL)
|
|
1789
|
+
* @param entity - Entity abbreviation (IP, VP, ORD, PRD)
|
|
1790
|
+
* @returns Unique job ID string
|
|
1791
|
+
*/
|
|
1792
|
+
export function generateJobId(type: string, entity: string): string {
|
|
1793
|
+
const now = new Date();
|
|
1794
|
+
|
|
1795
|
+
// Format: YYYYMMDD
|
|
1796
|
+
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
1797
|
+
|
|
1798
|
+
// Format: HHMMSS
|
|
1799
|
+
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '');
|
|
1800
|
+
|
|
1801
|
+
// Random suffix (6 chars)
|
|
1802
|
+
const randomStr = Math.random().toString(36).substring(2, 8);
|
|
1803
|
+
|
|
1804
|
+
return `${type}_${entity}_${dateStr}_${timeStr}_${randomStr}`;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* Generate timestamped filename for extraction output
|
|
1809
|
+
*
|
|
1810
|
+
* @param prefix - Filename prefix (from activation variable)
|
|
1811
|
+
* @param extension - File extension (xml, csv, json)
|
|
1812
|
+
* @returns Timestamped filename
|
|
1813
|
+
*
|
|
1814
|
+
* Example: extractFileName('inventorypositions', 'xml')
|
|
1815
|
+
* Returns: 'inventorypositions-2025-11-04T14-30-45-123Z.xml'
|
|
1816
|
+
*/
|
|
1817
|
+
export function extractFileName(prefix: string, extension: string = 'xml'): string {
|
|
1818
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1819
|
+
return `${prefix}-${timestamp}.${extension}`;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Parse job ID components
|
|
1824
|
+
*
|
|
1825
|
+
* @param jobId - Job ID to parse
|
|
1826
|
+
* @returns Parsed components or null if invalid
|
|
1827
|
+
*/
|
|
1828
|
+
export function parseJobId(jobId: string): {
|
|
1829
|
+
type: string;
|
|
1830
|
+
entity: string;
|
|
1831
|
+
date: string;
|
|
1832
|
+
time: string;
|
|
1833
|
+
random: string;
|
|
1834
|
+
} | null {
|
|
1835
|
+
const parts = jobId.split('_');
|
|
1836
|
+
|
|
1837
|
+
if (parts.length !== 5) {
|
|
1838
|
+
return null;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return {
|
|
1842
|
+
type: parts[0],
|
|
1843
|
+
entity: parts[1],
|
|
1844
|
+
date: parts[2],
|
|
1845
|
+
time: parts[3],
|
|
1846
|
+
random: parts[4],
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
```
|
|
1850
|
+
|
|
1851
|
+
---
|
|
1852
|
+
|
|
1853
|
+
## 5. Package Configuration
|
|
1854
|
+
|
|
1855
|
+
### package.json
|
|
1856
|
+
|
|
1857
|
+
```json
|
|
1858
|
+
{
|
|
1859
|
+
"name": "inventory-positions-to-sftp-xml",
|
|
1860
|
+
"version": "1.0.0",
|
|
1861
|
+
"description": "Extract inventory positions from Fluent Commerce and export to SFTP as XML",
|
|
1862
|
+
"type": "module",
|
|
1863
|
+
"main": "src/index.ts",
|
|
1864
|
+
"scripts": {
|
|
1865
|
+
"dev": "versori dev",
|
|
1866
|
+
"build": "versori build",
|
|
1867
|
+
"deploy": "versori deploy"
|
|
1868
|
+
},
|
|
1869
|
+
"dependencies": {
|
|
1870
|
+
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
1871
|
+
"@versori/run": "latest",
|
|
1872
|
+
"fast-xml-parser": "^5.2.5"
|
|
1873
|
+
},
|
|
1874
|
+
"devDependencies": {
|
|
1875
|
+
"@types/node": "^20.0.0",
|
|
1876
|
+
"typescript": "^5.0.0"
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
```
|
|
1880
|
+
|
|
1881
|
+
### tsconfig.json
|
|
1882
|
+
|
|
1883
|
+
```json
|
|
1884
|
+
{
|
|
1885
|
+
"compilerOptions": {
|
|
1886
|
+
"module": "ES2022",
|
|
1887
|
+
"target": "ES2024",
|
|
1888
|
+
"moduleResolution": "node"
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
```
|
|
1892
|
+
|
|
1893
|
+
---
|
|
1894
|
+
|
|
1895
|
+
## 6. Deployment Instructions
|
|
1896
|
+
|
|
1897
|
+
### Deploy to Versori
|
|
1898
|
+
|
|
1899
|
+
```bash
|
|
1900
|
+
# 1. Install dependencies
|
|
1901
|
+
npm install
|
|
1902
|
+
|
|
1903
|
+
# 2. Test locally (if using Versori CLI)
|
|
1904
|
+
npm run dev
|
|
1905
|
+
|
|
1906
|
+
# 3. Deploy to Versori platform
|
|
1907
|
+
npm run deploy
|
|
1908
|
+
```
|
|
1909
|
+
|
|
1910
|
+
### Configure Activation Variables
|
|
1911
|
+
|
|
1912
|
+
In Versori platform settings, configure:
|
|
1913
|
+
|
|
1914
|
+
```json
|
|
1915
|
+
{
|
|
1916
|
+
"sftpHost": "sftp.partner.com",
|
|
1917
|
+
"sftpPort": 22,
|
|
1918
|
+
"sftpUsername": "export_user",
|
|
1919
|
+
"sftpPassword": "********",
|
|
1920
|
+
"sftpPath": "/incoming/inventory/",
|
|
1921
|
+
"fileNamePrefix": "inventorypositions",
|
|
1922
|
+
"pageSize": 200,
|
|
1923
|
+
"maxRecords": 100000,
|
|
1924
|
+
"overlapBufferSeconds": 60
|
|
1925
|
+
}
|
|
1926
|
+
```
|
|
1927
|
+
|
|
1928
|
+
---
|
|
1929
|
+
|
|
1930
|
+
## 7. Performance Notes
|
|
1931
|
+
|
|
1932
|
+
- **Pagination:** Default pageSize=200 is optimized for most use cases; increase to 500 if network latency is high
|
|
1933
|
+
- **Large outputs:** For extractions exceeding ~50k records, consider splitting into multiple files or streaming to S3
|
|
1934
|
+
- **Memory limits:** maxRecords parameter prevents unbounded memory growth; adjust based on available memory
|
|
1935
|
+
|
|
1936
|
+
---
|
|
1937
|
+
|
|
1938
|
+
## 8. Testing
|
|
1939
|
+
|
|
1940
|
+
### Test Scheduled Extraction
|
|
1941
|
+
|
|
1942
|
+
The scheduled workflow runs automatically based on cron schedule.
|
|
1943
|
+
|
|
1944
|
+
**Check logs:**
|
|
1945
|
+
|
|
1946
|
+
```
|
|
1947
|
+
[STEP 1/8] Initializing job tracking
|
|
1948
|
+
[STEP 2/8] Initializing Fluent Commerce client
|
|
1949
|
+
[STEP 3/8] Determining date range for extraction
|
|
1950
|
+
[STEP 4/8] Extracting data from Fluent Commerce
|
|
1951
|
+
[STEP 5/8] Transforming data with UniversalMapper
|
|
1952
|
+
[STEP 6/8] Generating XML file
|
|
1953
|
+
[STEP 7/8] Uploading to SFTP
|
|
1954
|
+
[STEP 8/8] Updating state and completing job
|
|
1955
|
+
```
|
|
1956
|
+
|
|
1957
|
+
### Test Ad hoc Extraction
|
|
1958
|
+
|
|
1959
|
+
```bash
|
|
1960
|
+
# Incremental (uses last sync timestamp)
|
|
1961
|
+
curl -X POST https://api.versori.com/webhooks/inventory-positions-adhoc \
|
|
1962
|
+
-H "Content-Type: application/json" \
|
|
1963
|
+
-H "X-API-Key: your-api-key" \
|
|
1964
|
+
-d '{}'
|
|
1965
|
+
|
|
1966
|
+
# Date range override
|
|
1967
|
+
curl -X POST https://api.versori.com/webhooks/inventory-positions-adhoc \
|
|
1968
|
+
-H "Content-Type: application/json" \
|
|
1969
|
+
-H "X-API-Key: your-api-key" \
|
|
1970
|
+
-d '{
|
|
1971
|
+
"fromDate": "2025-01-01T00:00:00Z",
|
|
1972
|
+
"toDate": "2025-01-31T23:59:59Z",
|
|
1973
|
+
"updateState": false
|
|
1974
|
+
}'
|
|
1975
|
+
```
|
|
1976
|
+
|
|
1977
|
+
### Test Job Status Query
|
|
1978
|
+
|
|
1979
|
+
```bash
|
|
1980
|
+
curl -X POST https://api.versori.com/webhooks/inventory-positions-job-status \
|
|
1981
|
+
-H "Content-Type: application/json" \
|
|
1982
|
+
-H "X-API-Key: your-api-key" \
|
|
1983
|
+
-d '{
|
|
1984
|
+
"jobId": "ADHOC_IP_20251027_183045_abc123"
|
|
1985
|
+
}'
|
|
1986
|
+
```
|
|
1987
|
+
|
|
1988
|
+
**Response:**
|
|
1989
|
+
|
|
1990
|
+
```json
|
|
1991
|
+
{
|
|
1992
|
+
"success": true,
|
|
1993
|
+
"jobId": "ADHOC_IP_20251027_183045_abc123",
|
|
1994
|
+
"status": "processing",
|
|
1995
|
+
"stage": "transformation",
|
|
1996
|
+
"message": "Transforming 15000 records",
|
|
1997
|
+
"createdAt": "2025-10-27T18:30:45.000Z",
|
|
1998
|
+
"startedAt": "2025-10-27T18:30:46.000Z"
|
|
1999
|
+
}
|
|
2000
|
+
```
|
|
2001
|
+
|
|
2002
|
+
### Monitoring
|
|
2003
|
+
|
|
2004
|
+
Use Versori logs filtered by `jobId` or `stage`; optional status webhook for dashboards.
|
|
2005
|
+
|
|
2006
|
+
---
|
|
2007
|
+
|
|
2008
|
+
### Schema requirements
|
|
2009
|
+
|
|
2010
|
+
Ensure your GraphQL query includes Relay pagination fields and variables ($first, $after), and that `resultPath` matches the edges.node path. The orchestrator injects pagination variables automatically (don't include them in your variables object).
|
|
2011
|
+
|
|
2012
|
+
---
|
|
2013
|
+
|
|
2014
|
+
## ?? Troubleshooting (quick)
|
|
2015
|
+
|
|
2016
|
+
- No records extracted: Check dateRange (manual override vs incremental).
|
|
2017
|
+
- SFTP upload failed: Job fails; state not advanced. Next run retries same window.
|
|
2018
|
+
- GraphQL pagination error: Ensure edges.cursor and pageInfo.hasNextPage are in the query.
|
|
2019
|
+
- Memory pressure: Lower pageSize or maxRecords; split output or stream to S3.
|
|
2020
|
+
|
|
2021
|
+
## 🗺️ Mapping tips (compact)
|
|
2022
|
+
|
|
2023
|
+
- Required fields: mark in mapping JSON; invalid records are skipped and reported.
|
|
2024
|
+
- Nested paths: use dot notation (e.g., catalogue.ref).
|
|
2025
|
+
- Arrays: map arrays directly; one record in, one mapped object out.
|
|
2026
|
+
- Resolvers: prefer built-ins (trim, uppercase, parseInt); keep output types aligned.
|
|
2027
|
+
|
|
2028
|
+
---
|
|
2029
|
+
|
|
2030
|
+
## 9. Replication Checklist
|
|
2031
|
+
|
|
2032
|
+
**To replicate this template for other entities/formats:**
|
|
2033
|
+
|
|
2034
|
+
1. **File Naming:** Replace `inventory-positions`, `IP`, `InventoryPosition` with your entity name across all files
|
|
2035
|
+
2. **GraphQL Query:** Update query constant and field selection to match your entity schema
|
|
2036
|
+
3. **Mapping Config:** Create new mapping file in `config/` with correct field paths
|
|
2037
|
+
4. **Workflows:** Rename workflow exports to match entity (e.g., `scheduledVirtualPositionsExtraction`)
|
|
2038
|
+
5. **Service Function:** Rename main function (e.g., `executeVirtualPositionExtraction`)
|
|
2039
|
+
6. **State Key:** Update KV key (e.g., `lastVirtualPositionSync`)
|
|
2040
|
+
7. **Output Format:** For CSV use `CSVParserService`, for JSON use `JSON.stringify()`, for XML use `XMLBuilder`
|
|
2041
|
+
8. **Upload Destination:** For S3 replace `SftpDataSource` with `S3DataSource`
|
|
2042
|
+
|
|
2043
|
+
---
|
|
2044
|
+
|
|
2045
|
+
### Pattern 8: Backward Pagination (Optional - Advanced)
|
|
2046
|
+
|
|
2047
|
+
**Use Case**: Extract data in reverse chronological order (newest to oldest) instead of oldest to newest.
|
|
2048
|
+
|
|
2049
|
+
**When to Use**:
|
|
2050
|
+
|
|
2051
|
+
- ✅ Need most recent records first (e.g., latest orders, recent inventory updates)
|
|
2052
|
+
- ✅ Time-bounded reverse traversal for auditing
|
|
2053
|
+
- ✅ Display newest-first in UI/reports
|
|
2054
|
+
- ❌ **Don't use for standard incremental sync** - use forward pagination (default)
|
|
2055
|
+
|
|
2056
|
+
**GraphQL Query Requirements**:
|
|
2057
|
+
|
|
2058
|
+
Your query must support backward pagination by including `$last` and `$before`:
|
|
2059
|
+
|
|
2060
|
+
```graphql
|
|
2061
|
+
query GetData(
|
|
2062
|
+
$retailerId: ID!
|
|
2063
|
+
$first: Int # For forward pagination
|
|
2064
|
+
$after: String # For forward pagination
|
|
2065
|
+
$last: Int # For backward pagination
|
|
2066
|
+
$before: String # For backward pagination
|
|
2067
|
+
) {
|
|
2068
|
+
data(retailerId: $retailerId, first: $first, after: $after, last: $last, before: $before) {
|
|
2069
|
+
edges {
|
|
2070
|
+
cursor # ? REQUIRED
|
|
2071
|
+
node {
|
|
2072
|
+
id
|
|
2073
|
+
createdAt
|
|
2074
|
+
# ... other fields
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
pageInfo {
|
|
2078
|
+
hasNextPage # For forward
|
|
2079
|
+
hasPreviousPage # ? REQUIRED for backward
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
```
|
|
2084
|
+
|
|
2085
|
+
**Implementation**:
|
|
2086
|
+
|
|
2087
|
+
```typescript
|
|
2088
|
+
// Backward pagination - newest records first
|
|
2089
|
+
const result = await orchestrator.extract({
|
|
2090
|
+
query: YOUR_QUERY,
|
|
2091
|
+
resultPath: 'data.edges.node',
|
|
2092
|
+
variables: {
|
|
2093
|
+
retailerId,
|
|
2094
|
+
dateRangeFilter: { from: bufferedLastRunTime, to: effectiveEndTime },
|
|
2095
|
+
// Don't include last/before - orchestrator injects them
|
|
2096
|
+
},
|
|
2097
|
+
pageSize: 200,
|
|
2098
|
+
direction: 'backward', // ? Enable reverse pagination
|
|
2099
|
+
maxRecords: 10000,
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
// Records are returned in reverse chronological order
|
|
2103
|
+
console.log(result.data[0].createdAt); // Newest
|
|
2104
|
+
console.log(result.data[result.data.length - 1].createdAt); // Oldest (within range)
|
|
2105
|
+
```
|
|
2106
|
+
|
|
2107
|
+
**Key Differences from Forward Pagination**:
|
|
2108
|
+
|
|
2109
|
+
| Aspect | Forward (Default) | Backward |
|
|
2110
|
+
| ---------------------- | -------------------------------- | ----------------------- |
|
|
2111
|
+
| **Direction** | `direction: 'forward'` (default) | `direction: 'backward'` |
|
|
2112
|
+
| **Variables Injected** | `first`, `after` | `last`, `before` |
|
|
2113
|
+
| **PageInfo Field** | `hasNextPage` | `hasPreviousPage` |
|
|
2114
|
+
| **Cursor Source** | Last edge of page | First edge of page |
|
|
2115
|
+
| **Record Order** | Oldest ? Newest | Newest ? Oldest |
|
|
2116
|
+
|
|
2117
|
+
**Important Notes**:
|
|
2118
|
+
|
|
2119
|
+
1. **Orchestrator injects variables**: Don't pass `last` or `before` in your variables object - the orchestrator injects them based on `pageSize` and cursor tracking.
|
|
2120
|
+
|
|
2121
|
+
2. **Query signature**: Your GraphQL query must declare `$last` and `$before` parameters even if you don't pass them explicitly.
|
|
2122
|
+
|
|
2123
|
+
3. **PageInfo requirement**: Response must include `pageInfo.hasPreviousPage` or the orchestrator will throw an error.
|
|
2124
|
+
|
|
2125
|
+
4. **Cursor requirement**: Each edge must include `cursor` field for pagination to work.
|
|
2126
|
+
|
|
2127
|
+
**Example: Extract Latest 1000 Orders**
|
|
2128
|
+
|
|
2129
|
+
```typescript
|
|
2130
|
+
const latestOrders = await orchestrator.extract({
|
|
2131
|
+
query: ORDERS_QUERY,
|
|
2132
|
+
resultPath: 'orders.edges.node',
|
|
2133
|
+
variables: {
|
|
2134
|
+
retailerId,
|
|
2135
|
+
statuses: ['BOOKED', 'ALLOCATED'],
|
|
2136
|
+
},
|
|
2137
|
+
direction: 'backward', // Start from newest
|
|
2138
|
+
maxRecords: 1000, // Stop after 1000 records
|
|
2139
|
+
pageSize: 100, // 100 per page = 10 pages
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
// latestOrders.data[0] is the newest order
|
|
2143
|
+
// latestOrders.data[999] is the 1000th newest order
|
|
2144
|
+
```
|
|
2145
|
+
|
|
2146
|
+
**When to Use Forward vs Backward**:
|
|
2147
|
+
|
|
2148
|
+
```typescript
|
|
2149
|
+
// ? Forward (default) - For incremental sync
|
|
2150
|
+
const incrementalData = await orchestrator.extract({
|
|
2151
|
+
query: YOUR_QUERY,
|
|
2152
|
+
resultPath: 'data.edges.node',
|
|
2153
|
+
variables: {
|
|
2154
|
+
dateRangeFilter: { from: lastSyncTime, to: now },
|
|
2155
|
+
},
|
|
2156
|
+
// direction defaults to 'forward'
|
|
2157
|
+
// Processes oldest ? newest for proper sequencing
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
// ? Backward - For "latest N records" use cases
|
|
2161
|
+
const latestData = await orchestrator.extract({
|
|
2162
|
+
query: YOUR_QUERY,
|
|
2163
|
+
resultPath: 'data.edges.node',
|
|
2164
|
+
direction: 'backward',
|
|
2165
|
+
maxRecords: 100, // Just get latest 100
|
|
2166
|
+
// Gets newest ? oldest
|
|
2167
|
+
});
|
|
2168
|
+
```
|
|
2169
|
+
|
|
2170
|
+
**Pagination Variables Reference**:
|
|
2171
|
+
|
|
2172
|
+
| Variable | Forward | Backward | Injected By | Notes |
|
|
2173
|
+
| -------- | ------------ | ------------ | ------------ | ------------------------ |
|
|
2174
|
+
| `first` | ? Used | Not used | Orchestrator | From `pageSize` |
|
|
2175
|
+
| `after` | ? Used | Not used | Orchestrator | From cursor (last edge) |
|
|
2176
|
+
| `last` | Not used | ? Used | Orchestrator | From `pageSize` |
|
|
2177
|
+
| `before` | Not used | ? Used | Orchestrator | From cursor (first edge) |
|
|
2178
|
+
|
|
2179
|
+
**Common Mistakes to Avoid**:
|
|
2180
|
+
|
|
2181
|
+
```typescript
|
|
2182
|
+
// WRONG - Don't pass pagination variables
|
|
2183
|
+
const result = await orchestrator.extract({
|
|
2184
|
+
variables: {
|
|
2185
|
+
last: 200, // Orchestrator will override this
|
|
2186
|
+
before: cursor, // Orchestrator manages cursor
|
|
2187
|
+
},
|
|
2188
|
+
direction: 'backward',
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
// ? CORRECT - Let orchestrator inject pagination
|
|
2192
|
+
const result = await orchestrator.extract({
|
|
2193
|
+
variables: {
|
|
2194
|
+
retailerId, // ? Your business variables only
|
|
2195
|
+
},
|
|
2196
|
+
pageSize: 200, // ? Orchestrator uses this for last/before
|
|
2197
|
+
direction: 'backward',
|
|
2198
|
+
});
|
|
2199
|
+
```
|
|
2200
|
+
|
|
2201
|
+
#### Optional: Reverse Pagination
|
|
2202
|
+
|
|
2203
|
+
- Default remains forward pagination ($first/$after) using pageInfo.hasNextPage.
|
|
2204
|
+
- To paginate backward, define $last/$before and add pageInfo.hasPreviousPage; set direction='backward' in the orchestrator.
|
|
2205
|
+
|
|
2206
|
+
Snippet:
|
|
2207
|
+
|
|
2208
|
+
```graphql
|
|
2209
|
+
query GetInventoryPositionsBackward($last: Int!, $before: String) {
|
|
2210
|
+
inventoryPositions(last: $last, before: $before) {
|
|
2211
|
+
edges {
|
|
2212
|
+
cursor
|
|
2213
|
+
node {
|
|
2214
|
+
id
|
|
2215
|
+
ref
|
|
2216
|
+
updatedOn
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
pageInfo {
|
|
2220
|
+
hasPreviousPage
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
```
|
|
2225
|
+
|
|
2226
|
+
SDK:
|
|
2227
|
+
|
|
2228
|
+
```typescript
|
|
2229
|
+
await orchestrator.extract({
|
|
2230
|
+
query: INVENTORY_POSITIONS_BACKWARD_QUERY,
|
|
2231
|
+
resultPath: 'inventoryPositions.edges.node',
|
|
2232
|
+
variables: { dateRangeFilter },
|
|
2233
|
+
pageSize,
|
|
2234
|
+
direction: 'backward',
|
|
2235
|
+
});
|
|
2236
|
+
```
|
|
2237
|
+
|
|
2238
|
+
---
|
|
2239
|
+
|
|
2240
|
+
## Testing Checklist
|
|
2241
|
+
|
|
2242
|
+
**Before production deployment:**
|
|
2243
|
+
|
|
2244
|
+
### 1. Schema Validation
|
|
2245
|
+
|
|
2246
|
+
- [ ] Run `npx fc-connect introspect-schema --url <your-graphql-url>`
|
|
2247
|
+
- [ ] Run `npx fc-connect validate-schema --mapping ./config/inventory-positions.export.xml.json --schema ./fluent-schema.json`
|
|
2248
|
+
- [ ] Run `npx fc-connect analyze-coverage --mapping ./config/inventory-positions.export.xml.json --schema ./fluent-schema.json`
|
|
2249
|
+
- [ ] Verify all `source` paths in mapping exist in GraphQL schema
|
|
2250
|
+
- [ ] Verify query structure matches schema (fields, types, filters)
|
|
2251
|
+
|
|
2252
|
+
### 2. Extraction Testing
|
|
2253
|
+
|
|
2254
|
+
- [ ] Test with small dataset first (maxRecords=10)
|
|
2255
|
+
- [ ] Verify ExtractionOrchestrator pagination works correctly
|
|
2256
|
+
- [ ] Test with multiple pages of data (verify cursor handling)
|
|
2257
|
+
- [ ] Verify date range filtering (updatedOn filter)
|
|
2258
|
+
- [ ] Test empty result handling (no records in date range)
|
|
2259
|
+
- [ ] Verify extraction stops at maxRecords limit
|
|
2260
|
+
|
|
2261
|
+
### 3. Mapping Testing
|
|
2262
|
+
|
|
2263
|
+
- [ ] Verify required fields are populated
|
|
2264
|
+
- [ ] Verify SDK resolvers work correctly (sdk.trim, sdk.parseInt, sdk.formatDate, etc.)
|
|
2265
|
+
- [ ] Test custom resolvers with edge cases (if any)
|
|
2266
|
+
- [ ] Verify nested field extraction
|
|
2267
|
+
- [ ] Test with null/missing fields
|
|
2268
|
+
- [ ] Verify mapping error collection works
|
|
2269
|
+
|
|
2270
|
+
### 4. XML Generation Testing
|
|
2271
|
+
|
|
2272
|
+
- [ ] Verify XML structure matches expected format
|
|
2273
|
+
- [ ] Test XML validation against XSD schema (if applicable)
|
|
2274
|
+
- [ ] Verify special character escaping in XML
|
|
2275
|
+
- [ ] Test with large datasets (>1000 records)
|
|
2276
|
+
- [ ] Verify UTF-8 encoding
|
|
2277
|
+
- [ ] Test XML namespace handling (if applicable)
|
|
2278
|
+
|
|
2279
|
+
### 5. SFTP Upload Testing
|
|
2280
|
+
|
|
2281
|
+
- [ ] Test SFTP connection and authentication
|
|
2282
|
+
- [ ] Verify file upload to correct path
|
|
2283
|
+
- [ ] Test file naming convention (timestamp format)
|
|
2284
|
+
- [ ] Verify file permissions on SFTP server
|
|
2285
|
+
- [ ] Test upload retry logic (simulate network failure)
|
|
2286
|
+
- [ ] Verify SFTP connection disposal (no connection leaks)
|
|
2287
|
+
|
|
2288
|
+
### 6. State Management Testing
|
|
2289
|
+
|
|
2290
|
+
- [ ] Verify overlap buffer prevents missed records (60-second default)
|
|
2291
|
+
- [ ] Test state recovery after extraction failure
|
|
2292
|
+
- [ ] Verify timestamp saved WITHOUT buffer (MAX(updatedOn))
|
|
2293
|
+
- [ ] Test first run with no previous state (uses fallbackStartDate)
|
|
2294
|
+
- [ ] Verify state update only happens on successful upload
|
|
2295
|
+
- [ ] Test manual date override (doesn't update state)
|
|
2296
|
+
|
|
2297
|
+
### 7. Job Tracking Testing
|
|
2298
|
+
|
|
2299
|
+
- [ ] Test job creation with JobTracker
|
|
2300
|
+
- [ ] Verify job status updates at each stage
|
|
2301
|
+
- [ ] Test job completion with metadata
|
|
2302
|
+
- [ ] Test job failure handling
|
|
2303
|
+
- [ ] Query job status via webhook endpoint
|
|
2304
|
+
- [ ] Verify job status persists in KV store
|
|
2305
|
+
|
|
2306
|
+
### 8. Error Handling Testing
|
|
2307
|
+
|
|
2308
|
+
- [ ] Test with invalid GraphQL query
|
|
2309
|
+
- [ ] Test with mapping errors (invalid field paths)
|
|
2310
|
+
- [ ] Test with SFTP connection failures
|
|
2311
|
+
- [ ] Test with authentication failures
|
|
2312
|
+
- [ ] Test with network timeouts
|
|
2313
|
+
- [ ] Verify error logging includes context (jobId, stage, error details)
|
|
2314
|
+
- [ ] Test error threshold logic (if applicable)
|
|
2315
|
+
|
|
2316
|
+
### 9. Staging Environment Testing
|
|
2317
|
+
|
|
2318
|
+
- [ ] Run full extraction in staging environment
|
|
2319
|
+
- [ ] Verify XML file format with downstream system
|
|
2320
|
+
- [ ] Monitor extraction duration and resource usage
|
|
2321
|
+
- [ ] Test with production-like data volumes
|
|
2322
|
+
- [ ] Verify no performance degradation over time
|
|
2323
|
+
|
|
2324
|
+
### 10. Integration Testing
|
|
2325
|
+
|
|
2326
|
+
- [ ] Test scheduled workflow (cron trigger)
|
|
2327
|
+
- [ ] Test ad hoc webhook trigger
|
|
2328
|
+
- [ ] Test job status query webhook
|
|
2329
|
+
- [ ] Verify activation variables are read correctly
|
|
2330
|
+
- [ ] Test with different extraction modes (incremental, date range)
|
|
2331
|
+
- [ ] End-to-end test: trigger ? extract ? transform ? upload ? verify file
|
|
2332
|
+
|
|
2333
|
+
---
|
|
2334
|
+
## Monitoring & Alerting
|
|
2335
|
+
|
|
2336
|
+
### Success Response Example
|
|
2337
|
+
|
|
2338
|
+
```json
|
|
2339
|
+
{
|
|
2340
|
+
"success": true,
|
|
2341
|
+
"jobId": "SCHEDULED_IP_20251102_140000_abc123",
|
|
2342
|
+
"recordsExtracted": 1523,
|
|
2343
|
+
"fileName": "inventory-positions-2025-11-02T14-00-00-000Z.xml",
|
|
2344
|
+
"sftpPath": "/outbound/inventory-positions/inventory-positions-2025-11-02T14-00-00-000Z.xml",
|
|
2345
|
+
"metrics": {
|
|
2346
|
+
"extractionDurationMs": 12543,
|
|
2347
|
+
"totalPages": 8,
|
|
2348
|
+
"pageSize": 200,
|
|
2349
|
+
"mappingErrors": 0,
|
|
2350
|
+
"fileSizeBytes": 524288,
|
|
2351
|
+
"uploadDurationMs": 1234
|
|
2352
|
+
},
|
|
2353
|
+
"timestamps": {
|
|
2354
|
+
"extractionStart": "2025-11-02T14:00:00.000Z",
|
|
2355
|
+
"extractionEnd": "2025-11-02T14:00:12.543Z",
|
|
2356
|
+
"uploadComplete": "2025-11-02T14:00:13.777Z"
|
|
2357
|
+
},
|
|
2358
|
+
"state": {
|
|
2359
|
+
"previousTimestamp": "2025-11-02T13:00:00.000Z",
|
|
2360
|
+
"newTimestamp": "2025-11-02T13:59:58.123Z",
|
|
2361
|
+
"stateUpdated": true,
|
|
2362
|
+
"overlapBufferSeconds": 60
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
```
|
|
2366
|
+
|
|
2367
|
+
### Error Response Example
|
|
2368
|
+
|
|
2369
|
+
```json
|
|
2370
|
+
{
|
|
2371
|
+
"success": false,
|
|
2372
|
+
"jobId": "ADHOC_IP_20251102_140500_xyz789",
|
|
2373
|
+
"error": "SFTP upload failed: Connection timeout",
|
|
2374
|
+
"errorCategory": "NETWORK",
|
|
2375
|
+
"recordsExtracted": 0,
|
|
2376
|
+
"stage": "sftp_upload",
|
|
2377
|
+
"details": {
|
|
2378
|
+
"message": "Failed to upload file after 3 retry attempts",
|
|
2379
|
+
"retryAttempts": 3,
|
|
2380
|
+
"lastError": "ETIMEDOUT: Connection timed out after 30000ms"
|
|
2381
|
+
},
|
|
2382
|
+
"state": {
|
|
2383
|
+
"stateUpdated": false,
|
|
2384
|
+
"willRetryNextRun": true,
|
|
2385
|
+
"note": "State not advanced - next extraction will retry same time window"
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
```
|
|
2389
|
+
|
|
2390
|
+
### Key Metrics to Track
|
|
2391
|
+
|
|
2392
|
+
```typescript
|
|
2393
|
+
const METRICS = {
|
|
2394
|
+
// Extraction Performance
|
|
2395
|
+
extractionDurationMs: Date.now() - extractionStart,
|
|
2396
|
+
recordCount: records.length,
|
|
2397
|
+
pageCount: extractionResult.stats.totalPages,
|
|
2398
|
+
avgRecordsPerPage: records.length / extractionResult.stats.totalPages,
|
|
2399
|
+
|
|
2400
|
+
// Transformation Performance
|
|
2401
|
+
transformedCount: transformedRecords.length,
|
|
2402
|
+
failedCount: mappingErrors.length,
|
|
2403
|
+
errorRate: ((mappingErrors.length / records.length) * 100).toFixed(2) + '%',
|
|
2404
|
+
|
|
2405
|
+
// File Generation
|
|
2406
|
+
fileSizeMB: (xmlContent.length / (1024 * 1024)).toFixed(2),
|
|
2407
|
+
|
|
2408
|
+
// Upload Performance
|
|
2409
|
+
uploadDurationMs: uploadEnd - uploadStart,
|
|
2410
|
+
uploadSpeedMBps: (fileSizeMB / (uploadDurationMs / 1000)).toFixed(2),
|
|
2411
|
+
|
|
2412
|
+
// State Management
|
|
2413
|
+
timeSinceLastRun: Date.now() - new Date(lastTimestamp).getTime(),
|
|
2414
|
+
recordsPerMinute: (records.length / (extractionDurationMs / 60000)).toFixed(0),
|
|
2415
|
+
};
|
|
2416
|
+
|
|
2417
|
+
log.info('Extraction metrics', metrics);
|
|
2418
|
+
```
|
|
2419
|
+
|
|
2420
|
+
### Alert Thresholds
|
|
2421
|
+
|
|
2422
|
+
```typescript
|
|
2423
|
+
const ALERT_THRESHOLDS = {
|
|
2424
|
+
// Duration Alerts
|
|
2425
|
+
EXTRACTION_DURATION_MS: 5 * 60 * 1000, // 5 minutes
|
|
2426
|
+
UPLOAD_DURATION_MS: 2 * 60 * 1000, // 2 minutes
|
|
2427
|
+
TOTAL_DURATION_MS: 10 * 60 * 1000, // 10 minutes
|
|
2428
|
+
|
|
2429
|
+
// Error Rate Alerts
|
|
2430
|
+
MAX_ERROR_RATE: 0.05, // 5% mapping errors
|
|
2431
|
+
MAX_VALIDATION_FAILURES: 0.02, // 2% validation failures
|
|
2432
|
+
|
|
2433
|
+
// Volume Alerts
|
|
2434
|
+
MAX_RECORDS_PER_RUN: 100000,
|
|
2435
|
+
MIN_RECORDS_WARNING: 0, // Alert if no records found
|
|
2436
|
+
MAX_FILE_SIZE_MB: 150, // 150MB
|
|
2437
|
+
|
|
2438
|
+
// State Alerts
|
|
2439
|
+
MAX_TIME_SINCE_LAST_RUN_HOURS: 25, // Alert if >25 hours (should run hourly)
|
|
2440
|
+
MAX_OVERLAP_BUFFER_SECONDS: 300, // Alert if buffer >5 minutes
|
|
2441
|
+
};
|
|
2442
|
+
|
|
2443
|
+
// Check thresholds
|
|
2444
|
+
if (metrics.extractionDurationMs > ALERT_THRESHOLDS.EXTRACTION_DURATION_MS) {
|
|
2445
|
+
log.warn('Extraction duration exceeded threshold', {
|
|
2446
|
+
duration: metrics.extractionDurationMs,
|
|
2447
|
+
threshold: ALERT_THRESHOLDS.EXTRACTION_DURATION_MS,
|
|
2448
|
+
recommendation: 'Consider reducing maxRecords or increasing extraction frequency'
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
```
|
|
2452
|
+
|
|
2453
|
+
### Monitoring Dashboard Queries
|
|
2454
|
+
|
|
2455
|
+
**Versori Platform Logs Query:**
|
|
2456
|
+
|
|
2457
|
+
```
|
|
2458
|
+
# Successful extractions
|
|
2459
|
+
log_level:info AND message:"Extraction complete" AND jobId:*
|
|
2460
|
+
|
|
2461
|
+
# Failed extractions
|
|
2462
|
+
log_level:error AND message:"Extraction workflow failed" AND jobId:*
|
|
2463
|
+
|
|
2464
|
+
# Performance issues
|
|
2465
|
+
extractionDurationMs:>300000 OR uploadDurationMs:>120000
|
|
2466
|
+
|
|
2467
|
+
# High error rates
|
|
2468
|
+
errorRate:>5
|
|
2469
|
+
|
|
2470
|
+
# State management issues
|
|
2471
|
+
stateUpdated:false AND success:true
|
|
2472
|
+
```
|
|
2473
|
+
|
|
2474
|
+
### Common Issues and Solutions
|
|
2475
|
+
|
|
2476
|
+
**Issue**: "Extraction timeout after 10 minutes"
|
|
2477
|
+
|
|
2478
|
+
- **Cause**: Too many records in single extraction
|
|
2479
|
+
- **Fix**: Reduce maxRecords, increase extraction frequency, or optimize query filters
|
|
2480
|
+
- **Prevention**: Monitor recordCount trends, set appropriate maxRecords
|
|
2481
|
+
|
|
2482
|
+
**Issue**: "Mapping errors for 50% of records"
|
|
2483
|
+
|
|
2484
|
+
- **Cause**: Schema mismatch between GraphQL response and mapping config
|
|
2485
|
+
- **Fix**: Run schema validation, update mapping config paths
|
|
2486
|
+
- **Prevention**: Use `npx fc-connect validate-schema` before deployment
|
|
2487
|
+
|
|
2488
|
+
**Issue**: "SFTP connection timeout"
|
|
2489
|
+
|
|
2490
|
+
- **Cause**: Network issues, firewall, or connection pool exhaustion
|
|
2491
|
+
- **Fix**: Check SFTP credentials, verify network connectivity
|
|
2492
|
+
- **Prevention**: Implement connection health checks, monitor connection status
|
|
2493
|
+
|
|
2494
|
+
**Issue**: "State not updating after successful extraction"
|
|
2495
|
+
|
|
2496
|
+
- **Cause**: KV write failure or intentional retry logic
|
|
2497
|
+
- **Fix**: Check KV logs, verify state update code executed
|
|
2498
|
+
- **Prevention**: Add KV write verification, log state updates explicitly
|
|
2499
|
+
|
|
2500
|
+
**Issue**: "First run exceeds record limits"
|
|
2501
|
+
|
|
2502
|
+
- **Cause**: No previous timestamp, fetches all historical records
|
|
2503
|
+
- **Fix**: Set fallbackStartDate close to current date, apply additional filters
|
|
2504
|
+
- **Prevention**: Use appropriate fallbackStartDate for initial runs
|
|
2505
|
+
|
|
2506
|
+
**Issue**: "Excessive duplicate records in output"
|
|
2507
|
+
|
|
2508
|
+
- **Cause**: Overlap buffer (expected) or timestamp not saved correctly
|
|
2509
|
+
- **Fix**: Verify newTimestamp saved WITHOUT buffer, check state persistence
|
|
2510
|
+
- **Prevention**: Monitor duplicate rates, verify state update logic
|
|
2511
|
+
|
|
2512
|
+
---
|
|
2513
|
+
|
|
2514
|
+
## Troubleshooting Quick Reference
|
|
2515
|
+
|
|
2516
|
+
| Error Message | Likely Cause | Solution |
|
|
2517
|
+
|--------------|--------------|----------|
|
|
2518
|
+
| "Failed to create Fluent Commerce client" | Authentication failure | Check OAuth2 credentials, verify connection config |
|
|
2519
|
+
| "GraphQL query validation error" | Invalid query syntax | Validate query against schema with introspection tool |
|
|
2520
|
+
| "Pagination cursor invalid" | Stale cursor or query change | Reset extraction, verify cursor handling in query |
|
|
2521
|
+
| "Mapping failed: field not found" | Schema mismatch | Run schema validation, update mapping paths |
|
|
2522
|
+
| "SFTP authentication failed" | Invalid credentials | Verify SFTP credentials in activation variables |
|
|
2523
|
+
| "Connection pool exhausted" | Too many concurrent requests | Reduce concurrency, increase pool size |
|
|
2524
|
+
| "KV operation failed" | Versori KV issue | Check Versori platform status, retry operation |
|
|
2525
|
+
| "Job status not found" | Invalid jobId or expired | Verify jobId format, check KV retention policy |
|
|
2526
|
+
| "Memory limit exceeded" | Dataset too large | Reduce maxRecords, enable streaming mode |
|
|
2527
|
+
| "XML generation failed" | Format-specific error | Check XML generation logic, validate output |
|
|
2528
|
+
|
|
2529
|
+
---
|