@fluentcommerce/fc-connect-sdk 0.1.54 → 0.1.56
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/README.md +11 -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,2476 +1,2476 @@
|
|
|
1
|
-
# Module 4: Workflows - HTTP, Webhooks, Scheduled & Internal Functions
|
|
2
|
-
|
|
3
|
-
[← Back to Versori Platform Guide](../platforms-versori-readme.md)
|
|
4
|
-
|
|
5
|
-
**Module 4 of 8** | **Level**: Intermediate | **Time**: 30 minutes
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Learning Objectives
|
|
10
|
-
|
|
11
|
-
By the end of this module, you will:
|
|
12
|
-
|
|
13
|
-
- ✅ Master all four workflow types: http(), webhook(), schedule(), fn()
|
|
14
|
-
- ✅ Understand when to use each workflow type
|
|
15
|
-
- ✅ Implement HTTP workflows with Fluent API calls
|
|
16
|
-
- ✅ Build webhook receivers with signature validation
|
|
17
|
-
- ✅ Create scheduled tasks with cron patterns
|
|
18
|
-
- ✅ Compose multi-step workflows with fn()
|
|
19
|
-
- ✅ **Handle non-JSON responses** (XML, HTML, CSV) correctly
|
|
20
|
-
- ✅ Implement error handling and retry strategies
|
|
21
|
-
|
|
22
|
-
---
|
|
23
|
-
|
|
24
|
-
## Workflow Types Overview
|
|
25
|
-
|
|
26
|
-
Versori provides four fundamental workflow types, each with specific capabilities and use cases:
|
|
27
|
-
|
|
28
|
-
| Type | Purpose | External API Access | Context | Use When |
|
|
29
|
-
| -------------- | --------------------- | -------------------------------- | ---------------------------- | ------------------------------------- |
|
|
30
|
-
| **http()** | External API calls | ✅ Yes (requires connection) | `fetch`, `log`, `activation` | Query/mutate Fluent API |
|
|
31
|
-
| **webhook()** | Receive HTTP requests | ❌ No (unless chained with http) | `data`, `request()`, `log` | Receive SFCC orders, Rubix events |
|
|
32
|
-
| **schedule()** | Time-based tasks | ✅ Yes (if connection provided) | `fetch`, `log`, `activation` | Daily sync, hourly extraction |
|
|
33
|
-
| **fn()** | Internal processing | ❌ No | `openKv`, `log`, `request()` | Data transformation, state management |
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
## ⚠️ CRITICAL: Accessing Headers & Choosing Workflow Types
|
|
38
|
-
|
|
39
|
-
### Accessing HTTP Headers
|
|
40
|
-
|
|
41
|
-
**The `Context<D>` interface does NOT have a `headers` property.** Always use `ctx.request()`:
|
|
42
|
-
|
|
43
|
-
```typescript
|
|
44
|
-
// ✅ CORRECT - Access headers via request()
|
|
45
|
-
const req = ctx.request();
|
|
46
|
-
const apiKey = req?.headers['x-api-key'] as string;
|
|
47
|
-
const contentType = req?.headers['content-type'];
|
|
48
|
-
|
|
49
|
-
// ❌ WRONG - ctx.headers doesn't exist in @versori/run
|
|
50
|
-
const apiKey = ctx.headers?.get?.('x-api-key'); // ERROR!
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
### When to Use http() vs fn()
|
|
54
|
-
|
|
55
|
-
**Decision Rule:** Do you need to call external APIs (Fluent Commerce, S3 presigned URLs, etc.)?
|
|
56
|
-
|
|
57
|
-
#### Use `http()` When:
|
|
58
|
-
|
|
59
|
-
- ✅ Calling Fluent Commerce GraphQL API
|
|
60
|
-
- ✅ Need connection credentials and authentication
|
|
61
|
-
- ✅ Require `ctx.fetch` with OAuth2 auth
|
|
62
|
-
- ✅ Need `connectionVariables` or `baseUrl`
|
|
63
|
-
|
|
64
|
-
**What you get in http():**
|
|
65
|
-
|
|
66
|
-
```typescript
|
|
67
|
-
http('my-task', { connection: 'fluent_commerce' }, async ctx => {
|
|
68
|
-
ctx.fetch; // ✅ Authenticated fetch with connection
|
|
69
|
-
ctx.connectionVariables; // ✅ Connection configuration
|
|
70
|
-
ctx.baseUrl; // ✅ API base URL from connection
|
|
71
|
-
ctx.request(); // ✅ HTTP request object (for headers)
|
|
72
|
-
(ctx.log, ctx.data, ctx.activation, ctx.openKv()); // ✅ All standard context
|
|
73
|
-
|
|
74
|
-
// Can call external APIs:
|
|
75
|
-
const client = await createClient(ctx); // ✅ Works!
|
|
76
|
-
});
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
#### Use `fn()` When:
|
|
80
|
-
|
|
81
|
-
- ✅ Pure data transformation (no external API calls)
|
|
82
|
-
- ✅ State management with KV storage only
|
|
83
|
-
- ✅ Parsing, mapping, validation logic
|
|
84
|
-
- ✅ File tracking and deduplication
|
|
85
|
-
|
|
86
|
-
**What you get in fn():**
|
|
87
|
-
|
|
88
|
-
```typescript
|
|
89
|
-
fn('my-task', async ctx => {
|
|
90
|
-
ctx.data; // ✅ Input data
|
|
91
|
-
ctx.log; // ✅ Logger
|
|
92
|
-
ctx.activation; // ✅ Variables from activation
|
|
93
|
-
ctx.openKv(); // ✅ KV storage access
|
|
94
|
-
ctx.request(); // ✅ HTTP request object (for headers)
|
|
95
|
-
|
|
96
|
-
// ❌ NO ctx.fetch - cannot make authenticated API calls
|
|
97
|
-
// ❌ NO connectionVariables - no connection context
|
|
98
|
-
// ❌ CANNOT call createClient() - will fail
|
|
99
|
-
});
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
### Example: Adhoc Extraction (Requires http())
|
|
103
|
-
|
|
104
|
-
```typescript
|
|
105
|
-
// ✅ CORRECT - Use http() for Fluent API access with connection-based security
|
|
106
|
-
export const adhocExtraction = webhook('adhoc-extract', {
|
|
107
|
-
connection: 'webhook-auth', // ← Platform validates API key automatically
|
|
108
|
-
response: { mode: 'sync' },
|
|
109
|
-
}).then(
|
|
110
|
-
http('execute-extraction', { connection: 'fluent_commerce' }, async ctx => {
|
|
111
|
-
const { log, data } = ctx;
|
|
112
|
-
|
|
113
|
-
// ✅ If we're here, authentication already passed via connection
|
|
114
|
-
// No manual validation code needed!
|
|
115
|
-
|
|
116
|
-
// http() provides connection context for createClient()
|
|
117
|
-
const client = await createClient(ctx); // ✅ Works!
|
|
118
|
-
const result = await client.graphql({ query: '...' });
|
|
119
|
-
|
|
120
|
-
return { success: true, data: result.data };
|
|
121
|
-
})
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
// ❌ WRONG - fn() cannot access Fluent API
|
|
125
|
-
export const brokenExtraction = webhook('adhoc-extract', {
|
|
126
|
-
connection: 'webhook-auth'
|
|
127
|
-
}).then(
|
|
128
|
-
fn('execute-extraction', async ctx => {
|
|
129
|
-
const client = await createClient(ctx); // ❌ FAILS - no connection context in fn()
|
|
130
|
-
})
|
|
131
|
-
);
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
### Quick Decision Table
|
|
135
|
-
|
|
136
|
-
| Need To | Use | Reason |
|
|
137
|
-
| ------------------------------- | -------- | ------------------------------------ |
|
|
138
|
-
| Call Fluent GraphQL API | `http()` | Requires connection + authentication |
|
|
139
|
-
| Parse CSV to JSON | `fn()` | No external API needed |
|
|
140
|
-
| Check if file already processed | `fn()` | Uses KV storage only |
|
|
141
|
-
| Send batch to Fluent | `http()` | Requires Fluent API access |
|
|
142
|
-
| Transform/map data | `fn()` | Pure data transformation |
|
|
143
|
-
| Query external REST API | `http()` | Requires fetch + connection |
|
|
144
|
-
| Access HTTP headers | **Both** | Use `ctx.request()` in either |
|
|
145
|
-
|
|
146
|
-
---
|
|
147
|
-
|
|
148
|
-
## ⚠️ Deno Compatibility: Buffer Import
|
|
149
|
-
|
|
150
|
-
**CRITICAL for Versori Platform (Deno runtime):** Always import Buffer explicitly when working with binary data, file uploads, or base64 encoding/decoding.
|
|
151
|
-
|
|
152
|
-
### Why Buffer Import is Required
|
|
153
|
-
|
|
154
|
-
Versori runs on Deno, not Node.js. In Node.js, `Buffer` is globally available, but in Deno it must be explicitly imported from `node:buffer`.
|
|
155
|
-
|
|
156
|
-
```typescript
|
|
157
|
-
// ✅ CORRECT - Always import Buffer in Versori/Deno
|
|
158
|
-
import { Buffer } from 'node:buffer';
|
|
159
|
-
|
|
160
|
-
await sftp.uploadFile('/path/file.xml', Buffer.from(xmlContent, 'utf-8'));
|
|
161
|
-
const decoded = Buffer.from(base64String, 'base64').toString('utf-8');
|
|
162
|
-
|
|
163
|
-
// ❌ WRONG - Buffer is not global in Deno
|
|
164
|
-
await sftp.uploadFile('/path/file.xml', Buffer.from(xmlContent, 'utf-8')); // ReferenceError!
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
### When to Import Buffer
|
|
168
|
-
|
|
169
|
-
Import Buffer when your workflow involves:
|
|
170
|
-
|
|
171
|
-
| Operation | Example | Requires Buffer? |
|
|
172
|
-
|-----------|---------|------------------|
|
|
173
|
-
| **File uploads** (SFTP, S3) | `sftp.uploadFile()` | ✅ Yes |
|
|
174
|
-
| **Base64 encoding/decoding** | `Buffer.from(str, 'base64')` | ✅ Yes |
|
|
175
|
-
| **Binary data handling** | Working with binary files | ✅ Yes |
|
|
176
|
-
| **String encoding** | `Buffer.from(str, 'utf-8')` | ✅ Yes |
|
|
177
|
-
| **XML response generation** | Returning XML as string | ❌ No (string only) |
|
|
178
|
-
| **JSON operations** | `JSON.parse()`, `JSON.stringify()` | ❌ No |
|
|
179
|
-
| **GraphQL queries** | `client.graphql()` | ❌ No |
|
|
180
|
-
|
|
181
|
-
### Pattern: Add Buffer Import at Top of File
|
|
182
|
-
|
|
183
|
-
```typescript
|
|
184
|
-
// ✅ CORRECT Pattern - Add at top of file with other imports
|
|
185
|
-
import { Buffer } from 'node:buffer'; // Required for Deno
|
|
186
|
-
import { schedule, http, fn } from '@versori/run';
|
|
187
|
-
import { createClient, SftpDataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
188
|
-
|
|
189
|
-
export const sftpUpload = schedule('upload', '0 * * * *')
|
|
190
|
-
.then(fn('prepare-file', async (ctx) => {
|
|
191
|
-
// Buffer available here
|
|
192
|
-
const fileContent = Buffer.from(ctx.data.content, 'utf-8');
|
|
193
|
-
return { fileContent };
|
|
194
|
-
}));
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
**See Also:** All examples in this module that use Buffer include the import statement.
|
|
198
|
-
|
|
199
|
-
---
|
|
200
|
-
|
|
201
|
-
## HTTP Functions - External API Calls
|
|
202
|
-
|
|
203
|
-
HTTP workflows enable authenticated calls to external APIs, including Fluent Commerce.
|
|
204
|
-
|
|
205
|
-
### Basic HTTP Workflow
|
|
206
|
-
|
|
207
|
-
```typescript
|
|
208
|
-
import { http } from '@versori/run';
|
|
209
|
-
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Query inventory positions from Fluent Commerce
|
|
213
|
-
*
|
|
214
|
-
* Endpoint: https://{workspace}.versori.run/query-inventory
|
|
215
|
-
* Method: GET
|
|
216
|
-
*/
|
|
217
|
-
export const queryInventory = http(
|
|
218
|
-
'query-inventory',
|
|
219
|
-
{
|
|
220
|
-
connection: 'fluent_commerce', // CRITICAL: Provides OAuth2 auth
|
|
221
|
-
timeout: 30000, // 30 second timeout
|
|
222
|
-
retry: {
|
|
223
|
-
attempts: 3, // Retry up to 3 times
|
|
224
|
-
delay: 1000, // 1 second between retries
|
|
225
|
-
},
|
|
226
|
-
},
|
|
227
|
-
async ctx => {
|
|
228
|
-
ctx.log('info', 'Starting inventory query');
|
|
229
|
-
|
|
230
|
-
try {
|
|
231
|
-
// Create SDK client (auto-configured from connection)
|
|
232
|
-
const client = await createClient(ctx);
|
|
233
|
-
|
|
234
|
-
// GraphQL query - schema validated against Fluent Commerce API
|
|
235
|
-
const result = await client.graphql({
|
|
236
|
-
query: `
|
|
237
|
-
query GetInventoryPositions($first: Int!) {
|
|
238
|
-
inventoryPositions(first: $first) {
|
|
239
|
-
edges {
|
|
240
|
-
node {
|
|
241
|
-
id
|
|
242
|
-
ref
|
|
243
|
-
productRef
|
|
244
|
-
locationRef
|
|
245
|
-
onHand # ✅ Correct field for InventoryPosition
|
|
246
|
-
status
|
|
247
|
-
}
|
|
248
|
-
cursor
|
|
249
|
-
}
|
|
250
|
-
pageInfo {
|
|
251
|
-
hasNextPage
|
|
252
|
-
endCursor
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
`,
|
|
257
|
-
variables: { first: 100 },
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
// Check for GraphQL errors
|
|
261
|
-
if (result.errors?.length) {
|
|
262
|
-
throw new Error(`GraphQL error: ${result.errors[0].message}`);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const positions = result.data.inventoryPositions.edges;
|
|
266
|
-
ctx.log('info', `Retrieved ${positions.length} inventory positions`);
|
|
267
|
-
|
|
268
|
-
return {
|
|
269
|
-
success: true,
|
|
270
|
-
count: positions.length,
|
|
271
|
-
data: positions.map(edge => edge.node),
|
|
272
|
-
pageInfo: result.data.inventoryPositions.pageInfo,
|
|
273
|
-
};
|
|
274
|
-
} catch (error) {
|
|
275
|
-
ctx.log('error', 'Inventory query failed', {
|
|
276
|
-
error: error instanceof Error ? error.message : String(error),
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
return {
|
|
280
|
-
success: false,
|
|
281
|
-
error: error instanceof Error ? error.message : 'Unknown error',
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
);
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
### HTTP with Query Parameters
|
|
289
|
-
|
|
290
|
-
```typescript
|
|
291
|
-
export const searchOrders = http(
|
|
292
|
-
'search-orders',
|
|
293
|
-
{
|
|
294
|
-
connection: 'fluent_commerce',
|
|
295
|
-
},
|
|
296
|
-
async ctx => {
|
|
297
|
-
// Extract query parameters
|
|
298
|
-
const status = ctx.query?.status || 'OPEN';
|
|
299
|
-
const limit = parseInt(ctx.query?.limit || '50');
|
|
300
|
-
const customerRef = ctx.query?.customerRef;
|
|
301
|
-
|
|
302
|
-
ctx.log('info', 'Searching orders', { status, limit, customerRef });
|
|
303
|
-
|
|
304
|
-
const client = await createClient(ctx);
|
|
305
|
-
|
|
306
|
-
// Build GraphQL query with filters - schema validated
|
|
307
|
-
const result = await client.graphql({
|
|
308
|
-
query: `
|
|
309
|
-
query SearchOrders($status: String!, $first: Int!, $customerRef: String) {
|
|
310
|
-
orders(status: $status, first: $first, customerRef: $customerRef) {
|
|
311
|
-
edges {
|
|
312
|
-
node {
|
|
313
|
-
id
|
|
314
|
-
ref
|
|
315
|
-
status
|
|
316
|
-
totalPrice
|
|
317
|
-
customer {
|
|
318
|
-
ref
|
|
319
|
-
firstName
|
|
320
|
-
lastName
|
|
321
|
-
}
|
|
322
|
-
items {
|
|
323
|
-
edges {
|
|
324
|
-
node {
|
|
325
|
-
productRef
|
|
326
|
-
quantity
|
|
327
|
-
price
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
`,
|
|
336
|
-
variables: { status, first: limit, customerRef },
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
return {
|
|
340
|
-
orders: result.data.orders.edges.map(e => e.node),
|
|
341
|
-
count: result.data.orders.edges.length,
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
);
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
### HTTP with POST Body (Mutations)
|
|
348
|
-
|
|
349
|
-
```typescript
|
|
350
|
-
import { http } from '@versori/run';
|
|
351
|
-
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Create inventory position
|
|
355
|
-
*
|
|
356
|
-
* POST https://{workspace}.versori.run/create-inventory
|
|
357
|
-
* Body: { productRef, locationRef, qty }
|
|
358
|
-
*/
|
|
359
|
-
export const createInventory = http(
|
|
360
|
-
'create-inventory',
|
|
361
|
-
{
|
|
362
|
-
connection: 'fluent_commerce',
|
|
363
|
-
},
|
|
364
|
-
async ctx => {
|
|
365
|
-
// Extract POST body
|
|
366
|
-
const { productRef, locationRef, qty } = ctx.data || {};
|
|
367
|
-
|
|
368
|
-
// Validation
|
|
369
|
-
if (!productRef || !locationRef || qty === undefined) {
|
|
370
|
-
return {
|
|
371
|
-
success: false,
|
|
372
|
-
error: 'Missing required fields: productRef, locationRef, qty',
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const client = await createClient(ctx);
|
|
377
|
-
|
|
378
|
-
// GraphQL mutation - schema validated
|
|
379
|
-
const result = await client.graphql({
|
|
380
|
-
query: `
|
|
381
|
-
mutation CreateInventoryPosition($input: CreateInventoryPositionInput!) {
|
|
382
|
-
createInventoryPosition(input: $input) {
|
|
383
|
-
id
|
|
384
|
-
ref
|
|
385
|
-
productRef
|
|
386
|
-
locationRef
|
|
387
|
-
onHand
|
|
388
|
-
status
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
`,
|
|
392
|
-
variables: {
|
|
393
|
-
input: {
|
|
394
|
-
productRef,
|
|
395
|
-
locationRef,
|
|
396
|
-
qty,
|
|
397
|
-
type: 'AVAILABLE',
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
if (result.errors?.length) {
|
|
403
|
-
return {
|
|
404
|
-
success: false,
|
|
405
|
-
error: result.errors[0].message,
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
ctx.log('info', 'Inventory position created', {
|
|
410
|
-
id: result.data.createInventoryPosition.id,
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
return {
|
|
414
|
-
success: true,
|
|
415
|
-
data: result.data.createInventoryPosition,
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
);
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
---
|
|
422
|
-
|
|
423
|
-
## Webhook Functions - Receiving External Requests
|
|
424
|
-
|
|
425
|
-
Webhook workflows receive external HTTP requests. They provide `ctx.data` (parsed body) and `ctx.request()` for accessing headers and raw request details.
|
|
426
|
-
|
|
427
|
-
### Basic Webhook Receiver
|
|
428
|
-
|
|
429
|
-
```typescript
|
|
430
|
-
import { webhook } from '@versori/run';
|
|
431
|
-
import { parseWebhookRequest } from '@fluentcommerce/fc-connect-sdk';
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Receive SFCC order webhook
|
|
435
|
-
*
|
|
436
|
-
* POST https://{workspace}.versori.run/receive-order
|
|
437
|
-
* Body: XML order payload
|
|
438
|
-
*/
|
|
439
|
-
export const receiveOrder = webhook('receive-order', async ctx => {
|
|
440
|
-
const req = ctx.request();
|
|
441
|
-
ctx.log('info', 'Received order webhook', {
|
|
442
|
-
contentType: req?.headers['content-type'],
|
|
443
|
-
bodySize: JSON.stringify(ctx.data).length,
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
try {
|
|
447
|
-
// Parse incoming payload
|
|
448
|
-
const payload = parseWebhookRequest(ctx.data);
|
|
449
|
-
|
|
450
|
-
if (!payload) {
|
|
451
|
-
return {
|
|
452
|
-
success: false,
|
|
453
|
-
error: 'Invalid payload format',
|
|
454
|
-
status: 400,
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Process order (use fn() or http() in chain)
|
|
459
|
-
ctx.log('info', 'Order parsed successfully', {
|
|
460
|
-
orderId: payload.orderId,
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
return {
|
|
464
|
-
success: true,
|
|
465
|
-
orderId: payload.orderId,
|
|
466
|
-
status: 200,
|
|
467
|
-
};
|
|
468
|
-
} catch (error) {
|
|
469
|
-
ctx.log('error', 'Webhook processing failed', {
|
|
470
|
-
error: error instanceof Error ? error.message : String(error),
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
return {
|
|
474
|
-
success: false,
|
|
475
|
-
error: error instanceof Error ? error.message : 'Processing failed',
|
|
476
|
-
status: 500,
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
});
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
### Webhook with Signature Validation (Fluent Rubix)
|
|
483
|
-
|
|
484
|
-
**IMPORTANT**: The webhook validation is **ONLY** for webhooks sent from **Fluent Commerce Rubix workflows**.
|
|
485
|
-
|
|
486
|
-
```typescript
|
|
487
|
-
import { webhook } from '@versori/run';
|
|
488
|
-
import {
|
|
489
|
-
WebhookValidationService,
|
|
490
|
-
SignatureAlgorithm,
|
|
491
|
-
parseWebhookRequest,
|
|
492
|
-
validateFluentEvent,
|
|
493
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Receive Fluent Rubix webhook with signature validation
|
|
497
|
-
*
|
|
498
|
-
* POST https://{workspace}.versori.run/rubix-event
|
|
499
|
-
* Headers: fluent-signature (or x-fluent-signature)
|
|
500
|
-
*/
|
|
501
|
-
export const receiveRubixEvent = webhook('rubix-event', async ctx => {
|
|
502
|
-
const logger = ctx.log; // Use native Versori logger
|
|
503
|
-
|
|
504
|
-
// Get public key from connector variables
|
|
505
|
-
const publicKey = ctx.vars?.FLUENT_WEBHOOK_PUBLIC_KEY;
|
|
506
|
-
if (!publicKey) {
|
|
507
|
-
logger.error('Missing FLUENT_WEBHOOK_PUBLIC_KEY in connector variables');
|
|
508
|
-
return { error: 'Configuration error: missing public key', status: 500 };
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// IMPORTANT: This validator is ONLY for Fluent Commerce Rubix workflows
|
|
512
|
-
const validator = new WebhookValidationService(
|
|
513
|
-
{
|
|
514
|
-
algorithm: SignatureAlgorithm.SHA512_WITH_RSA,
|
|
515
|
-
strictValidation: true,
|
|
516
|
-
},
|
|
517
|
-
logger
|
|
518
|
-
);
|
|
519
|
-
|
|
520
|
-
// Extract signature from headers using ctx.request()
|
|
521
|
-
const req = ctx.request();
|
|
522
|
-
const signature = req?.headers['fluent-signature'] || req?.headers['x-fluent-signature'];
|
|
523
|
-
|
|
524
|
-
if (!signature) {
|
|
525
|
-
logger.warn('Missing webhook signature in headers');
|
|
526
|
-
return { error: 'Missing signature', status: 401 };
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Validate Fluent Commerce Rubix webhook signature
|
|
530
|
-
const validationResult = await validator.validateWebhookSignature(
|
|
531
|
-
JSON.stringify(ctx.data),
|
|
532
|
-
signature,
|
|
533
|
-
publicKey,
|
|
534
|
-
SignatureAlgorithm.SHA512_WITH_RSA
|
|
535
|
-
);
|
|
536
|
-
|
|
537
|
-
if (!validationResult.isValid) {
|
|
538
|
-
logger.error('Fluent Commerce Rubix webhook validation failed', validationResult);
|
|
539
|
-
return {
|
|
540
|
-
error: 'Invalid Fluent Rubix webhook signature',
|
|
541
|
-
details: validationResult.error,
|
|
542
|
-
status: 401,
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Parse webhook payload
|
|
547
|
-
const payload = parseWebhookRequest(ctx.data, logger, 'receiveRubixEvent');
|
|
548
|
-
|
|
549
|
-
if (!payload) {
|
|
550
|
-
return { error: 'Invalid payload', status: 400 };
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Validate event structure
|
|
554
|
-
const validation = validateFluentEvent(payload, logger, 'receiveRubixEvent');
|
|
555
|
-
if (!validation.isValid) {
|
|
556
|
-
return {
|
|
557
|
-
error: 'Validation failed',
|
|
558
|
-
details: validation.warnings,
|
|
559
|
-
status: 400,
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
logger.info('Rubix webhook validated successfully', {
|
|
564
|
-
eventName: payload.name,
|
|
565
|
-
entityType: payload.entityType,
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
return {
|
|
569
|
-
success: true,
|
|
570
|
-
eventName: payload.name,
|
|
571
|
-
status: 200,
|
|
572
|
-
};
|
|
573
|
-
});
|
|
574
|
-
```
|
|
575
|
-
|
|
576
|
-
### CRITICAL: Non-JSON Response Handlers (XML, HTML, CSV)
|
|
577
|
-
|
|
578
|
-
**Problem**: By default, Versori JSON-encodes all webhook responses. This breaks XML, HTML, CSV, and other non-JSON content.
|
|
579
|
-
|
|
580
|
-
**Solution**: Use custom `onSuccess` and `onError` handlers that return `Response` objects.
|
|
581
|
-
|
|
582
|
-
#### XML Response Example
|
|
583
|
-
|
|
584
|
-
```typescript
|
|
585
|
-
import { webhook, fn } from '@versori/run';
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Return XML response from webhook
|
|
589
|
-
*
|
|
590
|
-
* GET/POST https://{workspace}.versori.run/order-status-xml
|
|
591
|
-
* Returns: Raw XML (NOT JSON-encoded)
|
|
592
|
-
*/
|
|
593
|
-
export const orderStatusXML = webhook('order-status-xml', {
|
|
594
|
-
response: {
|
|
595
|
-
mode: 'sync',
|
|
596
|
-
onSuccess: ctx => {
|
|
597
|
-
// ctx.data contains the final value from .then() chain
|
|
598
|
-
return new Response(ctx.data, {
|
|
599
|
-
status: 200,
|
|
600
|
-
headers: {
|
|
601
|
-
'Content-Type': 'application/xml; charset=utf-8',
|
|
602
|
-
'X-Execution-Id': ctx.executionId,
|
|
603
|
-
},
|
|
604
|
-
});
|
|
605
|
-
},
|
|
606
|
-
onError: ctx => {
|
|
607
|
-
// ctx.data contains the error from .catch()
|
|
608
|
-
const errorXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
609
|
-
<ErrorResponse>
|
|
610
|
-
<Error>
|
|
611
|
-
<Code>PROCESSING_ERROR</Code>
|
|
612
|
-
<Message>${ctx.data?.message || 'Unknown error'}</Message>
|
|
613
|
-
<Timestamp>${new Date().toISOString()}</Timestamp>
|
|
614
|
-
<ExecutionId>${ctx.executionId}</ExecutionId>
|
|
615
|
-
</Error>
|
|
616
|
-
</ErrorResponse>`;
|
|
617
|
-
|
|
618
|
-
return new Response(errorXml, {
|
|
619
|
-
status: 500,
|
|
620
|
-
headers: {
|
|
621
|
-
'Content-Type': 'application/xml; charset=utf-8',
|
|
622
|
-
'X-Execution-Id': ctx.executionId,
|
|
623
|
-
},
|
|
624
|
-
});
|
|
625
|
-
},
|
|
626
|
-
},
|
|
627
|
-
cors: true,
|
|
628
|
-
})
|
|
629
|
-
.then(
|
|
630
|
-
fn('generate-xml', ({ data }) => {
|
|
631
|
-
// Return raw XML string - this becomes ctx.data in onSuccess
|
|
632
|
-
const orderId = data?.orderId || 'unknown';
|
|
633
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
634
|
-
<OrderStatusResponse>
|
|
635
|
-
<OrderId>${orderId}</OrderId>
|
|
636
|
-
<Status>SHIPPED</Status>
|
|
637
|
-
<Timestamp>${new Date().toISOString()}</Timestamp>
|
|
638
|
-
</OrderStatusResponse>`;
|
|
639
|
-
})
|
|
640
|
-
)
|
|
641
|
-
.catch(({ data }) => {
|
|
642
|
-
// Return error - this becomes ctx.data in onError
|
|
643
|
-
return { message: data instanceof Error ? data.message : String(data) };
|
|
644
|
-
});
|
|
645
|
-
```
|
|
646
|
-
|
|
647
|
-
**Why This Works**:
|
|
648
|
-
|
|
649
|
-
- @versori/run's `sendResponse()` function streams `Response` objects directly **without JSON encoding**
|
|
650
|
-
- The Content-Type header is preserved exactly as specified
|
|
651
|
-
- Works with @versori/run v0.4.4 and all v0.4.x versions
|
|
652
|
-
|
|
653
|
-
#### HTML Response Example
|
|
654
|
-
|
|
655
|
-
```typescript
|
|
656
|
-
export const statusPage = webhook('status-page', {
|
|
657
|
-
response: {
|
|
658
|
-
mode: 'sync',
|
|
659
|
-
onSuccess: ctx =>
|
|
660
|
-
new Response(ctx.data, {
|
|
661
|
-
status: 200,
|
|
662
|
-
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
663
|
-
}),
|
|
664
|
-
},
|
|
665
|
-
}).then(
|
|
666
|
-
fn('generate-html', () => {
|
|
667
|
-
return `<!DOCTYPE html>
|
|
668
|
-
<html lang="en">
|
|
669
|
-
<head>
|
|
670
|
-
<meta charset="UTF-8">
|
|
671
|
-
<title>Order Status</title>
|
|
672
|
-
</head>
|
|
673
|
-
<body>
|
|
674
|
-
<h1>System Operational</h1>
|
|
675
|
-
<p>All services running normally.</p>
|
|
676
|
-
</body>
|
|
677
|
-
</html>`;
|
|
678
|
-
})
|
|
679
|
-
);
|
|
680
|
-
```
|
|
681
|
-
|
|
682
|
-
#### CSV Response Example
|
|
683
|
-
|
|
684
|
-
```typescript
|
|
685
|
-
export const exportCSV = webhook('export-csv', {
|
|
686
|
-
response: {
|
|
687
|
-
mode: 'sync',
|
|
688
|
-
onSuccess: ctx =>
|
|
689
|
-
new Response(ctx.data, {
|
|
690
|
-
status: 200,
|
|
691
|
-
headers: {
|
|
692
|
-
'Content-Type': 'text/csv; charset=utf-8',
|
|
693
|
-
'Content-Disposition': 'attachment; filename="orders.csv"',
|
|
694
|
-
},
|
|
695
|
-
}),
|
|
696
|
-
},
|
|
697
|
-
}).then(
|
|
698
|
-
fn('generate-csv', () => {
|
|
699
|
-
return 'OrderId,Status,Total\n123,SHIPPED,99.99\n456,PENDING,149.99';
|
|
700
|
-
})
|
|
701
|
-
);
|
|
702
|
-
```
|
|
703
|
-
|
|
704
|
-
#### Complex Multi-Step Workflow with XML Response
|
|
705
|
-
|
|
706
|
-
```typescript
|
|
707
|
-
import { webhook, fn, http } from '@versori/run';
|
|
708
|
-
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
709
|
-
import { XMLBuilder } from 'fast-xml-parser';
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* Fetch order from Fluent, return as XML
|
|
713
|
-
*
|
|
714
|
-
* POST https://{workspace}.versori.run/order-detail-xml
|
|
715
|
-
* Body: { orderId }
|
|
716
|
-
* Returns: Order details as XML
|
|
717
|
-
*/
|
|
718
|
-
export const orderDetailXML = webhook('order-detail-xml', {
|
|
719
|
-
response: {
|
|
720
|
-
mode: 'sync',
|
|
721
|
-
onSuccess: ctx =>
|
|
722
|
-
new Response(ctx.data, {
|
|
723
|
-
status: 200,
|
|
724
|
-
headers: {
|
|
725
|
-
'Content-Type': 'application/xml; charset=utf-8',
|
|
726
|
-
'X-Execution-Id': ctx.executionId,
|
|
727
|
-
'X-Order-Id': ctx.metadata?.orderId || 'unknown',
|
|
728
|
-
},
|
|
729
|
-
}),
|
|
730
|
-
onError: ctx => {
|
|
731
|
-
const errorXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
732
|
-
<ErrorResponse>
|
|
733
|
-
<Error>
|
|
734
|
-
<Code>PROCESSING_ERROR</Code>
|
|
735
|
-
<Message>${ctx.data?.message || 'Unknown error'}</Message>
|
|
736
|
-
<Timestamp>${new Date().toISOString()}</Timestamp>
|
|
737
|
-
<ExecutionId>${ctx.executionId}</ExecutionId>
|
|
738
|
-
</Error>
|
|
739
|
-
</ErrorResponse>`;
|
|
740
|
-
|
|
741
|
-
return new Response(errorXml, {
|
|
742
|
-
status: 500,
|
|
743
|
-
headers: {
|
|
744
|
-
'Content-Type': 'application/xml; charset=utf-8',
|
|
745
|
-
'X-Execution-Id': ctx.executionId,
|
|
746
|
-
},
|
|
747
|
-
});
|
|
748
|
-
},
|
|
749
|
-
},
|
|
750
|
-
cors: true,
|
|
751
|
-
})
|
|
752
|
-
// Step 1: Parse incoming request
|
|
753
|
-
.then(
|
|
754
|
-
fn('parse-request', ({ data }) => {
|
|
755
|
-
const orderId = data?.orderId;
|
|
756
|
-
if (!orderId) {
|
|
757
|
-
throw new Error('OrderId missing from request');
|
|
758
|
-
}
|
|
759
|
-
return { orderId };
|
|
760
|
-
})
|
|
761
|
-
)
|
|
762
|
-
|
|
763
|
-
// Step 2: Fetch from Fluent Commerce
|
|
764
|
-
.then(
|
|
765
|
-
http(
|
|
766
|
-
'fetch-order',
|
|
767
|
-
{
|
|
768
|
-
connection: 'fluent_commerce',
|
|
769
|
-
},
|
|
770
|
-
async ctx => {
|
|
771
|
-
const { orderId } = ctx.data;
|
|
772
|
-
const client = await createClient(ctx);
|
|
773
|
-
|
|
774
|
-
// GraphQL query - schema validated
|
|
775
|
-
const result = await client.graphql({
|
|
776
|
-
query: `
|
|
777
|
-
query GetOrder($ref: String!) {
|
|
778
|
-
order(ref: $ref) {
|
|
779
|
-
id
|
|
780
|
-
ref
|
|
781
|
-
status
|
|
782
|
-
totalPrice
|
|
783
|
-
customer {
|
|
784
|
-
ref
|
|
785
|
-
firstName
|
|
786
|
-
lastName
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
`,
|
|
791
|
-
variables: { ref: orderId },
|
|
792
|
-
});
|
|
793
|
-
|
|
794
|
-
if (!result.data?.order) {
|
|
795
|
-
throw new Error(`Order not found: ${orderId}`);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
return {
|
|
799
|
-
orderId,
|
|
800
|
-
orderData: result.data.order,
|
|
801
|
-
};
|
|
802
|
-
}
|
|
803
|
-
)
|
|
804
|
-
)
|
|
805
|
-
|
|
806
|
-
// Step 3: Build XML response
|
|
807
|
-
.then(
|
|
808
|
-
fn('build-xml', ({ data }) => {
|
|
809
|
-
const builder = new XMLBuilder({
|
|
810
|
-
ignoreAttributes: false,
|
|
811
|
-
attributeNamePrefix: '@',
|
|
812
|
-
format: true,
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
const xmlObject = {
|
|
816
|
-
'?xml': { '@version': '1.0', '@encoding': 'UTF-8' },
|
|
817
|
-
OrderDetailResponse: {
|
|
818
|
-
'@orderId': data.orderId,
|
|
819
|
-
Order: {
|
|
820
|
-
Id: data.orderData.id,
|
|
821
|
-
Ref: data.orderData.ref,
|
|
822
|
-
Status: data.orderData.status,
|
|
823
|
-
TotalPrice: data.orderData.totalPrice,
|
|
824
|
-
Customer: {
|
|
825
|
-
Ref: data.orderData.customer.ref,
|
|
826
|
-
Name: `${data.orderData.customer.firstName} ${data.orderData.customer.lastName}`,
|
|
827
|
-
},
|
|
828
|
-
},
|
|
829
|
-
},
|
|
830
|
-
};
|
|
831
|
-
|
|
832
|
-
return builder.build(xmlObject);
|
|
833
|
-
})
|
|
834
|
-
)
|
|
835
|
-
|
|
836
|
-
.catch(({ data }) => {
|
|
837
|
-
// Return error message as object - onError handler will format as XML
|
|
838
|
-
return { message: data instanceof Error ? data.message : String(data) };
|
|
839
|
-
});
|
|
840
|
-
```
|
|
841
|
-
|
|
842
|
-
**Key Patterns**:
|
|
843
|
-
|
|
844
|
-
- `onSuccess` and `onError` **return Response objects**
|
|
845
|
-
- Workflow steps **return raw strings** (NOT Response objects)
|
|
846
|
-
- Content-Type header matches actual content format
|
|
847
|
-
- Error handler uses same Content-Type as success handler
|
|
848
|
-
|
|
849
|
-
---
|
|
850
|
-
|
|
851
|
-
## Scheduled Functions - Time-Based Recurring Tasks
|
|
852
|
-
|
|
853
|
-
Scheduled workflows run on a cron schedule. They **MUST** be chained with `.then()` to add processing logic. To access external APIs (like Fluent Commerce), chain with `http()` task.
|
|
854
|
-
|
|
855
|
-
### ⚠️ CRITICAL: schedule() Signature
|
|
856
|
-
|
|
857
|
-
**schedule() does NOT accept a handler or options as parameters**. It returns a `Workflow` that must be chained.
|
|
858
|
-
|
|
859
|
-
**Signature**:
|
|
860
|
-
```typescript
|
|
861
|
-
function schedule(
|
|
862
|
-
id: string,
|
|
863
|
-
schedule: string,
|
|
864
|
-
activationPredicate?: ActivationPredicate
|
|
865
|
-
): Workflow<ScheduleData>
|
|
866
|
-
```
|
|
867
|
-
|
|
868
|
-
**activationPredicate**: Optional - `'all'` or custom filter function `(activation?: Activation) => boolean`
|
|
869
|
-
|
|
870
|
-
### Basic Scheduled Workflow
|
|
871
|
-
|
|
872
|
-
```typescript
|
|
873
|
-
import { schedule, http } from '@versori/run';
|
|
874
|
-
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
875
|
-
|
|
876
|
-
/**
|
|
877
|
-
* Daily inventory sync - runs at 2 AM UTC daily
|
|
878
|
-
*
|
|
879
|
-
* Cron: 0 2 * * * (every day at 2:00 AM)
|
|
880
|
-
*/
|
|
881
|
-
export const dailyInventorySync = schedule(
|
|
882
|
-
'daily-inventory-sync',
|
|
883
|
-
'0 2 * * *'
|
|
884
|
-
)
|
|
885
|
-
.then(
|
|
886
|
-
http(
|
|
887
|
-
'sync-inventory',
|
|
888
|
-
{ connection: 'fluent_commerce' },
|
|
889
|
-
async ctx => {
|
|
890
|
-
ctx.log.info('Starting daily inventory sync');
|
|
891
|
-
|
|
892
|
-
const client = await createClient(ctx);
|
|
893
|
-
|
|
894
|
-
// Fetch inventory from external source (e.g., S3)
|
|
895
|
-
const inventoryData = await fetchInventoryFromS3(ctx);
|
|
896
|
-
|
|
897
|
-
// Create batch job
|
|
898
|
-
const job = await client.createJob({
|
|
899
|
-
name: 'Daily Inventory Sync',
|
|
900
|
-
retailerId: ctx.activation.getVariable('FLUENT_RETAILER_ID'),
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
// Send batch data
|
|
904
|
-
const batch = await client.sendBatch(job.id, {
|
|
905
|
-
action: 'UPSERT',
|
|
906
|
-
entityType: 'INVENTORY',
|
|
907
|
-
entities: inventoryData,
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
ctx.log.info('Daily sync complete', {
|
|
911
|
-
jobId: job.id,
|
|
912
|
-
batchId: batch.id,
|
|
913
|
-
recordCount: inventoryData.length,
|
|
914
|
-
});
|
|
915
|
-
|
|
916
|
-
return {
|
|
917
|
-
success: true,
|
|
918
|
-
jobId: job.id,
|
|
919
|
-
recordCount: inventoryData.length,
|
|
920
|
-
};
|
|
921
|
-
}
|
|
922
|
-
)
|
|
923
|
-
);
|
|
924
|
-
```
|
|
925
|
-
|
|
926
|
-
### Common Cron Patterns
|
|
927
|
-
|
|
928
|
-
```typescript
|
|
929
|
-
// Every minute - with http() for API access
|
|
930
|
-
export const everyMinute = schedule('every-minute', '* * * * *')
|
|
931
|
-
.then(http('task', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
932
|
-
// Fluent API access available
|
|
933
|
-
const client = await createClient(ctx);
|
|
934
|
-
// ... process
|
|
935
|
-
}));
|
|
936
|
-
|
|
937
|
-
// Every minute - with fn() for KV-only
|
|
938
|
-
export const everyMinuteKV = schedule('every-minute-kv', '* * * * *')
|
|
939
|
-
.then(fn('task', async (ctx) => {
|
|
940
|
-
// KV storage only, no external API
|
|
941
|
-
const kv = ctx.openKv(':project:');
|
|
942
|
-
// ... process
|
|
943
|
-
}));
|
|
944
|
-
|
|
945
|
-
// Every hour at minute 0
|
|
946
|
-
export const hourly = schedule('hourly', '0 * * * *')
|
|
947
|
-
.then(http('sync', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
948
|
-
// Hourly inventory sync
|
|
949
|
-
}));
|
|
950
|
-
|
|
951
|
-
// Every 6 hours - Fluent inventory snapshot
|
|
952
|
-
export const every6Hours = schedule('every-6-hours', '0 */6 * * *')
|
|
953
|
-
.then(http('snapshot', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
954
|
-
// Extract inventory snapshot every 6 hours
|
|
955
|
-
}));
|
|
956
|
-
|
|
957
|
-
// Daily at 2 AM - Full sync
|
|
958
|
-
export const dailyAt2AM = schedule('daily-2am', '0 2 * * *')
|
|
959
|
-
.then(http('full-sync', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
960
|
-
// Daily full inventory sync
|
|
961
|
-
}));
|
|
962
|
-
|
|
963
|
-
// Weekdays at 9 AM - Business hours processing
|
|
964
|
-
export const weekdaysAt9AM = schedule('weekdays-9am', '0 9 * * 1-5')
|
|
965
|
-
.then(http('business-hours', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
966
|
-
// Weekday order processing
|
|
967
|
-
}));
|
|
968
|
-
|
|
969
|
-
// First of every month at midnight - Monthly reports
|
|
970
|
-
export const monthlyFirst = schedule('monthly-first', '0 0 1 * *')
|
|
971
|
-
.then(http('monthly-report', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
972
|
-
// Generate monthly inventory reports
|
|
973
|
-
}));
|
|
974
|
-
|
|
975
|
-
// Every Sunday at 3 AM - Weekly cleanup
|
|
976
|
-
export const weeklySunday = schedule('weekly-sunday', '0 3 * * 0')
|
|
977
|
-
.then(http('weekly-cleanup', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
978
|
-
// Weekly data cleanup
|
|
979
|
-
}));
|
|
980
|
-
|
|
981
|
-
// With activation predicate (filter by activation)
|
|
982
|
-
export const allActivations = schedule('all-act', '0 * * * *', 'all')
|
|
983
|
-
.then(fn('task', async (ctx) => {
|
|
984
|
-
// Runs for all activations
|
|
985
|
-
}));
|
|
986
|
-
|
|
987
|
-
export const customPredicate = schedule('custom', '0 * * * *', (a) => a?.id?.startsWith('prod'))
|
|
988
|
-
.then(fn('task', async (ctx) => {
|
|
989
|
-
// Runs only for production activations
|
|
990
|
-
}));
|
|
991
|
-
```
|
|
992
|
-
|
|
993
|
-
### Scheduled Workflow with State Management
|
|
994
|
-
|
|
995
|
-
```typescript
|
|
996
|
-
import { schedule, fn, http } from '@versori/run';
|
|
997
|
-
import { createClient, VersoriFileTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
998
|
-
|
|
999
|
-
/**
|
|
1000
|
-
* Hourly incremental sync with file tracking
|
|
1001
|
-
*
|
|
1002
|
-
* Cron: 0 * * * * (every hour)
|
|
1003
|
-
*/
|
|
1004
|
-
export const hourlyIncrementalSync = schedule(
|
|
1005
|
-
'hourly-sync',
|
|
1006
|
-
'0 * * * *'
|
|
1007
|
-
)
|
|
1008
|
-
.then(
|
|
1009
|
-
fn('check-files', async ctx => {
|
|
1010
|
-
const kv = ctx.openKv(':project:');
|
|
1011
|
-
const tracker = new VersoriFileTracker(kv, 'hourly-sync');
|
|
1012
|
-
|
|
1013
|
-
const lastFile = await tracker.getLastProcessedFile();
|
|
1014
|
-
const newFiles = await listFilesAfter(lastFile);
|
|
1015
|
-
|
|
1016
|
-
if (newFiles.length === 0) {
|
|
1017
|
-
ctx.log.info('No new files to process');
|
|
1018
|
-
throw new Error('SKIP'); // Signal to skip processing
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
return { newFiles, tracker };
|
|
1022
|
-
})
|
|
1023
|
-
)
|
|
1024
|
-
.then(
|
|
1025
|
-
http(
|
|
1026
|
-
'process-files',
|
|
1027
|
-
{ connection: 'fluent_commerce' },
|
|
1028
|
-
async ctx => {
|
|
1029
|
-
const { newFiles, tracker } = ctx.data;
|
|
1030
|
-
const client = await createClient(ctx);
|
|
1031
|
-
let processedCount = 0;
|
|
1032
|
-
|
|
1033
|
-
for (const file of newFiles) {
|
|
1034
|
-
if (await tracker.wasFileProcessed(file.name)) {
|
|
1035
|
-
ctx.log.info('Skipping already processed file', { file: file.name });
|
|
1036
|
-
continue;
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
const records = await processFile(file);
|
|
1040
|
-
|
|
1041
|
-
const job = await client.createJob({ name: `Hourly Sync - ${file.name}` });
|
|
1042
|
-
await client.sendBatch(job.id, {
|
|
1043
|
-
action: 'UPSERT',
|
|
1044
|
-
entityType: 'INVENTORY',
|
|
1045
|
-
entities: records,
|
|
1046
|
-
});
|
|
1047
|
-
|
|
1048
|
-
await tracker.markFileProcessed(file.name, {
|
|
1049
|
-
recordCount: records.length,
|
|
1050
|
-
timestamp: Date.now(),
|
|
1051
|
-
});
|
|
1052
|
-
|
|
1053
|
-
await tracker.setLastProcessedFile(file.name);
|
|
1054
|
-
processedCount += records.length;
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
ctx.log.info('Hourly sync complete', {
|
|
1058
|
-
filesProcessed: newFiles.length,
|
|
1059
|
-
recordsProcessed: processedCount,
|
|
1060
|
-
});
|
|
1061
|
-
|
|
1062
|
-
return {
|
|
1063
|
-
success: true,
|
|
1064
|
-
filesProcessed: newFiles.length,
|
|
1065
|
-
recordsProcessed: processedCount,
|
|
1066
|
-
};
|
|
1067
|
-
}
|
|
1068
|
-
)
|
|
1069
|
-
)
|
|
1070
|
-
.catch(({ data }) => {
|
|
1071
|
-
if (data.message === 'SKIP') {
|
|
1072
|
-
return { skipped: true, reason: 'No new files' };
|
|
1073
|
-
}
|
|
1074
|
-
return { success: false, error: data.message };
|
|
1075
|
-
});
|
|
1076
|
-
```
|
|
1077
|
-
|
|
1078
|
-
---
|
|
1079
|
-
|
|
1080
|
-
## Internal Functions (fn) - Data Transformation & State
|
|
1081
|
-
|
|
1082
|
-
Internal functions (`fn()`) are for processing that **does NOT require external API access**. They have access to KV storage and logging, but **NOT** `ctx.fetch`.
|
|
1083
|
-
|
|
1084
|
-
### File Tracking with fn()
|
|
1085
|
-
|
|
1086
|
-
```typescript
|
|
1087
|
-
import { fn } from '@versori/run';
|
|
1088
|
-
import { VersoriFileTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* Track file processing state
|
|
1092
|
-
*
|
|
1093
|
-
* Used internally by other workflows (NOT exposed as HTTP endpoint)
|
|
1094
|
-
*/
|
|
1095
|
-
export const trackFileProcessing = fn('track-file', async ctx => {
|
|
1096
|
-
const kv = ctx.openKv(':project:');
|
|
1097
|
-
const tracker = new VersoriFileTracker(kv, 'inventory-ingestion');
|
|
1098
|
-
|
|
1099
|
-
const fileName = ctx.data?.fileName;
|
|
1100
|
-
if (!fileName) {
|
|
1101
|
-
return { error: 'Missing fileName' };
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
// Check if processed
|
|
1105
|
-
if (await tracker.wasFileProcessed(fileName)) {
|
|
1106
|
-
return { skipped: true, reason: 'Already processed' };
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
// Mark as processed
|
|
1110
|
-
await tracker.markFileProcessed(fileName, {
|
|
1111
|
-
records: ctx.data?.recordCount || 0,
|
|
1112
|
-
timestamp: Date.now(),
|
|
1113
|
-
});
|
|
1114
|
-
|
|
1115
|
-
return { tracked: true, fileName };
|
|
1116
|
-
});
|
|
1117
|
-
```
|
|
1118
|
-
|
|
1119
|
-
### Data Transformation with fn()
|
|
1120
|
-
|
|
1121
|
-
```typescript
|
|
1122
|
-
import { fn } from '@versori/run';
|
|
1123
|
-
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
1124
|
-
|
|
1125
|
-
/**
|
|
1126
|
-
* Transform CSV data to Fluent format
|
|
1127
|
-
*
|
|
1128
|
-
* Used as part of workflow composition
|
|
1129
|
-
*/
|
|
1130
|
-
export const transformInventoryData = fn('transform-inventory', async ctx => {
|
|
1131
|
-
const rawData = ctx.data?.records;
|
|
1132
|
-
if (!rawData || !Array.isArray(rawData)) {
|
|
1133
|
-
return { error: 'Invalid input data' };
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
// Define field mapping
|
|
1137
|
-
const mappingConfig = {
|
|
1138
|
-
fields: {
|
|
1139
|
-
productRef: { source: 'sku_id', required: true },
|
|
1140
|
-
locationRef: { source: 'location_code', required: true },
|
|
1141
|
-
qty: { source: 'quantity', resolver: 'sdk.parseInt' },
|
|
1142
|
-
status: { source: 'status', resolver: 'sdk.uppercase' },
|
|
1143
|
-
},
|
|
1144
|
-
};
|
|
1145
|
-
|
|
1146
|
-
const mapper = new UniversalMapper(mappingConfig);
|
|
1147
|
-
|
|
1148
|
-
// Transform all records
|
|
1149
|
-
const transformed = [];
|
|
1150
|
-
const errors = [];
|
|
1151
|
-
|
|
1152
|
-
for (const record of rawData) {
|
|
1153
|
-
const result = await mapper.map(record);
|
|
1154
|
-
if (result.success) {
|
|
1155
|
-
transformed.push(result.data);
|
|
1156
|
-
} else {
|
|
1157
|
-
errors.push({ record, errors: result.errors });
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
ctx.log('info', 'Transformation complete', {
|
|
1162
|
-
totalRecords: rawData.length,
|
|
1163
|
-
transformed: transformed.length,
|
|
1164
|
-
errors: errors.length,
|
|
1165
|
-
});
|
|
1166
|
-
|
|
1167
|
-
return {
|
|
1168
|
-
transformed,
|
|
1169
|
-
errors,
|
|
1170
|
-
stats: {
|
|
1171
|
-
total: rawData.length,
|
|
1172
|
-
success: transformed.length,
|
|
1173
|
-
failed: errors.length,
|
|
1174
|
-
},
|
|
1175
|
-
};
|
|
1176
|
-
});
|
|
1177
|
-
```
|
|
1178
|
-
|
|
1179
|
-
---
|
|
1180
|
-
|
|
1181
|
-
## Workflow Composition - Multi-Step Patterns
|
|
1182
|
-
|
|
1183
|
-
Combine multiple workflow types using `.then()` and `.catch()` chaining.
|
|
1184
|
-
|
|
1185
|
-
### Pattern 1: Webhook → fn() → http()
|
|
1186
|
-
|
|
1187
|
-
```typescript
|
|
1188
|
-
import { webhook, fn, http } from '@versori/run';
|
|
1189
|
-
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1190
|
-
|
|
1191
|
-
/**
|
|
1192
|
-
* Receive order → validate → send to Fluent
|
|
1193
|
-
*
|
|
1194
|
-
* POST https://{workspace}.versori.run/process-order
|
|
1195
|
-
*/
|
|
1196
|
-
export const processOrder = webhook('process-order')
|
|
1197
|
-
// Step 1: Parse and validate (fn - no external API)
|
|
1198
|
-
.then(
|
|
1199
|
-
fn('validate-order', ({ data }) => {
|
|
1200
|
-
if (!data?.orderId || !data?.items) {
|
|
1201
|
-
throw new Error('Invalid order data: missing orderId or items');
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
return {
|
|
1205
|
-
orderId: data.orderId,
|
|
1206
|
-
items: data.items.map(item => ({
|
|
1207
|
-
productRef: item.sku,
|
|
1208
|
-
quantity: parseInt(item.qty, 10),
|
|
1209
|
-
price: parseFloat(item.price),
|
|
1210
|
-
})),
|
|
1211
|
-
};
|
|
1212
|
-
})
|
|
1213
|
-
)
|
|
1214
|
-
|
|
1215
|
-
// Step 2: Send to Fluent (http - requires connection)
|
|
1216
|
-
.then(
|
|
1217
|
-
http(
|
|
1218
|
-
'send-to-fluent',
|
|
1219
|
-
{
|
|
1220
|
-
connection: 'fluent_commerce',
|
|
1221
|
-
},
|
|
1222
|
-
async ctx => {
|
|
1223
|
-
const { orderId, items } = ctx.data;
|
|
1224
|
-
const client = await createClient(ctx);
|
|
1225
|
-
|
|
1226
|
-
// GraphQL mutation - schema validated
|
|
1227
|
-
const result = await client.graphql({
|
|
1228
|
-
query: `
|
|
1229
|
-
mutation CreateOrder($input: CreateOrderInput!) {
|
|
1230
|
-
createOrder(input: $input) {
|
|
1231
|
-
id
|
|
1232
|
-
ref
|
|
1233
|
-
status
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
`,
|
|
1237
|
-
variables: {
|
|
1238
|
-
input: {
|
|
1239
|
-
ref: orderId,
|
|
1240
|
-
items: items.map(item => ({
|
|
1241
|
-
productRef: item.productRef,
|
|
1242
|
-
quantity: item.quantity,
|
|
1243
|
-
price: item.price,
|
|
1244
|
-
})),
|
|
1245
|
-
},
|
|
1246
|
-
},
|
|
1247
|
-
});
|
|
1248
|
-
|
|
1249
|
-
return {
|
|
1250
|
-
success: true,
|
|
1251
|
-
fluentOrderId: result.data.createOrder.id,
|
|
1252
|
-
fluentOrderRef: result.data.createOrder.ref,
|
|
1253
|
-
};
|
|
1254
|
-
}
|
|
1255
|
-
)
|
|
1256
|
-
)
|
|
1257
|
-
|
|
1258
|
-
// Error handling
|
|
1259
|
-
.catch(({ data }) => {
|
|
1260
|
-
return {
|
|
1261
|
-
success: false,
|
|
1262
|
-
error: data instanceof Error ? data.message : 'Processing failed',
|
|
1263
|
-
status: 500,
|
|
1264
|
-
};
|
|
1265
|
-
});
|
|
1266
|
-
```
|
|
1267
|
-
|
|
1268
|
-
### Pattern 2: Scheduled → fn() (File Tracking) → http() (API Call)
|
|
1269
|
-
|
|
1270
|
-
```typescript
|
|
1271
|
-
import { schedule, fn, http } from '@versori/run';
|
|
1272
|
-
import { createClient, VersoriFileTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
1273
|
-
|
|
1274
|
-
/**
|
|
1275
|
-
* Daily sync with file tracking
|
|
1276
|
-
*
|
|
1277
|
-
* Cron: 0 1 * * * (daily at 1 AM)
|
|
1278
|
-
*/
|
|
1279
|
-
export const dailySyncWithTracking = schedule('daily-sync', '0 1 * * *', {
|
|
1280
|
-
connection: 'fluent_commerce',
|
|
1281
|
-
})
|
|
1282
|
-
// Step 1: Check if already processed today (fn - KV access)
|
|
1283
|
-
.then(
|
|
1284
|
-
fn('check-processed', async ctx => {
|
|
1285
|
-
const kv = ctx.openKv(':project:');
|
|
1286
|
-
const tracker = new VersoriFileTracker(kv, 'daily-sync');
|
|
1287
|
-
|
|
1288
|
-
const today = new Date().toISOString().split('T')[0];
|
|
1289
|
-
const fileKey = `inventory-${today}.csv`;
|
|
1290
|
-
|
|
1291
|
-
if (await tracker.wasFileProcessed(fileKey)) {
|
|
1292
|
-
throw new Error('Already processed today');
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
return { fileKey, today };
|
|
1296
|
-
})
|
|
1297
|
-
)
|
|
1298
|
-
|
|
1299
|
-
// Step 2: Fetch and process (http - external API)
|
|
1300
|
-
.then(
|
|
1301
|
-
http(
|
|
1302
|
-
'process-sync',
|
|
1303
|
-
{
|
|
1304
|
-
connection: 'fluent_commerce',
|
|
1305
|
-
},
|
|
1306
|
-
async ctx => {
|
|
1307
|
-
const { fileKey } = ctx.data;
|
|
1308
|
-
const client = await createClient(ctx);
|
|
1309
|
-
|
|
1310
|
-
// Fetch data from external source
|
|
1311
|
-
const records = await fetchDailyInventory();
|
|
1312
|
-
|
|
1313
|
-
// Send to Fluent
|
|
1314
|
-
const job = await client.createJob({ name: `Daily Sync ${fileKey}` });
|
|
1315
|
-
await client.sendBatch(job.id, {
|
|
1316
|
-
action: 'UPSERT',
|
|
1317
|
-
entityType: 'INVENTORY',
|
|
1318
|
-
entities: records,
|
|
1319
|
-
});
|
|
1320
|
-
|
|
1321
|
-
return {
|
|
1322
|
-
fileKey,
|
|
1323
|
-
jobId: job.id,
|
|
1324
|
-
recordCount: records.length,
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
)
|
|
1328
|
-
)
|
|
1329
|
-
|
|
1330
|
-
// Step 3: Mark as processed (fn - KV access)
|
|
1331
|
-
.then(
|
|
1332
|
-
fn('mark-processed', async ctx => {
|
|
1333
|
-
const kv = ctx.openKv(':project:');
|
|
1334
|
-
const tracker = new VersoriFileTracker(kv, 'daily-sync');
|
|
1335
|
-
const { fileKey, recordCount } = ctx.data;
|
|
1336
|
-
|
|
1337
|
-
await tracker.markFileProcessed(fileKey, {
|
|
1338
|
-
recordCount,
|
|
1339
|
-
timestamp: Date.now(),
|
|
1340
|
-
});
|
|
1341
|
-
|
|
1342
|
-
return {
|
|
1343
|
-
success: true,
|
|
1344
|
-
fileKey,
|
|
1345
|
-
recordCount,
|
|
1346
|
-
};
|
|
1347
|
-
})
|
|
1348
|
-
)
|
|
1349
|
-
|
|
1350
|
-
.catch(({ data }) => {
|
|
1351
|
-
if (data.message === 'Already processed today') {
|
|
1352
|
-
return { skipped: true, reason: 'Already processed today' };
|
|
1353
|
-
}
|
|
1354
|
-
return { success: false, error: data.message };
|
|
1355
|
-
});
|
|
1356
|
-
```
|
|
1357
|
-
|
|
1358
|
-
---
|
|
1359
|
-
|
|
1360
|
-
## Error Handling & Retry Strategies
|
|
1361
|
-
|
|
1362
|
-
### Basic Error Handling
|
|
1363
|
-
|
|
1364
|
-
```typescript
|
|
1365
|
-
export const robustWorkflow = http(
|
|
1366
|
-
'robust',
|
|
1367
|
-
{
|
|
1368
|
-
connection: 'fluent_commerce',
|
|
1369
|
-
retry: {
|
|
1370
|
-
attempts: 3,
|
|
1371
|
-
delay: 1000,
|
|
1372
|
-
backoff: 2, // Exponential backoff multiplier
|
|
1373
|
-
},
|
|
1374
|
-
},
|
|
1375
|
-
async ctx => {
|
|
1376
|
-
try {
|
|
1377
|
-
const client = await createClient(ctx);
|
|
1378
|
-
const result = await client.graphql({ query: '...' });
|
|
1379
|
-
|
|
1380
|
-
if (result.errors?.length) {
|
|
1381
|
-
throw new Error(`GraphQL failed: ${result.errors[0].message}`);
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
return { success: true, data: result.data };
|
|
1385
|
-
} catch (error) {
|
|
1386
|
-
ctx.log('error', 'Operation failed', {
|
|
1387
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1388
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1389
|
-
});
|
|
1390
|
-
|
|
1391
|
-
throw error; // Re-throw for retry mechanism
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
);
|
|
1395
|
-
```
|
|
1396
|
-
|
|
1397
|
-
### Advanced Error Handling with Fallback
|
|
1398
|
-
|
|
1399
|
-
```typescript
|
|
1400
|
-
export const withFallback = http(
|
|
1401
|
-
'with-fallback',
|
|
1402
|
-
{
|
|
1403
|
-
connection: 'fluent_commerce',
|
|
1404
|
-
},
|
|
1405
|
-
async ctx => {
|
|
1406
|
-
const client = await createClient(ctx);
|
|
1407
|
-
|
|
1408
|
-
try {
|
|
1409
|
-
// Primary operation
|
|
1410
|
-
const result = await client.graphql({ query: '...' });
|
|
1411
|
-
return { source: 'primary', data: result.data };
|
|
1412
|
-
} catch (primaryError) {
|
|
1413
|
-
ctx.log('warn', 'Primary operation failed, trying fallback', {
|
|
1414
|
-
error: primaryError instanceof Error ? primaryError.message : String(primaryError),
|
|
1415
|
-
});
|
|
1416
|
-
|
|
1417
|
-
try {
|
|
1418
|
-
// Fallback operation
|
|
1419
|
-
const fallbackResult = await client.graphql({ query: '... (simpler query)' });
|
|
1420
|
-
return { source: 'fallback', data: fallbackResult.data };
|
|
1421
|
-
} catch (fallbackError) {
|
|
1422
|
-
ctx.log('error', 'Both primary and fallback failed', {
|
|
1423
|
-
primaryError: primaryError instanceof Error ? primaryError.message : String(primaryError),
|
|
1424
|
-
fallbackError:
|
|
1425
|
-
fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
|
|
1426
|
-
});
|
|
1427
|
-
|
|
1428
|
-
throw new Error('All operations failed');
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
);
|
|
1433
|
-
```
|
|
1434
|
-
|
|
1435
|
-
---
|
|
1436
|
-
|
|
1437
|
-
## Real-World Fluent Commerce Use Cases
|
|
1438
|
-
|
|
1439
|
-
This section provides detailed, production-ready examples for common Fluent Commerce integration patterns.
|
|
1440
|
-
|
|
1441
|
-
### Use Case 1: Inventory Position Ingestion from CSV
|
|
1442
|
-
|
|
1443
|
-
**Scenario**: Daily CSV file from warehouse management system → Fluent inventory positions
|
|
1444
|
-
|
|
1445
|
-
```typescript
|
|
1446
|
-
import { schedule, fn, http } from '@versori/run';
|
|
1447
|
-
import {
|
|
1448
|
-
createClient,
|
|
1449
|
-
S3DataSource,
|
|
1450
|
-
CSVParserService,
|
|
1451
|
-
UniversalMapper,
|
|
1452
|
-
VersoriFileTracker
|
|
1453
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1454
|
-
|
|
1455
|
-
/**
|
|
1456
|
-
* Daily inventory ingestion from S3 CSV
|
|
1457
|
-
*
|
|
1458
|
-
* Cron: 0 3 * * * (3 AM daily)
|
|
1459
|
-
* Flow: Check file → Parse CSV → Transform → Send to Fluent
|
|
1460
|
-
*/
|
|
1461
|
-
export const dailyInventoryIngestion = schedule('daily-inventory-ingestion', '0 3 * * *')
|
|
1462
|
-
// Step 1: Check for new files (fn - KV only)
|
|
1463
|
-
.then(fn('check-new-files', async ctx => {
|
|
1464
|
-
const kv = ctx.openKv(':project:');
|
|
1465
|
-
const tracker = new VersoriFileTracker(kv, 'inventory-ingestion');
|
|
1466
|
-
|
|
1467
|
-
const s3Config = {
|
|
1468
|
-
bucket: ctx.activation.getVariable<string>('S3_BUCKET'),
|
|
1469
|
-
region: ctx.activation.getVariable<string>('AWS_REGION'),
|
|
1470
|
-
accessKeyId: ctx.activation.getVariable<string>('AWS_ACCESS_KEY_ID'),
|
|
1471
|
-
secretAccessKey: ctx.activation.getVariable<string>('AWS_SECRET_ACCESS_KEY'),
|
|
1472
|
-
};
|
|
1473
|
-
|
|
1474
|
-
const s3 = new S3DataSource(s3Config, ctx.log);
|
|
1475
|
-
const files = await s3.listFiles({ prefix: 'inventory/daily/' });
|
|
1476
|
-
|
|
1477
|
-
// Filter unprocessed files
|
|
1478
|
-
const newFiles = [];
|
|
1479
|
-
for (const file of files) {
|
|
1480
|
-
if (!(await tracker.wasFileProcessed(file.key))) {
|
|
1481
|
-
newFiles.push(file);
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
if (newFiles.length === 0) {
|
|
1486
|
-
throw new Error('NO_NEW_FILES');
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
ctx.log.info('Found new inventory files', { count: newFiles.length });
|
|
1490
|
-
return { s3Config, files: newFiles, tracker };
|
|
1491
|
-
}))
|
|
1492
|
-
|
|
1493
|
-
// Step 2: Parse and transform (fn - no API needed)
|
|
1494
|
-
.then(fn('parse-transform', async ctx => {
|
|
1495
|
-
const { s3Config, files, tracker } = ctx.data;
|
|
1496
|
-
const s3 = new S3DataSource(s3Config, ctx.log);
|
|
1497
|
-
const parser = new CSVParserService(ctx.log);
|
|
1498
|
-
|
|
1499
|
-
// Mapping configuration
|
|
1500
|
-
const mappingConfig = {
|
|
1501
|
-
fields: {
|
|
1502
|
-
// Fluent field: CSV column
|
|
1503
|
-
ref: { source: 'sku', required: true },
|
|
1504
|
-
productRef: { source: 'sku', required: true },
|
|
1505
|
-
locationRef: { source: 'location_code', required: true },
|
|
1506
|
-
qty: { source: 'quantity', resolver: 'sdk.parseInt', required: true },
|
|
1507
|
-
type: { value: 'AVAILABLE' }, // Static value
|
|
1508
|
-
storageArea: { source: 'warehouse_zone' },
|
|
1509
|
-
expectedOn: { source: 'delivery_date', resolver: 'sdk.formatDate' },
|
|
1510
|
-
},
|
|
1511
|
-
};
|
|
1512
|
-
|
|
1513
|
-
const mapper = new UniversalMapper(mappingConfig);
|
|
1514
|
-
const allRecords = [];
|
|
1515
|
-
|
|
1516
|
-
for (const file of files) {
|
|
1517
|
-
const content = await s3.readFile(file.key);
|
|
1518
|
-
const parsed = await parser.parse(content, {
|
|
1519
|
-
headers: true,
|
|
1520
|
-
skipEmptyLines: true
|
|
1521
|
-
});
|
|
1522
|
-
|
|
1523
|
-
// Transform each row
|
|
1524
|
-
for (const row of parsed) {
|
|
1525
|
-
const result = await mapper.map(row);
|
|
1526
|
-
if (result.success) {
|
|
1527
|
-
allRecords.push(result.data);
|
|
1528
|
-
} else {
|
|
1529
|
-
ctx.log.warn('Mapping failed', { row, errors: result.errors });
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
ctx.log.info('Parsed and transformed records', {
|
|
1535
|
-
fileCount: files.length,
|
|
1536
|
-
recordCount: allRecords.length
|
|
1537
|
-
});
|
|
1538
|
-
|
|
1539
|
-
return { records: allRecords, files, tracker };
|
|
1540
|
-
}))
|
|
1541
|
-
|
|
1542
|
-
// Step 3: Send to Fluent (http - requires API connection)
|
|
1543
|
-
.then(http('send-to-fluent', { connection: 'fluent_commerce' }, async ctx => {
|
|
1544
|
-
const { records, files, tracker } = ctx.data;
|
|
1545
|
-
const client = await createClient(ctx);
|
|
1546
|
-
|
|
1547
|
-
// Create batch job
|
|
1548
|
-
const job = await client.createJob({
|
|
1549
|
-
name: `Daily Inventory Ingestion - ${new Date().toISOString().split('T')[0]}`,
|
|
1550
|
-
retailerId: ctx.activation.getVariable<number>('FLUENT_RETAILER_ID'),
|
|
1551
|
-
});
|
|
1552
|
-
|
|
1553
|
-
// Send in batches of 500
|
|
1554
|
-
const batchSize = 500;
|
|
1555
|
-
const batches = [];
|
|
1556
|
-
|
|
1557
|
-
for (let i = 0; i < records.length; i += batchSize) {
|
|
1558
|
-
const chunk = records.slice(i, i + batchSize);
|
|
1559
|
-
const batch = await client.sendBatch(job.id, {
|
|
1560
|
-
action: 'UPSERT',
|
|
1561
|
-
entityType: 'INVENTORY_POSITION',
|
|
1562
|
-
entities: chunk,
|
|
1563
|
-
meta: {
|
|
1564
|
-
preprocessing: 'skip', // Delta file, skip BPP
|
|
1565
|
-
},
|
|
1566
|
-
});
|
|
1567
|
-
batches.push(batch.id);
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
// Mark files as processed
|
|
1571
|
-
for (const file of files) {
|
|
1572
|
-
await tracker.markFileProcessed(file.key, {
|
|
1573
|
-
jobId: job.id,
|
|
1574
|
-
recordCount: records.length,
|
|
1575
|
-
timestamp: Date.now(),
|
|
1576
|
-
});
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
ctx.log.info('Inventory ingestion complete', {
|
|
1580
|
-
jobId: job.id,
|
|
1581
|
-
batches: batches.length,
|
|
1582
|
-
totalRecords: records.length,
|
|
1583
|
-
});
|
|
1584
|
-
|
|
1585
|
-
return {
|
|
1586
|
-
success: true,
|
|
1587
|
-
jobId: job.id,
|
|
1588
|
-
recordCount: records.length,
|
|
1589
|
-
filesProcessed: files.length,
|
|
1590
|
-
};
|
|
1591
|
-
}))
|
|
1592
|
-
|
|
1593
|
-
.catch(({ data }) => {
|
|
1594
|
-
if (data.message === 'NO_NEW_FILES') {
|
|
1595
|
-
return { skipped: true, reason: 'No new files to process' };
|
|
1596
|
-
}
|
|
1597
|
-
return { success: false, error: data.message || 'Unknown error' };
|
|
1598
|
-
});
|
|
1599
|
-
```
|
|
1600
|
-
|
|
1601
|
-
### Use Case 2: Order Creation from SFCC XML Webhook
|
|
1602
|
-
|
|
1603
|
-
**Scenario**: SFCC sends order XML → Validate → Create order in Fluent
|
|
1604
|
-
|
|
1605
|
-
```typescript
|
|
1606
|
-
import { webhook, fn, http } from '@versori/run';
|
|
1607
|
-
import {
|
|
1608
|
-
createClient,
|
|
1609
|
-
XMLParserService,
|
|
1610
|
-
GraphQLMutationMapper
|
|
1611
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1612
|
-
|
|
1613
|
-
/**
|
|
1614
|
-
* Receive SFCC order XML webhook and create order in Fluent
|
|
1615
|
-
*
|
|
1616
|
-
* POST https://{workspace}.versori.run/sfcc-order-create
|
|
1617
|
-
* Content-Type: application/xml
|
|
1618
|
-
*/
|
|
1619
|
-
export const sfccOrderCreate = webhook('sfcc-order-create', {
|
|
1620
|
-
connection: 'sfcc-webhook-auth', // Platform validates API key
|
|
1621
|
-
response: {
|
|
1622
|
-
mode: 'sync',
|
|
1623
|
-
onSuccess: ctx => new Response(ctx.data, {
|
|
1624
|
-
status: 200,
|
|
1625
|
-
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
|
1626
|
-
}),
|
|
1627
|
-
onError: ctx => {
|
|
1628
|
-
const errorXml = `<?xml version="1.0"?>
|
|
1629
|
-
<ErrorResponse>
|
|
1630
|
-
<Error>
|
|
1631
|
-
<Code>PROCESSING_ERROR</Code>
|
|
1632
|
-
<Message>${ctx.data?.message || 'Order processing failed'}</Message>
|
|
1633
|
-
<ExecutionId>${ctx.executionId}</ExecutionId>
|
|
1634
|
-
</Error>
|
|
1635
|
-
</ErrorResponse>`;
|
|
1636
|
-
return new Response(errorXml, {
|
|
1637
|
-
status: 500,
|
|
1638
|
-
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
|
1639
|
-
});
|
|
1640
|
-
},
|
|
1641
|
-
},
|
|
1642
|
-
})
|
|
1643
|
-
// Step 1: Parse XML (fn - no API needed)
|
|
1644
|
-
.then(fn('parse-xml', async ctx => {
|
|
1645
|
-
const parser = new XMLParserService(ctx.log);
|
|
1646
|
-
const xmlData = await parser.parse(ctx.data);
|
|
1647
|
-
|
|
1648
|
-
// Validate required fields
|
|
1649
|
-
if (!xmlData.order?.['@id']) {
|
|
1650
|
-
throw new Error('Invalid order XML: missing order ID');
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
ctx.log.info('Parsed SFCC order XML', {
|
|
1654
|
-
orderId: xmlData.order['@id'],
|
|
1655
|
-
customerEmail: xmlData.order.customer?.email
|
|
1656
|
-
});
|
|
1657
|
-
|
|
1658
|
-
return { xmlData };
|
|
1659
|
-
}))
|
|
1660
|
-
|
|
1661
|
-
// Step 2: Transform and create order (http - Fluent API access)
|
|
1662
|
-
.then(http('create-fluent-order', { connection: 'fluent_commerce' }, async ctx => {
|
|
1663
|
-
const { xmlData } = ctx.data;
|
|
1664
|
-
const client = await createClient(ctx);
|
|
1665
|
-
|
|
1666
|
-
// GraphQL mutation mapping config
|
|
1667
|
-
const mappingConfig = {
|
|
1668
|
-
mutationName: 'createOrder',
|
|
1669
|
-
sourceFormat: 'xml',
|
|
1670
|
-
arguments: {
|
|
1671
|
-
input: {
|
|
1672
|
-
ref: { source: 'order.@id' },
|
|
1673
|
-
type: { value: 'STANDARD' },
|
|
1674
|
-
retailer: { source: 'order.@retailerId', resolver: 'sdk.parseInt' },
|
|
1675
|
-
customer: {
|
|
1676
|
-
firstName: { source: 'order.customer.firstname' },
|
|
1677
|
-
lastName: { source: 'order.customer.lastname' },
|
|
1678
|
-
email: { source: 'order.customer.email' },
|
|
1679
|
-
},
|
|
1680
|
-
items: {
|
|
1681
|
-
source: 'order.items.item',
|
|
1682
|
-
isArray: true,
|
|
1683
|
-
mapping: {
|
|
1684
|
-
productRef: { source: '@sku' },
|
|
1685
|
-
quantity: { source: 'quantity', resolver: 'sdk.parseInt' },
|
|
1686
|
-
price: {
|
|
1687
|
-
value: { source: 'price', resolver: 'sdk.parseFloat' },
|
|
1688
|
-
currency: { source: '@currency', default: 'USD' },
|
|
1689
|
-
},
|
|
1690
|
-
},
|
|
1691
|
-
},
|
|
1692
|
-
fulfilment: {
|
|
1693
|
-
type: { value: 'STANDARD' },
|
|
1694
|
-
deliveryType: { source: 'order.shipping.method' },
|
|
1695
|
-
deliveryAddress: {
|
|
1696
|
-
name: { source: 'order.shipping.address.name' },
|
|
1697
|
-
street: { source: 'order.shipping.address.street' },
|
|
1698
|
-
city: { source: 'order.shipping.address.city' },
|
|
1699
|
-
state: { source: 'order.shipping.address.state' },
|
|
1700
|
-
postcode: { source: 'order.shipping.address.postalcode' },
|
|
1701
|
-
country: { source: 'order.shipping.address.country' },
|
|
1702
|
-
},
|
|
1703
|
-
},
|
|
1704
|
-
payments: {
|
|
1705
|
-
source: 'order.payments.payment',
|
|
1706
|
-
isArray: true,
|
|
1707
|
-
mapping: {
|
|
1708
|
-
method: { source: '@method' },
|
|
1709
|
-
amount: { source: 'amount', resolver: 'sdk.parseFloat' },
|
|
1710
|
-
cardType: { source: 'creditcard.type' },
|
|
1711
|
-
},
|
|
1712
|
-
},
|
|
1713
|
-
},
|
|
1714
|
-
},
|
|
1715
|
-
};
|
|
1716
|
-
|
|
1717
|
-
const mapper = new GraphQLMutationMapper(mappingConfig, ctx.log, { fluentClient: client });
|
|
1718
|
-
const { mutation, variables } = await mapper.generateMutation(xmlData);
|
|
1719
|
-
|
|
1720
|
-
// Execute mutation
|
|
1721
|
-
const result = await client.graphql({ query: mutation, variables });
|
|
1722
|
-
|
|
1723
|
-
if (result.errors?.length) {
|
|
1724
|
-
throw new Error(`Order creation failed: ${result.errors[0].message}`);
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
const order = result.data.createOrder;
|
|
1728
|
-
ctx.log.info('Order created in Fluent', {
|
|
1729
|
-
fluentOrderId: order.id,
|
|
1730
|
-
sfccOrderId: xmlData.order['@id']
|
|
1731
|
-
});
|
|
1732
|
-
|
|
1733
|
-
return {
|
|
1734
|
-
fluentOrderId: order.id,
|
|
1735
|
-
fluentOrderRef: order.ref,
|
|
1736
|
-
sfccOrderId: xmlData.order['@id'],
|
|
1737
|
-
};
|
|
1738
|
-
}))
|
|
1739
|
-
|
|
1740
|
-
// Step 3: Generate success XML response (fn)
|
|
1741
|
-
.then(fn('generate-response', ctx => {
|
|
1742
|
-
const { fluentOrderId, sfccOrderId } = ctx.data;
|
|
1743
|
-
return `<?xml version="1.0"?>
|
|
1744
|
-
<OrderResponse>
|
|
1745
|
-
<Success>true</Success>
|
|
1746
|
-
<SFCCOrderId>${sfccOrderId}</SFCCOrderId>
|
|
1747
|
-
<FluentOrderId>${fluentOrderId}</FluentOrderId>
|
|
1748
|
-
<Timestamp>${new Date().toISOString()}</Timestamp>
|
|
1749
|
-
</OrderResponse>`;
|
|
1750
|
-
}))
|
|
1751
|
-
|
|
1752
|
-
.catch(({ data }) => {
|
|
1753
|
-
return { message: data instanceof Error ? data.message : String(data) };
|
|
1754
|
-
});
|
|
1755
|
-
```
|
|
1756
|
-
|
|
1757
|
-
### Use Case 3: Virtual Position Extraction to SFTP
|
|
1758
|
-
|
|
1759
|
-
**Scenario**: Scheduled extraction of Fluent virtual positions → XML → Upload to SFTP
|
|
1760
|
-
|
|
1761
|
-
```typescript
|
|
1762
|
-
import { Buffer } from 'node:buffer'; // ⚠️ Required for Deno compatibility
|
|
1763
|
-
import { schedule, http, fn } from '@versori/run';
|
|
1764
|
-
import {
|
|
1765
|
-
createClient,
|
|
1766
|
-
ExtractionOrchestrator,
|
|
1767
|
-
SftpDataSource,
|
|
1768
|
-
VersoriFileTracker
|
|
1769
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1770
|
-
import { XMLBuilder } from 'fast-xml-parser';
|
|
1771
|
-
|
|
1772
|
-
/**
|
|
1773
|
-
* Extract virtual positions and upload to SFTP as XML
|
|
1774
|
-
*
|
|
1775
|
-
* Cron: 0 */4 * * * (every 4 hours)
|
|
1776
|
-
*/
|
|
1777
|
-
export const virtualPositionExtraction = schedule('virtual-position-extraction', '0 */4 * * *')
|
|
1778
|
-
// Step 1: Extract from Fluent (http - API access)
|
|
1779
|
-
.then(http('extract-positions', { connection: 'fluent_commerce' }, async ctx => {
|
|
1780
|
-
const client = await createClient(ctx);
|
|
1781
|
-
|
|
1782
|
-
// Define extraction config
|
|
1783
|
-
const extractionConfig = {
|
|
1784
|
-
query: `
|
|
1785
|
-
query GetVirtualPositions($first: Int!, $after: String) {
|
|
1786
|
-
virtualPositions(first: $first, after: $after) {
|
|
1787
|
-
edges {
|
|
1788
|
-
node {
|
|
1789
|
-
id
|
|
1790
|
-
ref
|
|
1791
|
-
productRef
|
|
1792
|
-
groupRef
|
|
1793
|
-
onHand
|
|
1794
|
-
quantity
|
|
1795
|
-
type
|
|
1796
|
-
status
|
|
1797
|
-
createdOn
|
|
1798
|
-
updatedOn
|
|
1799
|
-
}
|
|
1800
|
-
cursor
|
|
1801
|
-
}
|
|
1802
|
-
pageInfo {
|
|
1803
|
-
hasNextPage
|
|
1804
|
-
endCursor
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
`,
|
|
1809
|
-
variables: { first: 200 },
|
|
1810
|
-
pathToData: 'virtualPositions',
|
|
1811
|
-
pathToCursor: 'pageInfo.endCursor',
|
|
1812
|
-
pathToHasNext: 'pageInfo.hasNextPage',
|
|
1813
|
-
};
|
|
1814
|
-
|
|
1815
|
-
// Use ExtractionOrchestrator for auto-pagination
|
|
1816
|
-
const orchestrator = new ExtractionOrchestrator(client, ctx.log);
|
|
1817
|
-
const result = await orchestrator.extractWithPagination(extractionConfig);
|
|
1818
|
-
|
|
1819
|
-
if (!result.success) {
|
|
1820
|
-
throw new Error(`Extraction failed: ${result.errors?.join(', ')}`);
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
ctx.log.info('Extracted virtual positions', {
|
|
1824
|
-
recordCount: result.data?.length || 0
|
|
1825
|
-
});
|
|
1826
|
-
|
|
1827
|
-
return { positions: result.data || [] };
|
|
1828
|
-
}))
|
|
1829
|
-
|
|
1830
|
-
// Step 2: Transform to XML (fn - no API)
|
|
1831
|
-
.then(fn('transform-to-xml', ctx => {
|
|
1832
|
-
const { positions } = ctx.data;
|
|
1833
|
-
|
|
1834
|
-
const xmlBuilder = new XMLBuilder({
|
|
1835
|
-
ignoreAttributes: false,
|
|
1836
|
-
attributeNamePrefix: '@',
|
|
1837
|
-
format: true,
|
|
1838
|
-
indentBy: ' ',
|
|
1839
|
-
});
|
|
1840
|
-
|
|
1841
|
-
const xmlObject = {
|
|
1842
|
-
'?xml': { '@version': '1.0', '@encoding': 'UTF-8' },
|
|
1843
|
-
VirtualPositionFeed: {
|
|
1844
|
-
'@timestamp': new Date().toISOString(),
|
|
1845
|
-
'@recordCount': positions.length,
|
|
1846
|
-
Positions: {
|
|
1847
|
-
Position: positions.map(pos => ({
|
|
1848
|
-
'@id': pos.id,
|
|
1849
|
-
Ref: pos.ref,
|
|
1850
|
-
ProductRef: pos.productRef,
|
|
1851
|
-
GroupRef: pos.groupRef,
|
|
1852
|
-
OnHand: pos.onHand,
|
|
1853
|
-
Quantity: pos.quantity,
|
|
1854
|
-
Type: pos.type,
|
|
1855
|
-
Status: pos.status,
|
|
1856
|
-
CreatedOn: pos.createdOn,
|
|
1857
|
-
UpdatedOn: pos.updatedOn,
|
|
1858
|
-
})),
|
|
1859
|
-
},
|
|
1860
|
-
},
|
|
1861
|
-
};
|
|
1862
|
-
|
|
1863
|
-
const xmlContent = xmlBuilder.build(xmlObject);
|
|
1864
|
-
|
|
1865
|
-
const fileName = `virtual-positions-${new Date().toISOString().replace(/:/g, '-')}.xml`;
|
|
1866
|
-
|
|
1867
|
-
ctx.log.info('Generated XML feed', { fileName, recordCount: positions.length });
|
|
1868
|
-
|
|
1869
|
-
return { xmlContent, fileName, recordCount: positions.length };
|
|
1870
|
-
}))
|
|
1871
|
-
|
|
1872
|
-
// Step 3: Upload to SFTP (fn - external SFTP, not Fluent API)
|
|
1873
|
-
.then(fn('upload-to-sftp', async ctx => {
|
|
1874
|
-
const { xmlContent, fileName, recordCount } = ctx.data;
|
|
1875
|
-
|
|
1876
|
-
const sftpConfig = {
|
|
1877
|
-
host: ctx.activation.getVariable<string>('SFTP_HOST'),
|
|
1878
|
-
port: ctx.activation.getVariable<number>('SFTP_PORT') || 22,
|
|
1879
|
-
username: ctx.activation.getVariable<string>('SFTP_USERNAME'),
|
|
1880
|
-
password: ctx.activation.getVariable<string>('SFTP_PASSWORD'),
|
|
1881
|
-
};
|
|
1882
|
-
|
|
1883
|
-
const sftp = new SftpDataSource(sftpConfig, ctx.log);
|
|
1884
|
-
await sftp.connect();
|
|
1885
|
-
|
|
1886
|
-
try {
|
|
1887
|
-
const remotePath = `/outbound/${fileName}`;
|
|
1888
|
-
await sftp.uploadFile(remotePath, Buffer.from(xmlContent, 'utf-8'));
|
|
1889
|
-
|
|
1890
|
-
ctx.log.info('Uploaded to SFTP', { remotePath, recordCount });
|
|
1891
|
-
|
|
1892
|
-
// Track in KV
|
|
1893
|
-
const kv = ctx.openKv(':project:');
|
|
1894
|
-
const tracker = new VersoriFileTracker(kv, 'virtual-position-extraction');
|
|
1895
|
-
await tracker.markFileProcessed(fileName, {
|
|
1896
|
-
recordCount,
|
|
1897
|
-
timestamp: Date.now(),
|
|
1898
|
-
remotePath,
|
|
1899
|
-
});
|
|
1900
|
-
|
|
1901
|
-
return {
|
|
1902
|
-
success: true,
|
|
1903
|
-
fileName,
|
|
1904
|
-
remotePath,
|
|
1905
|
-
recordCount,
|
|
1906
|
-
};
|
|
1907
|
-
} finally {
|
|
1908
|
-
await sftp.disconnect();
|
|
1909
|
-
}
|
|
1910
|
-
}))
|
|
1911
|
-
|
|
1912
|
-
.catch(({ data }) => ({
|
|
1913
|
-
success: false,
|
|
1914
|
-
error: data instanceof Error ? data.message : String(data),
|
|
1915
|
-
}));
|
|
1916
|
-
```
|
|
1917
|
-
|
|
1918
|
-
### Use Case 4: Multi-Tenant Order Status Webhook
|
|
1919
|
-
|
|
1920
|
-
**Scenario**: Multiple customers share one connector, route to correct Fluent instance
|
|
1921
|
-
|
|
1922
|
-
```typescript
|
|
1923
|
-
import { webhook, fn, http } from '@versori/run';
|
|
1924
|
-
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1925
|
-
|
|
1926
|
-
/**
|
|
1927
|
-
* Multi-tenant order status update
|
|
1928
|
-
*
|
|
1929
|
-
* POST https://{workspace}.versori.run/order-status-update
|
|
1930
|
-
* Body: { customerId, orderId, status }
|
|
1931
|
-
*/
|
|
1932
|
-
export const orderStatusUpdate = webhook('order-status-update', {
|
|
1933
|
-
response: { mode: 'sync' },
|
|
1934
|
-
})
|
|
1935
|
-
// Step 1: Route to correct connection (fn)
|
|
1936
|
-
.then(fn('route-connection', ctx => {
|
|
1937
|
-
const { customerId, orderId, status } = ctx.data;
|
|
1938
|
-
|
|
1939
|
-
if (!customerId || !orderId || !status) {
|
|
1940
|
-
throw new Error('Missing required fields: customerId, orderId, status');
|
|
1941
|
-
}
|
|
1942
|
-
|
|
1943
|
-
// Access all connections via activation.connections
|
|
1944
|
-
const connections = ctx.activation.connections;
|
|
1945
|
-
|
|
1946
|
-
// Find customer-specific Fluent connection
|
|
1947
|
-
const connectionName = `fluent_${customerId.toLowerCase()}`;
|
|
1948
|
-
const connection = connections?.find(c => c.name === connectionName);
|
|
1949
|
-
|
|
1950
|
-
if (!connection) {
|
|
1951
|
-
throw new Error(`No Fluent connection found for customer: ${customerId}`);
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
ctx.log.info('Routed to customer connection', {
|
|
1955
|
-
customerId,
|
|
1956
|
-
connectionName,
|
|
1957
|
-
connectionId: connection.id
|
|
1958
|
-
});
|
|
1959
|
-
|
|
1960
|
-
return { connectionName, orderId, status, customerId };
|
|
1961
|
-
}))
|
|
1962
|
-
|
|
1963
|
-
// Step 2: Update order status (http - with dynamic connection)
|
|
1964
|
-
.then(http('update-order', (ctx) => {
|
|
1965
|
-
// Return dynamic connection name
|
|
1966
|
-
return { connection: ctx.data.connectionName };
|
|
1967
|
-
}, async ctx => {
|
|
1968
|
-
const { orderId, status, customerId } = ctx.data;
|
|
1969
|
-
const client = await createClient(ctx);
|
|
1970
|
-
|
|
1971
|
-
// Update order status
|
|
1972
|
-
const result = await client.graphql({
|
|
1973
|
-
query: `
|
|
1974
|
-
mutation UpdateOrder($ref: String!, $status: String!) {
|
|
1975
|
-
updateOrder(input: { ref: $ref, status: $status }) {
|
|
1976
|
-
id
|
|
1977
|
-
ref
|
|
1978
|
-
status
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
`,
|
|
1982
|
-
variables: { ref: orderId, status },
|
|
1983
|
-
});
|
|
1984
|
-
|
|
1985
|
-
if (result.errors?.length) {
|
|
1986
|
-
throw new Error(`Failed to update order: ${result.errors[0].message}`);
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
ctx.log.info('Order status updated', {
|
|
1990
|
-
customerId,
|
|
1991
|
-
orderId,
|
|
1992
|
-
newStatus: status
|
|
1993
|
-
});
|
|
1994
|
-
|
|
1995
|
-
return {
|
|
1996
|
-
success: true,
|
|
1997
|
-
customerId,
|
|
1998
|
-
orderId,
|
|
1999
|
-
status: result.data.updateOrder.status,
|
|
2000
|
-
};
|
|
2001
|
-
}))
|
|
2002
|
-
|
|
2003
|
-
.catch(({ data }) => ({
|
|
2004
|
-
success: false,
|
|
2005
|
-
error: data instanceof Error ? data.message : String(data),
|
|
2006
|
-
status: 500,
|
|
2007
|
-
}));
|
|
2008
|
-
```
|
|
2009
|
-
|
|
2010
|
-
### Use Case 5: Product Catalog Sync with Enrichment
|
|
2011
|
-
|
|
2012
|
-
**Scenario**: Fetch products from PIM system → Enrich → Update Fluent catalog
|
|
2013
|
-
|
|
2014
|
-
```typescript
|
|
2015
|
-
import { schedule, fn, http } from '@versori/run';
|
|
2016
|
-
import {
|
|
2017
|
-
createClient,
|
|
2018
|
-
UniversalMapper,
|
|
2019
|
-
VersoriFileTracker
|
|
2020
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
2021
|
-
|
|
2022
|
-
/**
|
|
2023
|
-
* Daily product catalog sync
|
|
2024
|
-
*
|
|
2025
|
-
* Cron: 0 4 * * * (4 AM daily)
|
|
2026
|
-
*/
|
|
2027
|
-
export const productCatalogSync = schedule('product-catalog-sync', '0 4 * * *')
|
|
2028
|
-
// Step 1: Fetch from external PIM (http - external API)
|
|
2029
|
-
.then(http('fetch-from-pim', { connection: 'pim_system' }, async ctx => {
|
|
2030
|
-
// Fetch products from PIM API
|
|
2031
|
-
const response = await ctx.fetch('/api/v1/products?updated_since=yesterday');
|
|
2032
|
-
const products = await response.json();
|
|
2033
|
-
|
|
2034
|
-
ctx.log.info('Fetched products from PIM', { count: products.length });
|
|
2035
|
-
return { pimProducts: products };
|
|
2036
|
-
}))
|
|
2037
|
-
|
|
2038
|
-
// Step 2: Enrich and transform (fn - data processing)
|
|
2039
|
-
.then(fn('enrich-transform', async ctx => {
|
|
2040
|
-
const { pimProducts } = ctx.data;
|
|
2041
|
-
|
|
2042
|
-
const mappingConfig = {
|
|
2043
|
-
fields: {
|
|
2044
|
-
ref: { source: 'sku', required: true },
|
|
2045
|
-
type: { value: 'STANDARD' },
|
|
2046
|
-
name: { source: 'name', required: true },
|
|
2047
|
-
summary: { source: 'short_description' },
|
|
2048
|
-
gtin: { source: 'barcode' },
|
|
2049
|
-
prices: {
|
|
2050
|
-
source: 'pricing',
|
|
2051
|
-
isArray: true,
|
|
2052
|
-
mapping: {
|
|
2053
|
-
type: { source: 'price_type' },
|
|
2054
|
-
value: { source: 'amount', resolver: 'sdk.parseFloat' },
|
|
2055
|
-
currency: { source: 'currency', default: 'USD' },
|
|
2056
|
-
},
|
|
2057
|
-
},
|
|
2058
|
-
attributes: {
|
|
2059
|
-
source: 'custom_attributes',
|
|
2060
|
-
isArray: true,
|
|
2061
|
-
mapping: {
|
|
2062
|
-
name: { source: 'attr_name' },
|
|
2063
|
-
value: { source: 'attr_value', resolver: 'sdk.toString' },
|
|
2064
|
-
type: { value: 'STRING' },
|
|
2065
|
-
},
|
|
2066
|
-
},
|
|
2067
|
-
taxType: {
|
|
2068
|
-
type: { source: 'tax.type' },
|
|
2069
|
-
group: { source: 'tax.group' },
|
|
2070
|
-
tariff: { source: 'tax.tariff_code' },
|
|
2071
|
-
},
|
|
2072
|
-
},
|
|
2073
|
-
};
|
|
2074
|
-
|
|
2075
|
-
const mapper = new UniversalMapper(mappingConfig);
|
|
2076
|
-
const enrichedProducts = [];
|
|
2077
|
-
|
|
2078
|
-
for (const product of pimProducts) {
|
|
2079
|
-
const result = await mapper.map(product);
|
|
2080
|
-
if (result.success) {
|
|
2081
|
-
enrichedProducts.push(result.data);
|
|
2082
|
-
} else {
|
|
2083
|
-
ctx.log.warn('Product mapping failed', {
|
|
2084
|
-
sku: product.sku,
|
|
2085
|
-
errors: result.errors
|
|
2086
|
-
});
|
|
2087
|
-
}
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
|
-
ctx.log.info('Products enriched', { count: enrichedProducts.length });
|
|
2091
|
-
return { products: enrichedProducts };
|
|
2092
|
-
}))
|
|
2093
|
-
|
|
2094
|
-
// Step 3: Send to Fluent (http - Fluent API)
|
|
2095
|
-
.then(http('sync-to-fluent', { connection: 'fluent_commerce' }, async ctx => {
|
|
2096
|
-
const { products } = ctx.data;
|
|
2097
|
-
const client = await createClient(ctx);
|
|
2098
|
-
|
|
2099
|
-
const job = await client.createJob({
|
|
2100
|
-
name: `Product Catalog Sync - ${new Date().toISOString().split('T')[0]}`,
|
|
2101
|
-
retailerId: ctx.activation.getVariable<number>('FLUENT_RETAILER_ID'),
|
|
2102
|
-
});
|
|
2103
|
-
|
|
2104
|
-
// Send in chunks
|
|
2105
|
-
const batchSize = 100;
|
|
2106
|
-
const batches = [];
|
|
2107
|
-
|
|
2108
|
-
for (let i = 0; i < products.length; i += batchSize) {
|
|
2109
|
-
const chunk = products.slice(i, i + batchSize);
|
|
2110
|
-
const batch = await client.sendBatch(job.id, {
|
|
2111
|
-
action: 'UPSERT',
|
|
2112
|
-
entityType: 'PRODUCT',
|
|
2113
|
-
entities: chunk,
|
|
2114
|
-
meta: {
|
|
2115
|
-
preprocessing: 'apply', // Full snapshot, use BPP
|
|
2116
|
-
},
|
|
2117
|
-
});
|
|
2118
|
-
batches.push(batch.id);
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
ctx.log.info('Product sync complete', {
|
|
2122
|
-
jobId: job.id,
|
|
2123
|
-
productCount: products.length,
|
|
2124
|
-
batchCount: batches.length,
|
|
2125
|
-
});
|
|
2126
|
-
|
|
2127
|
-
return {
|
|
2128
|
-
success: true,
|
|
2129
|
-
jobId: job.id,
|
|
2130
|
-
productCount: products.length,
|
|
2131
|
-
};
|
|
2132
|
-
}))
|
|
2133
|
-
|
|
2134
|
-
.catch(({ data }) => ({
|
|
2135
|
-
success: false,
|
|
2136
|
-
error: data instanceof Error ? data.message : String(data),
|
|
2137
|
-
}));
|
|
2138
|
-
```
|
|
2139
|
-
|
|
2140
|
-
---
|
|
2141
|
-
|
|
2142
|
-
## ⚠️ CRITICAL: `.parallel()` Error Behavior
|
|
2143
|
-
|
|
2144
|
-
**BEFORE USING `.parallel()` - READ THIS:**
|
|
2145
|
-
|
|
2146
|
-
The `.parallel()` method has **all-or-nothing error behavior** that makes it **UNSUITABLE for most batch processing use cases**.
|
|
2147
|
-
|
|
2148
|
-
### What Happens When One Task Fails
|
|
2149
|
-
|
|
2150
|
-
```typescript
|
|
2151
|
-
// ❌ PROBLEM: If ANY file fails, you lose ALL results
|
|
2152
|
-
schedule('process-files', '0 2 * * *')
|
|
2153
|
-
.then(fn('fetch', () => ['file1.xml', 'file2.xml', 'file3.xml']))
|
|
2154
|
-
.unpack()
|
|
2155
|
-
.parallel(fn('process', async (ctx) => {
|
|
2156
|
-
return await processFile(ctx.data);
|
|
2157
|
-
}));
|
|
2158
|
-
```
|
|
2159
|
-
|
|
2160
|
-
**If `file2.xml` fails:**
|
|
2161
|
-
- ❌ `file1.xml` might have processed successfully but **result is lost**
|
|
2162
|
-
- ❌ `file3.xml` never runs (or is cancelled mid-execution)
|
|
2163
|
-
- ❌ **Entire workflow fails**
|
|
2164
|
-
- ❌ **NO partial results returned**
|
|
2165
|
-
- ❌ You can't retry only failed items
|
|
2166
|
-
|
|
2167
|
-
### Implementation Detail
|
|
2168
|
-
|
|
2169
|
-
The `.parallel()` method uses RxJS `mergeMap` + `toArray()`:
|
|
2170
|
-
- Any error in `mergeMap` propagates to the stream
|
|
2171
|
-
- `toArray()` never completes if the stream errors
|
|
2172
|
-
- **Result: one failure stops everything**
|
|
2173
|
-
|
|
2174
|
-
### Comparison: `.parallel()` vs `Promise.allSettled`
|
|
2175
|
-
|
|
2176
|
-
| Feature | `.parallel()` | `Promise.allSettled` |
|
|
2177
|
-
|---------|--------------|---------------------|
|
|
2178
|
-
| Fault tolerance | ❌ Fails on first error | ✅ Continues on errors |
|
|
2179
|
-
| Partial results | ❌ None if any fail | ✅ All results |
|
|
2180
|
-
| Error isolation | ❌ No isolation | ✅ Complete isolation |
|
|
2181
|
-
| Retry failures | ❌ Must retry all | ✅ Retry only failures |
|
|
2182
|
-
|
|
2183
|
-
### When to Use `.parallel()`
|
|
2184
|
-
|
|
2185
|
-
**ONLY use `.parallel()` when:**
|
|
2186
|
-
1. ✅ **All tasks MUST succeed** (transactional requirement)
|
|
2187
|
-
2. ✅ **Small number of items** (3-10 max)
|
|
2188
|
-
3. ✅ **Fast, reliable operations** (< 10 seconds each)
|
|
2189
|
-
4. ✅ **Partial success is worse than complete failure**
|
|
2190
|
-
|
|
2191
|
-
**Examples where `.parallel()` is appropriate:**
|
|
2192
|
-
- Fetching 3-5 required entity types (all needed for next step)
|
|
2193
|
-
- Validating multiple webhook signatures (all must be valid)
|
|
2194
|
-
- Small, predictable, low-failure-rate operations
|
|
2195
|
-
|
|
2196
|
-
### When to Use `Promise.allSettled` (SDK Pattern)
|
|
2197
|
-
|
|
2198
|
-
**PREFER `Promise.allSettled` for:**
|
|
2199
|
-
1. ✅ **Batch processing** (files, records, entities)
|
|
2200
|
-
2. ✅ **Large datasets** (10+ items)
|
|
2201
|
-
3. ✅ **Failures are expected** (network issues, bad data)
|
|
2202
|
-
4. ✅ **Partial success is valuable** (process 990/1000 items)
|
|
2203
|
-
5. ✅ **Need retry logic** (retry only failures)
|
|
2204
|
-
|
|
2205
|
-
**Example using SDK pattern:**
|
|
2206
|
-
```typescript
|
|
2207
|
-
export const batchProcess = schedule('batch', '0 */6 * * *')
|
|
2208
|
-
.then(
|
|
2209
|
-
http('process', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
2210
|
-
const items = await fetchItems();
|
|
2211
|
-
|
|
2212
|
-
// ✅ Use Promise.allSettled for fault tolerance
|
|
2213
|
-
const results = await Promise.allSettled(
|
|
2214
|
-
items.map(item =>
|
|
2215
|
-
processItem(item)
|
|
2216
|
-
.then(result => ({ success: true, item, result }))
|
|
2217
|
-
.catch(error => ({ success: false, item, error: error.message }))
|
|
2218
|
-
)
|
|
2219
|
-
);
|
|
2220
|
-
|
|
2221
|
-
const successes = results
|
|
2222
|
-
.filter(r => r.status === 'fulfilled')
|
|
2223
|
-
.map(r => r.value);
|
|
2224
|
-
|
|
2225
|
-
const failures = results
|
|
2226
|
-
.filter(r => r.status === 'rejected' || !r.value.success);
|
|
2227
|
-
|
|
2228
|
-
ctx.log.info(`Processed ${successes.length} items, ${failures.length} failed`);
|
|
2229
|
-
|
|
2230
|
-
// ✅ Handle failures gracefully
|
|
2231
|
-
if (failures.length > 0) {
|
|
2232
|
-
// Store failures for retry
|
|
2233
|
-
const kv = ctx.openKv(':project:');
|
|
2234
|
-
await kv.set('failed-items', failures);
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
return { successes, failures };
|
|
2238
|
-
})
|
|
2239
|
-
);
|
|
2240
|
-
```
|
|
2241
|
-
|
|
2242
|
-
---
|
|
2243
|
-
|
|
2244
|
-
## Parallel Processing with `.unpack()` and `.parallel()`
|
|
2245
|
-
|
|
2246
|
-
Versori supports parallel processing via `.unpack()` and `.parallel()` methods, but **they have critical limitations** that affect fault tolerance and error handling (see warning above).
|
|
2247
|
-
|
|
2248
|
-
### Available Methods
|
|
2249
|
-
|
|
2250
|
-
```typescript
|
|
2251
|
-
import { schedule, fn } from '@versori/run';
|
|
2252
|
-
|
|
2253
|
-
schedule('process-files', '0 2 * * *')
|
|
2254
|
-
.then(fn('fetch-files', (ctx) => {
|
|
2255
|
-
return ['file1.xml', 'file2.xml', 'file3.xml']; // Returns array
|
|
2256
|
-
}))
|
|
2257
|
-
.unpack() // ✅ Converts array to ArrayTask
|
|
2258
|
-
.parallel( // ✅ Processes each item in parallel
|
|
2259
|
-
fn('process-file', async (ctx) => {
|
|
2260
|
-
// ctx.data is now a single item from the array
|
|
2261
|
-
return await processFile(ctx.data);
|
|
2262
|
-
})
|
|
2263
|
-
);
|
|
2264
|
-
```
|
|
2265
|
-
|
|
2266
|
-
### ⚠️ CRITICAL LIMITATIONS
|
|
2267
|
-
|
|
2268
|
-
#### 1. **NOT Fault-Tolerant: One Failure Stops Everything**
|
|
2269
|
-
|
|
2270
|
-
**Problem**: If any parallel task fails, the **entire workflow fails** and **no results are returned**.
|
|
2271
|
-
|
|
2272
|
-
```typescript
|
|
2273
|
-
// ❌ RISKY: If file2.xml fails, entire workflow fails
|
|
2274
|
-
schedule('process-files', '0 2 * * *')
|
|
2275
|
-
.then(fn('fetch-files', (ctx) => ['file1.xml', 'file2.xml', 'file3.xml']))
|
|
2276
|
-
.unpack()
|
|
2277
|
-
.parallel(fn('process-file', async (ctx) => {
|
|
2278
|
-
// If ANY file fails here, ALL results lost
|
|
2279
|
-
return await processFile(ctx.data);
|
|
2280
|
-
}));
|
|
2281
|
-
|
|
2282
|
-
// Result:
|
|
2283
|
-
// ✅ file1.xml might complete
|
|
2284
|
-
// ❌ file2.xml fails → ENTIRE WORKFLOW FAILS
|
|
2285
|
-
// ❌ file3.xml NEVER RUNS (or gets cancelled)
|
|
2286
|
-
// ❌ No partial results returned
|
|
2287
|
-
```
|
|
2288
|
-
|
|
2289
|
-
**Why**: Uses RxJS `mergeMap` + `toArray()` which stops on first error.
|
|
2290
|
-
|
|
2291
|
-
#### 2. **Shared Timeout Across All Tasks**
|
|
2292
|
-
|
|
2293
|
-
**Problem**: All parallel tasks share the **same workflow timeout**.
|
|
2294
|
-
|
|
2295
|
-
| Trigger Type | Timeout | Impact |
|
|
2296
|
-
|-------------|---------|--------|
|
|
2297
|
-
| **HTTP/Webhook** | 30 seconds | All parallel tasks must complete in 30s total |
|
|
2298
|
-
| **Schedule** | 5 minutes | All parallel tasks must complete in 5min total |
|
|
2299
|
-
|
|
2300
|
-
```typescript
|
|
2301
|
-
// ⚠️ If processing 10 files in parallel:
|
|
2302
|
-
schedule('process-files', '0 2 * * *')
|
|
2303
|
-
.then(fn('fetch-files', (ctx) => Array(10).fill('file.xml')))
|
|
2304
|
-
.unpack()
|
|
2305
|
-
.parallel(fn('process-file', async (ctx) => {
|
|
2306
|
-
// If ANY file takes > 5 minutes, ENTIRE workflow fails
|
|
2307
|
-
return await processFile(ctx.data);
|
|
2308
|
-
}));
|
|
2309
|
-
```
|
|
2310
|
-
|
|
2311
|
-
#### 3. **NOT ACID Compliant**
|
|
2312
|
-
|
|
2313
|
-
**Problem**: No atomicity guarantees. If one task fails:
|
|
2314
|
-
- ✅ Successful tasks may have completed their work
|
|
2315
|
-
- ❌ But you won't know which ones succeeded
|
|
2316
|
-
- ❌ No rollback mechanism
|
|
2317
|
-
- ❌ No transaction support
|
|
2318
|
-
|
|
2319
|
-
### ✅ Recommended: Use `Promise.allSettled()` Instead
|
|
2320
|
-
|
|
2321
|
-
For **fault-tolerant** parallel processing, use `Promise.allSettled()` which continues even when some tasks fail:
|
|
2322
|
-
|
|
2323
|
-
```typescript
|
|
2324
|
-
import { schedule, fn } from '@versori/run';
|
|
2325
|
-
|
|
2326
|
-
schedule('process-files', '0 2 * * *')
|
|
2327
|
-
.then(fn('process-all-files', async (ctx) => {
|
|
2328
|
-
const files = ['file1.xml', 'file2.xml', 'file3.xml'];
|
|
2329
|
-
|
|
2330
|
-
// ✅ Fault-tolerant: Continues even if some fail
|
|
2331
|
-
const results = await Promise.allSettled(
|
|
2332
|
-
files.map(file => processFile(file))
|
|
2333
|
-
);
|
|
2334
|
-
|
|
2335
|
-
// Process results
|
|
2336
|
-
const successes = results
|
|
2337
|
-
.filter(r => r.status === 'fulfilled')
|
|
2338
|
-
.map(r => r.value);
|
|
2339
|
-
|
|
2340
|
-
const failures = results
|
|
2341
|
-
.filter(r => r.status === 'rejected')
|
|
2342
|
-
.map(r => ({ file: r.reason.file, error: r.reason.message }));
|
|
2343
|
-
|
|
2344
|
-
ctx.log.info(`Processed ${successes.length} files, ${failures.length} failed`);
|
|
2345
|
-
|
|
2346
|
-
return { successes, failures };
|
|
2347
|
-
}));
|
|
2348
|
-
```
|
|
2349
|
-
|
|
2350
|
-
**Comparison**:
|
|
2351
|
-
|
|
2352
|
-
| Feature | `.unpack().parallel()` | `Promise.allSettled()` |
|
|
2353
|
-
|---------|------------------------|------------------------|
|
|
2354
|
-
| Fault tolerance | ❌ Fails on first error | ✅ Continues on errors |
|
|
2355
|
-
| Partial results | ❌ No results if any fail | ✅ Returns all results |
|
|
2356
|
-
| Error isolation | ❌ No isolation | ✅ Complete isolation |
|
|
2357
|
-
| Time limits | ⚠️ Shared timeout | ⚠️ Shared timeout |
|
|
2358
|
-
| Best for | All-or-nothing workflows | Real-world with failures |
|
|
2359
|
-
|
|
2360
|
-
### When to Use `.unpack().parallel()`
|
|
2361
|
-
|
|
2362
|
-
**Only use if**:
|
|
2363
|
-
- ✅ All tasks **must** succeed (transactional)
|
|
2364
|
-
- ✅ You can wrap each task with error handling that returns error objects instead of throwing
|
|
2365
|
-
- ✅ You're okay with losing all results on any failure
|
|
2366
|
-
|
|
2367
|
-
**Example with error handling**:
|
|
2368
|
-
|
|
2369
|
-
```typescript
|
|
2370
|
-
schedule('process-files', '0 2 * * *')
|
|
2371
|
-
.then(fn('fetch-files', (ctx) => ['file1.xml', 'file2.xml', 'file3.xml']))
|
|
2372
|
-
.unpack()
|
|
2373
|
-
.parallel(fn('process-file', async (ctx) => {
|
|
2374
|
-
try {
|
|
2375
|
-
const result = await processFile(ctx.data);
|
|
2376
|
-
return { success: true, file: ctx.data, result };
|
|
2377
|
-
} catch (error) {
|
|
2378
|
-
// ✅ Return error object instead of throwing
|
|
2379
|
-
return {
|
|
2380
|
-
success: false,
|
|
2381
|
-
file: ctx.data,
|
|
2382
|
-
error: error.message
|
|
2383
|
-
};
|
|
2384
|
-
}
|
|
2385
|
-
}))
|
|
2386
|
-
.then(fn('process-results', (ctx) => {
|
|
2387
|
-
// ✅ Now you have all results (success + failures)
|
|
2388
|
-
const results = ctx.data;
|
|
2389
|
-
const successes = results.filter(r => r.success);
|
|
2390
|
-
const failures = results.filter(r => !r.success);
|
|
2391
|
-
|
|
2392
|
-
ctx.log.info(`Processed ${successes.length} files, ${failures.length} failed`);
|
|
2393
|
-
return { successes, failures };
|
|
2394
|
-
}));
|
|
2395
|
-
```
|
|
2396
|
-
|
|
2397
|
-
### When to Use `Promise.allSettled()` (Recommended)
|
|
2398
|
-
|
|
2399
|
-
**Use `Promise.allSettled()` for**:
|
|
2400
|
-
- ✅ Batch API submissions
|
|
2401
|
-
- ✅ File processing
|
|
2402
|
-
- ✅ Any scenario where failures are expected
|
|
2403
|
-
- ✅ When you need partial results
|
|
2404
|
-
|
|
2405
|
-
**Already implemented in your code**:
|
|
2406
|
-
|
|
2407
|
-
```typescript
|
|
2408
|
-
// ✅ Your current BatchProcessorService uses this pattern
|
|
2409
|
-
const batchResults = await Promise.allSettled(
|
|
2410
|
-
batchChunk.map((chunk, index) =>
|
|
2411
|
-
this.client.sendBatch(jobId, {
|
|
2412
|
-
action: 'UPSERT',
|
|
2413
|
-
entityType: 'INVENTORY',
|
|
2414
|
-
entities: chunk,
|
|
2415
|
-
}).then(batch => ({ success: true, batch }))
|
|
2416
|
-
.catch(error => ({ success: false, error }))
|
|
2417
|
-
)
|
|
2418
|
-
);
|
|
2419
|
-
```
|
|
2420
|
-
|
|
2421
|
-
---
|
|
2422
|
-
|
|
2423
|
-
## Key Takeaways
|
|
2424
|
-
|
|
2425
|
-
- 🎯 **http()** for external API calls - requires connection, provides `ctx.fetch`
|
|
2426
|
-
- 🎯 **webhook()** for receiving requests - provides `ctx.data`, `ctx.request()` for headers
|
|
2427
|
-
- 🎯 **schedule()** for time-based tasks - supports cron patterns, can have connection
|
|
2428
|
-
- 🎯 **fn()** for internal processing - NO external API access, has `ctx.openKv()`
|
|
2429
|
-
- 🎯 **Non-JSON responses** require custom `onSuccess`/`onError` handlers returning Response objects
|
|
2430
|
-
- 🎯 **Workflow composition** with `.then()` and `.catch()` enables multi-step patterns
|
|
2431
|
-
- 🎯 **Error handling** with try/catch and retry configuration ensures robustness
|
|
2432
|
-
- ⚠️ **`.unpack().parallel()`** exists but is NOT fault-tolerant - use `Promise.allSettled()` for resilience
|
|
2433
|
-
|
|
2434
|
-
---
|
|
2435
|
-
|
|
2436
|
-
## Practice Exercise
|
|
2437
|
-
|
|
2438
|
-
Create a scheduled workflow that:
|
|
2439
|
-
|
|
2440
|
-
1. Runs hourly (cron: `0 * * * *`)
|
|
2441
|
-
2. Fetches inventory from an S3 CSV file
|
|
2442
|
-
3. Checks KV storage to avoid duplicate processing
|
|
2443
|
-
4. Transforms data using UniversalMapper
|
|
2444
|
-
5. Sends to Fluent via Batch API
|
|
2445
|
-
6. Marks file as processed in KV storage
|
|
2446
|
-
7. Returns XML response on error
|
|
2447
|
-
|
|
2448
|
-
**Hints**:
|
|
2449
|
-
|
|
2450
|
-
- Use `schedule()` with connection
|
|
2451
|
-
- Chain with `fn()` for KV checks
|
|
2452
|
-
- Use `http()` context for Fluent API calls
|
|
2453
|
-
- Implement custom error handler for XML response
|
|
2454
|
-
|
|
2455
|
-
**Solution** available in [Module 8: Best Practices](./platforms-versori-08-best-practices.md#practice-solutions)
|
|
2456
|
-
|
|
2457
|
-
---
|
|
2458
|
-
|
|
2459
|
-
## Next Steps
|
|
2460
|
-
|
|
2461
|
-
Now that you've mastered all workflow types, let's explore connection management in detail.
|
|
2462
|
-
|
|
2463
|
-
Continue to [Module 5: Connections →](./platforms-versori-05-connections.md) to learn about OAuth2 connection types, management, and validation.
|
|
2464
|
-
|
|
2465
|
-
---
|
|
2466
|
-
|
|
2467
|
-
## Related Documentation
|
|
2468
|
-
|
|
2469
|
-
- [Module 3: Authentication](./platforms-versori-03-authentication.md) - OAuth2 basics
|
|
2470
|
-
- [Module 5: Connections](./platforms-versori-05-connections.md) - Connection management
|
|
2471
|
-
- [Module 6: KV Storage](./platforms-versori-06-kv-storage.md) - State management
|
|
2472
|
-
- [Webhook Response Patterns](.././modules/platforms-versori-04-workflows.md#critical-non-json-response-handlers-xml-html-csv) - Complete non-JSON response guide
|
|
2473
|
-
|
|
2474
|
-
---
|
|
2475
|
-
|
|
2476
|
-
[← Previous: Module 3](./platforms-versori-03-authentication.md) | [Back to Guide](../platforms-versori-readme.md) | [Next: Module 5: Connections →](./platforms-versori-05-connections.md)
|
|
1
|
+
# Module 4: Workflows - HTTP, Webhooks, Scheduled & Internal Functions
|
|
2
|
+
|
|
3
|
+
[← Back to Versori Platform Guide](../platforms-versori-readme.md)
|
|
4
|
+
|
|
5
|
+
**Module 4 of 8** | **Level**: Intermediate | **Time**: 30 minutes
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Learning Objectives
|
|
10
|
+
|
|
11
|
+
By the end of this module, you will:
|
|
12
|
+
|
|
13
|
+
- ✅ Master all four workflow types: http(), webhook(), schedule(), fn()
|
|
14
|
+
- ✅ Understand when to use each workflow type
|
|
15
|
+
- ✅ Implement HTTP workflows with Fluent API calls
|
|
16
|
+
- ✅ Build webhook receivers with signature validation
|
|
17
|
+
- ✅ Create scheduled tasks with cron patterns
|
|
18
|
+
- ✅ Compose multi-step workflows with fn()
|
|
19
|
+
- ✅ **Handle non-JSON responses** (XML, HTML, CSV) correctly
|
|
20
|
+
- ✅ Implement error handling and retry strategies
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Workflow Types Overview
|
|
25
|
+
|
|
26
|
+
Versori provides four fundamental workflow types, each with specific capabilities and use cases:
|
|
27
|
+
|
|
28
|
+
| Type | Purpose | External API Access | Context | Use When |
|
|
29
|
+
| -------------- | --------------------- | -------------------------------- | ---------------------------- | ------------------------------------- |
|
|
30
|
+
| **http()** | External API calls | ✅ Yes (requires connection) | `fetch`, `log`, `activation` | Query/mutate Fluent API |
|
|
31
|
+
| **webhook()** | Receive HTTP requests | ❌ No (unless chained with http) | `data`, `request()`, `log` | Receive SFCC orders, Rubix events |
|
|
32
|
+
| **schedule()** | Time-based tasks | ✅ Yes (if connection provided) | `fetch`, `log`, `activation` | Daily sync, hourly extraction |
|
|
33
|
+
| **fn()** | Internal processing | ❌ No | `openKv`, `log`, `request()` | Data transformation, state management |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## ⚠️ CRITICAL: Accessing Headers & Choosing Workflow Types
|
|
38
|
+
|
|
39
|
+
### Accessing HTTP Headers
|
|
40
|
+
|
|
41
|
+
**The `Context<D>` interface does NOT have a `headers` property.** Always use `ctx.request()`:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// ✅ CORRECT - Access headers via request()
|
|
45
|
+
const req = ctx.request();
|
|
46
|
+
const apiKey = req?.headers['x-api-key'] as string;
|
|
47
|
+
const contentType = req?.headers['content-type'];
|
|
48
|
+
|
|
49
|
+
// ❌ WRONG - ctx.headers doesn't exist in @versori/run
|
|
50
|
+
const apiKey = ctx.headers?.get?.('x-api-key'); // ERROR!
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### When to Use http() vs fn()
|
|
54
|
+
|
|
55
|
+
**Decision Rule:** Do you need to call external APIs (Fluent Commerce, S3 presigned URLs, etc.)?
|
|
56
|
+
|
|
57
|
+
#### Use `http()` When:
|
|
58
|
+
|
|
59
|
+
- ✅ Calling Fluent Commerce GraphQL API
|
|
60
|
+
- ✅ Need connection credentials and authentication
|
|
61
|
+
- ✅ Require `ctx.fetch` with OAuth2 auth
|
|
62
|
+
- ✅ Need `connectionVariables` or `baseUrl`
|
|
63
|
+
|
|
64
|
+
**What you get in http():**
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
http('my-task', { connection: 'fluent_commerce' }, async ctx => {
|
|
68
|
+
ctx.fetch; // ✅ Authenticated fetch with connection
|
|
69
|
+
ctx.connectionVariables; // ✅ Connection configuration
|
|
70
|
+
ctx.baseUrl; // ✅ API base URL from connection
|
|
71
|
+
ctx.request(); // ✅ HTTP request object (for headers)
|
|
72
|
+
(ctx.log, ctx.data, ctx.activation, ctx.openKv()); // ✅ All standard context
|
|
73
|
+
|
|
74
|
+
// Can call external APIs:
|
|
75
|
+
const client = await createClient(ctx); // ✅ Works!
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
#### Use `fn()` When:
|
|
80
|
+
|
|
81
|
+
- ✅ Pure data transformation (no external API calls)
|
|
82
|
+
- ✅ State management with KV storage only
|
|
83
|
+
- ✅ Parsing, mapping, validation logic
|
|
84
|
+
- ✅ File tracking and deduplication
|
|
85
|
+
|
|
86
|
+
**What you get in fn():**
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
fn('my-task', async ctx => {
|
|
90
|
+
ctx.data; // ✅ Input data
|
|
91
|
+
ctx.log; // ✅ Logger
|
|
92
|
+
ctx.activation; // ✅ Variables from activation
|
|
93
|
+
ctx.openKv(); // ✅ KV storage access
|
|
94
|
+
ctx.request(); // ✅ HTTP request object (for headers)
|
|
95
|
+
|
|
96
|
+
// ❌ NO ctx.fetch - cannot make authenticated API calls
|
|
97
|
+
// ❌ NO connectionVariables - no connection context
|
|
98
|
+
// ❌ CANNOT call createClient() - will fail
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Example: Adhoc Extraction (Requires http())
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// ✅ CORRECT - Use http() for Fluent API access with connection-based security
|
|
106
|
+
export const adhocExtraction = webhook('adhoc-extract', {
|
|
107
|
+
connection: 'webhook-auth', // ← Platform validates API key automatically
|
|
108
|
+
response: { mode: 'sync' },
|
|
109
|
+
}).then(
|
|
110
|
+
http('execute-extraction', { connection: 'fluent_commerce' }, async ctx => {
|
|
111
|
+
const { log, data } = ctx;
|
|
112
|
+
|
|
113
|
+
// ✅ If we're here, authentication already passed via connection
|
|
114
|
+
// No manual validation code needed!
|
|
115
|
+
|
|
116
|
+
// http() provides connection context for createClient()
|
|
117
|
+
const client = await createClient(ctx); // ✅ Works!
|
|
118
|
+
const result = await client.graphql({ query: '...' });
|
|
119
|
+
|
|
120
|
+
return { success: true, data: result.data };
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// ❌ WRONG - fn() cannot access Fluent API
|
|
125
|
+
export const brokenExtraction = webhook('adhoc-extract', {
|
|
126
|
+
connection: 'webhook-auth'
|
|
127
|
+
}).then(
|
|
128
|
+
fn('execute-extraction', async ctx => {
|
|
129
|
+
const client = await createClient(ctx); // ❌ FAILS - no connection context in fn()
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Quick Decision Table
|
|
135
|
+
|
|
136
|
+
| Need To | Use | Reason |
|
|
137
|
+
| ------------------------------- | -------- | ------------------------------------ |
|
|
138
|
+
| Call Fluent GraphQL API | `http()` | Requires connection + authentication |
|
|
139
|
+
| Parse CSV to JSON | `fn()` | No external API needed |
|
|
140
|
+
| Check if file already processed | `fn()` | Uses KV storage only |
|
|
141
|
+
| Send batch to Fluent | `http()` | Requires Fluent API access |
|
|
142
|
+
| Transform/map data | `fn()` | Pure data transformation |
|
|
143
|
+
| Query external REST API | `http()` | Requires fetch + connection |
|
|
144
|
+
| Access HTTP headers | **Both** | Use `ctx.request()` in either |
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## ⚠️ Deno Compatibility: Buffer Import
|
|
149
|
+
|
|
150
|
+
**CRITICAL for Versori Platform (Deno runtime):** Always import Buffer explicitly when working with binary data, file uploads, or base64 encoding/decoding.
|
|
151
|
+
|
|
152
|
+
### Why Buffer Import is Required
|
|
153
|
+
|
|
154
|
+
Versori runs on Deno, not Node.js. In Node.js, `Buffer` is globally available, but in Deno it must be explicitly imported from `node:buffer`.
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// ✅ CORRECT - Always import Buffer in Versori/Deno
|
|
158
|
+
import { Buffer } from 'node:buffer';
|
|
159
|
+
|
|
160
|
+
await sftp.uploadFile('/path/file.xml', Buffer.from(xmlContent, 'utf-8'));
|
|
161
|
+
const decoded = Buffer.from(base64String, 'base64').toString('utf-8');
|
|
162
|
+
|
|
163
|
+
// ❌ WRONG - Buffer is not global in Deno
|
|
164
|
+
await sftp.uploadFile('/path/file.xml', Buffer.from(xmlContent, 'utf-8')); // ReferenceError!
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### When to Import Buffer
|
|
168
|
+
|
|
169
|
+
Import Buffer when your workflow involves:
|
|
170
|
+
|
|
171
|
+
| Operation | Example | Requires Buffer? |
|
|
172
|
+
|-----------|---------|------------------|
|
|
173
|
+
| **File uploads** (SFTP, S3) | `sftp.uploadFile()` | ✅ Yes |
|
|
174
|
+
| **Base64 encoding/decoding** | `Buffer.from(str, 'base64')` | ✅ Yes |
|
|
175
|
+
| **Binary data handling** | Working with binary files | ✅ Yes |
|
|
176
|
+
| **String encoding** | `Buffer.from(str, 'utf-8')` | ✅ Yes |
|
|
177
|
+
| **XML response generation** | Returning XML as string | ❌ No (string only) |
|
|
178
|
+
| **JSON operations** | `JSON.parse()`, `JSON.stringify()` | ❌ No |
|
|
179
|
+
| **GraphQL queries** | `client.graphql()` | ❌ No |
|
|
180
|
+
|
|
181
|
+
### Pattern: Add Buffer Import at Top of File
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
// ✅ CORRECT Pattern - Add at top of file with other imports
|
|
185
|
+
import { Buffer } from 'node:buffer'; // Required for Deno
|
|
186
|
+
import { schedule, http, fn } from '@versori/run';
|
|
187
|
+
import { createClient, SftpDataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
188
|
+
|
|
189
|
+
export const sftpUpload = schedule('upload', '0 * * * *')
|
|
190
|
+
.then(fn('prepare-file', async (ctx) => {
|
|
191
|
+
// Buffer available here
|
|
192
|
+
const fileContent = Buffer.from(ctx.data.content, 'utf-8');
|
|
193
|
+
return { fileContent };
|
|
194
|
+
}));
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**See Also:** All examples in this module that use Buffer include the import statement.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## HTTP Functions - External API Calls
|
|
202
|
+
|
|
203
|
+
HTTP workflows enable authenticated calls to external APIs, including Fluent Commerce.
|
|
204
|
+
|
|
205
|
+
### Basic HTTP Workflow
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { http } from '@versori/run';
|
|
209
|
+
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Query inventory positions from Fluent Commerce
|
|
213
|
+
*
|
|
214
|
+
* Endpoint: https://{workspace}.versori.run/query-inventory
|
|
215
|
+
* Method: GET
|
|
216
|
+
*/
|
|
217
|
+
export const queryInventory = http(
|
|
218
|
+
'query-inventory',
|
|
219
|
+
{
|
|
220
|
+
connection: 'fluent_commerce', // CRITICAL: Provides OAuth2 auth
|
|
221
|
+
timeout: 30000, // 30 second timeout
|
|
222
|
+
retry: {
|
|
223
|
+
attempts: 3, // Retry up to 3 times
|
|
224
|
+
delay: 1000, // 1 second between retries
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
async ctx => {
|
|
228
|
+
ctx.log('info', 'Starting inventory query');
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
// Create SDK client (auto-configured from connection)
|
|
232
|
+
const client = await createClient(ctx);
|
|
233
|
+
|
|
234
|
+
// GraphQL query - schema validated against Fluent Commerce API
|
|
235
|
+
const result = await client.graphql({
|
|
236
|
+
query: `
|
|
237
|
+
query GetInventoryPositions($first: Int!) {
|
|
238
|
+
inventoryPositions(first: $first) {
|
|
239
|
+
edges {
|
|
240
|
+
node {
|
|
241
|
+
id
|
|
242
|
+
ref
|
|
243
|
+
productRef
|
|
244
|
+
locationRef
|
|
245
|
+
onHand # ✅ Correct field for InventoryPosition
|
|
246
|
+
status
|
|
247
|
+
}
|
|
248
|
+
cursor
|
|
249
|
+
}
|
|
250
|
+
pageInfo {
|
|
251
|
+
hasNextPage
|
|
252
|
+
endCursor
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
`,
|
|
257
|
+
variables: { first: 100 },
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Check for GraphQL errors
|
|
261
|
+
if (result.errors?.length) {
|
|
262
|
+
throw new Error(`GraphQL error: ${result.errors[0].message}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const positions = result.data.inventoryPositions.edges;
|
|
266
|
+
ctx.log('info', `Retrieved ${positions.length} inventory positions`);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
success: true,
|
|
270
|
+
count: positions.length,
|
|
271
|
+
data: positions.map(edge => edge.node),
|
|
272
|
+
pageInfo: result.data.inventoryPositions.pageInfo,
|
|
273
|
+
};
|
|
274
|
+
} catch (error) {
|
|
275
|
+
ctx.log('error', 'Inventory query failed', {
|
|
276
|
+
error: error instanceof Error ? error.message : String(error),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
success: false,
|
|
281
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### HTTP with Query Parameters
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
export const searchOrders = http(
|
|
292
|
+
'search-orders',
|
|
293
|
+
{
|
|
294
|
+
connection: 'fluent_commerce',
|
|
295
|
+
},
|
|
296
|
+
async ctx => {
|
|
297
|
+
// Extract query parameters
|
|
298
|
+
const status = ctx.query?.status || 'OPEN';
|
|
299
|
+
const limit = parseInt(ctx.query?.limit || '50');
|
|
300
|
+
const customerRef = ctx.query?.customerRef;
|
|
301
|
+
|
|
302
|
+
ctx.log('info', 'Searching orders', { status, limit, customerRef });
|
|
303
|
+
|
|
304
|
+
const client = await createClient(ctx);
|
|
305
|
+
|
|
306
|
+
// Build GraphQL query with filters - schema validated
|
|
307
|
+
const result = await client.graphql({
|
|
308
|
+
query: `
|
|
309
|
+
query SearchOrders($status: String!, $first: Int!, $customerRef: String) {
|
|
310
|
+
orders(status: $status, first: $first, customerRef: $customerRef) {
|
|
311
|
+
edges {
|
|
312
|
+
node {
|
|
313
|
+
id
|
|
314
|
+
ref
|
|
315
|
+
status
|
|
316
|
+
totalPrice
|
|
317
|
+
customer {
|
|
318
|
+
ref
|
|
319
|
+
firstName
|
|
320
|
+
lastName
|
|
321
|
+
}
|
|
322
|
+
items {
|
|
323
|
+
edges {
|
|
324
|
+
node {
|
|
325
|
+
productRef
|
|
326
|
+
quantity
|
|
327
|
+
price
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
`,
|
|
336
|
+
variables: { status, first: limit, customerRef },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
orders: result.data.orders.edges.map(e => e.node),
|
|
341
|
+
count: result.data.orders.edges.length,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
);
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### HTTP with POST Body (Mutations)
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
import { http } from '@versori/run';
|
|
351
|
+
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Create inventory position
|
|
355
|
+
*
|
|
356
|
+
* POST https://{workspace}.versori.run/create-inventory
|
|
357
|
+
* Body: { productRef, locationRef, qty }
|
|
358
|
+
*/
|
|
359
|
+
export const createInventory = http(
|
|
360
|
+
'create-inventory',
|
|
361
|
+
{
|
|
362
|
+
connection: 'fluent_commerce',
|
|
363
|
+
},
|
|
364
|
+
async ctx => {
|
|
365
|
+
// Extract POST body
|
|
366
|
+
const { productRef, locationRef, qty } = ctx.data || {};
|
|
367
|
+
|
|
368
|
+
// Validation
|
|
369
|
+
if (!productRef || !locationRef || qty === undefined) {
|
|
370
|
+
return {
|
|
371
|
+
success: false,
|
|
372
|
+
error: 'Missing required fields: productRef, locationRef, qty',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const client = await createClient(ctx);
|
|
377
|
+
|
|
378
|
+
// GraphQL mutation - schema validated
|
|
379
|
+
const result = await client.graphql({
|
|
380
|
+
query: `
|
|
381
|
+
mutation CreateInventoryPosition($input: CreateInventoryPositionInput!) {
|
|
382
|
+
createInventoryPosition(input: $input) {
|
|
383
|
+
id
|
|
384
|
+
ref
|
|
385
|
+
productRef
|
|
386
|
+
locationRef
|
|
387
|
+
onHand
|
|
388
|
+
status
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
`,
|
|
392
|
+
variables: {
|
|
393
|
+
input: {
|
|
394
|
+
productRef,
|
|
395
|
+
locationRef,
|
|
396
|
+
qty,
|
|
397
|
+
type: 'AVAILABLE',
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (result.errors?.length) {
|
|
403
|
+
return {
|
|
404
|
+
success: false,
|
|
405
|
+
error: result.errors[0].message,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
ctx.log('info', 'Inventory position created', {
|
|
410
|
+
id: result.data.createInventoryPosition.id,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
success: true,
|
|
415
|
+
data: result.data.createInventoryPosition,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## Webhook Functions - Receiving External Requests
|
|
424
|
+
|
|
425
|
+
Webhook workflows receive external HTTP requests. They provide `ctx.data` (parsed body) and `ctx.request()` for accessing headers and raw request details.
|
|
426
|
+
|
|
427
|
+
### Basic Webhook Receiver
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
import { webhook } from '@versori/run';
|
|
431
|
+
import { parseWebhookRequest } from '@fluentcommerce/fc-connect-sdk';
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Receive SFCC order webhook
|
|
435
|
+
*
|
|
436
|
+
* POST https://{workspace}.versori.run/receive-order
|
|
437
|
+
* Body: XML order payload
|
|
438
|
+
*/
|
|
439
|
+
export const receiveOrder = webhook('receive-order', async ctx => {
|
|
440
|
+
const req = ctx.request();
|
|
441
|
+
ctx.log('info', 'Received order webhook', {
|
|
442
|
+
contentType: req?.headers['content-type'],
|
|
443
|
+
bodySize: JSON.stringify(ctx.data).length,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
// Parse incoming payload
|
|
448
|
+
const payload = parseWebhookRequest(ctx.data);
|
|
449
|
+
|
|
450
|
+
if (!payload) {
|
|
451
|
+
return {
|
|
452
|
+
success: false,
|
|
453
|
+
error: 'Invalid payload format',
|
|
454
|
+
status: 400,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Process order (use fn() or http() in chain)
|
|
459
|
+
ctx.log('info', 'Order parsed successfully', {
|
|
460
|
+
orderId: payload.orderId,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
success: true,
|
|
465
|
+
orderId: payload.orderId,
|
|
466
|
+
status: 200,
|
|
467
|
+
};
|
|
468
|
+
} catch (error) {
|
|
469
|
+
ctx.log('error', 'Webhook processing failed', {
|
|
470
|
+
error: error instanceof Error ? error.message : String(error),
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
success: false,
|
|
475
|
+
error: error instanceof Error ? error.message : 'Processing failed',
|
|
476
|
+
status: 500,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Webhook with Signature Validation (Fluent Rubix)
|
|
483
|
+
|
|
484
|
+
**IMPORTANT**: The webhook validation is **ONLY** for webhooks sent from **Fluent Commerce Rubix workflows**.
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
import { webhook } from '@versori/run';
|
|
488
|
+
import {
|
|
489
|
+
WebhookValidationService,
|
|
490
|
+
SignatureAlgorithm,
|
|
491
|
+
parseWebhookRequest,
|
|
492
|
+
validateFluentEvent,
|
|
493
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Receive Fluent Rubix webhook with signature validation
|
|
497
|
+
*
|
|
498
|
+
* POST https://{workspace}.versori.run/rubix-event
|
|
499
|
+
* Headers: fluent-signature (or x-fluent-signature)
|
|
500
|
+
*/
|
|
501
|
+
export const receiveRubixEvent = webhook('rubix-event', async ctx => {
|
|
502
|
+
const logger = ctx.log; // Use native Versori logger
|
|
503
|
+
|
|
504
|
+
// Get public key from connector variables
|
|
505
|
+
const publicKey = ctx.vars?.FLUENT_WEBHOOK_PUBLIC_KEY;
|
|
506
|
+
if (!publicKey) {
|
|
507
|
+
logger.error('Missing FLUENT_WEBHOOK_PUBLIC_KEY in connector variables');
|
|
508
|
+
return { error: 'Configuration error: missing public key', status: 500 };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// IMPORTANT: This validator is ONLY for Fluent Commerce Rubix workflows
|
|
512
|
+
const validator = new WebhookValidationService(
|
|
513
|
+
{
|
|
514
|
+
algorithm: SignatureAlgorithm.SHA512_WITH_RSA,
|
|
515
|
+
strictValidation: true,
|
|
516
|
+
},
|
|
517
|
+
logger
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
// Extract signature from headers using ctx.request()
|
|
521
|
+
const req = ctx.request();
|
|
522
|
+
const signature = req?.headers['fluent-signature'] || req?.headers['x-fluent-signature'];
|
|
523
|
+
|
|
524
|
+
if (!signature) {
|
|
525
|
+
logger.warn('Missing webhook signature in headers');
|
|
526
|
+
return { error: 'Missing signature', status: 401 };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Validate Fluent Commerce Rubix webhook signature
|
|
530
|
+
const validationResult = await validator.validateWebhookSignature(
|
|
531
|
+
JSON.stringify(ctx.data),
|
|
532
|
+
signature,
|
|
533
|
+
publicKey,
|
|
534
|
+
SignatureAlgorithm.SHA512_WITH_RSA
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
if (!validationResult.isValid) {
|
|
538
|
+
logger.error('Fluent Commerce Rubix webhook validation failed', validationResult);
|
|
539
|
+
return {
|
|
540
|
+
error: 'Invalid Fluent Rubix webhook signature',
|
|
541
|
+
details: validationResult.error,
|
|
542
|
+
status: 401,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Parse webhook payload
|
|
547
|
+
const payload = parseWebhookRequest(ctx.data, logger, 'receiveRubixEvent');
|
|
548
|
+
|
|
549
|
+
if (!payload) {
|
|
550
|
+
return { error: 'Invalid payload', status: 400 };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Validate event structure
|
|
554
|
+
const validation = validateFluentEvent(payload, logger, 'receiveRubixEvent');
|
|
555
|
+
if (!validation.isValid) {
|
|
556
|
+
return {
|
|
557
|
+
error: 'Validation failed',
|
|
558
|
+
details: validation.warnings,
|
|
559
|
+
status: 400,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
logger.info('Rubix webhook validated successfully', {
|
|
564
|
+
eventName: payload.name,
|
|
565
|
+
entityType: payload.entityType,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
success: true,
|
|
570
|
+
eventName: payload.name,
|
|
571
|
+
status: 200,
|
|
572
|
+
};
|
|
573
|
+
});
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### CRITICAL: Non-JSON Response Handlers (XML, HTML, CSV)
|
|
577
|
+
|
|
578
|
+
**Problem**: By default, Versori JSON-encodes all webhook responses. This breaks XML, HTML, CSV, and other non-JSON content.
|
|
579
|
+
|
|
580
|
+
**Solution**: Use custom `onSuccess` and `onError` handlers that return `Response` objects.
|
|
581
|
+
|
|
582
|
+
#### XML Response Example
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
import { webhook, fn } from '@versori/run';
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Return XML response from webhook
|
|
589
|
+
*
|
|
590
|
+
* GET/POST https://{workspace}.versori.run/order-status-xml
|
|
591
|
+
* Returns: Raw XML (NOT JSON-encoded)
|
|
592
|
+
*/
|
|
593
|
+
export const orderStatusXML = webhook('order-status-xml', {
|
|
594
|
+
response: {
|
|
595
|
+
mode: 'sync',
|
|
596
|
+
onSuccess: ctx => {
|
|
597
|
+
// ctx.data contains the final value from .then() chain
|
|
598
|
+
return new Response(ctx.data, {
|
|
599
|
+
status: 200,
|
|
600
|
+
headers: {
|
|
601
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
602
|
+
'X-Execution-Id': ctx.executionId,
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
},
|
|
606
|
+
onError: ctx => {
|
|
607
|
+
// ctx.data contains the error from .catch()
|
|
608
|
+
const errorXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
609
|
+
<ErrorResponse>
|
|
610
|
+
<Error>
|
|
611
|
+
<Code>PROCESSING_ERROR</Code>
|
|
612
|
+
<Message>${ctx.data?.message || 'Unknown error'}</Message>
|
|
613
|
+
<Timestamp>${new Date().toISOString()}</Timestamp>
|
|
614
|
+
<ExecutionId>${ctx.executionId}</ExecutionId>
|
|
615
|
+
</Error>
|
|
616
|
+
</ErrorResponse>`;
|
|
617
|
+
|
|
618
|
+
return new Response(errorXml, {
|
|
619
|
+
status: 500,
|
|
620
|
+
headers: {
|
|
621
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
622
|
+
'X-Execution-Id': ctx.executionId,
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
cors: true,
|
|
628
|
+
})
|
|
629
|
+
.then(
|
|
630
|
+
fn('generate-xml', ({ data }) => {
|
|
631
|
+
// Return raw XML string - this becomes ctx.data in onSuccess
|
|
632
|
+
const orderId = data?.orderId || 'unknown';
|
|
633
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
634
|
+
<OrderStatusResponse>
|
|
635
|
+
<OrderId>${orderId}</OrderId>
|
|
636
|
+
<Status>SHIPPED</Status>
|
|
637
|
+
<Timestamp>${new Date().toISOString()}</Timestamp>
|
|
638
|
+
</OrderStatusResponse>`;
|
|
639
|
+
})
|
|
640
|
+
)
|
|
641
|
+
.catch(({ data }) => {
|
|
642
|
+
// Return error - this becomes ctx.data in onError
|
|
643
|
+
return { message: data instanceof Error ? data.message : String(data) };
|
|
644
|
+
});
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
**Why This Works**:
|
|
648
|
+
|
|
649
|
+
- @versori/run's `sendResponse()` function streams `Response` objects directly **without JSON encoding**
|
|
650
|
+
- The Content-Type header is preserved exactly as specified
|
|
651
|
+
- Works with @versori/run v0.4.4 and all v0.4.x versions
|
|
652
|
+
|
|
653
|
+
#### HTML Response Example
|
|
654
|
+
|
|
655
|
+
```typescript
|
|
656
|
+
export const statusPage = webhook('status-page', {
|
|
657
|
+
response: {
|
|
658
|
+
mode: 'sync',
|
|
659
|
+
onSuccess: ctx =>
|
|
660
|
+
new Response(ctx.data, {
|
|
661
|
+
status: 200,
|
|
662
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
663
|
+
}),
|
|
664
|
+
},
|
|
665
|
+
}).then(
|
|
666
|
+
fn('generate-html', () => {
|
|
667
|
+
return `<!DOCTYPE html>
|
|
668
|
+
<html lang="en">
|
|
669
|
+
<head>
|
|
670
|
+
<meta charset="UTF-8">
|
|
671
|
+
<title>Order Status</title>
|
|
672
|
+
</head>
|
|
673
|
+
<body>
|
|
674
|
+
<h1>System Operational</h1>
|
|
675
|
+
<p>All services running normally.</p>
|
|
676
|
+
</body>
|
|
677
|
+
</html>`;
|
|
678
|
+
})
|
|
679
|
+
);
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
#### CSV Response Example
|
|
683
|
+
|
|
684
|
+
```typescript
|
|
685
|
+
export const exportCSV = webhook('export-csv', {
|
|
686
|
+
response: {
|
|
687
|
+
mode: 'sync',
|
|
688
|
+
onSuccess: ctx =>
|
|
689
|
+
new Response(ctx.data, {
|
|
690
|
+
status: 200,
|
|
691
|
+
headers: {
|
|
692
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
693
|
+
'Content-Disposition': 'attachment; filename="orders.csv"',
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
},
|
|
697
|
+
}).then(
|
|
698
|
+
fn('generate-csv', () => {
|
|
699
|
+
return 'OrderId,Status,Total\n123,SHIPPED,99.99\n456,PENDING,149.99';
|
|
700
|
+
})
|
|
701
|
+
);
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
#### Complex Multi-Step Workflow with XML Response
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
import { webhook, fn, http } from '@versori/run';
|
|
708
|
+
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
709
|
+
import { XMLBuilder } from 'fast-xml-parser';
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Fetch order from Fluent, return as XML
|
|
713
|
+
*
|
|
714
|
+
* POST https://{workspace}.versori.run/order-detail-xml
|
|
715
|
+
* Body: { orderId }
|
|
716
|
+
* Returns: Order details as XML
|
|
717
|
+
*/
|
|
718
|
+
export const orderDetailXML = webhook('order-detail-xml', {
|
|
719
|
+
response: {
|
|
720
|
+
mode: 'sync',
|
|
721
|
+
onSuccess: ctx =>
|
|
722
|
+
new Response(ctx.data, {
|
|
723
|
+
status: 200,
|
|
724
|
+
headers: {
|
|
725
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
726
|
+
'X-Execution-Id': ctx.executionId,
|
|
727
|
+
'X-Order-Id': ctx.metadata?.orderId || 'unknown',
|
|
728
|
+
},
|
|
729
|
+
}),
|
|
730
|
+
onError: ctx => {
|
|
731
|
+
const errorXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
732
|
+
<ErrorResponse>
|
|
733
|
+
<Error>
|
|
734
|
+
<Code>PROCESSING_ERROR</Code>
|
|
735
|
+
<Message>${ctx.data?.message || 'Unknown error'}</Message>
|
|
736
|
+
<Timestamp>${new Date().toISOString()}</Timestamp>
|
|
737
|
+
<ExecutionId>${ctx.executionId}</ExecutionId>
|
|
738
|
+
</Error>
|
|
739
|
+
</ErrorResponse>`;
|
|
740
|
+
|
|
741
|
+
return new Response(errorXml, {
|
|
742
|
+
status: 500,
|
|
743
|
+
headers: {
|
|
744
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
745
|
+
'X-Execution-Id': ctx.executionId,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
cors: true,
|
|
751
|
+
})
|
|
752
|
+
// Step 1: Parse incoming request
|
|
753
|
+
.then(
|
|
754
|
+
fn('parse-request', ({ data }) => {
|
|
755
|
+
const orderId = data?.orderId;
|
|
756
|
+
if (!orderId) {
|
|
757
|
+
throw new Error('OrderId missing from request');
|
|
758
|
+
}
|
|
759
|
+
return { orderId };
|
|
760
|
+
})
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
// Step 2: Fetch from Fluent Commerce
|
|
764
|
+
.then(
|
|
765
|
+
http(
|
|
766
|
+
'fetch-order',
|
|
767
|
+
{
|
|
768
|
+
connection: 'fluent_commerce',
|
|
769
|
+
},
|
|
770
|
+
async ctx => {
|
|
771
|
+
const { orderId } = ctx.data;
|
|
772
|
+
const client = await createClient(ctx);
|
|
773
|
+
|
|
774
|
+
// GraphQL query - schema validated
|
|
775
|
+
const result = await client.graphql({
|
|
776
|
+
query: `
|
|
777
|
+
query GetOrder($ref: String!) {
|
|
778
|
+
order(ref: $ref) {
|
|
779
|
+
id
|
|
780
|
+
ref
|
|
781
|
+
status
|
|
782
|
+
totalPrice
|
|
783
|
+
customer {
|
|
784
|
+
ref
|
|
785
|
+
firstName
|
|
786
|
+
lastName
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
`,
|
|
791
|
+
variables: { ref: orderId },
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
if (!result.data?.order) {
|
|
795
|
+
throw new Error(`Order not found: ${orderId}`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return {
|
|
799
|
+
orderId,
|
|
800
|
+
orderData: result.data.order,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
)
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
// Step 3: Build XML response
|
|
807
|
+
.then(
|
|
808
|
+
fn('build-xml', ({ data }) => {
|
|
809
|
+
const builder = new XMLBuilder({
|
|
810
|
+
ignoreAttributes: false,
|
|
811
|
+
attributeNamePrefix: '@',
|
|
812
|
+
format: true,
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const xmlObject = {
|
|
816
|
+
'?xml': { '@version': '1.0', '@encoding': 'UTF-8' },
|
|
817
|
+
OrderDetailResponse: {
|
|
818
|
+
'@orderId': data.orderId,
|
|
819
|
+
Order: {
|
|
820
|
+
Id: data.orderData.id,
|
|
821
|
+
Ref: data.orderData.ref,
|
|
822
|
+
Status: data.orderData.status,
|
|
823
|
+
TotalPrice: data.orderData.totalPrice,
|
|
824
|
+
Customer: {
|
|
825
|
+
Ref: data.orderData.customer.ref,
|
|
826
|
+
Name: `${data.orderData.customer.firstName} ${data.orderData.customer.lastName}`,
|
|
827
|
+
},
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
return builder.build(xmlObject);
|
|
833
|
+
})
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
.catch(({ data }) => {
|
|
837
|
+
// Return error message as object - onError handler will format as XML
|
|
838
|
+
return { message: data instanceof Error ? data.message : String(data) };
|
|
839
|
+
});
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
**Key Patterns**:
|
|
843
|
+
|
|
844
|
+
- `onSuccess` and `onError` **return Response objects**
|
|
845
|
+
- Workflow steps **return raw strings** (NOT Response objects)
|
|
846
|
+
- Content-Type header matches actual content format
|
|
847
|
+
- Error handler uses same Content-Type as success handler
|
|
848
|
+
|
|
849
|
+
---
|
|
850
|
+
|
|
851
|
+
## Scheduled Functions - Time-Based Recurring Tasks
|
|
852
|
+
|
|
853
|
+
Scheduled workflows run on a cron schedule. They **MUST** be chained with `.then()` to add processing logic. To access external APIs (like Fluent Commerce), chain with `http()` task.
|
|
854
|
+
|
|
855
|
+
### ⚠️ CRITICAL: schedule() Signature
|
|
856
|
+
|
|
857
|
+
**schedule() does NOT accept a handler or options as parameters**. It returns a `Workflow` that must be chained.
|
|
858
|
+
|
|
859
|
+
**Signature**:
|
|
860
|
+
```typescript
|
|
861
|
+
function schedule(
|
|
862
|
+
id: string,
|
|
863
|
+
schedule: string,
|
|
864
|
+
activationPredicate?: ActivationPredicate
|
|
865
|
+
): Workflow<ScheduleData>
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
**activationPredicate**: Optional - `'all'` or custom filter function `(activation?: Activation) => boolean`
|
|
869
|
+
|
|
870
|
+
### Basic Scheduled Workflow
|
|
871
|
+
|
|
872
|
+
```typescript
|
|
873
|
+
import { schedule, http } from '@versori/run';
|
|
874
|
+
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Daily inventory sync - runs at 2 AM UTC daily
|
|
878
|
+
*
|
|
879
|
+
* Cron: 0 2 * * * (every day at 2:00 AM)
|
|
880
|
+
*/
|
|
881
|
+
export const dailyInventorySync = schedule(
|
|
882
|
+
'daily-inventory-sync',
|
|
883
|
+
'0 2 * * *'
|
|
884
|
+
)
|
|
885
|
+
.then(
|
|
886
|
+
http(
|
|
887
|
+
'sync-inventory',
|
|
888
|
+
{ connection: 'fluent_commerce' },
|
|
889
|
+
async ctx => {
|
|
890
|
+
ctx.log.info('Starting daily inventory sync');
|
|
891
|
+
|
|
892
|
+
const client = await createClient(ctx);
|
|
893
|
+
|
|
894
|
+
// Fetch inventory from external source (e.g., S3)
|
|
895
|
+
const inventoryData = await fetchInventoryFromS3(ctx);
|
|
896
|
+
|
|
897
|
+
// Create batch job
|
|
898
|
+
const job = await client.createJob({
|
|
899
|
+
name: 'Daily Inventory Sync',
|
|
900
|
+
retailerId: ctx.activation.getVariable('FLUENT_RETAILER_ID'),
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// Send batch data
|
|
904
|
+
const batch = await client.sendBatch(job.id, {
|
|
905
|
+
action: 'UPSERT',
|
|
906
|
+
entityType: 'INVENTORY',
|
|
907
|
+
entities: inventoryData,
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
ctx.log.info('Daily sync complete', {
|
|
911
|
+
jobId: job.id,
|
|
912
|
+
batchId: batch.id,
|
|
913
|
+
recordCount: inventoryData.length,
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
return {
|
|
917
|
+
success: true,
|
|
918
|
+
jobId: job.id,
|
|
919
|
+
recordCount: inventoryData.length,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
)
|
|
923
|
+
);
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
### Common Cron Patterns
|
|
927
|
+
|
|
928
|
+
```typescript
|
|
929
|
+
// Every minute - with http() for API access
|
|
930
|
+
export const everyMinute = schedule('every-minute', '* * * * *')
|
|
931
|
+
.then(http('task', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
932
|
+
// Fluent API access available
|
|
933
|
+
const client = await createClient(ctx);
|
|
934
|
+
// ... process
|
|
935
|
+
}));
|
|
936
|
+
|
|
937
|
+
// Every minute - with fn() for KV-only
|
|
938
|
+
export const everyMinuteKV = schedule('every-minute-kv', '* * * * *')
|
|
939
|
+
.then(fn('task', async (ctx) => {
|
|
940
|
+
// KV storage only, no external API
|
|
941
|
+
const kv = ctx.openKv(':project:');
|
|
942
|
+
// ... process
|
|
943
|
+
}));
|
|
944
|
+
|
|
945
|
+
// Every hour at minute 0
|
|
946
|
+
export const hourly = schedule('hourly', '0 * * * *')
|
|
947
|
+
.then(http('sync', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
948
|
+
// Hourly inventory sync
|
|
949
|
+
}));
|
|
950
|
+
|
|
951
|
+
// Every 6 hours - Fluent inventory snapshot
|
|
952
|
+
export const every6Hours = schedule('every-6-hours', '0 */6 * * *')
|
|
953
|
+
.then(http('snapshot', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
954
|
+
// Extract inventory snapshot every 6 hours
|
|
955
|
+
}));
|
|
956
|
+
|
|
957
|
+
// Daily at 2 AM - Full sync
|
|
958
|
+
export const dailyAt2AM = schedule('daily-2am', '0 2 * * *')
|
|
959
|
+
.then(http('full-sync', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
960
|
+
// Daily full inventory sync
|
|
961
|
+
}));
|
|
962
|
+
|
|
963
|
+
// Weekdays at 9 AM - Business hours processing
|
|
964
|
+
export const weekdaysAt9AM = schedule('weekdays-9am', '0 9 * * 1-5')
|
|
965
|
+
.then(http('business-hours', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
966
|
+
// Weekday order processing
|
|
967
|
+
}));
|
|
968
|
+
|
|
969
|
+
// First of every month at midnight - Monthly reports
|
|
970
|
+
export const monthlyFirst = schedule('monthly-first', '0 0 1 * *')
|
|
971
|
+
.then(http('monthly-report', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
972
|
+
// Generate monthly inventory reports
|
|
973
|
+
}));
|
|
974
|
+
|
|
975
|
+
// Every Sunday at 3 AM - Weekly cleanup
|
|
976
|
+
export const weeklySunday = schedule('weekly-sunday', '0 3 * * 0')
|
|
977
|
+
.then(http('weekly-cleanup', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
978
|
+
// Weekly data cleanup
|
|
979
|
+
}));
|
|
980
|
+
|
|
981
|
+
// With activation predicate (filter by activation)
|
|
982
|
+
export const allActivations = schedule('all-act', '0 * * * *', 'all')
|
|
983
|
+
.then(fn('task', async (ctx) => {
|
|
984
|
+
// Runs for all activations
|
|
985
|
+
}));
|
|
986
|
+
|
|
987
|
+
export const customPredicate = schedule('custom', '0 * * * *', (a) => a?.id?.startsWith('prod'))
|
|
988
|
+
.then(fn('task', async (ctx) => {
|
|
989
|
+
// Runs only for production activations
|
|
990
|
+
}));
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
### Scheduled Workflow with State Management
|
|
994
|
+
|
|
995
|
+
```typescript
|
|
996
|
+
import { schedule, fn, http } from '@versori/run';
|
|
997
|
+
import { createClient, VersoriFileTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Hourly incremental sync with file tracking
|
|
1001
|
+
*
|
|
1002
|
+
* Cron: 0 * * * * (every hour)
|
|
1003
|
+
*/
|
|
1004
|
+
export const hourlyIncrementalSync = schedule(
|
|
1005
|
+
'hourly-sync',
|
|
1006
|
+
'0 * * * *'
|
|
1007
|
+
)
|
|
1008
|
+
.then(
|
|
1009
|
+
fn('check-files', async ctx => {
|
|
1010
|
+
const kv = ctx.openKv(':project:');
|
|
1011
|
+
const tracker = new VersoriFileTracker(kv, 'hourly-sync');
|
|
1012
|
+
|
|
1013
|
+
const lastFile = await tracker.getLastProcessedFile();
|
|
1014
|
+
const newFiles = await listFilesAfter(lastFile);
|
|
1015
|
+
|
|
1016
|
+
if (newFiles.length === 0) {
|
|
1017
|
+
ctx.log.info('No new files to process');
|
|
1018
|
+
throw new Error('SKIP'); // Signal to skip processing
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return { newFiles, tracker };
|
|
1022
|
+
})
|
|
1023
|
+
)
|
|
1024
|
+
.then(
|
|
1025
|
+
http(
|
|
1026
|
+
'process-files',
|
|
1027
|
+
{ connection: 'fluent_commerce' },
|
|
1028
|
+
async ctx => {
|
|
1029
|
+
const { newFiles, tracker } = ctx.data;
|
|
1030
|
+
const client = await createClient(ctx);
|
|
1031
|
+
let processedCount = 0;
|
|
1032
|
+
|
|
1033
|
+
for (const file of newFiles) {
|
|
1034
|
+
if (await tracker.wasFileProcessed(file.name)) {
|
|
1035
|
+
ctx.log.info('Skipping already processed file', { file: file.name });
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const records = await processFile(file);
|
|
1040
|
+
|
|
1041
|
+
const job = await client.createJob({ name: `Hourly Sync - ${file.name}` });
|
|
1042
|
+
await client.sendBatch(job.id, {
|
|
1043
|
+
action: 'UPSERT',
|
|
1044
|
+
entityType: 'INVENTORY',
|
|
1045
|
+
entities: records,
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
await tracker.markFileProcessed(file.name, {
|
|
1049
|
+
recordCount: records.length,
|
|
1050
|
+
timestamp: Date.now(),
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
await tracker.setLastProcessedFile(file.name);
|
|
1054
|
+
processedCount += records.length;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
ctx.log.info('Hourly sync complete', {
|
|
1058
|
+
filesProcessed: newFiles.length,
|
|
1059
|
+
recordsProcessed: processedCount,
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
return {
|
|
1063
|
+
success: true,
|
|
1064
|
+
filesProcessed: newFiles.length,
|
|
1065
|
+
recordsProcessed: processedCount,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
)
|
|
1069
|
+
)
|
|
1070
|
+
.catch(({ data }) => {
|
|
1071
|
+
if (data.message === 'SKIP') {
|
|
1072
|
+
return { skipped: true, reason: 'No new files' };
|
|
1073
|
+
}
|
|
1074
|
+
return { success: false, error: data.message };
|
|
1075
|
+
});
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
---
|
|
1079
|
+
|
|
1080
|
+
## Internal Functions (fn) - Data Transformation & State
|
|
1081
|
+
|
|
1082
|
+
Internal functions (`fn()`) are for processing that **does NOT require external API access**. They have access to KV storage and logging, but **NOT** `ctx.fetch`.
|
|
1083
|
+
|
|
1084
|
+
### File Tracking with fn()
|
|
1085
|
+
|
|
1086
|
+
```typescript
|
|
1087
|
+
import { fn } from '@versori/run';
|
|
1088
|
+
import { VersoriFileTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Track file processing state
|
|
1092
|
+
*
|
|
1093
|
+
* Used internally by other workflows (NOT exposed as HTTP endpoint)
|
|
1094
|
+
*/
|
|
1095
|
+
export const trackFileProcessing = fn('track-file', async ctx => {
|
|
1096
|
+
const kv = ctx.openKv(':project:');
|
|
1097
|
+
const tracker = new VersoriFileTracker(kv, 'inventory-ingestion');
|
|
1098
|
+
|
|
1099
|
+
const fileName = ctx.data?.fileName;
|
|
1100
|
+
if (!fileName) {
|
|
1101
|
+
return { error: 'Missing fileName' };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Check if processed
|
|
1105
|
+
if (await tracker.wasFileProcessed(fileName)) {
|
|
1106
|
+
return { skipped: true, reason: 'Already processed' };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Mark as processed
|
|
1110
|
+
await tracker.markFileProcessed(fileName, {
|
|
1111
|
+
records: ctx.data?.recordCount || 0,
|
|
1112
|
+
timestamp: Date.now(),
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
return { tracked: true, fileName };
|
|
1116
|
+
});
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
### Data Transformation with fn()
|
|
1120
|
+
|
|
1121
|
+
```typescript
|
|
1122
|
+
import { fn } from '@versori/run';
|
|
1123
|
+
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Transform CSV data to Fluent format
|
|
1127
|
+
*
|
|
1128
|
+
* Used as part of workflow composition
|
|
1129
|
+
*/
|
|
1130
|
+
export const transformInventoryData = fn('transform-inventory', async ctx => {
|
|
1131
|
+
const rawData = ctx.data?.records;
|
|
1132
|
+
if (!rawData || !Array.isArray(rawData)) {
|
|
1133
|
+
return { error: 'Invalid input data' };
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Define field mapping
|
|
1137
|
+
const mappingConfig = {
|
|
1138
|
+
fields: {
|
|
1139
|
+
productRef: { source: 'sku_id', required: true },
|
|
1140
|
+
locationRef: { source: 'location_code', required: true },
|
|
1141
|
+
qty: { source: 'quantity', resolver: 'sdk.parseInt' },
|
|
1142
|
+
status: { source: 'status', resolver: 'sdk.uppercase' },
|
|
1143
|
+
},
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
const mapper = new UniversalMapper(mappingConfig);
|
|
1147
|
+
|
|
1148
|
+
// Transform all records
|
|
1149
|
+
const transformed = [];
|
|
1150
|
+
const errors = [];
|
|
1151
|
+
|
|
1152
|
+
for (const record of rawData) {
|
|
1153
|
+
const result = await mapper.map(record);
|
|
1154
|
+
if (result.success) {
|
|
1155
|
+
transformed.push(result.data);
|
|
1156
|
+
} else {
|
|
1157
|
+
errors.push({ record, errors: result.errors });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
ctx.log('info', 'Transformation complete', {
|
|
1162
|
+
totalRecords: rawData.length,
|
|
1163
|
+
transformed: transformed.length,
|
|
1164
|
+
errors: errors.length,
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
return {
|
|
1168
|
+
transformed,
|
|
1169
|
+
errors,
|
|
1170
|
+
stats: {
|
|
1171
|
+
total: rawData.length,
|
|
1172
|
+
success: transformed.length,
|
|
1173
|
+
failed: errors.length,
|
|
1174
|
+
},
|
|
1175
|
+
};
|
|
1176
|
+
});
|
|
1177
|
+
```
|
|
1178
|
+
|
|
1179
|
+
---
|
|
1180
|
+
|
|
1181
|
+
## Workflow Composition - Multi-Step Patterns
|
|
1182
|
+
|
|
1183
|
+
Combine multiple workflow types using `.then()` and `.catch()` chaining.
|
|
1184
|
+
|
|
1185
|
+
### Pattern 1: Webhook → fn() → http()
|
|
1186
|
+
|
|
1187
|
+
```typescript
|
|
1188
|
+
import { webhook, fn, http } from '@versori/run';
|
|
1189
|
+
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Receive order → validate → send to Fluent
|
|
1193
|
+
*
|
|
1194
|
+
* POST https://{workspace}.versori.run/process-order
|
|
1195
|
+
*/
|
|
1196
|
+
export const processOrder = webhook('process-order')
|
|
1197
|
+
// Step 1: Parse and validate (fn - no external API)
|
|
1198
|
+
.then(
|
|
1199
|
+
fn('validate-order', ({ data }) => {
|
|
1200
|
+
if (!data?.orderId || !data?.items) {
|
|
1201
|
+
throw new Error('Invalid order data: missing orderId or items');
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return {
|
|
1205
|
+
orderId: data.orderId,
|
|
1206
|
+
items: data.items.map(item => ({
|
|
1207
|
+
productRef: item.sku,
|
|
1208
|
+
quantity: parseInt(item.qty, 10),
|
|
1209
|
+
price: parseFloat(item.price),
|
|
1210
|
+
})),
|
|
1211
|
+
};
|
|
1212
|
+
})
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
// Step 2: Send to Fluent (http - requires connection)
|
|
1216
|
+
.then(
|
|
1217
|
+
http(
|
|
1218
|
+
'send-to-fluent',
|
|
1219
|
+
{
|
|
1220
|
+
connection: 'fluent_commerce',
|
|
1221
|
+
},
|
|
1222
|
+
async ctx => {
|
|
1223
|
+
const { orderId, items } = ctx.data;
|
|
1224
|
+
const client = await createClient(ctx);
|
|
1225
|
+
|
|
1226
|
+
// GraphQL mutation - schema validated
|
|
1227
|
+
const result = await client.graphql({
|
|
1228
|
+
query: `
|
|
1229
|
+
mutation CreateOrder($input: CreateOrderInput!) {
|
|
1230
|
+
createOrder(input: $input) {
|
|
1231
|
+
id
|
|
1232
|
+
ref
|
|
1233
|
+
status
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
`,
|
|
1237
|
+
variables: {
|
|
1238
|
+
input: {
|
|
1239
|
+
ref: orderId,
|
|
1240
|
+
items: items.map(item => ({
|
|
1241
|
+
productRef: item.productRef,
|
|
1242
|
+
quantity: item.quantity,
|
|
1243
|
+
price: item.price,
|
|
1244
|
+
})),
|
|
1245
|
+
},
|
|
1246
|
+
},
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
return {
|
|
1250
|
+
success: true,
|
|
1251
|
+
fluentOrderId: result.data.createOrder.id,
|
|
1252
|
+
fluentOrderRef: result.data.createOrder.ref,
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
)
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
// Error handling
|
|
1259
|
+
.catch(({ data }) => {
|
|
1260
|
+
return {
|
|
1261
|
+
success: false,
|
|
1262
|
+
error: data instanceof Error ? data.message : 'Processing failed',
|
|
1263
|
+
status: 500,
|
|
1264
|
+
};
|
|
1265
|
+
});
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
### Pattern 2: Scheduled → fn() (File Tracking) → http() (API Call)
|
|
1269
|
+
|
|
1270
|
+
```typescript
|
|
1271
|
+
import { schedule, fn, http } from '@versori/run';
|
|
1272
|
+
import { createClient, VersoriFileTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Daily sync with file tracking
|
|
1276
|
+
*
|
|
1277
|
+
* Cron: 0 1 * * * (daily at 1 AM)
|
|
1278
|
+
*/
|
|
1279
|
+
export const dailySyncWithTracking = schedule('daily-sync', '0 1 * * *', {
|
|
1280
|
+
connection: 'fluent_commerce',
|
|
1281
|
+
})
|
|
1282
|
+
// Step 1: Check if already processed today (fn - KV access)
|
|
1283
|
+
.then(
|
|
1284
|
+
fn('check-processed', async ctx => {
|
|
1285
|
+
const kv = ctx.openKv(':project:');
|
|
1286
|
+
const tracker = new VersoriFileTracker(kv, 'daily-sync');
|
|
1287
|
+
|
|
1288
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1289
|
+
const fileKey = `inventory-${today}.csv`;
|
|
1290
|
+
|
|
1291
|
+
if (await tracker.wasFileProcessed(fileKey)) {
|
|
1292
|
+
throw new Error('Already processed today');
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return { fileKey, today };
|
|
1296
|
+
})
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
// Step 2: Fetch and process (http - external API)
|
|
1300
|
+
.then(
|
|
1301
|
+
http(
|
|
1302
|
+
'process-sync',
|
|
1303
|
+
{
|
|
1304
|
+
connection: 'fluent_commerce',
|
|
1305
|
+
},
|
|
1306
|
+
async ctx => {
|
|
1307
|
+
const { fileKey } = ctx.data;
|
|
1308
|
+
const client = await createClient(ctx);
|
|
1309
|
+
|
|
1310
|
+
// Fetch data from external source
|
|
1311
|
+
const records = await fetchDailyInventory();
|
|
1312
|
+
|
|
1313
|
+
// Send to Fluent
|
|
1314
|
+
const job = await client.createJob({ name: `Daily Sync ${fileKey}` });
|
|
1315
|
+
await client.sendBatch(job.id, {
|
|
1316
|
+
action: 'UPSERT',
|
|
1317
|
+
entityType: 'INVENTORY',
|
|
1318
|
+
entities: records,
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
return {
|
|
1322
|
+
fileKey,
|
|
1323
|
+
jobId: job.id,
|
|
1324
|
+
recordCount: records.length,
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
)
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
// Step 3: Mark as processed (fn - KV access)
|
|
1331
|
+
.then(
|
|
1332
|
+
fn('mark-processed', async ctx => {
|
|
1333
|
+
const kv = ctx.openKv(':project:');
|
|
1334
|
+
const tracker = new VersoriFileTracker(kv, 'daily-sync');
|
|
1335
|
+
const { fileKey, recordCount } = ctx.data;
|
|
1336
|
+
|
|
1337
|
+
await tracker.markFileProcessed(fileKey, {
|
|
1338
|
+
recordCount,
|
|
1339
|
+
timestamp: Date.now(),
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
return {
|
|
1343
|
+
success: true,
|
|
1344
|
+
fileKey,
|
|
1345
|
+
recordCount,
|
|
1346
|
+
};
|
|
1347
|
+
})
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
.catch(({ data }) => {
|
|
1351
|
+
if (data.message === 'Already processed today') {
|
|
1352
|
+
return { skipped: true, reason: 'Already processed today' };
|
|
1353
|
+
}
|
|
1354
|
+
return { success: false, error: data.message };
|
|
1355
|
+
});
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
---
|
|
1359
|
+
|
|
1360
|
+
## Error Handling & Retry Strategies
|
|
1361
|
+
|
|
1362
|
+
### Basic Error Handling
|
|
1363
|
+
|
|
1364
|
+
```typescript
|
|
1365
|
+
export const robustWorkflow = http(
|
|
1366
|
+
'robust',
|
|
1367
|
+
{
|
|
1368
|
+
connection: 'fluent_commerce',
|
|
1369
|
+
retry: {
|
|
1370
|
+
attempts: 3,
|
|
1371
|
+
delay: 1000,
|
|
1372
|
+
backoff: 2, // Exponential backoff multiplier
|
|
1373
|
+
},
|
|
1374
|
+
},
|
|
1375
|
+
async ctx => {
|
|
1376
|
+
try {
|
|
1377
|
+
const client = await createClient(ctx);
|
|
1378
|
+
const result = await client.graphql({ query: '...' });
|
|
1379
|
+
|
|
1380
|
+
if (result.errors?.length) {
|
|
1381
|
+
throw new Error(`GraphQL failed: ${result.errors[0].message}`);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
return { success: true, data: result.data };
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
ctx.log('error', 'Operation failed', {
|
|
1387
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1388
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
throw error; // Re-throw for retry mechanism
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
);
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
### Advanced Error Handling with Fallback
|
|
1398
|
+
|
|
1399
|
+
```typescript
|
|
1400
|
+
export const withFallback = http(
|
|
1401
|
+
'with-fallback',
|
|
1402
|
+
{
|
|
1403
|
+
connection: 'fluent_commerce',
|
|
1404
|
+
},
|
|
1405
|
+
async ctx => {
|
|
1406
|
+
const client = await createClient(ctx);
|
|
1407
|
+
|
|
1408
|
+
try {
|
|
1409
|
+
// Primary operation
|
|
1410
|
+
const result = await client.graphql({ query: '...' });
|
|
1411
|
+
return { source: 'primary', data: result.data };
|
|
1412
|
+
} catch (primaryError) {
|
|
1413
|
+
ctx.log('warn', 'Primary operation failed, trying fallback', {
|
|
1414
|
+
error: primaryError instanceof Error ? primaryError.message : String(primaryError),
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
try {
|
|
1418
|
+
// Fallback operation
|
|
1419
|
+
const fallbackResult = await client.graphql({ query: '... (simpler query)' });
|
|
1420
|
+
return { source: 'fallback', data: fallbackResult.data };
|
|
1421
|
+
} catch (fallbackError) {
|
|
1422
|
+
ctx.log('error', 'Both primary and fallback failed', {
|
|
1423
|
+
primaryError: primaryError instanceof Error ? primaryError.message : String(primaryError),
|
|
1424
|
+
fallbackError:
|
|
1425
|
+
fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
throw new Error('All operations failed');
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
);
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
---
|
|
1436
|
+
|
|
1437
|
+
## Real-World Fluent Commerce Use Cases
|
|
1438
|
+
|
|
1439
|
+
This section provides detailed, production-ready examples for common Fluent Commerce integration patterns.
|
|
1440
|
+
|
|
1441
|
+
### Use Case 1: Inventory Position Ingestion from CSV
|
|
1442
|
+
|
|
1443
|
+
**Scenario**: Daily CSV file from warehouse management system → Fluent inventory positions
|
|
1444
|
+
|
|
1445
|
+
```typescript
|
|
1446
|
+
import { schedule, fn, http } from '@versori/run';
|
|
1447
|
+
import {
|
|
1448
|
+
createClient,
|
|
1449
|
+
S3DataSource,
|
|
1450
|
+
CSVParserService,
|
|
1451
|
+
UniversalMapper,
|
|
1452
|
+
VersoriFileTracker
|
|
1453
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Daily inventory ingestion from S3 CSV
|
|
1457
|
+
*
|
|
1458
|
+
* Cron: 0 3 * * * (3 AM daily)
|
|
1459
|
+
* Flow: Check file → Parse CSV → Transform → Send to Fluent
|
|
1460
|
+
*/
|
|
1461
|
+
export const dailyInventoryIngestion = schedule('daily-inventory-ingestion', '0 3 * * *')
|
|
1462
|
+
// Step 1: Check for new files (fn - KV only)
|
|
1463
|
+
.then(fn('check-new-files', async ctx => {
|
|
1464
|
+
const kv = ctx.openKv(':project:');
|
|
1465
|
+
const tracker = new VersoriFileTracker(kv, 'inventory-ingestion');
|
|
1466
|
+
|
|
1467
|
+
const s3Config = {
|
|
1468
|
+
bucket: ctx.activation.getVariable<string>('S3_BUCKET'),
|
|
1469
|
+
region: ctx.activation.getVariable<string>('AWS_REGION'),
|
|
1470
|
+
accessKeyId: ctx.activation.getVariable<string>('AWS_ACCESS_KEY_ID'),
|
|
1471
|
+
secretAccessKey: ctx.activation.getVariable<string>('AWS_SECRET_ACCESS_KEY'),
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
const s3 = new S3DataSource(s3Config, ctx.log);
|
|
1475
|
+
const files = await s3.listFiles({ prefix: 'inventory/daily/' });
|
|
1476
|
+
|
|
1477
|
+
// Filter unprocessed files
|
|
1478
|
+
const newFiles = [];
|
|
1479
|
+
for (const file of files) {
|
|
1480
|
+
if (!(await tracker.wasFileProcessed(file.key))) {
|
|
1481
|
+
newFiles.push(file);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (newFiles.length === 0) {
|
|
1486
|
+
throw new Error('NO_NEW_FILES');
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
ctx.log.info('Found new inventory files', { count: newFiles.length });
|
|
1490
|
+
return { s3Config, files: newFiles, tracker };
|
|
1491
|
+
}))
|
|
1492
|
+
|
|
1493
|
+
// Step 2: Parse and transform (fn - no API needed)
|
|
1494
|
+
.then(fn('parse-transform', async ctx => {
|
|
1495
|
+
const { s3Config, files, tracker } = ctx.data;
|
|
1496
|
+
const s3 = new S3DataSource(s3Config, ctx.log);
|
|
1497
|
+
const parser = new CSVParserService(ctx.log);
|
|
1498
|
+
|
|
1499
|
+
// Mapping configuration
|
|
1500
|
+
const mappingConfig = {
|
|
1501
|
+
fields: {
|
|
1502
|
+
// Fluent field: CSV column
|
|
1503
|
+
ref: { source: 'sku', required: true },
|
|
1504
|
+
productRef: { source: 'sku', required: true },
|
|
1505
|
+
locationRef: { source: 'location_code', required: true },
|
|
1506
|
+
qty: { source: 'quantity', resolver: 'sdk.parseInt', required: true },
|
|
1507
|
+
type: { value: 'AVAILABLE' }, // Static value
|
|
1508
|
+
storageArea: { source: 'warehouse_zone' },
|
|
1509
|
+
expectedOn: { source: 'delivery_date', resolver: 'sdk.formatDate' },
|
|
1510
|
+
},
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
const mapper = new UniversalMapper(mappingConfig);
|
|
1514
|
+
const allRecords = [];
|
|
1515
|
+
|
|
1516
|
+
for (const file of files) {
|
|
1517
|
+
const content = await s3.readFile(file.key);
|
|
1518
|
+
const parsed = await parser.parse(content, {
|
|
1519
|
+
headers: true,
|
|
1520
|
+
skipEmptyLines: true
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
// Transform each row
|
|
1524
|
+
for (const row of parsed) {
|
|
1525
|
+
const result = await mapper.map(row);
|
|
1526
|
+
if (result.success) {
|
|
1527
|
+
allRecords.push(result.data);
|
|
1528
|
+
} else {
|
|
1529
|
+
ctx.log.warn('Mapping failed', { row, errors: result.errors });
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
ctx.log.info('Parsed and transformed records', {
|
|
1535
|
+
fileCount: files.length,
|
|
1536
|
+
recordCount: allRecords.length
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
return { records: allRecords, files, tracker };
|
|
1540
|
+
}))
|
|
1541
|
+
|
|
1542
|
+
// Step 3: Send to Fluent (http - requires API connection)
|
|
1543
|
+
.then(http('send-to-fluent', { connection: 'fluent_commerce' }, async ctx => {
|
|
1544
|
+
const { records, files, tracker } = ctx.data;
|
|
1545
|
+
const client = await createClient(ctx);
|
|
1546
|
+
|
|
1547
|
+
// Create batch job
|
|
1548
|
+
const job = await client.createJob({
|
|
1549
|
+
name: `Daily Inventory Ingestion - ${new Date().toISOString().split('T')[0]}`,
|
|
1550
|
+
retailerId: ctx.activation.getVariable<number>('FLUENT_RETAILER_ID'),
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
// Send in batches of 500
|
|
1554
|
+
const batchSize = 500;
|
|
1555
|
+
const batches = [];
|
|
1556
|
+
|
|
1557
|
+
for (let i = 0; i < records.length; i += batchSize) {
|
|
1558
|
+
const chunk = records.slice(i, i + batchSize);
|
|
1559
|
+
const batch = await client.sendBatch(job.id, {
|
|
1560
|
+
action: 'UPSERT',
|
|
1561
|
+
entityType: 'INVENTORY_POSITION',
|
|
1562
|
+
entities: chunk,
|
|
1563
|
+
meta: {
|
|
1564
|
+
preprocessing: 'skip', // Delta file, skip BPP
|
|
1565
|
+
},
|
|
1566
|
+
});
|
|
1567
|
+
batches.push(batch.id);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Mark files as processed
|
|
1571
|
+
for (const file of files) {
|
|
1572
|
+
await tracker.markFileProcessed(file.key, {
|
|
1573
|
+
jobId: job.id,
|
|
1574
|
+
recordCount: records.length,
|
|
1575
|
+
timestamp: Date.now(),
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
ctx.log.info('Inventory ingestion complete', {
|
|
1580
|
+
jobId: job.id,
|
|
1581
|
+
batches: batches.length,
|
|
1582
|
+
totalRecords: records.length,
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
return {
|
|
1586
|
+
success: true,
|
|
1587
|
+
jobId: job.id,
|
|
1588
|
+
recordCount: records.length,
|
|
1589
|
+
filesProcessed: files.length,
|
|
1590
|
+
};
|
|
1591
|
+
}))
|
|
1592
|
+
|
|
1593
|
+
.catch(({ data }) => {
|
|
1594
|
+
if (data.message === 'NO_NEW_FILES') {
|
|
1595
|
+
return { skipped: true, reason: 'No new files to process' };
|
|
1596
|
+
}
|
|
1597
|
+
return { success: false, error: data.message || 'Unknown error' };
|
|
1598
|
+
});
|
|
1599
|
+
```
|
|
1600
|
+
|
|
1601
|
+
### Use Case 2: Order Creation from SFCC XML Webhook
|
|
1602
|
+
|
|
1603
|
+
**Scenario**: SFCC sends order XML → Validate → Create order in Fluent
|
|
1604
|
+
|
|
1605
|
+
```typescript
|
|
1606
|
+
import { webhook, fn, http } from '@versori/run';
|
|
1607
|
+
import {
|
|
1608
|
+
createClient,
|
|
1609
|
+
XMLParserService,
|
|
1610
|
+
GraphQLMutationMapper
|
|
1611
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Receive SFCC order XML webhook and create order in Fluent
|
|
1615
|
+
*
|
|
1616
|
+
* POST https://{workspace}.versori.run/sfcc-order-create
|
|
1617
|
+
* Content-Type: application/xml
|
|
1618
|
+
*/
|
|
1619
|
+
export const sfccOrderCreate = webhook('sfcc-order-create', {
|
|
1620
|
+
connection: 'sfcc-webhook-auth', // Platform validates API key
|
|
1621
|
+
response: {
|
|
1622
|
+
mode: 'sync',
|
|
1623
|
+
onSuccess: ctx => new Response(ctx.data, {
|
|
1624
|
+
status: 200,
|
|
1625
|
+
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
|
1626
|
+
}),
|
|
1627
|
+
onError: ctx => {
|
|
1628
|
+
const errorXml = `<?xml version="1.0"?>
|
|
1629
|
+
<ErrorResponse>
|
|
1630
|
+
<Error>
|
|
1631
|
+
<Code>PROCESSING_ERROR</Code>
|
|
1632
|
+
<Message>${ctx.data?.message || 'Order processing failed'}</Message>
|
|
1633
|
+
<ExecutionId>${ctx.executionId}</ExecutionId>
|
|
1634
|
+
</Error>
|
|
1635
|
+
</ErrorResponse>`;
|
|
1636
|
+
return new Response(errorXml, {
|
|
1637
|
+
status: 500,
|
|
1638
|
+
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
|
1639
|
+
});
|
|
1640
|
+
},
|
|
1641
|
+
},
|
|
1642
|
+
})
|
|
1643
|
+
// Step 1: Parse XML (fn - no API needed)
|
|
1644
|
+
.then(fn('parse-xml', async ctx => {
|
|
1645
|
+
const parser = new XMLParserService(ctx.log);
|
|
1646
|
+
const xmlData = await parser.parse(ctx.data);
|
|
1647
|
+
|
|
1648
|
+
// Validate required fields
|
|
1649
|
+
if (!xmlData.order?.['@id']) {
|
|
1650
|
+
throw new Error('Invalid order XML: missing order ID');
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
ctx.log.info('Parsed SFCC order XML', {
|
|
1654
|
+
orderId: xmlData.order['@id'],
|
|
1655
|
+
customerEmail: xmlData.order.customer?.email
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
return { xmlData };
|
|
1659
|
+
}))
|
|
1660
|
+
|
|
1661
|
+
// Step 2: Transform and create order (http - Fluent API access)
|
|
1662
|
+
.then(http('create-fluent-order', { connection: 'fluent_commerce' }, async ctx => {
|
|
1663
|
+
const { xmlData } = ctx.data;
|
|
1664
|
+
const client = await createClient(ctx);
|
|
1665
|
+
|
|
1666
|
+
// GraphQL mutation mapping config
|
|
1667
|
+
const mappingConfig = {
|
|
1668
|
+
mutationName: 'createOrder',
|
|
1669
|
+
sourceFormat: 'xml',
|
|
1670
|
+
arguments: {
|
|
1671
|
+
input: {
|
|
1672
|
+
ref: { source: 'order.@id' },
|
|
1673
|
+
type: { value: 'STANDARD' },
|
|
1674
|
+
retailer: { source: 'order.@retailerId', resolver: 'sdk.parseInt' },
|
|
1675
|
+
customer: {
|
|
1676
|
+
firstName: { source: 'order.customer.firstname' },
|
|
1677
|
+
lastName: { source: 'order.customer.lastname' },
|
|
1678
|
+
email: { source: 'order.customer.email' },
|
|
1679
|
+
},
|
|
1680
|
+
items: {
|
|
1681
|
+
source: 'order.items.item',
|
|
1682
|
+
isArray: true,
|
|
1683
|
+
mapping: {
|
|
1684
|
+
productRef: { source: '@sku' },
|
|
1685
|
+
quantity: { source: 'quantity', resolver: 'sdk.parseInt' },
|
|
1686
|
+
price: {
|
|
1687
|
+
value: { source: 'price', resolver: 'sdk.parseFloat' },
|
|
1688
|
+
currency: { source: '@currency', default: 'USD' },
|
|
1689
|
+
},
|
|
1690
|
+
},
|
|
1691
|
+
},
|
|
1692
|
+
fulfilment: {
|
|
1693
|
+
type: { value: 'STANDARD' },
|
|
1694
|
+
deliveryType: { source: 'order.shipping.method' },
|
|
1695
|
+
deliveryAddress: {
|
|
1696
|
+
name: { source: 'order.shipping.address.name' },
|
|
1697
|
+
street: { source: 'order.shipping.address.street' },
|
|
1698
|
+
city: { source: 'order.shipping.address.city' },
|
|
1699
|
+
state: { source: 'order.shipping.address.state' },
|
|
1700
|
+
postcode: { source: 'order.shipping.address.postalcode' },
|
|
1701
|
+
country: { source: 'order.shipping.address.country' },
|
|
1702
|
+
},
|
|
1703
|
+
},
|
|
1704
|
+
payments: {
|
|
1705
|
+
source: 'order.payments.payment',
|
|
1706
|
+
isArray: true,
|
|
1707
|
+
mapping: {
|
|
1708
|
+
method: { source: '@method' },
|
|
1709
|
+
amount: { source: 'amount', resolver: 'sdk.parseFloat' },
|
|
1710
|
+
cardType: { source: 'creditcard.type' },
|
|
1711
|
+
},
|
|
1712
|
+
},
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
};
|
|
1716
|
+
|
|
1717
|
+
const mapper = new GraphQLMutationMapper(mappingConfig, ctx.log, { fluentClient: client });
|
|
1718
|
+
const { mutation, variables } = await mapper.generateMutation(xmlData);
|
|
1719
|
+
|
|
1720
|
+
// Execute mutation
|
|
1721
|
+
const result = await client.graphql({ query: mutation, variables });
|
|
1722
|
+
|
|
1723
|
+
if (result.errors?.length) {
|
|
1724
|
+
throw new Error(`Order creation failed: ${result.errors[0].message}`);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
const order = result.data.createOrder;
|
|
1728
|
+
ctx.log.info('Order created in Fluent', {
|
|
1729
|
+
fluentOrderId: order.id,
|
|
1730
|
+
sfccOrderId: xmlData.order['@id']
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
return {
|
|
1734
|
+
fluentOrderId: order.id,
|
|
1735
|
+
fluentOrderRef: order.ref,
|
|
1736
|
+
sfccOrderId: xmlData.order['@id'],
|
|
1737
|
+
};
|
|
1738
|
+
}))
|
|
1739
|
+
|
|
1740
|
+
// Step 3: Generate success XML response (fn)
|
|
1741
|
+
.then(fn('generate-response', ctx => {
|
|
1742
|
+
const { fluentOrderId, sfccOrderId } = ctx.data;
|
|
1743
|
+
return `<?xml version="1.0"?>
|
|
1744
|
+
<OrderResponse>
|
|
1745
|
+
<Success>true</Success>
|
|
1746
|
+
<SFCCOrderId>${sfccOrderId}</SFCCOrderId>
|
|
1747
|
+
<FluentOrderId>${fluentOrderId}</FluentOrderId>
|
|
1748
|
+
<Timestamp>${new Date().toISOString()}</Timestamp>
|
|
1749
|
+
</OrderResponse>`;
|
|
1750
|
+
}))
|
|
1751
|
+
|
|
1752
|
+
.catch(({ data }) => {
|
|
1753
|
+
return { message: data instanceof Error ? data.message : String(data) };
|
|
1754
|
+
});
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
### Use Case 3: Virtual Position Extraction to SFTP
|
|
1758
|
+
|
|
1759
|
+
**Scenario**: Scheduled extraction of Fluent virtual positions → XML → Upload to SFTP
|
|
1760
|
+
|
|
1761
|
+
```typescript
|
|
1762
|
+
import { Buffer } from 'node:buffer'; // ⚠️ Required for Deno compatibility
|
|
1763
|
+
import { schedule, http, fn } from '@versori/run';
|
|
1764
|
+
import {
|
|
1765
|
+
createClient,
|
|
1766
|
+
ExtractionOrchestrator,
|
|
1767
|
+
SftpDataSource,
|
|
1768
|
+
VersoriFileTracker
|
|
1769
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1770
|
+
import { XMLBuilder } from 'fast-xml-parser';
|
|
1771
|
+
|
|
1772
|
+
/**
|
|
1773
|
+
* Extract virtual positions and upload to SFTP as XML
|
|
1774
|
+
*
|
|
1775
|
+
* Cron: 0 */4 * * * (every 4 hours)
|
|
1776
|
+
*/
|
|
1777
|
+
export const virtualPositionExtraction = schedule('virtual-position-extraction', '0 */4 * * *')
|
|
1778
|
+
// Step 1: Extract from Fluent (http - API access)
|
|
1779
|
+
.then(http('extract-positions', { connection: 'fluent_commerce' }, async ctx => {
|
|
1780
|
+
const client = await createClient(ctx);
|
|
1781
|
+
|
|
1782
|
+
// Define extraction config
|
|
1783
|
+
const extractionConfig = {
|
|
1784
|
+
query: `
|
|
1785
|
+
query GetVirtualPositions($first: Int!, $after: String) {
|
|
1786
|
+
virtualPositions(first: $first, after: $after) {
|
|
1787
|
+
edges {
|
|
1788
|
+
node {
|
|
1789
|
+
id
|
|
1790
|
+
ref
|
|
1791
|
+
productRef
|
|
1792
|
+
groupRef
|
|
1793
|
+
onHand
|
|
1794
|
+
quantity
|
|
1795
|
+
type
|
|
1796
|
+
status
|
|
1797
|
+
createdOn
|
|
1798
|
+
updatedOn
|
|
1799
|
+
}
|
|
1800
|
+
cursor
|
|
1801
|
+
}
|
|
1802
|
+
pageInfo {
|
|
1803
|
+
hasNextPage
|
|
1804
|
+
endCursor
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
`,
|
|
1809
|
+
variables: { first: 200 },
|
|
1810
|
+
pathToData: 'virtualPositions',
|
|
1811
|
+
pathToCursor: 'pageInfo.endCursor',
|
|
1812
|
+
pathToHasNext: 'pageInfo.hasNextPage',
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
// Use ExtractionOrchestrator for auto-pagination
|
|
1816
|
+
const orchestrator = new ExtractionOrchestrator(client, ctx.log);
|
|
1817
|
+
const result = await orchestrator.extractWithPagination(extractionConfig);
|
|
1818
|
+
|
|
1819
|
+
if (!result.success) {
|
|
1820
|
+
throw new Error(`Extraction failed: ${result.errors?.join(', ')}`);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
ctx.log.info('Extracted virtual positions', {
|
|
1824
|
+
recordCount: result.data?.length || 0
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
return { positions: result.data || [] };
|
|
1828
|
+
}))
|
|
1829
|
+
|
|
1830
|
+
// Step 2: Transform to XML (fn - no API)
|
|
1831
|
+
.then(fn('transform-to-xml', ctx => {
|
|
1832
|
+
const { positions } = ctx.data;
|
|
1833
|
+
|
|
1834
|
+
const xmlBuilder = new XMLBuilder({
|
|
1835
|
+
ignoreAttributes: false,
|
|
1836
|
+
attributeNamePrefix: '@',
|
|
1837
|
+
format: true,
|
|
1838
|
+
indentBy: ' ',
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
const xmlObject = {
|
|
1842
|
+
'?xml': { '@version': '1.0', '@encoding': 'UTF-8' },
|
|
1843
|
+
VirtualPositionFeed: {
|
|
1844
|
+
'@timestamp': new Date().toISOString(),
|
|
1845
|
+
'@recordCount': positions.length,
|
|
1846
|
+
Positions: {
|
|
1847
|
+
Position: positions.map(pos => ({
|
|
1848
|
+
'@id': pos.id,
|
|
1849
|
+
Ref: pos.ref,
|
|
1850
|
+
ProductRef: pos.productRef,
|
|
1851
|
+
GroupRef: pos.groupRef,
|
|
1852
|
+
OnHand: pos.onHand,
|
|
1853
|
+
Quantity: pos.quantity,
|
|
1854
|
+
Type: pos.type,
|
|
1855
|
+
Status: pos.status,
|
|
1856
|
+
CreatedOn: pos.createdOn,
|
|
1857
|
+
UpdatedOn: pos.updatedOn,
|
|
1858
|
+
})),
|
|
1859
|
+
},
|
|
1860
|
+
},
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
const xmlContent = xmlBuilder.build(xmlObject);
|
|
1864
|
+
|
|
1865
|
+
const fileName = `virtual-positions-${new Date().toISOString().replace(/:/g, '-')}.xml`;
|
|
1866
|
+
|
|
1867
|
+
ctx.log.info('Generated XML feed', { fileName, recordCount: positions.length });
|
|
1868
|
+
|
|
1869
|
+
return { xmlContent, fileName, recordCount: positions.length };
|
|
1870
|
+
}))
|
|
1871
|
+
|
|
1872
|
+
// Step 3: Upload to SFTP (fn - external SFTP, not Fluent API)
|
|
1873
|
+
.then(fn('upload-to-sftp', async ctx => {
|
|
1874
|
+
const { xmlContent, fileName, recordCount } = ctx.data;
|
|
1875
|
+
|
|
1876
|
+
const sftpConfig = {
|
|
1877
|
+
host: ctx.activation.getVariable<string>('SFTP_HOST'),
|
|
1878
|
+
port: ctx.activation.getVariable<number>('SFTP_PORT') || 22,
|
|
1879
|
+
username: ctx.activation.getVariable<string>('SFTP_USERNAME'),
|
|
1880
|
+
password: ctx.activation.getVariable<string>('SFTP_PASSWORD'),
|
|
1881
|
+
};
|
|
1882
|
+
|
|
1883
|
+
const sftp = new SftpDataSource(sftpConfig, ctx.log);
|
|
1884
|
+
await sftp.connect();
|
|
1885
|
+
|
|
1886
|
+
try {
|
|
1887
|
+
const remotePath = `/outbound/${fileName}`;
|
|
1888
|
+
await sftp.uploadFile(remotePath, Buffer.from(xmlContent, 'utf-8'));
|
|
1889
|
+
|
|
1890
|
+
ctx.log.info('Uploaded to SFTP', { remotePath, recordCount });
|
|
1891
|
+
|
|
1892
|
+
// Track in KV
|
|
1893
|
+
const kv = ctx.openKv(':project:');
|
|
1894
|
+
const tracker = new VersoriFileTracker(kv, 'virtual-position-extraction');
|
|
1895
|
+
await tracker.markFileProcessed(fileName, {
|
|
1896
|
+
recordCount,
|
|
1897
|
+
timestamp: Date.now(),
|
|
1898
|
+
remotePath,
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
return {
|
|
1902
|
+
success: true,
|
|
1903
|
+
fileName,
|
|
1904
|
+
remotePath,
|
|
1905
|
+
recordCount,
|
|
1906
|
+
};
|
|
1907
|
+
} finally {
|
|
1908
|
+
await sftp.disconnect();
|
|
1909
|
+
}
|
|
1910
|
+
}))
|
|
1911
|
+
|
|
1912
|
+
.catch(({ data }) => ({
|
|
1913
|
+
success: false,
|
|
1914
|
+
error: data instanceof Error ? data.message : String(data),
|
|
1915
|
+
}));
|
|
1916
|
+
```
|
|
1917
|
+
|
|
1918
|
+
### Use Case 4: Multi-Tenant Order Status Webhook
|
|
1919
|
+
|
|
1920
|
+
**Scenario**: Multiple customers share one connector, route to correct Fluent instance
|
|
1921
|
+
|
|
1922
|
+
```typescript
|
|
1923
|
+
import { webhook, fn, http } from '@versori/run';
|
|
1924
|
+
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Multi-tenant order status update
|
|
1928
|
+
*
|
|
1929
|
+
* POST https://{workspace}.versori.run/order-status-update
|
|
1930
|
+
* Body: { customerId, orderId, status }
|
|
1931
|
+
*/
|
|
1932
|
+
export const orderStatusUpdate = webhook('order-status-update', {
|
|
1933
|
+
response: { mode: 'sync' },
|
|
1934
|
+
})
|
|
1935
|
+
// Step 1: Route to correct connection (fn)
|
|
1936
|
+
.then(fn('route-connection', ctx => {
|
|
1937
|
+
const { customerId, orderId, status } = ctx.data;
|
|
1938
|
+
|
|
1939
|
+
if (!customerId || !orderId || !status) {
|
|
1940
|
+
throw new Error('Missing required fields: customerId, orderId, status');
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Access all connections via activation.connections
|
|
1944
|
+
const connections = ctx.activation.connections;
|
|
1945
|
+
|
|
1946
|
+
// Find customer-specific Fluent connection
|
|
1947
|
+
const connectionName = `fluent_${customerId.toLowerCase()}`;
|
|
1948
|
+
const connection = connections?.find(c => c.name === connectionName);
|
|
1949
|
+
|
|
1950
|
+
if (!connection) {
|
|
1951
|
+
throw new Error(`No Fluent connection found for customer: ${customerId}`);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
ctx.log.info('Routed to customer connection', {
|
|
1955
|
+
customerId,
|
|
1956
|
+
connectionName,
|
|
1957
|
+
connectionId: connection.id
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
return { connectionName, orderId, status, customerId };
|
|
1961
|
+
}))
|
|
1962
|
+
|
|
1963
|
+
// Step 2: Update order status (http - with dynamic connection)
|
|
1964
|
+
.then(http('update-order', (ctx) => {
|
|
1965
|
+
// Return dynamic connection name
|
|
1966
|
+
return { connection: ctx.data.connectionName };
|
|
1967
|
+
}, async ctx => {
|
|
1968
|
+
const { orderId, status, customerId } = ctx.data;
|
|
1969
|
+
const client = await createClient(ctx);
|
|
1970
|
+
|
|
1971
|
+
// Update order status
|
|
1972
|
+
const result = await client.graphql({
|
|
1973
|
+
query: `
|
|
1974
|
+
mutation UpdateOrder($ref: String!, $status: String!) {
|
|
1975
|
+
updateOrder(input: { ref: $ref, status: $status }) {
|
|
1976
|
+
id
|
|
1977
|
+
ref
|
|
1978
|
+
status
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
`,
|
|
1982
|
+
variables: { ref: orderId, status },
|
|
1983
|
+
});
|
|
1984
|
+
|
|
1985
|
+
if (result.errors?.length) {
|
|
1986
|
+
throw new Error(`Failed to update order: ${result.errors[0].message}`);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
ctx.log.info('Order status updated', {
|
|
1990
|
+
customerId,
|
|
1991
|
+
orderId,
|
|
1992
|
+
newStatus: status
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
return {
|
|
1996
|
+
success: true,
|
|
1997
|
+
customerId,
|
|
1998
|
+
orderId,
|
|
1999
|
+
status: result.data.updateOrder.status,
|
|
2000
|
+
};
|
|
2001
|
+
}))
|
|
2002
|
+
|
|
2003
|
+
.catch(({ data }) => ({
|
|
2004
|
+
success: false,
|
|
2005
|
+
error: data instanceof Error ? data.message : String(data),
|
|
2006
|
+
status: 500,
|
|
2007
|
+
}));
|
|
2008
|
+
```
|
|
2009
|
+
|
|
2010
|
+
### Use Case 5: Product Catalog Sync with Enrichment
|
|
2011
|
+
|
|
2012
|
+
**Scenario**: Fetch products from PIM system → Enrich → Update Fluent catalog
|
|
2013
|
+
|
|
2014
|
+
```typescript
|
|
2015
|
+
import { schedule, fn, http } from '@versori/run';
|
|
2016
|
+
import {
|
|
2017
|
+
createClient,
|
|
2018
|
+
UniversalMapper,
|
|
2019
|
+
VersoriFileTracker
|
|
2020
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Daily product catalog sync
|
|
2024
|
+
*
|
|
2025
|
+
* Cron: 0 4 * * * (4 AM daily)
|
|
2026
|
+
*/
|
|
2027
|
+
export const productCatalogSync = schedule('product-catalog-sync', '0 4 * * *')
|
|
2028
|
+
// Step 1: Fetch from external PIM (http - external API)
|
|
2029
|
+
.then(http('fetch-from-pim', { connection: 'pim_system' }, async ctx => {
|
|
2030
|
+
// Fetch products from PIM API
|
|
2031
|
+
const response = await ctx.fetch('/api/v1/products?updated_since=yesterday');
|
|
2032
|
+
const products = await response.json();
|
|
2033
|
+
|
|
2034
|
+
ctx.log.info('Fetched products from PIM', { count: products.length });
|
|
2035
|
+
return { pimProducts: products };
|
|
2036
|
+
}))
|
|
2037
|
+
|
|
2038
|
+
// Step 2: Enrich and transform (fn - data processing)
|
|
2039
|
+
.then(fn('enrich-transform', async ctx => {
|
|
2040
|
+
const { pimProducts } = ctx.data;
|
|
2041
|
+
|
|
2042
|
+
const mappingConfig = {
|
|
2043
|
+
fields: {
|
|
2044
|
+
ref: { source: 'sku', required: true },
|
|
2045
|
+
type: { value: 'STANDARD' },
|
|
2046
|
+
name: { source: 'name', required: true },
|
|
2047
|
+
summary: { source: 'short_description' },
|
|
2048
|
+
gtin: { source: 'barcode' },
|
|
2049
|
+
prices: {
|
|
2050
|
+
source: 'pricing',
|
|
2051
|
+
isArray: true,
|
|
2052
|
+
mapping: {
|
|
2053
|
+
type: { source: 'price_type' },
|
|
2054
|
+
value: { source: 'amount', resolver: 'sdk.parseFloat' },
|
|
2055
|
+
currency: { source: 'currency', default: 'USD' },
|
|
2056
|
+
},
|
|
2057
|
+
},
|
|
2058
|
+
attributes: {
|
|
2059
|
+
source: 'custom_attributes',
|
|
2060
|
+
isArray: true,
|
|
2061
|
+
mapping: {
|
|
2062
|
+
name: { source: 'attr_name' },
|
|
2063
|
+
value: { source: 'attr_value', resolver: 'sdk.toString' },
|
|
2064
|
+
type: { value: 'STRING' },
|
|
2065
|
+
},
|
|
2066
|
+
},
|
|
2067
|
+
taxType: {
|
|
2068
|
+
type: { source: 'tax.type' },
|
|
2069
|
+
group: { source: 'tax.group' },
|
|
2070
|
+
tariff: { source: 'tax.tariff_code' },
|
|
2071
|
+
},
|
|
2072
|
+
},
|
|
2073
|
+
};
|
|
2074
|
+
|
|
2075
|
+
const mapper = new UniversalMapper(mappingConfig);
|
|
2076
|
+
const enrichedProducts = [];
|
|
2077
|
+
|
|
2078
|
+
for (const product of pimProducts) {
|
|
2079
|
+
const result = await mapper.map(product);
|
|
2080
|
+
if (result.success) {
|
|
2081
|
+
enrichedProducts.push(result.data);
|
|
2082
|
+
} else {
|
|
2083
|
+
ctx.log.warn('Product mapping failed', {
|
|
2084
|
+
sku: product.sku,
|
|
2085
|
+
errors: result.errors
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
ctx.log.info('Products enriched', { count: enrichedProducts.length });
|
|
2091
|
+
return { products: enrichedProducts };
|
|
2092
|
+
}))
|
|
2093
|
+
|
|
2094
|
+
// Step 3: Send to Fluent (http - Fluent API)
|
|
2095
|
+
.then(http('sync-to-fluent', { connection: 'fluent_commerce' }, async ctx => {
|
|
2096
|
+
const { products } = ctx.data;
|
|
2097
|
+
const client = await createClient(ctx);
|
|
2098
|
+
|
|
2099
|
+
const job = await client.createJob({
|
|
2100
|
+
name: `Product Catalog Sync - ${new Date().toISOString().split('T')[0]}`,
|
|
2101
|
+
retailerId: ctx.activation.getVariable<number>('FLUENT_RETAILER_ID'),
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
// Send in chunks
|
|
2105
|
+
const batchSize = 100;
|
|
2106
|
+
const batches = [];
|
|
2107
|
+
|
|
2108
|
+
for (let i = 0; i < products.length; i += batchSize) {
|
|
2109
|
+
const chunk = products.slice(i, i + batchSize);
|
|
2110
|
+
const batch = await client.sendBatch(job.id, {
|
|
2111
|
+
action: 'UPSERT',
|
|
2112
|
+
entityType: 'PRODUCT',
|
|
2113
|
+
entities: chunk,
|
|
2114
|
+
meta: {
|
|
2115
|
+
preprocessing: 'apply', // Full snapshot, use BPP
|
|
2116
|
+
},
|
|
2117
|
+
});
|
|
2118
|
+
batches.push(batch.id);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
ctx.log.info('Product sync complete', {
|
|
2122
|
+
jobId: job.id,
|
|
2123
|
+
productCount: products.length,
|
|
2124
|
+
batchCount: batches.length,
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
return {
|
|
2128
|
+
success: true,
|
|
2129
|
+
jobId: job.id,
|
|
2130
|
+
productCount: products.length,
|
|
2131
|
+
};
|
|
2132
|
+
}))
|
|
2133
|
+
|
|
2134
|
+
.catch(({ data }) => ({
|
|
2135
|
+
success: false,
|
|
2136
|
+
error: data instanceof Error ? data.message : String(data),
|
|
2137
|
+
}));
|
|
2138
|
+
```
|
|
2139
|
+
|
|
2140
|
+
---
|
|
2141
|
+
|
|
2142
|
+
## ⚠️ CRITICAL: `.parallel()` Error Behavior
|
|
2143
|
+
|
|
2144
|
+
**BEFORE USING `.parallel()` - READ THIS:**
|
|
2145
|
+
|
|
2146
|
+
The `.parallel()` method has **all-or-nothing error behavior** that makes it **UNSUITABLE for most batch processing use cases**.
|
|
2147
|
+
|
|
2148
|
+
### What Happens When One Task Fails
|
|
2149
|
+
|
|
2150
|
+
```typescript
|
|
2151
|
+
// ❌ PROBLEM: If ANY file fails, you lose ALL results
|
|
2152
|
+
schedule('process-files', '0 2 * * *')
|
|
2153
|
+
.then(fn('fetch', () => ['file1.xml', 'file2.xml', 'file3.xml']))
|
|
2154
|
+
.unpack()
|
|
2155
|
+
.parallel(fn('process', async (ctx) => {
|
|
2156
|
+
return await processFile(ctx.data);
|
|
2157
|
+
}));
|
|
2158
|
+
```
|
|
2159
|
+
|
|
2160
|
+
**If `file2.xml` fails:**
|
|
2161
|
+
- ❌ `file1.xml` might have processed successfully but **result is lost**
|
|
2162
|
+
- ❌ `file3.xml` never runs (or is cancelled mid-execution)
|
|
2163
|
+
- ❌ **Entire workflow fails**
|
|
2164
|
+
- ❌ **NO partial results returned**
|
|
2165
|
+
- ❌ You can't retry only failed items
|
|
2166
|
+
|
|
2167
|
+
### Implementation Detail
|
|
2168
|
+
|
|
2169
|
+
The `.parallel()` method uses RxJS `mergeMap` + `toArray()`:
|
|
2170
|
+
- Any error in `mergeMap` propagates to the stream
|
|
2171
|
+
- `toArray()` never completes if the stream errors
|
|
2172
|
+
- **Result: one failure stops everything**
|
|
2173
|
+
|
|
2174
|
+
### Comparison: `.parallel()` vs `Promise.allSettled`
|
|
2175
|
+
|
|
2176
|
+
| Feature | `.parallel()` | `Promise.allSettled` |
|
|
2177
|
+
|---------|--------------|---------------------|
|
|
2178
|
+
| Fault tolerance | ❌ Fails on first error | ✅ Continues on errors |
|
|
2179
|
+
| Partial results | ❌ None if any fail | ✅ All results |
|
|
2180
|
+
| Error isolation | ❌ No isolation | ✅ Complete isolation |
|
|
2181
|
+
| Retry failures | ❌ Must retry all | ✅ Retry only failures |
|
|
2182
|
+
|
|
2183
|
+
### When to Use `.parallel()`
|
|
2184
|
+
|
|
2185
|
+
**ONLY use `.parallel()` when:**
|
|
2186
|
+
1. ✅ **All tasks MUST succeed** (transactional requirement)
|
|
2187
|
+
2. ✅ **Small number of items** (3-10 max)
|
|
2188
|
+
3. ✅ **Fast, reliable operations** (< 10 seconds each)
|
|
2189
|
+
4. ✅ **Partial success is worse than complete failure**
|
|
2190
|
+
|
|
2191
|
+
**Examples where `.parallel()` is appropriate:**
|
|
2192
|
+
- Fetching 3-5 required entity types (all needed for next step)
|
|
2193
|
+
- Validating multiple webhook signatures (all must be valid)
|
|
2194
|
+
- Small, predictable, low-failure-rate operations
|
|
2195
|
+
|
|
2196
|
+
### When to Use `Promise.allSettled` (SDK Pattern)
|
|
2197
|
+
|
|
2198
|
+
**PREFER `Promise.allSettled` for:**
|
|
2199
|
+
1. ✅ **Batch processing** (files, records, entities)
|
|
2200
|
+
2. ✅ **Large datasets** (10+ items)
|
|
2201
|
+
3. ✅ **Failures are expected** (network issues, bad data)
|
|
2202
|
+
4. ✅ **Partial success is valuable** (process 990/1000 items)
|
|
2203
|
+
5. ✅ **Need retry logic** (retry only failures)
|
|
2204
|
+
|
|
2205
|
+
**Example using SDK pattern:**
|
|
2206
|
+
```typescript
|
|
2207
|
+
export const batchProcess = schedule('batch', '0 */6 * * *')
|
|
2208
|
+
.then(
|
|
2209
|
+
http('process', { connection: 'fluent_commerce' }, async (ctx) => {
|
|
2210
|
+
const items = await fetchItems();
|
|
2211
|
+
|
|
2212
|
+
// ✅ Use Promise.allSettled for fault tolerance
|
|
2213
|
+
const results = await Promise.allSettled(
|
|
2214
|
+
items.map(item =>
|
|
2215
|
+
processItem(item)
|
|
2216
|
+
.then(result => ({ success: true, item, result }))
|
|
2217
|
+
.catch(error => ({ success: false, item, error: error.message }))
|
|
2218
|
+
)
|
|
2219
|
+
);
|
|
2220
|
+
|
|
2221
|
+
const successes = results
|
|
2222
|
+
.filter(r => r.status === 'fulfilled')
|
|
2223
|
+
.map(r => r.value);
|
|
2224
|
+
|
|
2225
|
+
const failures = results
|
|
2226
|
+
.filter(r => r.status === 'rejected' || !r.value.success);
|
|
2227
|
+
|
|
2228
|
+
ctx.log.info(`Processed ${successes.length} items, ${failures.length} failed`);
|
|
2229
|
+
|
|
2230
|
+
// ✅ Handle failures gracefully
|
|
2231
|
+
if (failures.length > 0) {
|
|
2232
|
+
// Store failures for retry
|
|
2233
|
+
const kv = ctx.openKv(':project:');
|
|
2234
|
+
await kv.set('failed-items', failures);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
return { successes, failures };
|
|
2238
|
+
})
|
|
2239
|
+
);
|
|
2240
|
+
```
|
|
2241
|
+
|
|
2242
|
+
---
|
|
2243
|
+
|
|
2244
|
+
## Parallel Processing with `.unpack()` and `.parallel()`
|
|
2245
|
+
|
|
2246
|
+
Versori supports parallel processing via `.unpack()` and `.parallel()` methods, but **they have critical limitations** that affect fault tolerance and error handling (see warning above).
|
|
2247
|
+
|
|
2248
|
+
### Available Methods
|
|
2249
|
+
|
|
2250
|
+
```typescript
|
|
2251
|
+
import { schedule, fn } from '@versori/run';
|
|
2252
|
+
|
|
2253
|
+
schedule('process-files', '0 2 * * *')
|
|
2254
|
+
.then(fn('fetch-files', (ctx) => {
|
|
2255
|
+
return ['file1.xml', 'file2.xml', 'file3.xml']; // Returns array
|
|
2256
|
+
}))
|
|
2257
|
+
.unpack() // ✅ Converts array to ArrayTask
|
|
2258
|
+
.parallel( // ✅ Processes each item in parallel
|
|
2259
|
+
fn('process-file', async (ctx) => {
|
|
2260
|
+
// ctx.data is now a single item from the array
|
|
2261
|
+
return await processFile(ctx.data);
|
|
2262
|
+
})
|
|
2263
|
+
);
|
|
2264
|
+
```
|
|
2265
|
+
|
|
2266
|
+
### ⚠️ CRITICAL LIMITATIONS
|
|
2267
|
+
|
|
2268
|
+
#### 1. **NOT Fault-Tolerant: One Failure Stops Everything**
|
|
2269
|
+
|
|
2270
|
+
**Problem**: If any parallel task fails, the **entire workflow fails** and **no results are returned**.
|
|
2271
|
+
|
|
2272
|
+
```typescript
|
|
2273
|
+
// ❌ RISKY: If file2.xml fails, entire workflow fails
|
|
2274
|
+
schedule('process-files', '0 2 * * *')
|
|
2275
|
+
.then(fn('fetch-files', (ctx) => ['file1.xml', 'file2.xml', 'file3.xml']))
|
|
2276
|
+
.unpack()
|
|
2277
|
+
.parallel(fn('process-file', async (ctx) => {
|
|
2278
|
+
// If ANY file fails here, ALL results lost
|
|
2279
|
+
return await processFile(ctx.data);
|
|
2280
|
+
}));
|
|
2281
|
+
|
|
2282
|
+
// Result:
|
|
2283
|
+
// ✅ file1.xml might complete
|
|
2284
|
+
// ❌ file2.xml fails → ENTIRE WORKFLOW FAILS
|
|
2285
|
+
// ❌ file3.xml NEVER RUNS (or gets cancelled)
|
|
2286
|
+
// ❌ No partial results returned
|
|
2287
|
+
```
|
|
2288
|
+
|
|
2289
|
+
**Why**: Uses RxJS `mergeMap` + `toArray()` which stops on first error.
|
|
2290
|
+
|
|
2291
|
+
#### 2. **Shared Timeout Across All Tasks**
|
|
2292
|
+
|
|
2293
|
+
**Problem**: All parallel tasks share the **same workflow timeout**.
|
|
2294
|
+
|
|
2295
|
+
| Trigger Type | Timeout | Impact |
|
|
2296
|
+
|-------------|---------|--------|
|
|
2297
|
+
| **HTTP/Webhook** | 30 seconds | All parallel tasks must complete in 30s total |
|
|
2298
|
+
| **Schedule** | 5 minutes | All parallel tasks must complete in 5min total |
|
|
2299
|
+
|
|
2300
|
+
```typescript
|
|
2301
|
+
// ⚠️ If processing 10 files in parallel:
|
|
2302
|
+
schedule('process-files', '0 2 * * *')
|
|
2303
|
+
.then(fn('fetch-files', (ctx) => Array(10).fill('file.xml')))
|
|
2304
|
+
.unpack()
|
|
2305
|
+
.parallel(fn('process-file', async (ctx) => {
|
|
2306
|
+
// If ANY file takes > 5 minutes, ENTIRE workflow fails
|
|
2307
|
+
return await processFile(ctx.data);
|
|
2308
|
+
}));
|
|
2309
|
+
```
|
|
2310
|
+
|
|
2311
|
+
#### 3. **NOT ACID Compliant**
|
|
2312
|
+
|
|
2313
|
+
**Problem**: No atomicity guarantees. If one task fails:
|
|
2314
|
+
- ✅ Successful tasks may have completed their work
|
|
2315
|
+
- ❌ But you won't know which ones succeeded
|
|
2316
|
+
- ❌ No rollback mechanism
|
|
2317
|
+
- ❌ No transaction support
|
|
2318
|
+
|
|
2319
|
+
### ✅ Recommended: Use `Promise.allSettled()` Instead
|
|
2320
|
+
|
|
2321
|
+
For **fault-tolerant** parallel processing, use `Promise.allSettled()` which continues even when some tasks fail:
|
|
2322
|
+
|
|
2323
|
+
```typescript
|
|
2324
|
+
import { schedule, fn } from '@versori/run';
|
|
2325
|
+
|
|
2326
|
+
schedule('process-files', '0 2 * * *')
|
|
2327
|
+
.then(fn('process-all-files', async (ctx) => {
|
|
2328
|
+
const files = ['file1.xml', 'file2.xml', 'file3.xml'];
|
|
2329
|
+
|
|
2330
|
+
// ✅ Fault-tolerant: Continues even if some fail
|
|
2331
|
+
const results = await Promise.allSettled(
|
|
2332
|
+
files.map(file => processFile(file))
|
|
2333
|
+
);
|
|
2334
|
+
|
|
2335
|
+
// Process results
|
|
2336
|
+
const successes = results
|
|
2337
|
+
.filter(r => r.status === 'fulfilled')
|
|
2338
|
+
.map(r => r.value);
|
|
2339
|
+
|
|
2340
|
+
const failures = results
|
|
2341
|
+
.filter(r => r.status === 'rejected')
|
|
2342
|
+
.map(r => ({ file: r.reason.file, error: r.reason.message }));
|
|
2343
|
+
|
|
2344
|
+
ctx.log.info(`Processed ${successes.length} files, ${failures.length} failed`);
|
|
2345
|
+
|
|
2346
|
+
return { successes, failures };
|
|
2347
|
+
}));
|
|
2348
|
+
```
|
|
2349
|
+
|
|
2350
|
+
**Comparison**:
|
|
2351
|
+
|
|
2352
|
+
| Feature | `.unpack().parallel()` | `Promise.allSettled()` |
|
|
2353
|
+
|---------|------------------------|------------------------|
|
|
2354
|
+
| Fault tolerance | ❌ Fails on first error | ✅ Continues on errors |
|
|
2355
|
+
| Partial results | ❌ No results if any fail | ✅ Returns all results |
|
|
2356
|
+
| Error isolation | ❌ No isolation | ✅ Complete isolation |
|
|
2357
|
+
| Time limits | ⚠️ Shared timeout | ⚠️ Shared timeout |
|
|
2358
|
+
| Best for | All-or-nothing workflows | Real-world with failures |
|
|
2359
|
+
|
|
2360
|
+
### When to Use `.unpack().parallel()`
|
|
2361
|
+
|
|
2362
|
+
**Only use if**:
|
|
2363
|
+
- ✅ All tasks **must** succeed (transactional)
|
|
2364
|
+
- ✅ You can wrap each task with error handling that returns error objects instead of throwing
|
|
2365
|
+
- ✅ You're okay with losing all results on any failure
|
|
2366
|
+
|
|
2367
|
+
**Example with error handling**:
|
|
2368
|
+
|
|
2369
|
+
```typescript
|
|
2370
|
+
schedule('process-files', '0 2 * * *')
|
|
2371
|
+
.then(fn('fetch-files', (ctx) => ['file1.xml', 'file2.xml', 'file3.xml']))
|
|
2372
|
+
.unpack()
|
|
2373
|
+
.parallel(fn('process-file', async (ctx) => {
|
|
2374
|
+
try {
|
|
2375
|
+
const result = await processFile(ctx.data);
|
|
2376
|
+
return { success: true, file: ctx.data, result };
|
|
2377
|
+
} catch (error) {
|
|
2378
|
+
// ✅ Return error object instead of throwing
|
|
2379
|
+
return {
|
|
2380
|
+
success: false,
|
|
2381
|
+
file: ctx.data,
|
|
2382
|
+
error: error.message
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
}))
|
|
2386
|
+
.then(fn('process-results', (ctx) => {
|
|
2387
|
+
// ✅ Now you have all results (success + failures)
|
|
2388
|
+
const results = ctx.data;
|
|
2389
|
+
const successes = results.filter(r => r.success);
|
|
2390
|
+
const failures = results.filter(r => !r.success);
|
|
2391
|
+
|
|
2392
|
+
ctx.log.info(`Processed ${successes.length} files, ${failures.length} failed`);
|
|
2393
|
+
return { successes, failures };
|
|
2394
|
+
}));
|
|
2395
|
+
```
|
|
2396
|
+
|
|
2397
|
+
### When to Use `Promise.allSettled()` (Recommended)
|
|
2398
|
+
|
|
2399
|
+
**Use `Promise.allSettled()` for**:
|
|
2400
|
+
- ✅ Batch API submissions
|
|
2401
|
+
- ✅ File processing
|
|
2402
|
+
- ✅ Any scenario where failures are expected
|
|
2403
|
+
- ✅ When you need partial results
|
|
2404
|
+
|
|
2405
|
+
**Already implemented in your code**:
|
|
2406
|
+
|
|
2407
|
+
```typescript
|
|
2408
|
+
// ✅ Your current BatchProcessorService uses this pattern
|
|
2409
|
+
const batchResults = await Promise.allSettled(
|
|
2410
|
+
batchChunk.map((chunk, index) =>
|
|
2411
|
+
this.client.sendBatch(jobId, {
|
|
2412
|
+
action: 'UPSERT',
|
|
2413
|
+
entityType: 'INVENTORY',
|
|
2414
|
+
entities: chunk,
|
|
2415
|
+
}).then(batch => ({ success: true, batch }))
|
|
2416
|
+
.catch(error => ({ success: false, error }))
|
|
2417
|
+
)
|
|
2418
|
+
);
|
|
2419
|
+
```
|
|
2420
|
+
|
|
2421
|
+
---
|
|
2422
|
+
|
|
2423
|
+
## Key Takeaways
|
|
2424
|
+
|
|
2425
|
+
- 🎯 **http()** for external API calls - requires connection, provides `ctx.fetch`
|
|
2426
|
+
- 🎯 **webhook()** for receiving requests - provides `ctx.data`, `ctx.request()` for headers
|
|
2427
|
+
- 🎯 **schedule()** for time-based tasks - supports cron patterns, can have connection
|
|
2428
|
+
- 🎯 **fn()** for internal processing - NO external API access, has `ctx.openKv()`
|
|
2429
|
+
- 🎯 **Non-JSON responses** require custom `onSuccess`/`onError` handlers returning Response objects
|
|
2430
|
+
- 🎯 **Workflow composition** with `.then()` and `.catch()` enables multi-step patterns
|
|
2431
|
+
- 🎯 **Error handling** with try/catch and retry configuration ensures robustness
|
|
2432
|
+
- ⚠️ **`.unpack().parallel()`** exists but is NOT fault-tolerant - use `Promise.allSettled()` for resilience
|
|
2433
|
+
|
|
2434
|
+
---
|
|
2435
|
+
|
|
2436
|
+
## Practice Exercise
|
|
2437
|
+
|
|
2438
|
+
Create a scheduled workflow that:
|
|
2439
|
+
|
|
2440
|
+
1. Runs hourly (cron: `0 * * * *`)
|
|
2441
|
+
2. Fetches inventory from an S3 CSV file
|
|
2442
|
+
3. Checks KV storage to avoid duplicate processing
|
|
2443
|
+
4. Transforms data using UniversalMapper
|
|
2444
|
+
5. Sends to Fluent via Batch API
|
|
2445
|
+
6. Marks file as processed in KV storage
|
|
2446
|
+
7. Returns XML response on error
|
|
2447
|
+
|
|
2448
|
+
**Hints**:
|
|
2449
|
+
|
|
2450
|
+
- Use `schedule()` with connection
|
|
2451
|
+
- Chain with `fn()` for KV checks
|
|
2452
|
+
- Use `http()` context for Fluent API calls
|
|
2453
|
+
- Implement custom error handler for XML response
|
|
2454
|
+
|
|
2455
|
+
**Solution** available in [Module 8: Best Practices](./platforms-versori-08-best-practices.md#practice-solutions)
|
|
2456
|
+
|
|
2457
|
+
---
|
|
2458
|
+
|
|
2459
|
+
## Next Steps
|
|
2460
|
+
|
|
2461
|
+
Now that you've mastered all workflow types, let's explore connection management in detail.
|
|
2462
|
+
|
|
2463
|
+
Continue to [Module 5: Connections →](./platforms-versori-05-connections.md) to learn about OAuth2 connection types, management, and validation.
|
|
2464
|
+
|
|
2465
|
+
---
|
|
2466
|
+
|
|
2467
|
+
## Related Documentation
|
|
2468
|
+
|
|
2469
|
+
- [Module 3: Authentication](./platforms-versori-03-authentication.md) - OAuth2 basics
|
|
2470
|
+
- [Module 5: Connections](./platforms-versori-05-connections.md) - Connection management
|
|
2471
|
+
- [Module 6: KV Storage](./platforms-versori-06-kv-storage.md) - State management
|
|
2472
|
+
- [Webhook Response Patterns](.././modules/platforms-versori-04-workflows.md#critical-non-json-response-handlers-xml-html-csv) - Complete non-JSON response guide
|
|
2473
|
+
|
|
2474
|
+
---
|
|
2475
|
+
|
|
2476
|
+
[← Previous: Module 3](./platforms-versori-03-authentication.md) | [Back to Guide](../platforms-versori-readme.md) | [Next: Module 5: Connections →](./platforms-versori-05-connections.md)
|