@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,2312 +1,2312 @@
|
|
|
1
|
-
---
|
|
2
|
-
template_id: tpl-ingest-s3-csv-to-product-event
|
|
3
|
-
canonical_filename: template-ingestion-s3-csv-product-event.md
|
|
4
|
-
sdk_version: ^0.1.39
|
|
5
|
-
runtime: versori
|
|
6
|
-
versori_run_version: ^0.4.4
|
|
7
|
-
direction: ingestion
|
|
8
|
-
source: s3-csv
|
|
9
|
-
destination: fluent-event-api
|
|
10
|
-
entity: product
|
|
11
|
-
format: csv
|
|
12
|
-
logging: versori
|
|
13
|
-
status: stable
|
|
14
|
-
features:
|
|
15
|
-
- batched-events
|
|
16
|
-
- attribute-transformation
|
|
17
|
-
- memory-management
|
|
18
|
-
- json-serialization-handling
|
|
19
|
-
- enhanced-logging
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
# Template: Ingestion - S3 CSV to Product Event
|
|
23
|
-
|
|
24
|
-
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
25
|
-
**Last Updated:** 2025-01-24
|
|
26
|
-
**Deployment Target:** Versori Platform
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## 📋 Implementation Prompt
|
|
31
|
-
|
|
32
|
-
```
|
|
33
|
-
I need a Versori scheduled ingestion that:
|
|
34
|
-
|
|
35
|
-
1) Lists CSV files on S3 (deduplication via moveObject - NO VersoriFileTracker)
|
|
36
|
-
2) Downloads and parses CSV with field validation (using modular processFile() function)
|
|
37
|
-
3) Transforms records with UniversalMapper per mapping JSON
|
|
38
|
-
4) Sends UPSERT_PRODUCT events (async) to Fluent Commerce with per-record error handling (using sendEvents() function)
|
|
39
|
-
5) Writes event logs to S3 using writeEventLog() function (non-blocking)
|
|
40
|
-
6) Moves files to processed/ or errors/ prefixes for natural deduplication
|
|
41
|
-
7) Tracks progress with JobTracker and exposes a job-status webhook
|
|
42
|
-
8) Uses native Versori log from context
|
|
43
|
-
9) Uses modular service functions (NO IngestionOrchestrator - removed from SDK)
|
|
44
|
-
10) Imports Buffer from 'node:buffer' for Deno/Versori compatibility
|
|
45
|
-
|
|
46
|
-
Use the loaded docs to fill in SDK specifics and best practices.
|
|
47
|
-
Keep the structure identical to the template; only adapt where needed.
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
## 📋 Template Overview
|
|
53
|
-
|
|
54
|
-
This connector runs on the Versori platform. Most operational settings (Fluent account/connection, S3 credentials, schedule, file patterns) are configured via activation variables. Data shape and logic (mapping JSON, CSV structure, parsing rules, per-record handling) are adjusted in code as needed. It reads product data from S3 CSV, transforms it, and sends events to the Fluent Commerce Event API.
|
|
55
|
-
|
|
56
|
-
### What This Template Does
|
|
57
|
-
|
|
58
|
-
```
|
|
59
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
60
|
-
│ INGESTION WORKFLOW │
|
|
61
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
62
|
-
|
|
63
|
-
1. TRIGGER
|
|
64
|
-
├─ Scheduled (Cron): Runs automatically every hour
|
|
65
|
-
├─ Ad hoc (Webhook): Manual trigger for immediate processing
|
|
66
|
-
└─ Status Query (Webhook): Check job progress
|
|
67
|
-
|
|
68
|
-
2. DISCOVER FILES (S3DataSource)
|
|
69
|
-
├─ List objects from S3 bucket
|
|
70
|
-
├─ Filter by prefix and pattern (products/*.csv)
|
|
71
|
-
├─ NO VersoriFileTracker (S3 uses moveObject for deduplication)
|
|
72
|
-
└─ Sort by last modified
|
|
73
|
-
|
|
74
|
-
3. DOWNLOAD & PARSE (CSVParserService)
|
|
75
|
-
├─ Download object from S3
|
|
76
|
-
├─ Parse CSV to JavaScript array
|
|
77
|
-
├─ Extract headers from first row
|
|
78
|
-
└─ Validate CSV structure
|
|
79
|
-
|
|
80
|
-
4. TRANSFORM (UniversalMapper)
|
|
81
|
-
├─ Map CSV fields to Fluent schema
|
|
82
|
-
├─ Apply SDK resolvers (trim, uppercase, etc.)
|
|
83
|
-
├─ Handle nested objects (price, taxType)
|
|
84
|
-
├─ Handle arrays (categoryRefs)
|
|
85
|
-
└─ Collect transformation errors
|
|
86
|
-
|
|
87
|
-
5. SEND EVENTS (Event API)
|
|
88
|
-
├─ Loop through transformed products
|
|
89
|
-
├─ Send UPSERT_PRODUCT event (async)
|
|
90
|
-
├─ Track success/failure count
|
|
91
|
-
└─ Continue on individual failures
|
|
92
|
-
|
|
93
|
-
6. ARCHIVE (S3DataSource - Natural Deduplication)
|
|
94
|
-
├─ Move object to processed/ prefix (success)
|
|
95
|
-
├─ Or move to errors/ prefix (failures)
|
|
96
|
-
├─ Generate timestamped archive name
|
|
97
|
-
└─ Files in incoming/ are removed naturally
|
|
98
|
-
|
|
99
|
-
7. TRACK JOB (JobTracker)
|
|
100
|
-
├─ Update job status at each step
|
|
101
|
-
├─ Store final result in KV
|
|
102
|
-
├─ Enable status queries via webhook
|
|
103
|
-
└─ Handle errors gracefully
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
### Key Features
|
|
107
|
-
|
|
108
|
-
- **Modular service functions** - processFile(), sendEvents(), writeEventLog() for composability
|
|
109
|
-
- Job tracking with status queries via JobTracker
|
|
110
|
-
- Execution modes: scheduled, ad hoc, status query
|
|
111
|
-
- Uses S3DataSource, CSVParserService, UniversalMapper, JobTracker (NO IngestionOrchestrator)
|
|
112
|
-
- Error handling, retry logic, and S3 cleanup
|
|
113
|
-
- **S3 Deduplication:** moveObject to processed/errors prefixes (NO VersoriFileTracker)
|
|
114
|
-
- **Key Difference from SFTP:** Physical file movement vs KV state tracking
|
|
115
|
-
- Event API: Per-record failures don't block other records
|
|
116
|
-
- S3 dispose() in finally block
|
|
117
|
-
- Buffer import for Deno/Versori compatibility
|
|
118
|
-
|
|
119
|
-
Note: JobTracker persists stage/status to Versori KV for visibility, job-status webhooks, and auditing. Recommended for production multi-step flows; can be skipped for trivial single-step utilities.
|
|
120
|
-
|
|
121
|
-
### 📦 Package Information
|
|
122
|
-
|
|
123
|
-
**SDK:** [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk) `^0.1.30`
|
|
124
|
-
**Versori Runtime:** [@versori/run](https://www.npmjs.com/package/@versori/run) `^0.4.4`
|
|
125
|
-
|
|
126
|
-
```bash
|
|
127
|
-
npm install @fluentcommerce/fc-connect-sdk@latest
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
---
|
|
131
|
-
|
|
132
|
-
**Templates are designed for direct deployment; customize via activation variables.**
|
|
133
|
-
|
|
134
|
-
---
|
|
135
|
-
|
|
136
|
-
## 📦 SDK Imports (Verified - Versori Optimized)
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
// ✅ VERIFIED IMPORTS - These match actual SDK exports
|
|
140
|
-
import {
|
|
141
|
-
createClient, // Universal client factory
|
|
142
|
-
S3DataSource, // S3 operations with retry logic
|
|
143
|
-
CSVParserService, // CSV parsing
|
|
144
|
-
UniversalMapper, // Field mapping with SDK resolvers
|
|
145
|
-
JobTracker, // Job status tracking
|
|
146
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
147
|
-
|
|
148
|
-
import type { FluentClient, S3DataSourceConfig } from '@fluentcommerce/fc-connect-sdk';
|
|
149
|
-
|
|
150
|
-
// Versori platform imports
|
|
151
|
-
import { schedule, webhook, http, fn } from '@versori/run';
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
**Note:** All imports are from actual SDK exports - this code compiles and runs as-is.
|
|
155
|
-
|
|
156
|
-
**✅ VERSORI PLATFORM - Use Native Logs:**
|
|
157
|
-
|
|
158
|
-
- Use `log` from context: `const { log } = ctx;`
|
|
159
|
-
- Native Versori logs are simpler and automatically integrated with platform monitoring
|
|
160
|
-
|
|
161
|
-
---
|
|
162
|
-
|
|
163
|
-
## ⚙️ Activation Variables
|
|
164
|
-
|
|
165
|
-
**Configuration is driven by activation variables - modify these instead of code:**
|
|
166
|
-
|
|
167
|
-
```json
|
|
168
|
-
{
|
|
169
|
-
"fluentRetailerId": "1",
|
|
170
|
-
"eventConcurrency": 1,
|
|
171
|
-
"s3Bucket": "my-product-bucket",
|
|
172
|
-
"s3Region": "us-east-1",
|
|
173
|
-
"s3AccessKeyId": "AKIA...",
|
|
174
|
-
"s3SecretAccessKey": "********",
|
|
175
|
-
"s3IncomingPrefix": "products/incoming/",
|
|
176
|
-
"s3ProcessedPrefix": "products/processed/",
|
|
177
|
-
"s3ErrorPrefix": "products/errors/",
|
|
178
|
-
"s3LogsPrefix": "products/logs/",
|
|
179
|
-
"filePattern": "products_*.csv",
|
|
180
|
-
"catalogueRef": "PC:MASTER:2",
|
|
181
|
-
"catalogueType": "MASTER",
|
|
182
|
-
"eventName": "UPSERT_PRODUCT",
|
|
183
|
-
"eventMode": "async",
|
|
184
|
-
"maxFilesPerRun": 10,
|
|
185
|
-
"enableFileTracking": false,
|
|
186
|
-
"useBatchedEvents": false,
|
|
187
|
-
"maxProductsUnderBatchedEvent": 100,
|
|
188
|
-
"memoryBatchSize": 250
|
|
189
|
-
}
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Note: Webhook security is handled by Versori's native connection authentication. The `connection` parameter in webhook definitions ensures only authenticated requests are processed.
|
|
193
|
-
|
|
194
|
-
### Variable Explanations
|
|
195
|
-
|
|
196
|
-
| Variable | Purpose | Default | Customization Hints |
|
|
197
|
-
| ------------------- | ---------------------------- | --------------------- | ----------------------------------- |
|
|
198
|
-
| `fluentRetailerId` | Retailer ID for Event API | - | Required - Fluent retailer ID |
|
|
199
|
-
| `eventConcurrency` | Event sending concurrency | `1` | `1` = sequential, `>1` = parallel (3-10 recommended) |
|
|
200
|
-
| `s3Bucket` | S3 bucket name | - | Required - your S3 bucket |
|
|
201
|
-
| `s3Region` | S3 region | `us-east-1` | AWS region (e.g., us-west-2) |
|
|
202
|
-
| `s3AccessKeyId` | AWS access key ID | - | Required - AWS credentials |
|
|
203
|
-
| `s3SecretAccessKey` | AWS secret access key | - | Required - AWS credentials |
|
|
204
|
-
| `s3IncomingPrefix` | Incoming files prefix | `products/incoming/` | Where new files arrive |
|
|
205
|
-
| `s3ProcessedPrefix` | Processed files archive | `products/processed/` | Where files move after success |
|
|
206
|
-
| `s3ErrorPrefix` | Failed files directory | `products/errors/` | Where files move after errors |
|
|
207
|
-
| `s3LogsPrefix` | Event logs directory | `products/logs/` | Where event logs are written |
|
|
208
|
-
| `filePattern` | File name filter | `products_*.csv` | Glob pattern for matching files |
|
|
209
|
-
| `catalogueRef` | Product catalogue reference | `PC:MASTER:2` | Target catalogue in Fluent |
|
|
210
|
-
| `catalogueType` | Catalogue type | `MASTER` | Usually MASTER or STANDARD |
|
|
211
|
-
| `eventName` | Event to send | `UPSERT_PRODUCT` | Event name from Rubix |
|
|
212
|
-
| `eventMode` | Event processing mode | `async` | async (recommended) or sync |
|
|
213
|
-
| `maxFilesPerRun` | Max files per execution | `10` | Prevent timeout on large batches |
|
|
214
|
-
| `enableFileTracking` | Enable KV-based file tracking | `false` | `false` = moveObject only (default), `true` = KV + moveObject |
|
|
215
|
-
| `webhookApiKey` | API key for webhook security | - | Required for webhook authentication |
|
|
216
|
-
| `useBatchedEvents` | Use batched UPSERT_PRODUCTS | `false` | `true` = batched events (10x faster for 100+ products), `false` = individual events |
|
|
217
|
-
| `maxProductsUnderBatchedEvent` | Products per batch | `100` | Only used when `useBatchedEvents=true` (default: 100) |
|
|
218
|
-
| `memoryBatchSize` | Products per mapping batch | `250` | Processes products in batches during mapping (prevents OOM on large files) |
|
|
219
|
-
|
|
220
|
-
---
|
|
221
|
-
|
|
222
|
-
## 🔒 SDK Automatic Behaviors (v0.1.40+)
|
|
223
|
-
|
|
224
|
-
**The SDK automatically validates and retries for improved reliability:**
|
|
225
|
-
|
|
226
|
-
###retailerId Validation
|
|
227
|
-
- **SDK validates** `retailerId` before calling `sendEvent()`
|
|
228
|
-
- **Checks:** `event.retailerId || client.retailerId`
|
|
229
|
-
- **If missing:** Throws `"retailerId is required for Event API..."`
|
|
230
|
-
- **Configuration:** Set via `fluentRetailerId` activation variable (recommended)
|
|
231
|
-
|
|
232
|
-
### 401 Auth Retry
|
|
233
|
-
- **Automatic retry** for platform auth failures (3 attempts)
|
|
234
|
-
- **Delay:** Exponential backoff (1s → 2s → 4s)
|
|
235
|
-
- **Applies to:** All `sendEvent()` calls (async and sync modes)
|
|
236
|
-
- **Log:** `"[fc-connect-sdk:auth] Platform auth failure (401), retrying..."`
|
|
237
|
-
|
|
238
|
-
### 5xx Server Retry
|
|
239
|
-
- **Automatic retry** for transient server errors (3 attempts)
|
|
240
|
-
- **Delay:** Exponential backoff (1s → 2s → 4s, capped at 10s)
|
|
241
|
-
- **Protects:** Against Fluent API transient failures
|
|
242
|
-
|
|
243
|
-
### No Code Changes Required
|
|
244
|
-
- All templates remain compatible
|
|
245
|
-
- Retry logic is automatic and transparent
|
|
246
|
-
- Better error messages guide configuration
|
|
247
|
-
|
|
248
|
-
**See:** [Event API Guide](./event-api-guide.md) for complete details
|
|
249
|
-
|
|
250
|
-
---
|
|
251
|
-
|
|
252
|
-
### 📁 S3 Deduplication Pattern (NO VersoriFileTracker)
|
|
253
|
-
|
|
254
|
-
**CRITICAL: S3 uses physical file movement, NOT VersoriFileTracker**
|
|
255
|
-
|
|
256
|
-
VersoriFileTracker is **SFTP-specific** and should **NOT** be used with S3. S3 achieves deduplication through physical file movement.
|
|
257
|
-
|
|
258
|
-
#### How S3 Deduplication Works
|
|
259
|
-
|
|
260
|
-
**3-Step Process:**
|
|
261
|
-
|
|
262
|
-
1. **Discovery:** List objects from `s3IncomingPrefix` matching `filePattern`
|
|
263
|
-
2. **Process:** Download, parse, transform, and send events
|
|
264
|
-
3. **Archive:** Move object to `s3ProcessedPrefix` or `s3ErrorPrefix`
|
|
265
|
-
4. **Natural Deduplication:** Once moved, files are no longer in incoming/
|
|
266
|
-
|
|
267
|
-
#### File Lifecycle Example
|
|
268
|
-
|
|
269
|
-
```
|
|
270
|
-
INITIAL STATE:
|
|
271
|
-
s3://bucket/products/incoming/products_20250124_001.csv
|
|
272
|
-
|
|
273
|
-
↓ (workflow discovers file)
|
|
274
|
-
|
|
275
|
-
PROCESSING:
|
|
276
|
-
- List objects from "products/incoming/"
|
|
277
|
-
- Download: products_20250124_001.csv
|
|
278
|
-
- Parse CSV → Map to events → Send to Event API
|
|
279
|
-
|
|
280
|
-
↓ (success)
|
|
281
|
-
|
|
282
|
-
ARCHIVE (moveObject):
|
|
283
|
-
s3://bucket/products/processed/products_20250124_001-20250124T183045.csv
|
|
284
|
-
|
|
285
|
-
↓ (result)
|
|
286
|
-
|
|
287
|
-
INCOMING FOLDER NOW EMPTY:
|
|
288
|
-
s3://bucket/products/incoming/ (file is gone - no duplicates possible)
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
#### Key Differences: S3 vs SFTP
|
|
292
|
-
|
|
293
|
-
| Aspect | S3 (This Template) | SFTP (Other Templates) |
|
|
294
|
-
| ------------------------ | ----------------------------- | -------------------------------- |
|
|
295
|
-
| **Deduplication Method** | Physical file movement | VersoriFileTracker (KV state) |
|
|
296
|
-
| **State Storage** | None needed | KV store tracks processed files |
|
|
297
|
-
| **File Tracking** | ❌ NO VersoriFileTracker | ✅ VersoriFileTracker required |
|
|
298
|
-
| **Reprocessing** | Move files back to incoming/ | Clear KV state or forceReprocess |
|
|
299
|
-
| **Complexity** | Simpler (no state management) | More complex (state management) |
|
|
300
|
-
|
|
301
|
-
#### Why S3 Doesn't Need VersoriFileTracker
|
|
302
|
-
|
|
303
|
-
**S3 moveObject provides natural deduplication:**
|
|
304
|
-
|
|
305
|
-
- Files are **physically moved** from `incoming/` to `processed/` or `errors/`
|
|
306
|
-
- Once moved, the file **no longer exists** in the incoming prefix
|
|
307
|
-
- Next discovery lists `incoming/` → file is not found → no duplicate processing
|
|
308
|
-
- **No KV state tracking needed** - the S3 bucket structure IS the state
|
|
309
|
-
|
|
310
|
-
#### Reprocessing Files (If Needed)
|
|
311
|
-
|
|
312
|
-
To reprocess a file that was already archived:
|
|
313
|
-
|
|
314
|
-
**Option 1: Manual S3 Move**
|
|
315
|
-
|
|
316
|
-
```bash
|
|
317
|
-
# Move file back from processed/ to incoming/
|
|
318
|
-
aws s3 mv \
|
|
319
|
-
s3://bucket/products/processed/products_20250124_001-timestamp.csv \
|
|
320
|
-
s3://bucket/products/incoming/products_20250124_001.csv
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
**Option 2: Webhook with forceReprocess**
|
|
324
|
-
|
|
325
|
-
```bash
|
|
326
|
-
# Set forceReprocess: true in webhook payload
|
|
327
|
-
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
328
|
-
-H "X-API-Key: your-secret-key" \
|
|
329
|
-
-H "Content-Type: application/json" \
|
|
330
|
-
-d '{
|
|
331
|
-
"forceReprocess": true,
|
|
332
|
-
"filePattern": "products_20250124_001.csv"
|
|
333
|
-
}'
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
**Use cases for reprocessing:**
|
|
337
|
-
|
|
338
|
-
- Fixing bad data that was previously ingested
|
|
339
|
-
- Reingesting after mapping config changes
|
|
340
|
-
- Testing with production files
|
|
341
|
-
|
|
342
|
-
#### Summary
|
|
343
|
-
|
|
344
|
-
✅ **DO:** Use S3 moveObject for deduplication
|
|
345
|
-
✅ **DO:** Move files to processed/ or errors/ prefixes
|
|
346
|
-
❌ **DON'T:** Import VersoriFileTracker in S3 templates
|
|
347
|
-
❌ **DON'T:** Use KV state tracking for S3 files
|
|
348
|
-
✅ **DO:** Use VersoriFileTracker for SFTP templates (different use case)
|
|
349
|
-
|
|
350
|
-
---
|
|
351
|
-
|
|
352
|
-
### 📁 S3 Prefix vs File Pattern
|
|
353
|
-
|
|
354
|
-
**Critical:** Folder prefixes and file patterns are configured separately.
|
|
355
|
-
|
|
356
|
-
- **Folder Prefixes:** Configured via activation variables:
|
|
357
|
-
- `s3IncomingPrefix` - Where new files arrive (e.g., `products/incoming/`)
|
|
358
|
-
- `s3ProcessedPrefix` - Where successful files are archived (e.g., `products/processed/`)
|
|
359
|
-
- `s3ErrorPrefix` - Where failed files are moved (e.g., `products/errors/`)
|
|
360
|
-
|
|
361
|
-
- **File Pattern:** Configured via `filePattern` activation variable
|
|
362
|
-
- Matches object keys (filenames)
|
|
363
|
-
- Supports wildcards: `*.csv`, `products_*.csv`, `urgent_*.csv`
|
|
364
|
-
|
|
365
|
-
**How it works:**
|
|
366
|
-
|
|
367
|
-
```typescript
|
|
368
|
-
// The workflow lists objects like this:
|
|
369
|
-
s3.listObjects({
|
|
370
|
-
prefix: 'products/incoming/', // ← Prefix from activation variable
|
|
371
|
-
pattern: 'products_*.csv', // ← Pattern matches object keys
|
|
372
|
-
});
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
**Example:** With these settings:
|
|
376
|
-
|
|
377
|
-
```json
|
|
378
|
-
{
|
|
379
|
-
"s3IncomingPrefix": "products/incoming/",
|
|
380
|
-
"filePattern": "products_*.csv"
|
|
381
|
-
}
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
The workflow will find objects like:
|
|
385
|
-
|
|
386
|
-
- ✅ `products/incoming/products_20250124.csv`
|
|
387
|
-
- ✅ `products/incoming/products_urgent.csv`
|
|
388
|
-
- ❌ `products/incoming/orders_20250124.csv` (doesn't match pattern)
|
|
389
|
-
- ❌ `orders/incoming/products_20250124.csv` (wrong prefix)
|
|
390
|
-
|
|
391
|
-
**Note:** To process files from different prefixes, change `s3IncomingPrefix` (not `filePattern`).
|
|
392
|
-
|
|
393
|
-
---
|
|
394
|
-
|
|
395
|
-
## 📄 Mapping Configuration
|
|
396
|
-
|
|
397
|
-
**File:** `config/products.import.csv.json`
|
|
398
|
-
|
|
399
|
-
```json
|
|
400
|
-
{
|
|
401
|
-
"name": "products.import.csv",
|
|
402
|
-
"version": "1.0.0",
|
|
403
|
-
"description": "CSV → Product Event Mapping",
|
|
404
|
-
"fields": {
|
|
405
|
-
"ref": {
|
|
406
|
-
"source": "ref",
|
|
407
|
-
"required": true,
|
|
408
|
-
"resolver": "sdk.trim"
|
|
409
|
-
},
|
|
410
|
-
"type": {
|
|
411
|
-
"source": "type",
|
|
412
|
-
"resolver": "sdk.uppercase",
|
|
413
|
-
"defaultValue": "STANDARD"
|
|
414
|
-
},
|
|
415
|
-
"status": {
|
|
416
|
-
"source": "status",
|
|
417
|
-
"resolver": "sdk.uppercase",
|
|
418
|
-
"defaultValue": "ACTIVE"
|
|
419
|
-
},
|
|
420
|
-
"gtin": {
|
|
421
|
-
"source": "gtin",
|
|
422
|
-
"resolver": "sdk.trim"
|
|
423
|
-
},
|
|
424
|
-
"name": {
|
|
425
|
-
"source": "name",
|
|
426
|
-
"required": true,
|
|
427
|
-
"resolver": "sdk.trim"
|
|
428
|
-
},
|
|
429
|
-
"summary": {
|
|
430
|
-
"source": "summary"
|
|
431
|
-
},
|
|
432
|
-
"categoryRefs": {
|
|
433
|
-
"source": "category_refs",
|
|
434
|
-
"resolver": "custom.splitByPipe"
|
|
435
|
-
},
|
|
436
|
-
"price": {
|
|
437
|
-
"resolver": "custom.buildPriceArray"
|
|
438
|
-
},
|
|
439
|
-
"taxType": {
|
|
440
|
-
"resolver": "custom.buildTaxType"
|
|
441
|
-
},
|
|
442
|
-
"attributes": {
|
|
443
|
-
"defaultValue": []
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
**Custom Resolvers:**
|
|
450
|
-
|
|
451
|
-
```typescript
|
|
452
|
-
const customResolvers = {
|
|
453
|
-
/**
|
|
454
|
-
* Split pipe-separated string into array
|
|
455
|
-
* Example: "CAT1|CAT2|CAT3" → ["CAT1", "CAT2", "CAT3"]
|
|
456
|
-
*/
|
|
457
|
-
'custom.splitByPipe': (value: string) => {
|
|
458
|
-
if (!value || value.trim() === '') return [];
|
|
459
|
-
return value
|
|
460
|
-
.split('|')
|
|
461
|
-
.map(v => v.trim())
|
|
462
|
-
.filter(v => v !== '');
|
|
463
|
-
},
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Build price array from flat CSV columns
|
|
467
|
-
*/
|
|
468
|
-
'custom.buildPriceArray': (value: any, sourceData: any) => {
|
|
469
|
-
const prices = [];
|
|
470
|
-
if (sourceData.price_default_value) {
|
|
471
|
-
prices.push({
|
|
472
|
-
type: 'DEFAULT',
|
|
473
|
-
currency: sourceData.price_default_currency || 'USD',
|
|
474
|
-
value: sourceData.price_default_value,
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
if (sourceData.price_special_value) {
|
|
478
|
-
prices.push({
|
|
479
|
-
type: 'SPECIAL',
|
|
480
|
-
currency: sourceData.price_special_currency || 'USD',
|
|
481
|
-
value: sourceData.price_special_value,
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
return prices;
|
|
485
|
-
},
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Build tax type object from flat CSV columns
|
|
489
|
-
*/
|
|
490
|
-
'custom.buildTaxType': (value: any, sourceData: any) => {
|
|
491
|
-
if (!sourceData.tax_country) return undefined;
|
|
492
|
-
return {
|
|
493
|
-
country: sourceData.tax_country,
|
|
494
|
-
group: sourceData.tax_group,
|
|
495
|
-
tariff: sourceData.tax_tariff,
|
|
496
|
-
};
|
|
497
|
-
},
|
|
498
|
-
};
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
Note: Adjust fields in the JSON above as needed; prefer built-in resolvers. For advanced patterns, see SDK Universal Mapping guide.
|
|
502
|
-
|
|
503
|
-
---
|
|
504
|
-
|
|
505
|
-
## Expected CSV Format
|
|
506
|
-
|
|
507
|
-
```csv
|
|
508
|
-
ref,type,status,gtin,name,summary,category_refs,price_default_currency,price_default_value,price_special_currency,price_special_value,tax_country,tax_group,tax_tariff
|
|
509
|
-
G_PROD_WITH_NO_STANDARD,VARIANT,ACTIVE,MH01-XS-Orange,Chaz Kangeroo Hoodie-XS-Orange main,<p>test short description</p>,STANDARD_CATEGORY,USD,52.000000,USD,9.000000,AU,Tax Group,Tax Tariff
|
|
510
|
-
G_PROD_002,VARIANT,ACTIVE,MH02-M-Blue,Kangeroo Hoodie-M-Blue,<p>Blue variant</p>,STANDARD_CATEGORY|SEASONAL,USD,45.000000,USD,35.000000,AU,Tax Group,Tax Tariff
|
|
511
|
-
G_PROD_003,STANDARD,ACTIVE,MH03-BASE,Base Hoodie Product,<p>Base product</p>,STANDARD_CATEGORY,USD,50.000000,,,AU,Tax Group,Tax Tariff
|
|
512
|
-
```
|
|
513
|
-
|
|
514
|
-
---
|
|
515
|
-
|
|
516
|
-
## Event Sending Configuration
|
|
517
|
-
|
|
518
|
-
**Simple Configuration:** One variable controls everything
|
|
519
|
-
|
|
520
|
-
```json
|
|
521
|
-
{
|
|
522
|
-
"eventConcurrency": 1 // 1 = sequential, 3-10 = parallel
|
|
523
|
-
}
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
**How it works:**
|
|
527
|
-
|
|
528
|
-
- `eventConcurrency: 1` → Sequential (sends events one at a time, safe default)
|
|
529
|
-
- `eventConcurrency: 3` → Parallel (sends 3 events concurrently)
|
|
530
|
-
- `eventConcurrency: 5` → Parallel (sends 5 events concurrently)
|
|
531
|
-
- `eventConcurrency: 10` → Parallel (sends 10 events concurrently)
|
|
532
|
-
|
|
533
|
-
**Configuration Guidelines:**
|
|
534
|
-
|
|
535
|
-
- **Conservative:** `1` → Sequential (safe, predictable, ~1 req/sec)
|
|
536
|
-
- **Balanced:** `3-5` → Parallel (most common, ~3-5 req/sec)
|
|
537
|
-
- **Aggressive:** `10` → Parallel (high-volume, ~10 req/sec)
|
|
538
|
-
- **Note:** Fluent API supports concurrent requests - adjust based on your needs
|
|
539
|
-
|
|
540
|
-
**Why Single Variable?**
|
|
541
|
-
|
|
542
|
-
- **Simpler:** One variable instead of two (`mode` + `concurrency`)
|
|
543
|
-
- **Clearer:** `concurrency: 1` = sequential, `concurrency > 1` = parallel
|
|
544
|
-
- **Less config:** Fewer activation variables to manage
|
|
545
|
-
- **Flexible:** Easy to tune performance (just change the number)
|
|
546
|
-
|
|
547
|
-
---
|
|
548
|
-
|
|
549
|
-
## 🔧 Production Reference Implementation
|
|
550
|
-
|
|
551
|
-
### Processing Approach
|
|
552
|
-
|
|
553
|
-
This template uses **per-file sequential processing**:
|
|
554
|
-
- Discovers files from S3 incoming prefix
|
|
555
|
-
- Processes each file completely (download → parse → map → send events → archive) before moving to the next
|
|
556
|
-
- Files are processed sequentially to prevent timeout issues and ensure proper error handling
|
|
557
|
-
- Use `maxFilesPerRun` activation variable to limit files per execution (default: 10)
|
|
558
|
-
|
|
559
|
-
**Why per-file processing?**
|
|
560
|
-
- ✅ Better error isolation - one bad file doesn't affect others
|
|
561
|
-
- ✅ Progressive archiving - files move to processed/errors immediately
|
|
562
|
-
- ✅ Lower memory footprint - one file in memory at a time
|
|
563
|
-
- ✅ Simpler code - no complex batching logic needed
|
|
564
|
-
|
|
565
|
-
### Versori Workflows Structure
|
|
566
|
-
|
|
567
|
-
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
568
|
-
|
|
569
|
-
**Trigger Types:**
|
|
570
|
-
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
571
|
-
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
572
|
-
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
573
|
-
|
|
574
|
-
**Execution Steps (chained to triggers):**
|
|
575
|
-
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
576
|
-
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
577
|
-
|
|
578
|
-
### Recommended Project Structure
|
|
579
|
-
|
|
580
|
-
```
|
|
581
|
-
product-event-sync/
|
|
582
|
-
├── index.ts # Entry point - exports all workflows
|
|
583
|
-
└── src/
|
|
584
|
-
├── workflows/
|
|
585
|
-
│ ├── scheduled/
|
|
586
|
-
│ │ └── daily-product-sync.ts # Scheduled: Daily product sync
|
|
587
|
-
│ │
|
|
588
|
-
│ └── webhook/
|
|
589
|
-
│ ├── adhoc-product-sync.ts # Webhook: Manual trigger
|
|
590
|
-
│ └── job-status-check.ts # Webhook: Status query
|
|
591
|
-
│
|
|
592
|
-
├── services/
|
|
593
|
-
│ └── product-sync.service.ts # Shared orchestration logic (reusable)
|
|
594
|
-
│
|
|
595
|
-
└── types/
|
|
596
|
-
└── product.types.ts # Shared type definitions
|
|
597
|
-
```
|
|
598
|
-
|
|
599
|
-
**Benefits:**
|
|
600
|
-
- ✅ Clear trigger separation (`scheduled/` vs `webhook/`)
|
|
601
|
-
- ✅ Descriptive file names (easy to browse and understand)
|
|
602
|
-
- ✅ Scalable (add new workflows without cluttering)
|
|
603
|
-
- ✅ Reusable code in `services/` (DRY principle)
|
|
604
|
-
- ✅ Easy to modify individual workflows without affecting others
|
|
605
|
-
|
|
606
|
-
---
|
|
607
|
-
|
|
608
|
-
## Workflow Files
|
|
609
|
-
|
|
610
|
-
### 1. Scheduled Workflows (`src/workflows/scheduled/`)
|
|
611
|
-
|
|
612
|
-
All time-based triggers that run automatically on cron schedules.
|
|
613
|
-
|
|
614
|
-
#### `src/workflows/scheduled/daily-product-sync.ts`
|
|
615
|
-
|
|
616
|
-
**Purpose**: Automatic Daily product sync
|
|
617
|
-
**Trigger**: Cron schedule (`0 2 * * *`)
|
|
618
|
-
**Exposed as Endpoint**: ❌ NO - Runs automatically
|
|
619
|
-
|
|
620
|
-
```typescript
|
|
621
|
-
import { schedule, http } from '@versori/run';
|
|
622
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
623
|
-
import { executeProductIngestion } from '../../services/product-sync.service';
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Scheduled Workflow: Daily Product Sync
|
|
627
|
-
*
|
|
628
|
-
* Runs automatically daily at 2 AM UTC
|
|
629
|
-
* NOT exposed as HTTP endpoint - Versori executes on schedule
|
|
630
|
-
*
|
|
631
|
-
* Uses shared service: product-sync.service.ts
|
|
632
|
-
*/
|
|
633
|
-
export const dailyProductSync = schedule(
|
|
634
|
-
'product-sync-scheduled',
|
|
635
|
-
'0 2 * * *' // Daily at 2 AM UTC
|
|
636
|
-
).then(
|
|
637
|
-
http('run-product-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
638
|
-
const { log, openKv } = ctx;
|
|
639
|
-
const jobId = `product-sync-${Date.now()}`;
|
|
640
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
641
|
-
|
|
642
|
-
await tracker.createJob(jobId, { triggeredBy: 'schedule', stage: 'initialization' });
|
|
643
|
-
await tracker.updateJob(jobId, { status: 'processing' });
|
|
644
|
-
|
|
645
|
-
try {
|
|
646
|
-
// Reuse shared orchestration logic
|
|
647
|
-
const result = await executeProductIngestion(ctx, { jobId, triggeredBy: 'manual' }, tracker);
|
|
648
|
-
await tracker.markCompleted(jobId, result);
|
|
649
|
-
return { success: true, jobId, ...result };
|
|
650
|
-
} catch (e: any) {
|
|
651
|
-
await tracker.markFailed(jobId, e);
|
|
652
|
-
return { success: false, jobId, error: e?.message };
|
|
653
|
-
}
|
|
654
|
-
})
|
|
655
|
-
);
|
|
656
|
-
```
|
|
657
|
-
|
|
658
|
-
---
|
|
659
|
-
|
|
660
|
-
### 2. Webhook Workflows (`src/workflows/webhook/`)
|
|
661
|
-
|
|
662
|
-
All HTTP-based triggers that create webhook endpoints.
|
|
663
|
-
|
|
664
|
-
#### `src/workflows/webhook/adhoc-product-sync.ts`
|
|
665
|
-
|
|
666
|
-
**Purpose**: Manual product sync trigger (on-demand)
|
|
667
|
-
**Trigger**: HTTP POST
|
|
668
|
-
**Endpoint**: `POST https://{workspace}.versori.run/product-sync-adhoc`
|
|
669
|
-
**Use Cases**: Testing, priority processing, ad-hoc runs
|
|
670
|
-
|
|
671
|
-
```typescript
|
|
672
|
-
import { webhook, http } from '@versori/run';
|
|
673
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
674
|
-
import { executeProductIngestion } from '../../services/product-sync.service';
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Webhook: Manual Product Sync Trigger
|
|
678
|
-
*
|
|
679
|
-
* Endpoint: POST https://{workspace}.versori.run/product-sync-adhoc
|
|
680
|
-
* Request body (optional): { filePattern: "urgent_*.csv", maxFiles: 5 }
|
|
681
|
-
*
|
|
682
|
-
* Pattern: webhook().then(http()) - needs Fluent API access
|
|
683
|
-
* Uses shared service: product-sync.service.ts
|
|
684
|
-
*
|
|
685
|
-
* SECURITY: Authentication handled via connection parameter
|
|
686
|
-
* No manual API key validation needed - Versori manages this via connection auth
|
|
687
|
-
*/
|
|
688
|
-
export const adhocProductSync = webhook('product-sync-adhoc', {
|
|
689
|
-
response: { mode: 'sync' }, // ✅ Sync mode: response sent when handler returns
|
|
690
|
-
connection: 'product-sync-adhoc', // Versori validates API key
|
|
691
|
-
}).then(
|
|
692
|
-
http('run-product-sync-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
693
|
-
const { log, openKv, data } = ctx;
|
|
694
|
-
const jobId = `product-sync-adhoc-${Date.now()}`;
|
|
695
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
696
|
-
|
|
697
|
-
const filePattern = data?.filePattern as string;
|
|
698
|
-
const maxFiles = data?.maxFiles as number;
|
|
699
|
-
|
|
700
|
-
log.info('🚀 [WEBHOOK] Adhoc product sync triggered', {
|
|
701
|
-
jobId,
|
|
702
|
-
filePattern,
|
|
703
|
-
maxFiles,
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
// Create job entry FIRST (awaited to ensure job exists in KV)
|
|
707
|
-
await tracker.createJob(jobId, {
|
|
708
|
-
triggeredBy: 'manual',
|
|
709
|
-
stage: 'initialization',
|
|
710
|
-
status: 'queued',
|
|
711
|
-
options: { filePattern, maxFiles },
|
|
712
|
-
createdAt: new Date().toISOString(),
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
// ✅ Fire-and-forget: Start background processing WITHOUT await
|
|
716
|
-
// The promise continues execution after we return the response
|
|
717
|
-
executeProductIngestion(ctx, { jobId, triggeredBy: 'manual' }, tracker)
|
|
718
|
-
.then((result) => {
|
|
719
|
-
log.info('✅ [BACKGROUND] Product sync completed successfully', {
|
|
720
|
-
jobId,
|
|
721
|
-
filesProcessed: result.filesProcessed,
|
|
722
|
-
filesFailed: result.filesFailed,
|
|
723
|
-
recordsProcessed: result.recordsProcessed,
|
|
724
|
-
});
|
|
725
|
-
return tracker.markCompleted(jobId, result);
|
|
726
|
-
})
|
|
727
|
-
.catch((error: unknown) => {
|
|
728
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
729
|
-
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
730
|
-
|
|
731
|
-
log.error('❌ [BACKGROUND] Product sync failed', {
|
|
732
|
-
jobId,
|
|
733
|
-
error: errorMessage,
|
|
734
|
-
stack: errorStack,
|
|
735
|
-
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
return tracker.markFailed(jobId, errorMessage);
|
|
739
|
-
});
|
|
740
|
-
|
|
741
|
-
// Return immediately with jobId (response sent with this return value)
|
|
742
|
-
return {
|
|
743
|
-
success: true,
|
|
744
|
-
jobId,
|
|
745
|
-
message: 'Product sync started in background',
|
|
746
|
-
statusEndpoint: `https://{workspace}.versori.run/product-sync-job-status`,
|
|
747
|
-
note: 'Poll the status endpoint with jobId to check progress',
|
|
748
|
-
};
|
|
749
|
-
})
|
|
750
|
-
);
|
|
751
|
-
```
|
|
752
|
-
|
|
753
|
-
#### `src/workflows/webhook/job-status-check.ts`
|
|
754
|
-
|
|
755
|
-
**Purpose**: Query job status
|
|
756
|
-
**Trigger**: HTTP POST
|
|
757
|
-
**Endpoint**: `POST https://{workspace}.versori.run/product-sync-job-status`
|
|
758
|
-
**Request body**: `{ "jobId": "product-sync-1234567890" }`
|
|
759
|
-
|
|
760
|
-
```typescript
|
|
761
|
-
import { webhook, fn } from '@versori/run';
|
|
762
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* Webhook: Job Status Check
|
|
766
|
-
*
|
|
767
|
-
* Endpoint: POST https://{workspace}.versori.run/product-sync-job-status
|
|
768
|
-
* Request body: { "jobId": "product-sync-1234567890" }
|
|
769
|
-
*
|
|
770
|
-
* Pattern: webhook().then(fn()) - no external API needed, only KV storage
|
|
771
|
-
* Lightweight: Only queries KV store, no Fluent API calls
|
|
772
|
-
*
|
|
773
|
-
* SECURITY: Authentication handled via connection parameter
|
|
774
|
-
* No manual API key validation needed - Versori manages this via connection auth
|
|
775
|
-
*/
|
|
776
|
-
export const productSyncJobStatus = webhook('product-sync-job-status', {
|
|
777
|
-
response: { mode: 'sync' },
|
|
778
|
-
connection: 'product-sync-job-status',
|
|
779
|
-
}).then(
|
|
780
|
-
fn('status', async ctx => {
|
|
781
|
-
const { data, log, openKv } = ctx;
|
|
782
|
-
const jobId = data?.jobId as string;
|
|
783
|
-
|
|
784
|
-
if (!jobId) {
|
|
785
|
-
return { success: false, error: 'jobId required' };
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
789
|
-
const status = await tracker.getJob(jobId);
|
|
790
|
-
|
|
791
|
-
return status
|
|
792
|
-
? { success: true, jobId, ...status }
|
|
793
|
-
: { success: false, error: 'Job not found', jobId };
|
|
794
|
-
})
|
|
795
|
-
);
|
|
796
|
-
```
|
|
797
|
-
|
|
798
|
-
---
|
|
799
|
-
|
|
800
|
-
### 3. Entry Point (`index.ts`)
|
|
801
|
-
|
|
802
|
-
**Purpose**: Register all workflows with Versori platform (MemoryInterpreter pattern)
|
|
803
|
-
|
|
804
|
-
```typescript
|
|
805
|
-
/**
|
|
806
|
-
* ═══════════════════════════════════════════════════════════════
|
|
807
|
-
* 🚀 VERSORI PRODUCT SYNC - ENTRY POINT
|
|
808
|
-
* ═══════════════════════════════════════════════════════════════
|
|
809
|
-
*
|
|
810
|
-
* Entry Point - Registers all workflows with Versori platform
|
|
811
|
-
*
|
|
812
|
-
* PATTERN: MemoryInterpreter
|
|
813
|
-
* - Export all workflows from a single file
|
|
814
|
-
* - Versori automatically discovers and registers exported workflows
|
|
815
|
-
* - Clean separation: scheduled vs webhook triggers
|
|
816
|
-
*
|
|
817
|
-
* File Structure:
|
|
818
|
-
* - src/workflows/scheduled/ → Time-based triggers (cron)
|
|
819
|
-
* - src/workflows/webhook/ → HTTP-based triggers (webhooks)
|
|
820
|
-
* - src/services/ → Shared business logic
|
|
821
|
-
*
|
|
822
|
-
* ═══════════════════════════════════════════════════════════════
|
|
823
|
-
*/
|
|
824
|
-
|
|
825
|
-
// Import scheduled workflows
|
|
826
|
-
import { dailyProductSync } from './src/workflows/scheduled/daily-product-sync';
|
|
827
|
-
|
|
828
|
-
// Import webhook workflows
|
|
829
|
-
import { adhocProductSync } from './src/workflows/webhook/adhoc-product-sync';
|
|
830
|
-
import { productSyncJobStatus } from './src/workflows/webhook/job-status-check';
|
|
831
|
-
|
|
832
|
-
// ═══════════════════════════════════════════════════════════════
|
|
833
|
-
// Register all workflows with Versori
|
|
834
|
-
// ═══════════════════════════════════════════════════════════════
|
|
835
|
-
export {
|
|
836
|
-
// Scheduled (time-based triggers)
|
|
837
|
-
dailyProductSync,
|
|
838
|
-
|
|
839
|
-
// Webhooks (HTTP-based triggers)
|
|
840
|
-
adhocProductSync,
|
|
841
|
-
productSyncJobStatus,
|
|
842
|
-
};
|
|
843
|
-
```
|
|
844
|
-
|
|
845
|
-
**What Gets Exposed:**
|
|
846
|
-
- ✅ `adhocProductSync` → `https://{workspace}.versori.run/product-sync-adhoc`
|
|
847
|
-
- ✅ `productSyncJobStatus` → `https://{workspace}.versori.run/product-sync-job-status`
|
|
848
|
-
- ❌ `dailyProductSync` → NOT exposed (runs automatically on cron)
|
|
849
|
-
|
|
850
|
-
---
|
|
851
|
-
|
|
852
|
-
### Adding New Workflows
|
|
853
|
-
|
|
854
|
-
**To add a scheduled workflow:**
|
|
855
|
-
1. Create file in `src/workflows/scheduled/` with descriptive name (e.g., `hourly-delta-sync.ts`)
|
|
856
|
-
2. Export the workflow from the file
|
|
857
|
-
3. Import and re-export in `index.ts`
|
|
858
|
-
|
|
859
|
-
**To add a webhook workflow:**
|
|
860
|
-
1. Create file in `src/workflows/webhook/` with descriptive name (e.g., `error-notification.ts`)
|
|
861
|
-
2. Export the workflow from the file
|
|
862
|
-
3. Import and re-export in `index.ts`
|
|
863
|
-
|
|
864
|
-
**Example - Adding hourly delta sync:**
|
|
865
|
-
|
|
866
|
-
```typescript
|
|
867
|
-
// src/workflows/scheduled/hourly-delta-sync.ts
|
|
868
|
-
export const hourlyDeltaSync = schedule(
|
|
869
|
-
'product-delta-hourly',
|
|
870
|
-
'0 * * * *' // Every hour
|
|
871
|
-
).then(
|
|
872
|
-
http('run-delta-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
873
|
-
// Delta sync logic (skip BPP)
|
|
874
|
-
const result = await runIngestion(ctx, jobId, tracker, { bppEnabled: false });
|
|
875
|
-
return result;
|
|
876
|
-
})
|
|
877
|
-
);
|
|
878
|
-
|
|
879
|
-
// index.ts (add to imports and exports)
|
|
880
|
-
import { hourlyDeltaSync } from './src/workflows/scheduled/hourly-delta-sync';
|
|
881
|
-
export { daily_product_sync, hourlyDeltaSync, ... };
|
|
882
|
-
```
|
|
883
|
-
|
|
884
|
-
---
|
|
885
|
-
## 3. Type Definitions (src/types/product-ingestion.types.ts)
|
|
886
|
-
|
|
887
|
-
```typescript
|
|
888
|
-
/**
|
|
889
|
-
* Type Definitions for Product Ingestion
|
|
890
|
-
*
|
|
891
|
-
* Centralized type definitions for product ingestion workflow
|
|
892
|
-
*/
|
|
893
|
-
|
|
894
|
-
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* Product interface - represents transformed product data
|
|
898
|
-
*/
|
|
899
|
-
export interface Product {
|
|
900
|
-
ref: string;
|
|
901
|
-
type?: string;
|
|
902
|
-
status?: string;
|
|
903
|
-
name: string;
|
|
904
|
-
summary?: string;
|
|
905
|
-
gtin?: string;
|
|
906
|
-
catalogue?: { ref?: string };
|
|
907
|
-
attributes?: Record<string, unknown>;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/**
|
|
911
|
-
* Event configuration
|
|
912
|
-
*/
|
|
913
|
-
export interface EventConfig {
|
|
914
|
-
eventName: string;
|
|
915
|
-
catalogueRef: string;
|
|
916
|
-
catalogueType: string;
|
|
917
|
-
eventMode: 'async' | 'sync';
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
/**
|
|
921
|
-
* Event result - tracks success/failure counts
|
|
922
|
-
*/
|
|
923
|
-
export interface EventResult {
|
|
924
|
-
eventsSent: number;
|
|
925
|
-
eventsFailed: number;
|
|
926
|
-
errors: Array<{ productRef: string; error: string }>;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* Process file result
|
|
931
|
-
*/
|
|
932
|
-
export interface ProcessFileResult {
|
|
933
|
-
success: boolean;
|
|
934
|
-
products: Product[];
|
|
935
|
-
error?: string;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* Versori Context Interface
|
|
940
|
-
* Represents the Versori runtime context passed to workflow functions
|
|
941
|
-
*/
|
|
942
|
-
export interface VersoriContext {
|
|
943
|
-
log: {
|
|
944
|
-
info: (message: string, data?: Record<string, unknown>) => void;
|
|
945
|
-
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
946
|
-
error: (message: string, data?: Record<string, unknown>) => void;
|
|
947
|
-
debug?: (message: string, data?: Record<string, unknown>) => void;
|
|
948
|
-
};
|
|
949
|
-
openKv: (namespace: string) => {
|
|
950
|
-
get: (key: string) => Promise<unknown>;
|
|
951
|
-
set: (key: string, value: unknown) => Promise<void>;
|
|
952
|
-
delete: (key: string) => Promise<void>;
|
|
953
|
-
};
|
|
954
|
-
activation: {
|
|
955
|
-
getVariable: (name: string) => string | undefined;
|
|
956
|
-
connections?: Record<string, unknown>;
|
|
957
|
-
};
|
|
958
|
-
connections?: Record<string, unknown>;
|
|
959
|
-
data?: unknown;
|
|
960
|
-
fetch?: typeof fetch;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
/**
|
|
964
|
-
* Parameters for ingestion workflow
|
|
965
|
-
*/
|
|
966
|
-
export interface ProductIngestionParams {
|
|
967
|
-
jobId: string;
|
|
968
|
-
triggeredBy: 'schedule' | 'webhook';
|
|
969
|
-
filePattern?: string;
|
|
970
|
-
maxFiles?: number;
|
|
971
|
-
forceReprocess?: boolean;
|
|
972
|
-
catalogueRef?: string;
|
|
973
|
-
priority?: 'normal' | 'high';
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* Result from ingestion workflow
|
|
978
|
-
*/
|
|
979
|
-
export interface ProductIngestionResult {
|
|
980
|
-
success: boolean;
|
|
981
|
-
jobId: string;
|
|
982
|
-
filesProcessed: number;
|
|
983
|
-
filesFailed: number;
|
|
984
|
-
filesSkipped?: number;
|
|
985
|
-
recordsProcessed: number;
|
|
986
|
-
eventsSent: number;
|
|
987
|
-
eventsFailed: number;
|
|
988
|
-
fileResults: FileProcessingResult[];
|
|
989
|
-
error?: string;
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
/**
|
|
993
|
-
* Per-file processing result
|
|
994
|
-
*/
|
|
995
|
-
export interface FileProcessingResult {
|
|
996
|
-
fileName: string;
|
|
997
|
-
success: boolean;
|
|
998
|
-
skipped?: boolean;
|
|
999
|
-
recordsProcessed: number;
|
|
1000
|
-
eventsSent: number;
|
|
1001
|
-
eventsFailed: number;
|
|
1002
|
-
duration: number;
|
|
1003
|
-
error?: string;
|
|
1004
|
-
}
|
|
1005
|
-
```
|
|
1006
|
-
|
|
1007
|
-
## 4. Service: Product File Processor (`src/services/product-file-processor.service.ts`)
|
|
1008
|
-
|
|
1009
|
-
```typescript
|
|
1010
|
-
/**
|
|
1011
|
-
* Product File Processor Service
|
|
1012
|
-
*
|
|
1013
|
-
* Downloads CSV files from S3, parses, and transforms with UniversalMapper.
|
|
1014
|
-
* CSV-specific: Returns array directly - no normalization needed.
|
|
1015
|
-
*/
|
|
1016
|
-
|
|
1017
|
-
import {
|
|
1018
|
-
S3DataSource,
|
|
1019
|
-
CSVParserService,
|
|
1020
|
-
UniversalMapper,
|
|
1021
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1022
|
-
import type { ProcessFileResult, Product } from '../types/product-ingestion.types';
|
|
1023
|
-
|
|
1024
|
-
/**
|
|
1025
|
-
* Service for processing CSV product files from S3
|
|
1026
|
-
*/
|
|
1027
|
-
export class ProductFileProcessorService {
|
|
1028
|
-
constructor(
|
|
1029
|
-
private s3: S3DataSource,
|
|
1030
|
-
private csvParser: CSVParserService,
|
|
1031
|
-
private mapper: UniversalMapper,
|
|
1032
|
-
private catalogueRef: string
|
|
1033
|
-
) {}
|
|
1034
|
-
|
|
1035
|
-
/**
|
|
1036
|
-
* Download CSV file from S3, parse, and transform with UniversalMapper
|
|
1037
|
-
*
|
|
1038
|
-
* ✅ PRODUCTION ENHANCEMENT: Memory cleanup pattern
|
|
1039
|
-
* - Explicit null assignments after each step
|
|
1040
|
-
* - Finally block guarantees cleanup
|
|
1041
|
-
* - Prevents OOM errors on large CSV files
|
|
1042
|
-
*/
|
|
1043
|
-
async downloadParseAndTransform(objectKey: string): Promise<ProcessFileResult> {
|
|
1044
|
-
// ✅ CRITICAL: Variables for cleanup tracking
|
|
1045
|
-
let csvContent: string | null = null;
|
|
1046
|
-
let parsed: unknown | null = null;
|
|
1047
|
-
|
|
1048
|
-
try {
|
|
1049
|
-
// STEP 1: Download CSV content from S3
|
|
1050
|
-
csvContent = (await this.s3.downloadObject(objectKey, {
|
|
1051
|
-
encoding: 'utf8',
|
|
1052
|
-
})) as string;
|
|
1053
|
-
|
|
1054
|
-
// STEP 2: Parse CSV with type safety
|
|
1055
|
-
try {
|
|
1056
|
-
parsed = await this.csvParser.parse(csvContent);
|
|
1057
|
-
} catch (parseError: unknown) {
|
|
1058
|
-
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
|
1059
|
-
return {
|
|
1060
|
-
success: false,
|
|
1061
|
-
products: [],
|
|
1062
|
-
error: `CSV parse error: ${errorMessage}`,
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
// ✅ Clear CSV content from memory - no longer needed after parsing
|
|
1067
|
-
csvContent = null;
|
|
1068
|
-
|
|
1069
|
-
// STEP 3: CSV-SPECIFIC - Returns array directly, no normalization needed
|
|
1070
|
-
const rawProducts = Array.isArray(parsed) ? parsed : [];
|
|
1071
|
-
|
|
1072
|
-
if (rawProducts.length === 0) {
|
|
1073
|
-
return {
|
|
1074
|
-
success: false,
|
|
1075
|
-
products: [],
|
|
1076
|
-
error: 'No products found in CSV',
|
|
1077
|
-
};
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
// ✅ Clear parsed data from memory - only need rawProducts now
|
|
1081
|
-
parsed = null;
|
|
1082
|
-
|
|
1083
|
-
// STEP 4: Transform with UniversalMapper
|
|
1084
|
-
// ✅ S3 CSV: Merge context using spread pattern (same as SFTP CSV)
|
|
1085
|
-
const sourceDataWithContext = rawProducts.map(item => ({
|
|
1086
|
-
...item,
|
|
1087
|
-
$context: { catalogueRef: this.catalogueRef },
|
|
1088
|
-
}));
|
|
1089
|
-
|
|
1090
|
-
const mappingResult = await this.mapper.map(sourceDataWithContext);
|
|
1091
|
-
|
|
1092
|
-
if (!mappingResult.success) {
|
|
1093
|
-
return {
|
|
1094
|
-
success: false,
|
|
1095
|
-
products: [],
|
|
1096
|
-
error: `Mapping validation failed: ${mappingResult.errors?.join(', ')}`,
|
|
1097
|
-
};
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
1101
|
-
this.log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
1102
|
-
skippedFields: mappingResult.skippedFields,
|
|
1103
|
-
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
1104
|
-
});
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
return {
|
|
1108
|
-
success: true,
|
|
1109
|
-
products: mappingResult.data as Product[],
|
|
1110
|
-
};
|
|
1111
|
-
} catch (error: unknown) {
|
|
1112
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1113
|
-
return {
|
|
1114
|
-
success: false,
|
|
1115
|
-
products: [],
|
|
1116
|
-
error: errorMessage,
|
|
1117
|
-
};
|
|
1118
|
-
} finally {
|
|
1119
|
-
// ✅ CRITICAL: Ensure all large objects are cleared even if error occurs
|
|
1120
|
-
csvContent = null;
|
|
1121
|
-
parsed = null;
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
```
|
|
1126
|
-
|
|
1127
|
-
---
|
|
1128
|
-
|
|
1129
|
-
## 5. Service: Event Sender (`src/services/event-sender.service.ts`)
|
|
1130
|
-
|
|
1131
|
-
```typescript
|
|
1132
|
-
/**
|
|
1133
|
-
* Event Sender Service
|
|
1134
|
-
*
|
|
1135
|
-
* Sends product events to Fluent Commerce Event API with per-record error handling.
|
|
1136
|
-
* Continues processing on individual failures (Event API best practice).
|
|
1137
|
-
* Supports configurable concurrency (sequential or parallel).
|
|
1138
|
-
*/
|
|
1139
|
-
|
|
1140
|
-
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1141
|
-
import type { EventResult, EventConfig, Product } from '../types/product-ingestion.types';
|
|
1142
|
-
|
|
1143
|
-
/**
|
|
1144
|
-
* Service for sending events to Fluent Commerce Event API
|
|
1145
|
-
*
|
|
1146
|
-
* ✅ PRODUCTION ENHANCEMENT: Optional logger for detailed progress tracking
|
|
1147
|
-
*/
|
|
1148
|
-
export class EventSenderService {
|
|
1149
|
-
constructor(
|
|
1150
|
-
private client: FluentClient,
|
|
1151
|
-
private log?: any // ✅ Optional logger for progress tracking
|
|
1152
|
-
) {}
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* Send events to Fluent Commerce Event API with configurable concurrency
|
|
1156
|
-
*
|
|
1157
|
-
* **Performance Characteristics:**
|
|
1158
|
-
* - `concurrency: 1` → Sequential processing (safe default, ~1 event/sec)
|
|
1159
|
-
* - `concurrency: 3-5` → Balanced throughput (~3-5 events/sec, good for most cases)
|
|
1160
|
-
* - `concurrency: 10` → High-volume processing (~10 events/sec, 100+ products)
|
|
1161
|
-
*
|
|
1162
|
-
* **Implementation Strategy:**
|
|
1163
|
-
* - Concurrency = 1: Optimized sequential loop (no Promise.allSettled overhead)
|
|
1164
|
-
* - Concurrency > 1: Chunked parallel processing with bounded concurrency
|
|
1165
|
-
* - Both modes: Per-record error tracking (failures don't block other events)
|
|
1166
|
-
*
|
|
1167
|
-
* @param products - Array of products to send as events
|
|
1168
|
-
* @param eventConfig - Event configuration (name, catalogue ref, mode)
|
|
1169
|
-
* @param concurrency - Number of concurrent event requests (default: 1, min: 1)
|
|
1170
|
-
* @returns EventResult with counts (eventsSent/eventsFailed) and error details
|
|
1171
|
-
*/
|
|
1172
|
-
async sendEvents(
|
|
1173
|
-
products: Product[],
|
|
1174
|
-
eventConfig: EventConfig,
|
|
1175
|
-
concurrency: number = 1
|
|
1176
|
-
): Promise<EventResult> {
|
|
1177
|
-
// Validate concurrency (guard against invalid values)
|
|
1178
|
-
const safeConc = Math.max(1, Math.floor(concurrency));
|
|
1179
|
-
|
|
1180
|
-
// Result accumulators
|
|
1181
|
-
let eventsSent = 0;
|
|
1182
|
-
let eventsFailed = 0;
|
|
1183
|
-
const errors: Array<{ productRef: string; error: string }> = [];
|
|
1184
|
-
|
|
1185
|
-
// ✅ PRODUCTION ENHANCEMENT: Log event sending start
|
|
1186
|
-
if (this.log) {
|
|
1187
|
-
this.log.info('📤 Starting event sending', {
|
|
1188
|
-
totalProducts: products.length,
|
|
1189
|
-
concurrency: safeConc,
|
|
1190
|
-
processingMode: safeConc === 1 ? 'sequential (one at a time)' : `parallel (${safeConc} concurrently)`,
|
|
1191
|
-
eventName: eventConfig.eventName,
|
|
1192
|
-
catalogueRef: eventConfig.catalogueRef
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
// Helper: Build event payload (DRY - reused in both modes)
|
|
1197
|
-
const buildPayload = (product: Product) => ({
|
|
1198
|
-
name: eventConfig.eventName,
|
|
1199
|
-
entityRef: eventConfig.catalogueRef,
|
|
1200
|
-
entityType: 'PRODUCT_CATALOGUE' as const,
|
|
1201
|
-
entitySubtype: eventConfig.catalogueType,
|
|
1202
|
-
rootEntityRef: eventConfig.catalogueRef,
|
|
1203
|
-
rootEntityType: 'PRODUCT_CATALOGUE' as const,
|
|
1204
|
-
attributes: product,
|
|
1205
|
-
});
|
|
1206
|
-
|
|
1207
|
-
// ============================================================================
|
|
1208
|
-
// SEQUENTIAL MODE (concurrency === 1)
|
|
1209
|
-
// ============================================================================
|
|
1210
|
-
if (safeConc === 1) {
|
|
1211
|
-
for (let i = 0; i < products.length; i++) {
|
|
1212
|
-
const product = products[i];
|
|
1213
|
-
|
|
1214
|
-
// ✅ PRODUCTION ENHANCEMENT: Log progress every 10 products
|
|
1215
|
-
if (this.log && i % 10 === 0) {
|
|
1216
|
-
this.log.info(`📤 Sending product ${i + 1}/${products.length}`, {
|
|
1217
|
-
productRef: product.ref,
|
|
1218
|
-
progress: `${((i / products.length) * 100).toFixed(1)}%`
|
|
1219
|
-
});
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
try {
|
|
1223
|
-
await this.client.sendEvent(buildPayload(product), eventConfig.eventMode);
|
|
1224
|
-
eventsSent++;
|
|
1225
|
-
} catch (err: unknown) {
|
|
1226
|
-
eventsFailed++;
|
|
1227
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1228
|
-
errors.push({ productRef: product?.ref || 'unknown', error: errorMsg });
|
|
1229
|
-
// Continue processing (failure doesn't block other products)
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
// ✅ PRODUCTION ENHANCEMENT: Log completion
|
|
1234
|
-
if (this.log) {
|
|
1235
|
-
this.log.info('✅ Sequential event sending completed', {
|
|
1236
|
-
totalProducts: products.length,
|
|
1237
|
-
eventsSent,
|
|
1238
|
-
eventsFailed
|
|
1239
|
-
});
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
return { eventsSent, eventsFailed, errors };
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
// ============================================================================
|
|
1246
|
-
// PARALLEL MODE (concurrency > 1)
|
|
1247
|
-
// ============================================================================
|
|
1248
|
-
const totalChunks = Math.ceil(products.length / safeConc);
|
|
1249
|
-
|
|
1250
|
-
for (let i = 0; i < products.length; i += safeConc) {
|
|
1251
|
-
const chunk = products.slice(i, i + safeConc);
|
|
1252
|
-
const chunkNumber = Math.floor(i / safeConc) + 1;
|
|
1253
|
-
|
|
1254
|
-
// ✅ PRODUCTION ENHANCEMENT: Log chunk progress
|
|
1255
|
-
if (this.log) {
|
|
1256
|
-
this.log.info(`📦 Processing chunk ${chunkNumber}/${totalChunks}`, {
|
|
1257
|
-
chunkNumber,
|
|
1258
|
-
totalChunks,
|
|
1259
|
-
productsInChunk: chunk.length,
|
|
1260
|
-
productRange: `${i + 1}-${i + chunk.length}`,
|
|
1261
|
-
totalProducts: products.length,
|
|
1262
|
-
progress: `${((i / products.length) * 100).toFixed(1)}%`
|
|
1263
|
-
});
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
// Fire all requests in chunk concurrently
|
|
1267
|
-
const results = await Promise.allSettled(
|
|
1268
|
-
chunk.map(product =>
|
|
1269
|
-
this.client
|
|
1270
|
-
.sendEvent(buildPayload(product), eventConfig.eventMode)
|
|
1271
|
-
.then(() => ({ success: true as const, product }))
|
|
1272
|
-
.catch(error => ({ success: false as const, product, error }))
|
|
1273
|
-
)
|
|
1274
|
-
);
|
|
1275
|
-
|
|
1276
|
-
// Aggregate chunk results into totals
|
|
1277
|
-
let chunkSuccess = 0;
|
|
1278
|
-
let chunkFailed = 0;
|
|
1279
|
-
|
|
1280
|
-
for (const result of results) {
|
|
1281
|
-
if (result.status === 'fulfilled' && result.value.success) {
|
|
1282
|
-
eventsSent++;
|
|
1283
|
-
chunkSuccess++;
|
|
1284
|
-
} else {
|
|
1285
|
-
eventsFailed++;
|
|
1286
|
-
chunkFailed++;
|
|
1287
|
-
const error = result.status === 'fulfilled' ? result.value.error : result.reason;
|
|
1288
|
-
const product = result.status === 'fulfilled' ? result.value.product : null;
|
|
1289
|
-
errors.push({
|
|
1290
|
-
productRef: product?.ref || 'unknown',
|
|
1291
|
-
error: error?.message || String(error) || 'unknown error',
|
|
1292
|
-
});
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
// ✅ PRODUCTION ENHANCEMENT: Log chunk completion
|
|
1297
|
-
if (this.log) {
|
|
1298
|
-
this.log.info(`✅ Chunk ${chunkNumber}/${totalChunks} completed`, {
|
|
1299
|
-
chunkNumber,
|
|
1300
|
-
totalChunks,
|
|
1301
|
-
chunkSuccess,
|
|
1302
|
-
chunkFailed,
|
|
1303
|
-
totalSentSoFar: eventsSent,
|
|
1304
|
-
totalFailedSoFar: eventsFailed,
|
|
1305
|
-
progress: `${(((i + chunk.length) / products.length) * 100).toFixed(1)}%`
|
|
1306
|
-
});
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
// ✅ PRODUCTION ENHANCEMENT: Log parallel completion
|
|
1311
|
-
if (this.log) {
|
|
1312
|
-
this.log.info('✅ Parallel event sending completed', {
|
|
1313
|
-
totalProducts: products.length,
|
|
1314
|
-
totalChunks,
|
|
1315
|
-
concurrency: safeConc,
|
|
1316
|
-
eventsSent,
|
|
1317
|
-
eventsFailed
|
|
1318
|
-
});
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
return { eventsSent, eventsFailed, errors };
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
```
|
|
1325
|
-
|
|
1326
|
-
---
|
|
1327
|
-
|
|
1328
|
-
## 6. Service: Event Logger (`src/services/event-logger.service.ts`)
|
|
1329
|
-
|
|
1330
|
-
```typescript
|
|
1331
|
-
/**
|
|
1332
|
-
* Event Logger Service
|
|
1333
|
-
*
|
|
1334
|
-
* Writes event processing logs to S3 for audit/debugging.
|
|
1335
|
-
* Optional - can be used to create rejection reports.
|
|
1336
|
-
*/
|
|
1337
|
-
|
|
1338
|
-
import { Buffer } from 'node:buffer';
|
|
1339
|
-
import { S3DataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
1340
|
-
|
|
1341
|
-
/**
|
|
1342
|
-
* Service for writing event logs to S3
|
|
1343
|
-
*/
|
|
1344
|
-
export class EventLoggerService {
|
|
1345
|
-
constructor(private s3: S3DataSource) {}
|
|
1346
|
-
|
|
1347
|
-
/**
|
|
1348
|
-
* Write event log to S3
|
|
1349
|
-
*
|
|
1350
|
-
* @param objectKey - S3 object key for log file
|
|
1351
|
-
* @param logData - Log data to write (JSON format)
|
|
1352
|
-
*/
|
|
1353
|
-
async writeEventLog(objectKey: string, logData: unknown): Promise<void> {
|
|
1354
|
-
try {
|
|
1355
|
-
const logContent = JSON.stringify(logData, null, 2);
|
|
1356
|
-
await this.s3.uploadFile(objectKey, logContent);
|
|
1357
|
-
} catch (error: unknown) {
|
|
1358
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1359
|
-
throw new Error(`Failed to write event log: ${errorMessage}`);
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
```
|
|
1364
|
-
|
|
1365
|
-
## 7. Main Orchestration Service (src/services/product-ingestion.service.ts)
|
|
1366
|
-
|
|
1367
|
-
```typescript
|
|
1368
|
-
/**
|
|
1369
|
-
* ═══════════════════════════════════════════════════════════════
|
|
1370
|
-
* 📦 MAIN INGESTION ORCHESTRATION SERVICE
|
|
1371
|
-
* ═══════════════════════════════════════════════════════════════
|
|
1372
|
-
*
|
|
1373
|
-
* This is the heart of the ingestion workflow. It coordinates all steps:
|
|
1374
|
-
* 1. Initialize clients and services
|
|
1375
|
-
* 2. Discover files on S3
|
|
1376
|
-
* 3. Download and parse CSV files
|
|
1377
|
-
* 4. Transform data with UniversalMapper
|
|
1378
|
-
* 5. Send events to Fluent Commerce
|
|
1379
|
-
* 6. Archive processed files
|
|
1380
|
-
* 7. Track file processing state (S3 uses moveObject for deduplication)
|
|
1381
|
-
* 8. Track job progress with JobTracker
|
|
1382
|
-
*
|
|
1383
|
-
* KEY DIFFERENCES FOR S3 CSV:
|
|
1384
|
-
* - S3DataSource instead of SftpDataSource
|
|
1385
|
-
* - CSVParserService instead of XMLParserService
|
|
1386
|
-
* - No VersoriFileTracker (S3 uses moveObject for deduplication)
|
|
1387
|
-
* - S3 listObjects instead of SFTP listFiles
|
|
1388
|
-
* - S3 moveObject instead of SFTP moveFile
|
|
1389
|
-
* - CSV returns array directly (no normalization needed)
|
|
1390
|
-
* - CSV spread pattern for context injection
|
|
1391
|
-
*
|
|
1392
|
-
* ═══════════════════════════════════════════════════════════════
|
|
1393
|
-
*/
|
|
1394
|
-
|
|
1395
|
-
import { Buffer } from 'node:buffer';
|
|
1396
|
-
import {
|
|
1397
|
-
createClient,
|
|
1398
|
-
S3DataSource,
|
|
1399
|
-
CSVParserService,
|
|
1400
|
-
UniversalMapper,
|
|
1401
|
-
JobTracker,
|
|
1402
|
-
type FluentClient,
|
|
1403
|
-
type JobStatus,
|
|
1404
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1405
|
-
import type {
|
|
1406
|
-
ProductIngestionParams,
|
|
1407
|
-
ProductIngestionResult,
|
|
1408
|
-
FileProcessingResult,
|
|
1409
|
-
EventConfig,
|
|
1410
|
-
} from '../types/product-ingestion.types';
|
|
1411
|
-
import { ProductFileProcessorService } from './product-file-processor.service';
|
|
1412
|
-
import { EventSenderService } from './event-sender.service';
|
|
1413
|
-
import { EventLoggerService } from './event-logger.service';
|
|
1414
|
-
import { timestampedName } from '../utils/s3-path.utils';
|
|
1415
|
-
|
|
1416
|
-
import mappingConfig from '../../config/products.import.csv.json' with { type: 'json' };
|
|
1417
|
-
|
|
1418
|
-
// Custom resolvers for CSV mapping (prices, categories, taxType)
|
|
1419
|
-
const customResolvers = {
|
|
1420
|
-
'custom.splitByPipe': (value: string) => {
|
|
1421
|
-
if (!value || value.trim() === '') return [];
|
|
1422
|
-
return value
|
|
1423
|
-
.split('|')
|
|
1424
|
-
.map(v => v.trim())
|
|
1425
|
-
.filter(v => v !== '');
|
|
1426
|
-
},
|
|
1427
|
-
'custom.buildPriceArray': (value: any, sourceData: any) => {
|
|
1428
|
-
const prices = [];
|
|
1429
|
-
if (sourceData.price_default_value) {
|
|
1430
|
-
prices.push({
|
|
1431
|
-
type: 'DEFAULT',
|
|
1432
|
-
currency: sourceData.price_default_currency || 'USD',
|
|
1433
|
-
value: sourceData.price_default_value,
|
|
1434
|
-
});
|
|
1435
|
-
}
|
|
1436
|
-
if (sourceData.price_special_value) {
|
|
1437
|
-
prices.push({
|
|
1438
|
-
type: 'SPECIAL',
|
|
1439
|
-
currency: sourceData.price_special_currency || 'USD',
|
|
1440
|
-
value: sourceData.price_special_value,
|
|
1441
|
-
});
|
|
1442
|
-
}
|
|
1443
|
-
return prices;
|
|
1444
|
-
},
|
|
1445
|
-
'custom.buildTaxType': (value: any, sourceData: any) => {
|
|
1446
|
-
if (!sourceData.tax_country) return undefined;
|
|
1447
|
-
return {
|
|
1448
|
-
country: sourceData.tax_country,
|
|
1449
|
-
group: sourceData.tax_group,
|
|
1450
|
-
tariff: sourceData.tax_tariff,
|
|
1451
|
-
};
|
|
1452
|
-
},
|
|
1453
|
-
};
|
|
1454
|
-
|
|
1455
|
-
/**
|
|
1456
|
-
* Query job status from KV store
|
|
1457
|
-
*/
|
|
1458
|
-
export async function getJobStatus(
|
|
1459
|
-
kv: ReturnType<VersoriContext['openKv']>,
|
|
1460
|
-
jobId: string,
|
|
1461
|
-
log: VersoriContext['log']
|
|
1462
|
-
): Promise<JobStatus | undefined> {
|
|
1463
|
-
try {
|
|
1464
|
-
const tracker = new JobTracker(kv, log);
|
|
1465
|
-
return await tracker.getJob(jobId);
|
|
1466
|
-
} catch (error: unknown) {
|
|
1467
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1468
|
-
log.error('Failed to get job status', {
|
|
1469
|
-
jobId,
|
|
1470
|
-
message: errorMessage,
|
|
1471
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1472
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1473
|
-
});
|
|
1474
|
-
return undefined;
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
/**
|
|
1479
|
-
* MAIN ORCHESTRATION FUNCTION
|
|
1480
|
-
*
|
|
1481
|
-
* This function implements the complete ingestion workflow in 8 steps.
|
|
1482
|
-
*/
|
|
1483
|
-
export async function executeProductIngestion(
|
|
1484
|
-
ctx: VersoriContext,
|
|
1485
|
-
params: ProductIngestionParams
|
|
1486
|
-
): Promise<ProductIngestionResult> {
|
|
1487
|
-
|
|
1488
|
-
const { log, openKv, activation } = ctx;
|
|
1489
|
-
const { jobId, triggeredBy, filePattern, maxFiles, forceReprocess } = params;
|
|
1490
|
-
|
|
1491
|
-
const kv = openKv(':project:');
|
|
1492
|
-
const tracker = new JobTracker(kv, log);
|
|
1493
|
-
|
|
1494
|
-
const startTime = Date.now();
|
|
1495
|
-
const fileResults: FileProcessingResult[] = [];
|
|
1496
|
-
|
|
1497
|
-
// ⚠️ CRITICAL: Declare S3 outside try block for disposal pattern
|
|
1498
|
-
let s3: S3DataSource | undefined;
|
|
1499
|
-
|
|
1500
|
-
try {
|
|
1501
|
-
// ═══════════════════════════════════════════════════════════
|
|
1502
|
-
// 🚀 STEP 1/8: Initialize Job Tracking
|
|
1503
|
-
// ═══════════════════════════════════════════════════════════
|
|
1504
|
-
log.info('🚀 [STEP 1/8] Initializing job tracking', { jobId });
|
|
1505
|
-
|
|
1506
|
-
await tracker.createJob(jobId, {
|
|
1507
|
-
triggeredBy,
|
|
1508
|
-
filePattern: filePattern || 'default',
|
|
1509
|
-
maxFiles: maxFiles || 'unlimited',
|
|
1510
|
-
forceReprocess: !!forceReprocess,
|
|
1511
|
-
startTime,
|
|
1512
|
-
});
|
|
1513
|
-
|
|
1514
|
-
// ═══════════════════════════════════════════════════════════
|
|
1515
|
-
// 🔧 STEP 2/8: Initialize Fluent Client & S3 Connection
|
|
1516
|
-
// ═══════════════════════════════════════════════════════════
|
|
1517
|
-
log.info('🔧 [STEP 2/8] Initializing Fluent Commerce client and S3', { jobId });
|
|
1518
|
-
|
|
1519
|
-
const client = await createClient(ctx);
|
|
1520
|
-
|
|
1521
|
-
if (!client) {
|
|
1522
|
-
const error = 'Failed to create Fluent Commerce client';
|
|
1523
|
-
log.error(`❌ ${error}`);
|
|
1524
|
-
throw new Error(error);
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
log.info('✅ [Client] Fluent Commerce client created successfully');
|
|
1528
|
-
|
|
1529
|
-
// ✅ CRITICAL: Set retailerId for Event API calls
|
|
1530
|
-
const fluentRetailerId = activation.getVariable('fluentRetailerId');
|
|
1531
|
-
if (!fluentRetailerId) {
|
|
1532
|
-
const error = 'fluentRetailerId is required for Event API calls';
|
|
1533
|
-
log.error(`❌ ${error}`, {
|
|
1534
|
-
recommendation: 'Set fluentRetailerId in activation variables',
|
|
1535
|
-
variable: 'fluentRetailerId',
|
|
1536
|
-
});
|
|
1537
|
-
throw new Error(error);
|
|
1538
|
-
}
|
|
1539
|
-
client.setRetailerId(fluentRetailerId);
|
|
1540
|
-
log.info('✅ [Client] RetailerId set for Event API', { retailerId: fluentRetailerId });
|
|
1541
|
-
|
|
1542
|
-
// Get S3 configuration from activation variables
|
|
1543
|
-
const s3Config = {
|
|
1544
|
-
bucket: activation.getVariable('s3Bucket'),
|
|
1545
|
-
region: activation.getVariable('s3Region') || 'us-east-1',
|
|
1546
|
-
accessKeyId: activation.getVariable('s3AccessKeyId'),
|
|
1547
|
-
secretAccessKey: activation.getVariable('s3SecretAccessKey'),
|
|
1548
|
-
incomingPrefix: activation.getVariable('s3IncomingPrefix') || 'products/incoming/',
|
|
1549
|
-
processedPrefix: activation.getVariable('s3ProcessedPrefix') || 'products/processed/',
|
|
1550
|
-
errorPrefix: activation.getVariable('s3ErrorPrefix') || 'products/errors/',
|
|
1551
|
-
logsPrefix: activation.getVariable('s3LogsPrefix') || 'products/logs/',
|
|
1552
|
-
filePattern: filePattern || activation.getVariable('filePattern') || 'products_*.csv'
|
|
1553
|
-
};
|
|
1554
|
-
|
|
1555
|
-
// Validate S3 config
|
|
1556
|
-
if (!s3Config.bucket) {
|
|
1557
|
-
const error = 'S3 configuration incomplete: missing bucket';
|
|
1558
|
-
log.error(`❌ ${error}`, {
|
|
1559
|
-
recommendation: 'Set s3Bucket in activation variables',
|
|
1560
|
-
variable: 's3Bucket',
|
|
1561
|
-
});
|
|
1562
|
-
throw new Error(error);
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
if (!s3Config.accessKeyId || !s3Config.secretAccessKey) {
|
|
1566
|
-
const error = 'S3 configuration incomplete: missing accessKeyId or secretAccessKey';
|
|
1567
|
-
log.error(`❌ ${error}`, {
|
|
1568
|
-
recommendation: 'Set s3AccessKeyId and s3SecretAccessKey in activation variables',
|
|
1569
|
-
variables: ['s3AccessKeyId', 's3SecretAccessKey'],
|
|
1570
|
-
});
|
|
1571
|
-
throw new Error(error);
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
// Initialize S3 data source
|
|
1575
|
-
s3 = new S3DataSource(
|
|
1576
|
-
{
|
|
1577
|
-
type: 'S3_CSV',
|
|
1578
|
-
connectionId: 's3-product-ingestion',
|
|
1579
|
-
name: 'Product Ingestion S3',
|
|
1580
|
-
s3Config: {
|
|
1581
|
-
bucket: s3Config.bucket,
|
|
1582
|
-
region: s3Config.region,
|
|
1583
|
-
accessKeyId: s3Config.accessKeyId,
|
|
1584
|
-
secretAccessKey: s3Config.secretAccessKey,
|
|
1585
|
-
},
|
|
1586
|
-
validateConnection: true, // ✅ Enable connection validation
|
|
1587
|
-
},
|
|
1588
|
-
log
|
|
1589
|
-
);
|
|
1590
|
-
|
|
1591
|
-
try {
|
|
1592
|
-
// Validate S3 connection
|
|
1593
|
-
log.info('🔍 [S3] Validating S3 connection...');
|
|
1594
|
-
await s3.validateConnection();
|
|
1595
|
-
log.info('✅ [S3] S3 connection validated successfully');
|
|
1596
|
-
|
|
1597
|
-
// ═══════════════════════════════════════════════════════════
|
|
1598
|
-
// 🔍 STEP 3/8: Discover Files on S3
|
|
1599
|
-
// ═══════════════════════════════════════════════════════════
|
|
1600
|
-
log.info('🔍 [STEP 3/8] Discovering files on S3', {
|
|
1601
|
-
jobId,
|
|
1602
|
-
prefix: s3Config.incomingPrefix,
|
|
1603
|
-
filePattern: s3Config.filePattern
|
|
1604
|
-
});
|
|
1605
|
-
|
|
1606
|
-
await tracker.updateJob(jobId, {
|
|
1607
|
-
status: 'processing',
|
|
1608
|
-
stage: 'file_discovery',
|
|
1609
|
-
message: 'Discovering files on S3'
|
|
1610
|
-
});
|
|
1611
|
-
|
|
1612
|
-
// List objects from S3
|
|
1613
|
-
const allFiles = await s3.listObjects({
|
|
1614
|
-
prefix: s3Config.incomingPrefix,
|
|
1615
|
-
pattern: s3Config.filePattern
|
|
1616
|
-
});
|
|
1617
|
-
|
|
1618
|
-
log.info(`✅ [Discovery] Found ${allFiles.length} files matching pattern`);
|
|
1619
|
-
|
|
1620
|
-
// Apply maxFiles limit if specified
|
|
1621
|
-
const filesToProcess = maxFiles ? allFiles.slice(0, maxFiles) : allFiles;
|
|
1622
|
-
|
|
1623
|
-
if (filesToProcess.length === 0) {
|
|
1624
|
-
log.info('⚠️ [Discovery] No files to process');
|
|
1625
|
-
|
|
1626
|
-
await tracker.markCompleted(jobId, {
|
|
1627
|
-
filesProcessed: 0,
|
|
1628
|
-
message: 'No files found to process',
|
|
1629
|
-
duration: Date.now() - startTime,
|
|
1630
|
-
});
|
|
1631
|
-
|
|
1632
|
-
return {
|
|
1633
|
-
success: true,
|
|
1634
|
-
jobId,
|
|
1635
|
-
filesProcessed: 0,
|
|
1636
|
-
filesFailed: 0,
|
|
1637
|
-
recordsProcessed: 0,
|
|
1638
|
-
eventsSent: 0,
|
|
1639
|
-
eventsFailed: 0,
|
|
1640
|
-
fileResults: [],
|
|
1641
|
-
duration: Date.now() - startTime,
|
|
1642
|
-
};
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
log.info(`📄 [Discovery] Processing ${filesToProcess.length} files`);
|
|
1646
|
-
|
|
1647
|
-
// Initialize services
|
|
1648
|
-
const csvParser = new CSVParserService();
|
|
1649
|
-
const mapper = new UniversalMapper(mappingConfig, { customResolvers });
|
|
1650
|
-
|
|
1651
|
-
// Get event configuration
|
|
1652
|
-
const eventConfig: EventConfig = {
|
|
1653
|
-
catalogueRef: params.catalogueRef || activation.getVariable('catalogueRef') || 'PC:MASTER:2',
|
|
1654
|
-
catalogueType: activation.getVariable('catalogueType') || 'MASTER',
|
|
1655
|
-
eventName: activation.getVariable('eventName') || 'UPSERT_PRODUCT',
|
|
1656
|
-
eventMode: (activation.getVariable('eventMode') || 'async') as 'async' | 'sync',
|
|
1657
|
-
};
|
|
1658
|
-
|
|
1659
|
-
// Get event sending configuration
|
|
1660
|
-
const eventConcurrency = Math.max(
|
|
1661
|
-
1,
|
|
1662
|
-
parseInt(activation.getVariable('eventConcurrency') || '1', 10)
|
|
1663
|
-
);
|
|
1664
|
-
|
|
1665
|
-
log.info(`Event concurrency: ${eventConcurrency}`, {
|
|
1666
|
-
mode: eventConcurrency === 1 ? 'sequential' : 'parallel',
|
|
1667
|
-
concurrentRequests: eventConcurrency === 1 ? 'N/A' : eventConcurrency,
|
|
1668
|
-
});
|
|
1669
|
-
|
|
1670
|
-
// Initialize class-based services
|
|
1671
|
-
const fileProcessor = new ProductFileProcessorService(
|
|
1672
|
-
s3,
|
|
1673
|
-
csvParser,
|
|
1674
|
-
mapper,
|
|
1675
|
-
eventConfig.catalogueRef
|
|
1676
|
-
);
|
|
1677
|
-
// ✅ PRODUCTION ENHANCEMENT: Pass log to EventSenderService for detailed progress tracking
|
|
1678
|
-
const eventSender = new EventSenderService(client, log);
|
|
1679
|
-
const eventLogger = new EventLoggerService(s3);
|
|
1680
|
-
|
|
1681
|
-
// ═══════════════════════════════════════════════════════════
|
|
1682
|
-
// STEP 4-7: Process Each File (Download → Parse → Transform → Send → Archive)
|
|
1683
|
-
// ═══════════════════════════════════════════════════════════
|
|
1684
|
-
|
|
1685
|
-
for (let fileIndex = 0; fileIndex < filesToProcess.length; fileIndex++) {
|
|
1686
|
-
const file = filesToProcess[fileIndex];
|
|
1687
|
-
const fileStartTime = Date.now();
|
|
1688
|
-
|
|
1689
|
-
// Use object key from S3 listing
|
|
1690
|
-
const objectKey = file.key || (file as any).name;
|
|
1691
|
-
const baseName = objectKey.split('/').pop() || objectKey;
|
|
1692
|
-
|
|
1693
|
-
// ═══════════════════════════════════════════════════════════
|
|
1694
|
-
log.info(`\n${'═'.repeat(60)}`);
|
|
1695
|
-
log.info(`📄 [FILE ${fileIndex + 1}/${filesToProcess.length}] Processing: ${baseName}`);
|
|
1696
|
-
log.info(`${'═'.repeat(60)}`);
|
|
1697
|
-
// ═══════════════════════════════════════════════════════════
|
|
1698
|
-
|
|
1699
|
-
try {
|
|
1700
|
-
await tracker.updateJob(jobId, {
|
|
1701
|
-
status: 'processing',
|
|
1702
|
-
stage: 'downloading',
|
|
1703
|
-
message: `Processing file ${fileIndex + 1} of ${filesToProcess.length}: ${baseName}`
|
|
1704
|
-
});
|
|
1705
|
-
|
|
1706
|
-
// ═══════════════════════════════════════════════════════════
|
|
1707
|
-
// 📥 STEP 4: Process File (Download + Parse + Map)
|
|
1708
|
-
// ═══════════════════════════════════════════════════════════
|
|
1709
|
-
log.info(`📥 [STEP 4/8] Processing file: ${baseName}`);
|
|
1710
|
-
|
|
1711
|
-
// Process file: download → parse → transform
|
|
1712
|
-
const fileResult = await fileProcessor.downloadParseAndTransform(objectKey);
|
|
1713
|
-
|
|
1714
|
-
if (!fileResult.success || fileResult.products.length === 0) {
|
|
1715
|
-
// Move failed file to errors and record result
|
|
1716
|
-
const archivedName = timestampedName(baseName);
|
|
1717
|
-
const errorKey = `${s3Config.errorPrefix}${archivedName}`;
|
|
1718
|
-
await s3.moveObject(objectKey, errorKey);
|
|
1719
|
-
|
|
1720
|
-
fileResults.push({
|
|
1721
|
-
fileName: baseName,
|
|
1722
|
-
success: false,
|
|
1723
|
-
recordsProcessed: fileResult.products.length,
|
|
1724
|
-
eventsSent: 0,
|
|
1725
|
-
eventsFailed: 0,
|
|
1726
|
-
duration: Date.now() - fileStartTime,
|
|
1727
|
-
error: fileResult.error || 'Processing failed'
|
|
1728
|
-
});
|
|
1729
|
-
|
|
1730
|
-
continue;
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
// ═══════════════════════════════════════════════════════════
|
|
1734
|
-
// 🚀 STEP 5: Send Events
|
|
1735
|
-
// ═══════════════════════════════════════════════════════════
|
|
1736
|
-
log.info(`🚀 [STEP 5/8] Sending events for ${baseName}`);
|
|
1737
|
-
|
|
1738
|
-
// Extract context for progress logging
|
|
1739
|
-
const sampleProductRefs = fileResult.products.slice(0, 5).map((p: any) => p.ref || p.skuRef);
|
|
1740
|
-
const eventType = eventConfig.eventName || 'UPSERT_PRODUCT';
|
|
1741
|
-
|
|
1742
|
-
log.info(`[EventSender] Sending events for file "${baseName}"`, {
|
|
1743
|
-
totalProducts: fileResult.products.length,
|
|
1744
|
-
eventType,
|
|
1745
|
-
concurrency: eventConcurrency === 1 ? 'sequential' : `parallel (${eventConcurrency})`,
|
|
1746
|
-
sampleProductRefs: sampleProductRefs.join(', '),
|
|
1747
|
-
eventMode: eventConfig.eventMode || 'async'
|
|
1748
|
-
});
|
|
1749
|
-
|
|
1750
|
-
const eventResult = await eventSender.sendEvents(
|
|
1751
|
-
fileResult.products,
|
|
1752
|
-
eventConfig,
|
|
1753
|
-
eventConcurrency
|
|
1754
|
-
);
|
|
1755
|
-
|
|
1756
|
-
const eventsSent = eventResult.eventsSent;
|
|
1757
|
-
const eventsFailed = eventResult.eventsFailed;
|
|
1758
|
-
|
|
1759
|
-
log.info(`✅ [Events] Sent ${eventsSent} events for ${baseName}`, {
|
|
1760
|
-
successful: eventsSent,
|
|
1761
|
-
failed: eventsFailed
|
|
1762
|
-
});
|
|
1763
|
-
|
|
1764
|
-
// Completion logging with summary
|
|
1765
|
-
log.info(`[EventSender] Event submission completed for file "${baseName}"`, {
|
|
1766
|
-
totalProducts: fileResult.products.length,
|
|
1767
|
-
eventsSent,
|
|
1768
|
-
eventsFailed,
|
|
1769
|
-
successRate: fileResult.products.length > 0 ? `${Math.round((eventsSent / fileResult.products.length) * 100)}%` : '0%',
|
|
1770
|
-
eventType,
|
|
1771
|
-
duration: Date.now() - fileStartTime,
|
|
1772
|
-
});
|
|
1773
|
-
|
|
1774
|
-
// ═══════════════════════════════════════════════════════════
|
|
1775
|
-
// 📦 STEP 6: Archive File
|
|
1776
|
-
// ═══════════════════════════════════════════════════════════
|
|
1777
|
-
log.info(`📦 [STEP 6/8] Archiving file: ${baseName}`);
|
|
1778
|
-
|
|
1779
|
-
await tracker.updateJob(jobId, {
|
|
1780
|
-
status: 'processing',
|
|
1781
|
-
stage: 'archiving',
|
|
1782
|
-
message: `Archiving ${baseName}`
|
|
1783
|
-
});
|
|
1784
|
-
|
|
1785
|
-
// Conditionally archive: errors → errorPrefix, else → processedPrefix (timestamped)
|
|
1786
|
-
// ✅ S3: Uses moveObject for deduplication (no VersoriFileTracker needed)
|
|
1787
|
-
const archivedName = timestampedName(baseName);
|
|
1788
|
-
const targetPrefix = eventsFailed > 0 ? s3Config.errorPrefix : s3Config.processedPrefix;
|
|
1789
|
-
const targetKey = `${targetPrefix}${archivedName}`;
|
|
1790
|
-
await s3.moveObject(objectKey, targetKey);
|
|
1791
|
-
|
|
1792
|
-
log.info(`✅ [Archive] File archived successfully: ${baseName}`, { targetKey });
|
|
1793
|
-
|
|
1794
|
-
// Optional: Write event log for audit/debugging
|
|
1795
|
-
if (eventsFailed > 0) {
|
|
1796
|
-
const logKey = `${s3Config.logsPrefix}${baseName.replace(/\.csv$/i, '')}-event-log.json`;
|
|
1797
|
-
await eventLogger.writeEventLog(logKey, {
|
|
1798
|
-
fileName: baseName,
|
|
1799
|
-
totalRecords: fileResult.products.length,
|
|
1800
|
-
eventsSent,
|
|
1801
|
-
eventsFailed,
|
|
1802
|
-
errors: eventResult.errors,
|
|
1803
|
-
processedAt: new Date().toISOString()
|
|
1804
|
-
});
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
// Store file result
|
|
1808
|
-
fileResults.push({
|
|
1809
|
-
fileName: baseName,
|
|
1810
|
-
success: true,
|
|
1811
|
-
recordsProcessed: fileResult.products.length,
|
|
1812
|
-
eventsSent,
|
|
1813
|
-
eventsFailed,
|
|
1814
|
-
duration: Date.now() - fileStartTime
|
|
1815
|
-
});
|
|
1816
|
-
|
|
1817
|
-
} catch (error: unknown) {
|
|
1818
|
-
// ❌ Enhanced error logging with recommendations
|
|
1819
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1820
|
-
const errorDetails = {
|
|
1821
|
-
message: errorMessage,
|
|
1822
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1823
|
-
fileName: (error as any)?.fileName,
|
|
1824
|
-
lineNumber: (error as any)?.lineNumber,
|
|
1825
|
-
originalError: (error as any)?.context?.originalError?.message,
|
|
1826
|
-
errorType: error instanceof Error ? error.name : 'Error',
|
|
1827
|
-
recommendation: 'Check file format, S3 permissions, and mapping configuration',
|
|
1828
|
-
};
|
|
1829
|
-
log.error(`❌ [Error] Failed to process file ${baseName}:`, errorDetails);
|
|
1830
|
-
|
|
1831
|
-
// Best-effort: move to error archive on unexpected failure
|
|
1832
|
-
try {
|
|
1833
|
-
const archivedName = timestampedName(baseName);
|
|
1834
|
-
const errorKey = `${s3Config.errorPrefix}${archivedName}`;
|
|
1835
|
-
await s3.moveObject(objectKey, errorKey);
|
|
1836
|
-
} catch (moveError: unknown) {
|
|
1837
|
-
const moveErrorMessage = moveError instanceof Error ? moveError.message : String(moveError);
|
|
1838
|
-
log.error('Could not archive failed file', {
|
|
1839
|
-
file: baseName,
|
|
1840
|
-
message: moveErrorMessage,
|
|
1841
|
-
stack: moveError instanceof Error ? moveError.stack : undefined,
|
|
1842
|
-
errorType: moveError instanceof Error ? moveError.constructor.name : 'Error'
|
|
1843
|
-
});
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
fileResults.push({
|
|
1847
|
-
fileName: baseName,
|
|
1848
|
-
success: false,
|
|
1849
|
-
recordsProcessed: 0,
|
|
1850
|
-
eventsSent: 0,
|
|
1851
|
-
eventsFailed: 0,
|
|
1852
|
-
duration: Date.now() - fileStartTime,
|
|
1853
|
-
error: errorMessage
|
|
1854
|
-
});
|
|
1855
|
-
}
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
// ═══════════════════════════════════════════════════════════
|
|
1859
|
-
// ✅ STEP 8: Complete Job & Calculate Totals
|
|
1860
|
-
// ═══════════════════════════════════════════════════════════
|
|
1861
|
-
log.info('✅ [STEP 8/8] Completing job and calculating totals', { jobId });
|
|
1862
|
-
|
|
1863
|
-
const filesProcessed = fileResults.filter(r => r.success).length;
|
|
1864
|
-
const filesFailed = fileResults.filter(r => !r.success).length;
|
|
1865
|
-
const totalRecordsProcessed = fileResults.reduce((sum, r) => sum + r.recordsProcessed, 0);
|
|
1866
|
-
const totalEventsSent = fileResults.reduce((sum, r) => sum + r.eventsSent, 0);
|
|
1867
|
-
const totalEventsFailed = fileResults.reduce((sum, r) => sum + r.eventsFailed, 0);
|
|
1868
|
-
const totalDuration = Date.now() - startTime;
|
|
1869
|
-
|
|
1870
|
-
await tracker.markCompleted(jobId, {
|
|
1871
|
-
filesProcessed,
|
|
1872
|
-
filesFailed,
|
|
1873
|
-
recordsProcessed: totalRecordsProcessed,
|
|
1874
|
-
eventsSent: totalEventsSent,
|
|
1875
|
-
eventsFailed: totalEventsFailed,
|
|
1876
|
-
duration: totalDuration
|
|
1877
|
-
});
|
|
1878
|
-
|
|
1879
|
-
// ═══════════════════════════════════════════════════════════
|
|
1880
|
-
log.info(`\n${'═'.repeat(60)}`);
|
|
1881
|
-
log.info('✅ [COMPLETE] Ingestion completed successfully');
|
|
1882
|
-
log.info(`${'═'.repeat(60)}`);
|
|
1883
|
-
// ═══════════════════════════════════════════════════════════
|
|
1884
|
-
|
|
1885
|
-
log.info('[Summary]', {
|
|
1886
|
-
filesProcessed,
|
|
1887
|
-
filesFailed,
|
|
1888
|
-
recordsProcessed: totalRecordsProcessed,
|
|
1889
|
-
eventsSent: totalEventsSent,
|
|
1890
|
-
eventsFailed: totalEventsFailed,
|
|
1891
|
-
duration: `${totalDuration}ms`,
|
|
1892
|
-
});
|
|
1893
|
-
|
|
1894
|
-
return {
|
|
1895
|
-
success: true,
|
|
1896
|
-
jobId,
|
|
1897
|
-
filesProcessed,
|
|
1898
|
-
filesFailed,
|
|
1899
|
-
recordsProcessed: totalRecordsProcessed,
|
|
1900
|
-
eventsSent: totalEventsSent,
|
|
1901
|
-
eventsFailed: totalEventsFailed,
|
|
1902
|
-
fileResults
|
|
1903
|
-
};
|
|
1904
|
-
|
|
1905
|
-
} finally {
|
|
1906
|
-
// ⚠️ CRITICAL: Always dispose S3 connection
|
|
1907
|
-
if (s3) {
|
|
1908
|
-
await s3.dispose();
|
|
1909
|
-
log.info('✅ [Cleanup] S3 connection disposed');
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
|
-
} catch (error: unknown) {
|
|
1914
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1915
|
-
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
1916
|
-
log.error('❌ [FATAL] Ingestion workflow failed', {
|
|
1917
|
-
jobId,
|
|
1918
|
-
error: errorMessage,
|
|
1919
|
-
stack: errorStack,
|
|
1920
|
-
recommendation: 'Check logs for detailed error information and verify all activation variables',
|
|
1921
|
-
});
|
|
1922
|
-
|
|
1923
|
-
// Try to mark job as failed, but don't let tracking errors mask workflow error
|
|
1924
|
-
try {
|
|
1925
|
-
await tracker.markFailed(jobId, error);
|
|
1926
|
-
} catch (trackingError: unknown) {
|
|
1927
|
-
const trackingErrorMessage =
|
|
1928
|
-
trackingError instanceof Error ? trackingError.message : String(trackingError);
|
|
1929
|
-
log.warn('Failed to mark job as failed in tracker', {
|
|
1930
|
-
jobId,
|
|
1931
|
-
trackingError: trackingErrorMessage,
|
|
1932
|
-
});
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
return {
|
|
1936
|
-
success: false,
|
|
1937
|
-
jobId,
|
|
1938
|
-
filesProcessed: fileResults.filter(r => r.success && !r.skipped).length,
|
|
1939
|
-
filesFailed: fileResults.filter(r => !r.success).length,
|
|
1940
|
-
filesSkipped: fileResults.filter(r => r.skipped).length,
|
|
1941
|
-
recordsProcessed: fileResults.reduce((sum, r) => sum + r.recordsProcessed, 0),
|
|
1942
|
-
eventsSent: fileResults.reduce((sum, r) => sum + r.eventsSent, 0),
|
|
1943
|
-
eventsFailed: fileResults.reduce((sum, r) => sum + r.eventsFailed, 0),
|
|
1944
|
-
fileResults,
|
|
1945
|
-
error: errorMessage,
|
|
1946
|
-
};
|
|
1947
|
-
} finally {
|
|
1948
|
-
// ⚠️ CRITICAL: Ensure S3 is disposed even if outer error occurs
|
|
1949
|
-
if (s3) {
|
|
1950
|
-
try {
|
|
1951
|
-
await s3.dispose();
|
|
1952
|
-
log.info('✅ [Cleanup] S3 connection disposed (outer finally)');
|
|
1953
|
-
} catch (disposeError: unknown) {
|
|
1954
|
-
const disposeErrorMessage =
|
|
1955
|
-
disposeError instanceof Error ? disposeError.message : String(disposeError);
|
|
1956
|
-
log.warn('⚠️ [Warning] Error disposing S3 in outer finally', { error: disposeErrorMessage });
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
```
|
|
1962
|
-
|
|
1963
|
-
---
|
|
1964
|
-
|
|
1965
|
-
## 8. Utility Functions (src/utils/)
|
|
1966
|
-
|
|
1967
|
-
### S3 Path Helpers (src/utils/s3-path.utils.ts)
|
|
1968
|
-
|
|
1969
|
-
```typescript
|
|
1970
|
-
/**
|
|
1971
|
-
* S3 Path Utilities
|
|
1972
|
-
*
|
|
1973
|
-
* Helper functions for S3 path operations.
|
|
1974
|
-
*/
|
|
1975
|
-
|
|
1976
|
-
/**
|
|
1977
|
-
* Generate timestamped filename for archival
|
|
1978
|
-
*
|
|
1979
|
-
* Adds ISO timestamp to filename before extension for unique archival.
|
|
1980
|
-
* Handles CSV files specifically but can be adapted for other formats.
|
|
1981
|
-
*
|
|
1982
|
-
* @param name - Original filename
|
|
1983
|
-
* @returns Filename with timestamp appended
|
|
1984
|
-
*
|
|
1985
|
-
* @example
|
|
1986
|
-
* ```typescript
|
|
1987
|
-
* timestampedName('products.csv')
|
|
1988
|
-
* // Returns: 'products-2025-11-01T18-30-45-123Z.csv'
|
|
1989
|
-
* ```
|
|
1990
|
-
*/
|
|
1991
|
-
export function timestampedName(name: string): string {
|
|
1992
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1993
|
-
const base = name.replace(/\.csv$/i, '');
|
|
1994
|
-
return `${base}-${ts}.csv`;
|
|
1995
|
-
}
|
|
1996
|
-
```
|
|
1997
|
-
|
|
1998
|
-
### Job ID Generator (src/utils/job-id-generator.ts)
|
|
1999
|
-
|
|
2000
|
-
```typescript
|
|
2001
|
-
/**
|
|
2002
|
-
* Job ID Generator
|
|
2003
|
-
*
|
|
2004
|
-
* Generates unique job IDs for tracking ingestion runs.
|
|
2005
|
-
* Format: {PREFIX}_{ENTITY}_{DATE}_{TIME}_{RANDOM}
|
|
2006
|
-
* Example: SCHEDULED_PROD_20250124_183045_abc123
|
|
2007
|
-
*/
|
|
2008
|
-
|
|
2009
|
-
/**
|
|
2010
|
-
* Generate unique job ID
|
|
2011
|
-
*
|
|
2012
|
-
* @param prefix - Job prefix (SCHEDULED, ADHOC, etc.)
|
|
2013
|
-
* @param entity - Entity type (PROD, INV, etc.)
|
|
2014
|
-
* @returns Unique job ID string
|
|
2015
|
-
*/
|
|
2016
|
-
export function generateJobId(prefix: string, entity: string): string {
|
|
2017
|
-
const now = new Date();
|
|
2018
|
-
const date = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
2019
|
-
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
|
|
2020
|
-
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
2021
|
-
return `${prefix}_${entity}_${date}_${time}_${random}`;
|
|
2022
|
-
}
|
|
2023
|
-
```
|
|
2024
|
-
|
|
2025
|
-
---
|
|
2026
|
-
|
|
2027
|
-
## 📄 Mapping Configuration
|
|
2028
|
-
|
|
2029
|
-
**File:** `config/products.import.csv.json`
|
|
2030
|
-
|
|
2031
|
-
```json
|
|
2032
|
-
{
|
|
2033
|
-
"name": "products.import.csv",
|
|
2034
|
-
"version": "1.2.0",
|
|
2035
|
-
"description": "CSV → Product Event Mapping",
|
|
2036
|
-
"fields": {
|
|
2037
|
-
"ref": {
|
|
2038
|
-
"source": "ref",
|
|
2039
|
-
"required": true,
|
|
2040
|
-
"resolver": "sdk.trim",
|
|
2041
|
-
"comment": "Product reference from CSV"
|
|
2042
|
-
},
|
|
2043
|
-
"type": {
|
|
2044
|
-
"source": "type",
|
|
2045
|
-
"required": true,
|
|
2046
|
-
"resolver": "sdk.uppercase",
|
|
2047
|
-
"comment": "Product type (STANDARD, VARIANT)"
|
|
2048
|
-
},
|
|
2049
|
-
"status": {
|
|
2050
|
-
"source": "status",
|
|
2051
|
-
"required": true,
|
|
2052
|
-
"resolver": "sdk.uppercase",
|
|
2053
|
-
"comment": "Product status (ACTIVE, INACTIVE)"
|
|
2054
|
-
},
|
|
2055
|
-
"name": {
|
|
2056
|
-
"source": "name",
|
|
2057
|
-
"required": true,
|
|
2058
|
-
"resolver": "sdk.trim",
|
|
2059
|
-
"comment": "Product name/title"
|
|
2060
|
-
},
|
|
2061
|
-
"summary": {
|
|
2062
|
-
"source": "summary",
|
|
2063
|
-
"required": false,
|
|
2064
|
-
"comment": "Product short description"
|
|
2065
|
-
},
|
|
2066
|
-
"gtin": {
|
|
2067
|
-
"source": "gtin",
|
|
2068
|
-
"required": false,
|
|
2069
|
-
"resolver": "sdk.trim",
|
|
2070
|
-
"comment": "Global Trade Item Number (barcode)"
|
|
2071
|
-
},
|
|
2072
|
-
"catalogue.ref": {
|
|
2073
|
-
"source": "$context.catalogueRef",
|
|
2074
|
-
"required": false,
|
|
2075
|
-
"comment": "Catalogue reference (injected from context, NOT in CSV)"
|
|
2076
|
-
},
|
|
2077
|
-
"metadata.source": {
|
|
2078
|
-
"value": "S3_CSV",
|
|
2079
|
-
"required": false,
|
|
2080
|
-
"comment": "Static value - identifies data source (NOT in CSV)"
|
|
2081
|
-
},
|
|
2082
|
-
"categoryRefs": {
|
|
2083
|
-
"source": "category_refs",
|
|
2084
|
-
"resolver": "custom.splitByPipe",
|
|
2085
|
-
"comment": "Pipe-separated categories (custom resolver)"
|
|
2086
|
-
},
|
|
2087
|
-
"price": {
|
|
2088
|
-
"resolver": "custom.buildPriceArray",
|
|
2089
|
-
"comment": "Build price array from default/special fields (custom resolver)"
|
|
2090
|
-
},
|
|
2091
|
-
"taxType": {
|
|
2092
|
-
"resolver": "custom.buildTaxType",
|
|
2093
|
-
"comment": "Build taxType object from country/group/tariff (custom resolver)"
|
|
2094
|
-
}
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
```
|
|
2098
|
-
|
|
2099
|
-
**Note:** Custom resolvers (`custom.splitByPipe`, `custom.buildPriceArray`, `custom.buildTaxType`) are defined in the Main Orchestration Service.
|
|
2100
|
-
|
|
2101
|
-
---
|
|
2102
|
-
|
|
2103
|
-
## 9. Package Configuration
|
|
2104
|
-
|
|
2105
|
-
### `package.json`
|
|
2106
|
-
|
|
2107
|
-
```json
|
|
2108
|
-
{
|
|
2109
|
-
"name": "s3-csv-product-event-ingestion",
|
|
2110
|
-
"version": "1.2.0",
|
|
2111
|
-
"description": "S3 CSV Product Event Ingestion Connector",
|
|
2112
|
-
"type": "module",
|
|
2113
|
-
"main": "src/index.ts",
|
|
2114
|
-
"dependencies": {
|
|
2115
|
-
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
2116
|
-
"@versori/run": "^1.0.0"
|
|
2117
|
-
},
|
|
2118
|
-
"devDependencies": {
|
|
2119
|
-
"@types/node": "^20.0.0",
|
|
2120
|
-
"typescript": "^5.0.0"
|
|
2121
|
-
}
|
|
2122
|
-
}
|
|
2123
|
-
```
|
|
2124
|
-
|
|
2125
|
-
---
|
|
2126
|
-
|
|
2127
|
-
## 6. Deployment Instructions
|
|
2128
|
-
|
|
2129
|
-
### Deploy to Versori
|
|
2130
|
-
|
|
2131
|
-
```bash
|
|
2132
|
-
# 1. Install dependencies
|
|
2133
|
-
npm install
|
|
2134
|
-
|
|
2135
|
-
# 2. Test locally (if using Versori CLI)
|
|
2136
|
-
npm run dev
|
|
2137
|
-
|
|
2138
|
-
# 3. Deploy to Versori platform
|
|
2139
|
-
npm run deploy
|
|
2140
|
-
```
|
|
2141
|
-
|
|
2142
|
-
### Configure Activation Variables
|
|
2143
|
-
|
|
2144
|
-
In Versori platform settings, configure all variables listed in the Activation Variables section above.
|
|
2145
|
-
|
|
2146
|
-
---
|
|
2147
|
-
|
|
2148
|
-
## 7. Testing
|
|
2149
|
-
|
|
2150
|
-
### Test Scheduled Ingestion
|
|
2151
|
-
|
|
2152
|
-
Upload a test CSV file to S3 incoming prefix and wait for the scheduled run.
|
|
2153
|
-
|
|
2154
|
-
**Check logs:**
|
|
2155
|
-
|
|
2156
|
-
```
|
|
2157
|
-
[STEP 1/8] Initializing job tracking
|
|
2158
|
-
[STEP 2/8] Initializing Fluent Commerce client and S3
|
|
2159
|
-
[STEP 3/8] Discovering files on S3
|
|
2160
|
-
[FILE 1/1] Processing file: products_20250124.csv
|
|
2161
|
-
[STEP 4/8] Downloading and parsing: products_20250124.csv
|
|
2162
|
-
[STEP 5/8] Transforming 5 products from products_20250124.csv
|
|
2163
|
-
[STEP 6/8] Sending 5 events to Fluent Commerce
|
|
2164
|
-
[STEP 7/8] Archiving file: products_20250124.csv
|
|
2165
|
-
[STEP 8/8] Completing job and calculating totals
|
|
2166
|
-
```
|
|
2167
|
-
|
|
2168
|
-
### Test Ad hoc Ingestion
|
|
2169
|
-
|
|
2170
|
-
```bash
|
|
2171
|
-
# Process all pending files
|
|
2172
|
-
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2173
|
-
-H "X-API-Key: your-secret-key" \
|
|
2174
|
-
-H "Content-Type: application/json" \
|
|
2175
|
-
-d '{}'
|
|
2176
|
-
|
|
2177
|
-
# Process specific pattern
|
|
2178
|
-
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2179
|
-
-H "X-API-Key: your-secret-key" \
|
|
2180
|
-
-H "Content-Type: application/json" \
|
|
2181
|
-
-d '{
|
|
2182
|
-
"filePattern": "urgent_*.csv", }'
|
|
2183
|
-
```
|
|
2184
|
-
|
|
2185
|
-
### Test Job Status Query
|
|
2186
|
-
|
|
2187
|
-
```bash
|
|
2188
|
-
curl -X POST https://api.versori.com/webhooks/product-ingestion-job-status \
|
|
2189
|
-
-H "X-API-Key: your-secret-key" \
|
|
2190
|
-
-H "Content-Type: application/json" \
|
|
2191
|
-
-d '{
|
|
2192
|
-
"jobId": "ADHOC_PROD_20251024_183045_abc123"
|
|
2193
|
-
}'
|
|
2194
|
-
```
|
|
2195
|
-
|
|
2196
|
-
---
|
|
2197
|
-
|
|
2198
|
-
## Monitoring
|
|
2199
|
-
|
|
2200
|
-
### Success Response
|
|
2201
|
-
|
|
2202
|
-
```json
|
|
2203
|
-
{
|
|
2204
|
-
"success": true,
|
|
2205
|
-
"filesProcessed": 1,
|
|
2206
|
-
"filesSkipped": 0,
|
|
2207
|
-
"filesFailed": 0,
|
|
2208
|
-
"totalRecords": 50,
|
|
2209
|
-
"eventsSent": 50,
|
|
2210
|
-
"eventsFailed": 0,
|
|
2211
|
-
"results": [
|
|
2212
|
-
{
|
|
2213
|
-
"file": "products_2025-01-22.csv",
|
|
2214
|
-
"success": true,
|
|
2215
|
-
"recordsProcessed": 50,
|
|
2216
|
-
"eventsSent": 50,
|
|
2217
|
-
"eventsFailed": 0
|
|
2218
|
-
}
|
|
2219
|
-
],
|
|
2220
|
-
"duration": 12345
|
|
2221
|
-
}
|
|
2222
|
-
```
|
|
2223
|
-
|
|
2224
|
-
### Partial Success Response
|
|
2225
|
-
|
|
2226
|
-
```json
|
|
2227
|
-
{
|
|
2228
|
-
"success": true,
|
|
2229
|
-
"filesProcessed": 1,
|
|
2230
|
-
"filesSkipped": 0,
|
|
2231
|
-
"filesFailed": 0,
|
|
2232
|
-
"totalRecords": 50,
|
|
2233
|
-
"eventsSent": 45,
|
|
2234
|
-
"eventsFailed": 5,
|
|
2235
|
-
"results": [
|
|
2236
|
-
{
|
|
2237
|
-
"file": "products_2025-01-22.csv",
|
|
2238
|
-
"success": true,
|
|
2239
|
-
"recordsProcessed": 50,
|
|
2240
|
-
"eventsSent": 45,
|
|
2241
|
-
"eventsFailed": 5,
|
|
2242
|
-
"errors": ["PRD-001: Invalid SKU format", "PRD-002: Missing required field"]
|
|
2243
|
-
}
|
|
2244
|
-
],
|
|
2245
|
-
"duration": 12345
|
|
2246
|
-
}
|
|
2247
|
-
```
|
|
2248
|
-
|
|
2249
|
-
### Error Response
|
|
2250
|
-
|
|
2251
|
-
```json
|
|
2252
|
-
{
|
|
2253
|
-
"success": false,
|
|
2254
|
-
"filesProcessed": 0,
|
|
2255
|
-
"filesFailed": 1,
|
|
2256
|
-
"totalRecords": 0,
|
|
2257
|
-
"eventsSent": 0,
|
|
2258
|
-
"eventsFailed": 0,
|
|
2259
|
-
"results": [
|
|
2260
|
-
{
|
|
2261
|
-
"file": "products_2025-01-22.csv",
|
|
2262
|
-
"success": false,
|
|
2263
|
-
"error": "CSV parse error: Invalid structure"
|
|
2264
|
-
}
|
|
2265
|
-
],
|
|
2266
|
-
"duration": 876
|
|
2267
|
-
}
|
|
2268
|
-
```
|
|
2269
|
-
|
|
2270
|
-
### Monitoring Metrics
|
|
2271
|
-
|
|
2272
|
-
Monitor these metrics via Versori logs filtered by `jobId` or `stage`:
|
|
2273
|
-
|
|
2274
|
-
- **Files Processed** - Total files successfully processed
|
|
2275
|
-
- **Events Sent** - Total events sent to Fluent Commerce
|
|
2276
|
-
- **Events Failed** - Events that failed (check rejection reports)
|
|
2277
|
-
- **Processing Duration** - Time taken for complete workflow
|
|
2278
|
-
- **Rate Limiting** - Watch for 429 errors indicating throttling
|
|
2279
|
-
|
|
2280
|
-
Use the status webhook for dashboards and automated monitoring.
|
|
2281
|
-
|
|
2282
|
-
---
|
|
2283
|
-
|
|
2284
|
-
- **No files found:** Check `s3IncomingPrefix` and `filePattern` (object keys only)
|
|
2285
|
-
- **CSV parse failed:** Move to errors/; validate CSV structure and encoding
|
|
2286
|
-
- **High event failures:** Inspect logs; consider Batch API for very high volumes
|
|
2287
|
-
- **429 throttling:** Add small backoff/delay or use Batch API
|
|
2288
|
-
- **S3 connection errors:** Verify credentials, bucket, region, and permissions
|
|
2289
|
-
|
|
2290
|
-
---
|
|
2291
|
-
|
|
2292
|
-
## 9. Key Takeaways
|
|
2293
|
-
|
|
2294
|
-
- ✅ **Modular service functions** - processFile(), sendEvents(), writeEventLog() keep code composable
|
|
2295
|
-
- ✅ **Native Versori logs** - Use `log` from context (LoggingService removed - use native log)
|
|
2296
|
-
- ✅ **Buffer import required** - `import { Buffer } from 'node:buffer';` for Deno/Versori compatibility
|
|
2297
|
-
- ✅ **3 workflows** - Scheduled, ad hoc webhook, job status webhook
|
|
2298
|
-
- ✅ **JobTracker** - Track job lifecycle with KV persistence
|
|
2299
|
-
- ❌ **NO VersoriFileTracker** - S3 uses moveObject for deduplication (SFTP-specific only)
|
|
2300
|
-
- ❌ **NO IngestionOrchestrator** - Removed from SDK - use inline modular services instead
|
|
2301
|
-
- ✅ **S3 deduplication** - Physical file movement to processed/errors prefixes
|
|
2302
|
-
- ✅ **S3 dispose()** - Always cleanup in finally block
|
|
2303
|
-
- ✅ **S3 method names** - listObjects, downloadObject, moveObject (not \*File)
|
|
2304
|
-
- ✅ **Per-record error handling** - Continue on individual failures
|
|
2305
|
-
- ✅ **Externalized mapping** - Use JSON config file for field mappings
|
|
2306
|
-
- ✅ **File-level error handling** - Don't stop on single file failure
|
|
2307
|
-
- ✅ **S3 vs SFTP** - Different deduplication strategies (moveObject vs KV state)
|
|
2308
|
-
- ✅ **uploadFile signature** - Uses Buffer.from(content, 'utf8') for text content
|
|
2309
|
-
|
|
2310
|
-
---
|
|
2311
|
-
|
|
2312
|
-
[← Back to Versori Event API Templates](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) | [Versori Platform Guide →](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md)
|
|
1
|
+
---
|
|
2
|
+
template_id: tpl-ingest-s3-csv-to-product-event
|
|
3
|
+
canonical_filename: template-ingestion-s3-csv-product-event.md
|
|
4
|
+
sdk_version: ^0.1.39
|
|
5
|
+
runtime: versori
|
|
6
|
+
versori_run_version: ^0.4.4
|
|
7
|
+
direction: ingestion
|
|
8
|
+
source: s3-csv
|
|
9
|
+
destination: fluent-event-api
|
|
10
|
+
entity: product
|
|
11
|
+
format: csv
|
|
12
|
+
logging: versori
|
|
13
|
+
status: stable
|
|
14
|
+
features:
|
|
15
|
+
- batched-events
|
|
16
|
+
- attribute-transformation
|
|
17
|
+
- memory-management
|
|
18
|
+
- json-serialization-handling
|
|
19
|
+
- enhanced-logging
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Template: Ingestion - S3 CSV to Product Event
|
|
23
|
+
|
|
24
|
+
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
25
|
+
**Last Updated:** 2025-01-24
|
|
26
|
+
**Deployment Target:** Versori Platform
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 📋 Implementation Prompt
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
I need a Versori scheduled ingestion that:
|
|
34
|
+
|
|
35
|
+
1) Lists CSV files on S3 (deduplication via moveObject - NO VersoriFileTracker)
|
|
36
|
+
2) Downloads and parses CSV with field validation (using modular processFile() function)
|
|
37
|
+
3) Transforms records with UniversalMapper per mapping JSON
|
|
38
|
+
4) Sends UPSERT_PRODUCT events (async) to Fluent Commerce with per-record error handling (using sendEvents() function)
|
|
39
|
+
5) Writes event logs to S3 using writeEventLog() function (non-blocking)
|
|
40
|
+
6) Moves files to processed/ or errors/ prefixes for natural deduplication
|
|
41
|
+
7) Tracks progress with JobTracker and exposes a job-status webhook
|
|
42
|
+
8) Uses native Versori log from context
|
|
43
|
+
9) Uses modular service functions (NO IngestionOrchestrator - removed from SDK)
|
|
44
|
+
10) Imports Buffer from 'node:buffer' for Deno/Versori compatibility
|
|
45
|
+
|
|
46
|
+
Use the loaded docs to fill in SDK specifics and best practices.
|
|
47
|
+
Keep the structure identical to the template; only adapt where needed.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 📋 Template Overview
|
|
53
|
+
|
|
54
|
+
This connector runs on the Versori platform. Most operational settings (Fluent account/connection, S3 credentials, schedule, file patterns) are configured via activation variables. Data shape and logic (mapping JSON, CSV structure, parsing rules, per-record handling) are adjusted in code as needed. It reads product data from S3 CSV, transforms it, and sends events to the Fluent Commerce Event API.
|
|
55
|
+
|
|
56
|
+
### What This Template Does
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
60
|
+
│ INGESTION WORKFLOW │
|
|
61
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
62
|
+
|
|
63
|
+
1. TRIGGER
|
|
64
|
+
├─ Scheduled (Cron): Runs automatically every hour
|
|
65
|
+
├─ Ad hoc (Webhook): Manual trigger for immediate processing
|
|
66
|
+
└─ Status Query (Webhook): Check job progress
|
|
67
|
+
|
|
68
|
+
2. DISCOVER FILES (S3DataSource)
|
|
69
|
+
├─ List objects from S3 bucket
|
|
70
|
+
├─ Filter by prefix and pattern (products/*.csv)
|
|
71
|
+
├─ NO VersoriFileTracker (S3 uses moveObject for deduplication)
|
|
72
|
+
└─ Sort by last modified
|
|
73
|
+
|
|
74
|
+
3. DOWNLOAD & PARSE (CSVParserService)
|
|
75
|
+
├─ Download object from S3
|
|
76
|
+
├─ Parse CSV to JavaScript array
|
|
77
|
+
├─ Extract headers from first row
|
|
78
|
+
└─ Validate CSV structure
|
|
79
|
+
|
|
80
|
+
4. TRANSFORM (UniversalMapper)
|
|
81
|
+
├─ Map CSV fields to Fluent schema
|
|
82
|
+
├─ Apply SDK resolvers (trim, uppercase, etc.)
|
|
83
|
+
├─ Handle nested objects (price, taxType)
|
|
84
|
+
├─ Handle arrays (categoryRefs)
|
|
85
|
+
└─ Collect transformation errors
|
|
86
|
+
|
|
87
|
+
5. SEND EVENTS (Event API)
|
|
88
|
+
├─ Loop through transformed products
|
|
89
|
+
├─ Send UPSERT_PRODUCT event (async)
|
|
90
|
+
├─ Track success/failure count
|
|
91
|
+
└─ Continue on individual failures
|
|
92
|
+
|
|
93
|
+
6. ARCHIVE (S3DataSource - Natural Deduplication)
|
|
94
|
+
├─ Move object to processed/ prefix (success)
|
|
95
|
+
├─ Or move to errors/ prefix (failures)
|
|
96
|
+
├─ Generate timestamped archive name
|
|
97
|
+
└─ Files in incoming/ are removed naturally
|
|
98
|
+
|
|
99
|
+
7. TRACK JOB (JobTracker)
|
|
100
|
+
├─ Update job status at each step
|
|
101
|
+
├─ Store final result in KV
|
|
102
|
+
├─ Enable status queries via webhook
|
|
103
|
+
└─ Handle errors gracefully
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Key Features
|
|
107
|
+
|
|
108
|
+
- **Modular service functions** - processFile(), sendEvents(), writeEventLog() for composability
|
|
109
|
+
- Job tracking with status queries via JobTracker
|
|
110
|
+
- Execution modes: scheduled, ad hoc, status query
|
|
111
|
+
- Uses S3DataSource, CSVParserService, UniversalMapper, JobTracker (NO IngestionOrchestrator)
|
|
112
|
+
- Error handling, retry logic, and S3 cleanup
|
|
113
|
+
- **S3 Deduplication:** moveObject to processed/errors prefixes (NO VersoriFileTracker)
|
|
114
|
+
- **Key Difference from SFTP:** Physical file movement vs KV state tracking
|
|
115
|
+
- Event API: Per-record failures don't block other records
|
|
116
|
+
- S3 dispose() in finally block
|
|
117
|
+
- Buffer import for Deno/Versori compatibility
|
|
118
|
+
|
|
119
|
+
Note: JobTracker persists stage/status to Versori KV for visibility, job-status webhooks, and auditing. Recommended for production multi-step flows; can be skipped for trivial single-step utilities.
|
|
120
|
+
|
|
121
|
+
### 📦 Package Information
|
|
122
|
+
|
|
123
|
+
**SDK:** [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk) `^0.1.30`
|
|
124
|
+
**Versori Runtime:** [@versori/run](https://www.npmjs.com/package/@versori/run) `^0.4.4`
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm install @fluentcommerce/fc-connect-sdk@latest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
**Templates are designed for direct deployment; customize via activation variables.**
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 📦 SDK Imports (Verified - Versori Optimized)
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// ✅ VERIFIED IMPORTS - These match actual SDK exports
|
|
140
|
+
import {
|
|
141
|
+
createClient, // Universal client factory
|
|
142
|
+
S3DataSource, // S3 operations with retry logic
|
|
143
|
+
CSVParserService, // CSV parsing
|
|
144
|
+
UniversalMapper, // Field mapping with SDK resolvers
|
|
145
|
+
JobTracker, // Job status tracking
|
|
146
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
147
|
+
|
|
148
|
+
import type { FluentClient, S3DataSourceConfig } from '@fluentcommerce/fc-connect-sdk';
|
|
149
|
+
|
|
150
|
+
// Versori platform imports
|
|
151
|
+
import { schedule, webhook, http, fn } from '@versori/run';
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Note:** All imports are from actual SDK exports - this code compiles and runs as-is.
|
|
155
|
+
|
|
156
|
+
**✅ VERSORI PLATFORM - Use Native Logs:**
|
|
157
|
+
|
|
158
|
+
- Use `log` from context: `const { log } = ctx;`
|
|
159
|
+
- Native Versori logs are simpler and automatically integrated with platform monitoring
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## ⚙️ Activation Variables
|
|
164
|
+
|
|
165
|
+
**Configuration is driven by activation variables - modify these instead of code:**
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"fluentRetailerId": "1",
|
|
170
|
+
"eventConcurrency": 1,
|
|
171
|
+
"s3Bucket": "my-product-bucket",
|
|
172
|
+
"s3Region": "us-east-1",
|
|
173
|
+
"s3AccessKeyId": "AKIA...",
|
|
174
|
+
"s3SecretAccessKey": "********",
|
|
175
|
+
"s3IncomingPrefix": "products/incoming/",
|
|
176
|
+
"s3ProcessedPrefix": "products/processed/",
|
|
177
|
+
"s3ErrorPrefix": "products/errors/",
|
|
178
|
+
"s3LogsPrefix": "products/logs/",
|
|
179
|
+
"filePattern": "products_*.csv",
|
|
180
|
+
"catalogueRef": "PC:MASTER:2",
|
|
181
|
+
"catalogueType": "MASTER",
|
|
182
|
+
"eventName": "UPSERT_PRODUCT",
|
|
183
|
+
"eventMode": "async",
|
|
184
|
+
"maxFilesPerRun": 10,
|
|
185
|
+
"enableFileTracking": false,
|
|
186
|
+
"useBatchedEvents": false,
|
|
187
|
+
"maxProductsUnderBatchedEvent": 100,
|
|
188
|
+
"memoryBatchSize": 250
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Note: Webhook security is handled by Versori's native connection authentication. The `connection` parameter in webhook definitions ensures only authenticated requests are processed.
|
|
193
|
+
|
|
194
|
+
### Variable Explanations
|
|
195
|
+
|
|
196
|
+
| Variable | Purpose | Default | Customization Hints |
|
|
197
|
+
| ------------------- | ---------------------------- | --------------------- | ----------------------------------- |
|
|
198
|
+
| `fluentRetailerId` | Retailer ID for Event API | - | Required - Fluent retailer ID |
|
|
199
|
+
| `eventConcurrency` | Event sending concurrency | `1` | `1` = sequential, `>1` = parallel (3-10 recommended) |
|
|
200
|
+
| `s3Bucket` | S3 bucket name | - | Required - your S3 bucket |
|
|
201
|
+
| `s3Region` | S3 region | `us-east-1` | AWS region (e.g., us-west-2) |
|
|
202
|
+
| `s3AccessKeyId` | AWS access key ID | - | Required - AWS credentials |
|
|
203
|
+
| `s3SecretAccessKey` | AWS secret access key | - | Required - AWS credentials |
|
|
204
|
+
| `s3IncomingPrefix` | Incoming files prefix | `products/incoming/` | Where new files arrive |
|
|
205
|
+
| `s3ProcessedPrefix` | Processed files archive | `products/processed/` | Where files move after success |
|
|
206
|
+
| `s3ErrorPrefix` | Failed files directory | `products/errors/` | Where files move after errors |
|
|
207
|
+
| `s3LogsPrefix` | Event logs directory | `products/logs/` | Where event logs are written |
|
|
208
|
+
| `filePattern` | File name filter | `products_*.csv` | Glob pattern for matching files |
|
|
209
|
+
| `catalogueRef` | Product catalogue reference | `PC:MASTER:2` | Target catalogue in Fluent |
|
|
210
|
+
| `catalogueType` | Catalogue type | `MASTER` | Usually MASTER or STANDARD |
|
|
211
|
+
| `eventName` | Event to send | `UPSERT_PRODUCT` | Event name from Rubix |
|
|
212
|
+
| `eventMode` | Event processing mode | `async` | async (recommended) or sync |
|
|
213
|
+
| `maxFilesPerRun` | Max files per execution | `10` | Prevent timeout on large batches |
|
|
214
|
+
| `enableFileTracking` | Enable KV-based file tracking | `false` | `false` = moveObject only (default), `true` = KV + moveObject |
|
|
215
|
+
| `webhookApiKey` | API key for webhook security | - | Required for webhook authentication |
|
|
216
|
+
| `useBatchedEvents` | Use batched UPSERT_PRODUCTS | `false` | `true` = batched events (10x faster for 100+ products), `false` = individual events |
|
|
217
|
+
| `maxProductsUnderBatchedEvent` | Products per batch | `100` | Only used when `useBatchedEvents=true` (default: 100) |
|
|
218
|
+
| `memoryBatchSize` | Products per mapping batch | `250` | Processes products in batches during mapping (prevents OOM on large files) |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## 🔒 SDK Automatic Behaviors (v0.1.40+)
|
|
223
|
+
|
|
224
|
+
**The SDK automatically validates and retries for improved reliability:**
|
|
225
|
+
|
|
226
|
+
###retailerId Validation
|
|
227
|
+
- **SDK validates** `retailerId` before calling `sendEvent()`
|
|
228
|
+
- **Checks:** `event.retailerId || client.retailerId`
|
|
229
|
+
- **If missing:** Throws `"retailerId is required for Event API..."`
|
|
230
|
+
- **Configuration:** Set via `fluentRetailerId` activation variable (recommended)
|
|
231
|
+
|
|
232
|
+
### 401 Auth Retry
|
|
233
|
+
- **Automatic retry** for platform auth failures (3 attempts)
|
|
234
|
+
- **Delay:** Exponential backoff (1s → 2s → 4s)
|
|
235
|
+
- **Applies to:** All `sendEvent()` calls (async and sync modes)
|
|
236
|
+
- **Log:** `"[fc-connect-sdk:auth] Platform auth failure (401), retrying..."`
|
|
237
|
+
|
|
238
|
+
### 5xx Server Retry
|
|
239
|
+
- **Automatic retry** for transient server errors (3 attempts)
|
|
240
|
+
- **Delay:** Exponential backoff (1s → 2s → 4s, capped at 10s)
|
|
241
|
+
- **Protects:** Against Fluent API transient failures
|
|
242
|
+
|
|
243
|
+
### No Code Changes Required
|
|
244
|
+
- All templates remain compatible
|
|
245
|
+
- Retry logic is automatic and transparent
|
|
246
|
+
- Better error messages guide configuration
|
|
247
|
+
|
|
248
|
+
**See:** [Event API Guide](./event-api-guide.md) for complete details
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
### 📁 S3 Deduplication Pattern (NO VersoriFileTracker)
|
|
253
|
+
|
|
254
|
+
**CRITICAL: S3 uses physical file movement, NOT VersoriFileTracker**
|
|
255
|
+
|
|
256
|
+
VersoriFileTracker is **SFTP-specific** and should **NOT** be used with S3. S3 achieves deduplication through physical file movement.
|
|
257
|
+
|
|
258
|
+
#### How S3 Deduplication Works
|
|
259
|
+
|
|
260
|
+
**3-Step Process:**
|
|
261
|
+
|
|
262
|
+
1. **Discovery:** List objects from `s3IncomingPrefix` matching `filePattern`
|
|
263
|
+
2. **Process:** Download, parse, transform, and send events
|
|
264
|
+
3. **Archive:** Move object to `s3ProcessedPrefix` or `s3ErrorPrefix`
|
|
265
|
+
4. **Natural Deduplication:** Once moved, files are no longer in incoming/
|
|
266
|
+
|
|
267
|
+
#### File Lifecycle Example
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
INITIAL STATE:
|
|
271
|
+
s3://bucket/products/incoming/products_20250124_001.csv
|
|
272
|
+
|
|
273
|
+
↓ (workflow discovers file)
|
|
274
|
+
|
|
275
|
+
PROCESSING:
|
|
276
|
+
- List objects from "products/incoming/"
|
|
277
|
+
- Download: products_20250124_001.csv
|
|
278
|
+
- Parse CSV → Map to events → Send to Event API
|
|
279
|
+
|
|
280
|
+
↓ (success)
|
|
281
|
+
|
|
282
|
+
ARCHIVE (moveObject):
|
|
283
|
+
s3://bucket/products/processed/products_20250124_001-20250124T183045.csv
|
|
284
|
+
|
|
285
|
+
↓ (result)
|
|
286
|
+
|
|
287
|
+
INCOMING FOLDER NOW EMPTY:
|
|
288
|
+
s3://bucket/products/incoming/ (file is gone - no duplicates possible)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
#### Key Differences: S3 vs SFTP
|
|
292
|
+
|
|
293
|
+
| Aspect | S3 (This Template) | SFTP (Other Templates) |
|
|
294
|
+
| ------------------------ | ----------------------------- | -------------------------------- |
|
|
295
|
+
| **Deduplication Method** | Physical file movement | VersoriFileTracker (KV state) |
|
|
296
|
+
| **State Storage** | None needed | KV store tracks processed files |
|
|
297
|
+
| **File Tracking** | ❌ NO VersoriFileTracker | ✅ VersoriFileTracker required |
|
|
298
|
+
| **Reprocessing** | Move files back to incoming/ | Clear KV state or forceReprocess |
|
|
299
|
+
| **Complexity** | Simpler (no state management) | More complex (state management) |
|
|
300
|
+
|
|
301
|
+
#### Why S3 Doesn't Need VersoriFileTracker
|
|
302
|
+
|
|
303
|
+
**S3 moveObject provides natural deduplication:**
|
|
304
|
+
|
|
305
|
+
- Files are **physically moved** from `incoming/` to `processed/` or `errors/`
|
|
306
|
+
- Once moved, the file **no longer exists** in the incoming prefix
|
|
307
|
+
- Next discovery lists `incoming/` → file is not found → no duplicate processing
|
|
308
|
+
- **No KV state tracking needed** - the S3 bucket structure IS the state
|
|
309
|
+
|
|
310
|
+
#### Reprocessing Files (If Needed)
|
|
311
|
+
|
|
312
|
+
To reprocess a file that was already archived:
|
|
313
|
+
|
|
314
|
+
**Option 1: Manual S3 Move**
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
# Move file back from processed/ to incoming/
|
|
318
|
+
aws s3 mv \
|
|
319
|
+
s3://bucket/products/processed/products_20250124_001-timestamp.csv \
|
|
320
|
+
s3://bucket/products/incoming/products_20250124_001.csv
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**Option 2: Webhook with forceReprocess**
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
# Set forceReprocess: true in webhook payload
|
|
327
|
+
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
328
|
+
-H "X-API-Key: your-secret-key" \
|
|
329
|
+
-H "Content-Type: application/json" \
|
|
330
|
+
-d '{
|
|
331
|
+
"forceReprocess": true,
|
|
332
|
+
"filePattern": "products_20250124_001.csv"
|
|
333
|
+
}'
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**Use cases for reprocessing:**
|
|
337
|
+
|
|
338
|
+
- Fixing bad data that was previously ingested
|
|
339
|
+
- Reingesting after mapping config changes
|
|
340
|
+
- Testing with production files
|
|
341
|
+
|
|
342
|
+
#### Summary
|
|
343
|
+
|
|
344
|
+
✅ **DO:** Use S3 moveObject for deduplication
|
|
345
|
+
✅ **DO:** Move files to processed/ or errors/ prefixes
|
|
346
|
+
❌ **DON'T:** Import VersoriFileTracker in S3 templates
|
|
347
|
+
❌ **DON'T:** Use KV state tracking for S3 files
|
|
348
|
+
✅ **DO:** Use VersoriFileTracker for SFTP templates (different use case)
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
### 📁 S3 Prefix vs File Pattern
|
|
353
|
+
|
|
354
|
+
**Critical:** Folder prefixes and file patterns are configured separately.
|
|
355
|
+
|
|
356
|
+
- **Folder Prefixes:** Configured via activation variables:
|
|
357
|
+
- `s3IncomingPrefix` - Where new files arrive (e.g., `products/incoming/`)
|
|
358
|
+
- `s3ProcessedPrefix` - Where successful files are archived (e.g., `products/processed/`)
|
|
359
|
+
- `s3ErrorPrefix` - Where failed files are moved (e.g., `products/errors/`)
|
|
360
|
+
|
|
361
|
+
- **File Pattern:** Configured via `filePattern` activation variable
|
|
362
|
+
- Matches object keys (filenames)
|
|
363
|
+
- Supports wildcards: `*.csv`, `products_*.csv`, `urgent_*.csv`
|
|
364
|
+
|
|
365
|
+
**How it works:**
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
// The workflow lists objects like this:
|
|
369
|
+
s3.listObjects({
|
|
370
|
+
prefix: 'products/incoming/', // ← Prefix from activation variable
|
|
371
|
+
pattern: 'products_*.csv', // ← Pattern matches object keys
|
|
372
|
+
});
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**Example:** With these settings:
|
|
376
|
+
|
|
377
|
+
```json
|
|
378
|
+
{
|
|
379
|
+
"s3IncomingPrefix": "products/incoming/",
|
|
380
|
+
"filePattern": "products_*.csv"
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
The workflow will find objects like:
|
|
385
|
+
|
|
386
|
+
- ✅ `products/incoming/products_20250124.csv`
|
|
387
|
+
- ✅ `products/incoming/products_urgent.csv`
|
|
388
|
+
- ❌ `products/incoming/orders_20250124.csv` (doesn't match pattern)
|
|
389
|
+
- ❌ `orders/incoming/products_20250124.csv` (wrong prefix)
|
|
390
|
+
|
|
391
|
+
**Note:** To process files from different prefixes, change `s3IncomingPrefix` (not `filePattern`).
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## 📄 Mapping Configuration
|
|
396
|
+
|
|
397
|
+
**File:** `config/products.import.csv.json`
|
|
398
|
+
|
|
399
|
+
```json
|
|
400
|
+
{
|
|
401
|
+
"name": "products.import.csv",
|
|
402
|
+
"version": "1.0.0",
|
|
403
|
+
"description": "CSV → Product Event Mapping",
|
|
404
|
+
"fields": {
|
|
405
|
+
"ref": {
|
|
406
|
+
"source": "ref",
|
|
407
|
+
"required": true,
|
|
408
|
+
"resolver": "sdk.trim"
|
|
409
|
+
},
|
|
410
|
+
"type": {
|
|
411
|
+
"source": "type",
|
|
412
|
+
"resolver": "sdk.uppercase",
|
|
413
|
+
"defaultValue": "STANDARD"
|
|
414
|
+
},
|
|
415
|
+
"status": {
|
|
416
|
+
"source": "status",
|
|
417
|
+
"resolver": "sdk.uppercase",
|
|
418
|
+
"defaultValue": "ACTIVE"
|
|
419
|
+
},
|
|
420
|
+
"gtin": {
|
|
421
|
+
"source": "gtin",
|
|
422
|
+
"resolver": "sdk.trim"
|
|
423
|
+
},
|
|
424
|
+
"name": {
|
|
425
|
+
"source": "name",
|
|
426
|
+
"required": true,
|
|
427
|
+
"resolver": "sdk.trim"
|
|
428
|
+
},
|
|
429
|
+
"summary": {
|
|
430
|
+
"source": "summary"
|
|
431
|
+
},
|
|
432
|
+
"categoryRefs": {
|
|
433
|
+
"source": "category_refs",
|
|
434
|
+
"resolver": "custom.splitByPipe"
|
|
435
|
+
},
|
|
436
|
+
"price": {
|
|
437
|
+
"resolver": "custom.buildPriceArray"
|
|
438
|
+
},
|
|
439
|
+
"taxType": {
|
|
440
|
+
"resolver": "custom.buildTaxType"
|
|
441
|
+
},
|
|
442
|
+
"attributes": {
|
|
443
|
+
"defaultValue": []
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
**Custom Resolvers:**
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
const customResolvers = {
|
|
453
|
+
/**
|
|
454
|
+
* Split pipe-separated string into array
|
|
455
|
+
* Example: "CAT1|CAT2|CAT3" → ["CAT1", "CAT2", "CAT3"]
|
|
456
|
+
*/
|
|
457
|
+
'custom.splitByPipe': (value: string) => {
|
|
458
|
+
if (!value || value.trim() === '') return [];
|
|
459
|
+
return value
|
|
460
|
+
.split('|')
|
|
461
|
+
.map(v => v.trim())
|
|
462
|
+
.filter(v => v !== '');
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Build price array from flat CSV columns
|
|
467
|
+
*/
|
|
468
|
+
'custom.buildPriceArray': (value: any, sourceData: any) => {
|
|
469
|
+
const prices = [];
|
|
470
|
+
if (sourceData.price_default_value) {
|
|
471
|
+
prices.push({
|
|
472
|
+
type: 'DEFAULT',
|
|
473
|
+
currency: sourceData.price_default_currency || 'USD',
|
|
474
|
+
value: sourceData.price_default_value,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (sourceData.price_special_value) {
|
|
478
|
+
prices.push({
|
|
479
|
+
type: 'SPECIAL',
|
|
480
|
+
currency: sourceData.price_special_currency || 'USD',
|
|
481
|
+
value: sourceData.price_special_value,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return prices;
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Build tax type object from flat CSV columns
|
|
489
|
+
*/
|
|
490
|
+
'custom.buildTaxType': (value: any, sourceData: any) => {
|
|
491
|
+
if (!sourceData.tax_country) return undefined;
|
|
492
|
+
return {
|
|
493
|
+
country: sourceData.tax_country,
|
|
494
|
+
group: sourceData.tax_group,
|
|
495
|
+
tariff: sourceData.tax_tariff,
|
|
496
|
+
};
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Note: Adjust fields in the JSON above as needed; prefer built-in resolvers. For advanced patterns, see SDK Universal Mapping guide.
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## Expected CSV Format
|
|
506
|
+
|
|
507
|
+
```csv
|
|
508
|
+
ref,type,status,gtin,name,summary,category_refs,price_default_currency,price_default_value,price_special_currency,price_special_value,tax_country,tax_group,tax_tariff
|
|
509
|
+
G_PROD_WITH_NO_STANDARD,VARIANT,ACTIVE,MH01-XS-Orange,Chaz Kangeroo Hoodie-XS-Orange main,<p>test short description</p>,STANDARD_CATEGORY,USD,52.000000,USD,9.000000,AU,Tax Group,Tax Tariff
|
|
510
|
+
G_PROD_002,VARIANT,ACTIVE,MH02-M-Blue,Kangeroo Hoodie-M-Blue,<p>Blue variant</p>,STANDARD_CATEGORY|SEASONAL,USD,45.000000,USD,35.000000,AU,Tax Group,Tax Tariff
|
|
511
|
+
G_PROD_003,STANDARD,ACTIVE,MH03-BASE,Base Hoodie Product,<p>Base product</p>,STANDARD_CATEGORY,USD,50.000000,,,AU,Tax Group,Tax Tariff
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## Event Sending Configuration
|
|
517
|
+
|
|
518
|
+
**Simple Configuration:** One variable controls everything
|
|
519
|
+
|
|
520
|
+
```json
|
|
521
|
+
{
|
|
522
|
+
"eventConcurrency": 1 // 1 = sequential, 3-10 = parallel
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
**How it works:**
|
|
527
|
+
|
|
528
|
+
- `eventConcurrency: 1` → Sequential (sends events one at a time, safe default)
|
|
529
|
+
- `eventConcurrency: 3` → Parallel (sends 3 events concurrently)
|
|
530
|
+
- `eventConcurrency: 5` → Parallel (sends 5 events concurrently)
|
|
531
|
+
- `eventConcurrency: 10` → Parallel (sends 10 events concurrently)
|
|
532
|
+
|
|
533
|
+
**Configuration Guidelines:**
|
|
534
|
+
|
|
535
|
+
- **Conservative:** `1` → Sequential (safe, predictable, ~1 req/sec)
|
|
536
|
+
- **Balanced:** `3-5` → Parallel (most common, ~3-5 req/sec)
|
|
537
|
+
- **Aggressive:** `10` → Parallel (high-volume, ~10 req/sec)
|
|
538
|
+
- **Note:** Fluent API supports concurrent requests - adjust based on your needs
|
|
539
|
+
|
|
540
|
+
**Why Single Variable?**
|
|
541
|
+
|
|
542
|
+
- **Simpler:** One variable instead of two (`mode` + `concurrency`)
|
|
543
|
+
- **Clearer:** `concurrency: 1` = sequential, `concurrency > 1` = parallel
|
|
544
|
+
- **Less config:** Fewer activation variables to manage
|
|
545
|
+
- **Flexible:** Easy to tune performance (just change the number)
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
## 🔧 Production Reference Implementation
|
|
550
|
+
|
|
551
|
+
### Processing Approach
|
|
552
|
+
|
|
553
|
+
This template uses **per-file sequential processing**:
|
|
554
|
+
- Discovers files from S3 incoming prefix
|
|
555
|
+
- Processes each file completely (download → parse → map → send events → archive) before moving to the next
|
|
556
|
+
- Files are processed sequentially to prevent timeout issues and ensure proper error handling
|
|
557
|
+
- Use `maxFilesPerRun` activation variable to limit files per execution (default: 10)
|
|
558
|
+
|
|
559
|
+
**Why per-file processing?**
|
|
560
|
+
- ✅ Better error isolation - one bad file doesn't affect others
|
|
561
|
+
- ✅ Progressive archiving - files move to processed/errors immediately
|
|
562
|
+
- ✅ Lower memory footprint - one file in memory at a time
|
|
563
|
+
- ✅ Simpler code - no complex batching logic needed
|
|
564
|
+
|
|
565
|
+
### Versori Workflows Structure
|
|
566
|
+
|
|
567
|
+
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
568
|
+
|
|
569
|
+
**Trigger Types:**
|
|
570
|
+
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
571
|
+
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
572
|
+
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
573
|
+
|
|
574
|
+
**Execution Steps (chained to triggers):**
|
|
575
|
+
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
576
|
+
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
577
|
+
|
|
578
|
+
### Recommended Project Structure
|
|
579
|
+
|
|
580
|
+
```
|
|
581
|
+
product-event-sync/
|
|
582
|
+
├── index.ts # Entry point - exports all workflows
|
|
583
|
+
└── src/
|
|
584
|
+
├── workflows/
|
|
585
|
+
│ ├── scheduled/
|
|
586
|
+
│ │ └── daily-product-sync.ts # Scheduled: Daily product sync
|
|
587
|
+
│ │
|
|
588
|
+
│ └── webhook/
|
|
589
|
+
│ ├── adhoc-product-sync.ts # Webhook: Manual trigger
|
|
590
|
+
│ └── job-status-check.ts # Webhook: Status query
|
|
591
|
+
│
|
|
592
|
+
├── services/
|
|
593
|
+
│ └── product-sync.service.ts # Shared orchestration logic (reusable)
|
|
594
|
+
│
|
|
595
|
+
└── types/
|
|
596
|
+
└── product.types.ts # Shared type definitions
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
**Benefits:**
|
|
600
|
+
- ✅ Clear trigger separation (`scheduled/` vs `webhook/`)
|
|
601
|
+
- ✅ Descriptive file names (easy to browse and understand)
|
|
602
|
+
- ✅ Scalable (add new workflows without cluttering)
|
|
603
|
+
- ✅ Reusable code in `services/` (DRY principle)
|
|
604
|
+
- ✅ Easy to modify individual workflows without affecting others
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
## Workflow Files
|
|
609
|
+
|
|
610
|
+
### 1. Scheduled Workflows (`src/workflows/scheduled/`)
|
|
611
|
+
|
|
612
|
+
All time-based triggers that run automatically on cron schedules.
|
|
613
|
+
|
|
614
|
+
#### `src/workflows/scheduled/daily-product-sync.ts`
|
|
615
|
+
|
|
616
|
+
**Purpose**: Automatic Daily product sync
|
|
617
|
+
**Trigger**: Cron schedule (`0 2 * * *`)
|
|
618
|
+
**Exposed as Endpoint**: ❌ NO - Runs automatically
|
|
619
|
+
|
|
620
|
+
```typescript
|
|
621
|
+
import { schedule, http } from '@versori/run';
|
|
622
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
623
|
+
import { executeProductIngestion } from '../../services/product-sync.service';
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Scheduled Workflow: Daily Product Sync
|
|
627
|
+
*
|
|
628
|
+
* Runs automatically daily at 2 AM UTC
|
|
629
|
+
* NOT exposed as HTTP endpoint - Versori executes on schedule
|
|
630
|
+
*
|
|
631
|
+
* Uses shared service: product-sync.service.ts
|
|
632
|
+
*/
|
|
633
|
+
export const dailyProductSync = schedule(
|
|
634
|
+
'product-sync-scheduled',
|
|
635
|
+
'0 2 * * *' // Daily at 2 AM UTC
|
|
636
|
+
).then(
|
|
637
|
+
http('run-product-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
638
|
+
const { log, openKv } = ctx;
|
|
639
|
+
const jobId = `product-sync-${Date.now()}`;
|
|
640
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
641
|
+
|
|
642
|
+
await tracker.createJob(jobId, { triggeredBy: 'schedule', stage: 'initialization' });
|
|
643
|
+
await tracker.updateJob(jobId, { status: 'processing' });
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
// Reuse shared orchestration logic
|
|
647
|
+
const result = await executeProductIngestion(ctx, { jobId, triggeredBy: 'manual' }, tracker);
|
|
648
|
+
await tracker.markCompleted(jobId, result);
|
|
649
|
+
return { success: true, jobId, ...result };
|
|
650
|
+
} catch (e: any) {
|
|
651
|
+
await tracker.markFailed(jobId, e);
|
|
652
|
+
return { success: false, jobId, error: e?.message };
|
|
653
|
+
}
|
|
654
|
+
})
|
|
655
|
+
);
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
---
|
|
659
|
+
|
|
660
|
+
### 2. Webhook Workflows (`src/workflows/webhook/`)
|
|
661
|
+
|
|
662
|
+
All HTTP-based triggers that create webhook endpoints.
|
|
663
|
+
|
|
664
|
+
#### `src/workflows/webhook/adhoc-product-sync.ts`
|
|
665
|
+
|
|
666
|
+
**Purpose**: Manual product sync trigger (on-demand)
|
|
667
|
+
**Trigger**: HTTP POST
|
|
668
|
+
**Endpoint**: `POST https://{workspace}.versori.run/product-sync-adhoc`
|
|
669
|
+
**Use Cases**: Testing, priority processing, ad-hoc runs
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
import { webhook, http } from '@versori/run';
|
|
673
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
674
|
+
import { executeProductIngestion } from '../../services/product-sync.service';
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Webhook: Manual Product Sync Trigger
|
|
678
|
+
*
|
|
679
|
+
* Endpoint: POST https://{workspace}.versori.run/product-sync-adhoc
|
|
680
|
+
* Request body (optional): { filePattern: "urgent_*.csv", maxFiles: 5 }
|
|
681
|
+
*
|
|
682
|
+
* Pattern: webhook().then(http()) - needs Fluent API access
|
|
683
|
+
* Uses shared service: product-sync.service.ts
|
|
684
|
+
*
|
|
685
|
+
* SECURITY: Authentication handled via connection parameter
|
|
686
|
+
* No manual API key validation needed - Versori manages this via connection auth
|
|
687
|
+
*/
|
|
688
|
+
export const adhocProductSync = webhook('product-sync-adhoc', {
|
|
689
|
+
response: { mode: 'sync' }, // ✅ Sync mode: response sent when handler returns
|
|
690
|
+
connection: 'product-sync-adhoc', // Versori validates API key
|
|
691
|
+
}).then(
|
|
692
|
+
http('run-product-sync-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
693
|
+
const { log, openKv, data } = ctx;
|
|
694
|
+
const jobId = `product-sync-adhoc-${Date.now()}`;
|
|
695
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
696
|
+
|
|
697
|
+
const filePattern = data?.filePattern as string;
|
|
698
|
+
const maxFiles = data?.maxFiles as number;
|
|
699
|
+
|
|
700
|
+
log.info('🚀 [WEBHOOK] Adhoc product sync triggered', {
|
|
701
|
+
jobId,
|
|
702
|
+
filePattern,
|
|
703
|
+
maxFiles,
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// Create job entry FIRST (awaited to ensure job exists in KV)
|
|
707
|
+
await tracker.createJob(jobId, {
|
|
708
|
+
triggeredBy: 'manual',
|
|
709
|
+
stage: 'initialization',
|
|
710
|
+
status: 'queued',
|
|
711
|
+
options: { filePattern, maxFiles },
|
|
712
|
+
createdAt: new Date().toISOString(),
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// ✅ Fire-and-forget: Start background processing WITHOUT await
|
|
716
|
+
// The promise continues execution after we return the response
|
|
717
|
+
executeProductIngestion(ctx, { jobId, triggeredBy: 'manual' }, tracker)
|
|
718
|
+
.then((result) => {
|
|
719
|
+
log.info('✅ [BACKGROUND] Product sync completed successfully', {
|
|
720
|
+
jobId,
|
|
721
|
+
filesProcessed: result.filesProcessed,
|
|
722
|
+
filesFailed: result.filesFailed,
|
|
723
|
+
recordsProcessed: result.recordsProcessed,
|
|
724
|
+
});
|
|
725
|
+
return tracker.markCompleted(jobId, result);
|
|
726
|
+
})
|
|
727
|
+
.catch((error: unknown) => {
|
|
728
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
729
|
+
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
730
|
+
|
|
731
|
+
log.error('❌ [BACKGROUND] Product sync failed', {
|
|
732
|
+
jobId,
|
|
733
|
+
error: errorMessage,
|
|
734
|
+
stack: errorStack,
|
|
735
|
+
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
return tracker.markFailed(jobId, errorMessage);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// Return immediately with jobId (response sent with this return value)
|
|
742
|
+
return {
|
|
743
|
+
success: true,
|
|
744
|
+
jobId,
|
|
745
|
+
message: 'Product sync started in background',
|
|
746
|
+
statusEndpoint: `https://{workspace}.versori.run/product-sync-job-status`,
|
|
747
|
+
note: 'Poll the status endpoint with jobId to check progress',
|
|
748
|
+
};
|
|
749
|
+
})
|
|
750
|
+
);
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
#### `src/workflows/webhook/job-status-check.ts`
|
|
754
|
+
|
|
755
|
+
**Purpose**: Query job status
|
|
756
|
+
**Trigger**: HTTP POST
|
|
757
|
+
**Endpoint**: `POST https://{workspace}.versori.run/product-sync-job-status`
|
|
758
|
+
**Request body**: `{ "jobId": "product-sync-1234567890" }`
|
|
759
|
+
|
|
760
|
+
```typescript
|
|
761
|
+
import { webhook, fn } from '@versori/run';
|
|
762
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Webhook: Job Status Check
|
|
766
|
+
*
|
|
767
|
+
* Endpoint: POST https://{workspace}.versori.run/product-sync-job-status
|
|
768
|
+
* Request body: { "jobId": "product-sync-1234567890" }
|
|
769
|
+
*
|
|
770
|
+
* Pattern: webhook().then(fn()) - no external API needed, only KV storage
|
|
771
|
+
* Lightweight: Only queries KV store, no Fluent API calls
|
|
772
|
+
*
|
|
773
|
+
* SECURITY: Authentication handled via connection parameter
|
|
774
|
+
* No manual API key validation needed - Versori manages this via connection auth
|
|
775
|
+
*/
|
|
776
|
+
export const productSyncJobStatus = webhook('product-sync-job-status', {
|
|
777
|
+
response: { mode: 'sync' },
|
|
778
|
+
connection: 'product-sync-job-status',
|
|
779
|
+
}).then(
|
|
780
|
+
fn('status', async ctx => {
|
|
781
|
+
const { data, log, openKv } = ctx;
|
|
782
|
+
const jobId = data?.jobId as string;
|
|
783
|
+
|
|
784
|
+
if (!jobId) {
|
|
785
|
+
return { success: false, error: 'jobId required' };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
789
|
+
const status = await tracker.getJob(jobId);
|
|
790
|
+
|
|
791
|
+
return status
|
|
792
|
+
? { success: true, jobId, ...status }
|
|
793
|
+
: { success: false, error: 'Job not found', jobId };
|
|
794
|
+
})
|
|
795
|
+
);
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
### 3. Entry Point (`index.ts`)
|
|
801
|
+
|
|
802
|
+
**Purpose**: Register all workflows with Versori platform (MemoryInterpreter pattern)
|
|
803
|
+
|
|
804
|
+
```typescript
|
|
805
|
+
/**
|
|
806
|
+
* ═══════════════════════════════════════════════════════════════
|
|
807
|
+
* 🚀 VERSORI PRODUCT SYNC - ENTRY POINT
|
|
808
|
+
* ═══════════════════════════════════════════════════════════════
|
|
809
|
+
*
|
|
810
|
+
* Entry Point - Registers all workflows with Versori platform
|
|
811
|
+
*
|
|
812
|
+
* PATTERN: MemoryInterpreter
|
|
813
|
+
* - Export all workflows from a single file
|
|
814
|
+
* - Versori automatically discovers and registers exported workflows
|
|
815
|
+
* - Clean separation: scheduled vs webhook triggers
|
|
816
|
+
*
|
|
817
|
+
* File Structure:
|
|
818
|
+
* - src/workflows/scheduled/ → Time-based triggers (cron)
|
|
819
|
+
* - src/workflows/webhook/ → HTTP-based triggers (webhooks)
|
|
820
|
+
* - src/services/ → Shared business logic
|
|
821
|
+
*
|
|
822
|
+
* ═══════════════════════════════════════════════════════════════
|
|
823
|
+
*/
|
|
824
|
+
|
|
825
|
+
// Import scheduled workflows
|
|
826
|
+
import { dailyProductSync } from './src/workflows/scheduled/daily-product-sync';
|
|
827
|
+
|
|
828
|
+
// Import webhook workflows
|
|
829
|
+
import { adhocProductSync } from './src/workflows/webhook/adhoc-product-sync';
|
|
830
|
+
import { productSyncJobStatus } from './src/workflows/webhook/job-status-check';
|
|
831
|
+
|
|
832
|
+
// ═══════════════════════════════════════════════════════════════
|
|
833
|
+
// Register all workflows with Versori
|
|
834
|
+
// ═══════════════════════════════════════════════════════════════
|
|
835
|
+
export {
|
|
836
|
+
// Scheduled (time-based triggers)
|
|
837
|
+
dailyProductSync,
|
|
838
|
+
|
|
839
|
+
// Webhooks (HTTP-based triggers)
|
|
840
|
+
adhocProductSync,
|
|
841
|
+
productSyncJobStatus,
|
|
842
|
+
};
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
**What Gets Exposed:**
|
|
846
|
+
- ✅ `adhocProductSync` → `https://{workspace}.versori.run/product-sync-adhoc`
|
|
847
|
+
- ✅ `productSyncJobStatus` → `https://{workspace}.versori.run/product-sync-job-status`
|
|
848
|
+
- ❌ `dailyProductSync` → NOT exposed (runs automatically on cron)
|
|
849
|
+
|
|
850
|
+
---
|
|
851
|
+
|
|
852
|
+
### Adding New Workflows
|
|
853
|
+
|
|
854
|
+
**To add a scheduled workflow:**
|
|
855
|
+
1. Create file in `src/workflows/scheduled/` with descriptive name (e.g., `hourly-delta-sync.ts`)
|
|
856
|
+
2. Export the workflow from the file
|
|
857
|
+
3. Import and re-export in `index.ts`
|
|
858
|
+
|
|
859
|
+
**To add a webhook workflow:**
|
|
860
|
+
1. Create file in `src/workflows/webhook/` with descriptive name (e.g., `error-notification.ts`)
|
|
861
|
+
2. Export the workflow from the file
|
|
862
|
+
3. Import and re-export in `index.ts`
|
|
863
|
+
|
|
864
|
+
**Example - Adding hourly delta sync:**
|
|
865
|
+
|
|
866
|
+
```typescript
|
|
867
|
+
// src/workflows/scheduled/hourly-delta-sync.ts
|
|
868
|
+
export const hourlyDeltaSync = schedule(
|
|
869
|
+
'product-delta-hourly',
|
|
870
|
+
'0 * * * *' // Every hour
|
|
871
|
+
).then(
|
|
872
|
+
http('run-delta-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
873
|
+
// Delta sync logic (skip BPP)
|
|
874
|
+
const result = await runIngestion(ctx, jobId, tracker, { bppEnabled: false });
|
|
875
|
+
return result;
|
|
876
|
+
})
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
// index.ts (add to imports and exports)
|
|
880
|
+
import { hourlyDeltaSync } from './src/workflows/scheduled/hourly-delta-sync';
|
|
881
|
+
export { daily_product_sync, hourlyDeltaSync, ... };
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
---
|
|
885
|
+
## 3. Type Definitions (src/types/product-ingestion.types.ts)
|
|
886
|
+
|
|
887
|
+
```typescript
|
|
888
|
+
/**
|
|
889
|
+
* Type Definitions for Product Ingestion
|
|
890
|
+
*
|
|
891
|
+
* Centralized type definitions for product ingestion workflow
|
|
892
|
+
*/
|
|
893
|
+
|
|
894
|
+
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Product interface - represents transformed product data
|
|
898
|
+
*/
|
|
899
|
+
export interface Product {
|
|
900
|
+
ref: string;
|
|
901
|
+
type?: string;
|
|
902
|
+
status?: string;
|
|
903
|
+
name: string;
|
|
904
|
+
summary?: string;
|
|
905
|
+
gtin?: string;
|
|
906
|
+
catalogue?: { ref?: string };
|
|
907
|
+
attributes?: Record<string, unknown>;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Event configuration
|
|
912
|
+
*/
|
|
913
|
+
export interface EventConfig {
|
|
914
|
+
eventName: string;
|
|
915
|
+
catalogueRef: string;
|
|
916
|
+
catalogueType: string;
|
|
917
|
+
eventMode: 'async' | 'sync';
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Event result - tracks success/failure counts
|
|
922
|
+
*/
|
|
923
|
+
export interface EventResult {
|
|
924
|
+
eventsSent: number;
|
|
925
|
+
eventsFailed: number;
|
|
926
|
+
errors: Array<{ productRef: string; error: string }>;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Process file result
|
|
931
|
+
*/
|
|
932
|
+
export interface ProcessFileResult {
|
|
933
|
+
success: boolean;
|
|
934
|
+
products: Product[];
|
|
935
|
+
error?: string;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Versori Context Interface
|
|
940
|
+
* Represents the Versori runtime context passed to workflow functions
|
|
941
|
+
*/
|
|
942
|
+
export interface VersoriContext {
|
|
943
|
+
log: {
|
|
944
|
+
info: (message: string, data?: Record<string, unknown>) => void;
|
|
945
|
+
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
946
|
+
error: (message: string, data?: Record<string, unknown>) => void;
|
|
947
|
+
debug?: (message: string, data?: Record<string, unknown>) => void;
|
|
948
|
+
};
|
|
949
|
+
openKv: (namespace: string) => {
|
|
950
|
+
get: (key: string) => Promise<unknown>;
|
|
951
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
952
|
+
delete: (key: string) => Promise<void>;
|
|
953
|
+
};
|
|
954
|
+
activation: {
|
|
955
|
+
getVariable: (name: string) => string | undefined;
|
|
956
|
+
connections?: Record<string, unknown>;
|
|
957
|
+
};
|
|
958
|
+
connections?: Record<string, unknown>;
|
|
959
|
+
data?: unknown;
|
|
960
|
+
fetch?: typeof fetch;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Parameters for ingestion workflow
|
|
965
|
+
*/
|
|
966
|
+
export interface ProductIngestionParams {
|
|
967
|
+
jobId: string;
|
|
968
|
+
triggeredBy: 'schedule' | 'webhook';
|
|
969
|
+
filePattern?: string;
|
|
970
|
+
maxFiles?: number;
|
|
971
|
+
forceReprocess?: boolean;
|
|
972
|
+
catalogueRef?: string;
|
|
973
|
+
priority?: 'normal' | 'high';
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Result from ingestion workflow
|
|
978
|
+
*/
|
|
979
|
+
export interface ProductIngestionResult {
|
|
980
|
+
success: boolean;
|
|
981
|
+
jobId: string;
|
|
982
|
+
filesProcessed: number;
|
|
983
|
+
filesFailed: number;
|
|
984
|
+
filesSkipped?: number;
|
|
985
|
+
recordsProcessed: number;
|
|
986
|
+
eventsSent: number;
|
|
987
|
+
eventsFailed: number;
|
|
988
|
+
fileResults: FileProcessingResult[];
|
|
989
|
+
error?: string;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Per-file processing result
|
|
994
|
+
*/
|
|
995
|
+
export interface FileProcessingResult {
|
|
996
|
+
fileName: string;
|
|
997
|
+
success: boolean;
|
|
998
|
+
skipped?: boolean;
|
|
999
|
+
recordsProcessed: number;
|
|
1000
|
+
eventsSent: number;
|
|
1001
|
+
eventsFailed: number;
|
|
1002
|
+
duration: number;
|
|
1003
|
+
error?: string;
|
|
1004
|
+
}
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
## 4. Service: Product File Processor (`src/services/product-file-processor.service.ts`)
|
|
1008
|
+
|
|
1009
|
+
```typescript
|
|
1010
|
+
/**
|
|
1011
|
+
* Product File Processor Service
|
|
1012
|
+
*
|
|
1013
|
+
* Downloads CSV files from S3, parses, and transforms with UniversalMapper.
|
|
1014
|
+
* CSV-specific: Returns array directly - no normalization needed.
|
|
1015
|
+
*/
|
|
1016
|
+
|
|
1017
|
+
import {
|
|
1018
|
+
S3DataSource,
|
|
1019
|
+
CSVParserService,
|
|
1020
|
+
UniversalMapper,
|
|
1021
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1022
|
+
import type { ProcessFileResult, Product } from '../types/product-ingestion.types';
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Service for processing CSV product files from S3
|
|
1026
|
+
*/
|
|
1027
|
+
export class ProductFileProcessorService {
|
|
1028
|
+
constructor(
|
|
1029
|
+
private s3: S3DataSource,
|
|
1030
|
+
private csvParser: CSVParserService,
|
|
1031
|
+
private mapper: UniversalMapper,
|
|
1032
|
+
private catalogueRef: string
|
|
1033
|
+
) {}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Download CSV file from S3, parse, and transform with UniversalMapper
|
|
1037
|
+
*
|
|
1038
|
+
* ✅ PRODUCTION ENHANCEMENT: Memory cleanup pattern
|
|
1039
|
+
* - Explicit null assignments after each step
|
|
1040
|
+
* - Finally block guarantees cleanup
|
|
1041
|
+
* - Prevents OOM errors on large CSV files
|
|
1042
|
+
*/
|
|
1043
|
+
async downloadParseAndTransform(objectKey: string): Promise<ProcessFileResult> {
|
|
1044
|
+
// ✅ CRITICAL: Variables for cleanup tracking
|
|
1045
|
+
let csvContent: string | null = null;
|
|
1046
|
+
let parsed: unknown | null = null;
|
|
1047
|
+
|
|
1048
|
+
try {
|
|
1049
|
+
// STEP 1: Download CSV content from S3
|
|
1050
|
+
csvContent = (await this.s3.downloadObject(objectKey, {
|
|
1051
|
+
encoding: 'utf8',
|
|
1052
|
+
})) as string;
|
|
1053
|
+
|
|
1054
|
+
// STEP 2: Parse CSV with type safety
|
|
1055
|
+
try {
|
|
1056
|
+
parsed = await this.csvParser.parse(csvContent);
|
|
1057
|
+
} catch (parseError: unknown) {
|
|
1058
|
+
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
|
1059
|
+
return {
|
|
1060
|
+
success: false,
|
|
1061
|
+
products: [],
|
|
1062
|
+
error: `CSV parse error: ${errorMessage}`,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ✅ Clear CSV content from memory - no longer needed after parsing
|
|
1067
|
+
csvContent = null;
|
|
1068
|
+
|
|
1069
|
+
// STEP 3: CSV-SPECIFIC - Returns array directly, no normalization needed
|
|
1070
|
+
const rawProducts = Array.isArray(parsed) ? parsed : [];
|
|
1071
|
+
|
|
1072
|
+
if (rawProducts.length === 0) {
|
|
1073
|
+
return {
|
|
1074
|
+
success: false,
|
|
1075
|
+
products: [],
|
|
1076
|
+
error: 'No products found in CSV',
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// ✅ Clear parsed data from memory - only need rawProducts now
|
|
1081
|
+
parsed = null;
|
|
1082
|
+
|
|
1083
|
+
// STEP 4: Transform with UniversalMapper
|
|
1084
|
+
// ✅ S3 CSV: Merge context using spread pattern (same as SFTP CSV)
|
|
1085
|
+
const sourceDataWithContext = rawProducts.map(item => ({
|
|
1086
|
+
...item,
|
|
1087
|
+
$context: { catalogueRef: this.catalogueRef },
|
|
1088
|
+
}));
|
|
1089
|
+
|
|
1090
|
+
const mappingResult = await this.mapper.map(sourceDataWithContext);
|
|
1091
|
+
|
|
1092
|
+
if (!mappingResult.success) {
|
|
1093
|
+
return {
|
|
1094
|
+
success: false,
|
|
1095
|
+
products: [],
|
|
1096
|
+
error: `Mapping validation failed: ${mappingResult.errors?.join(', ')}`,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
1101
|
+
this.log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
1102
|
+
skippedFields: mappingResult.skippedFields,
|
|
1103
|
+
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return {
|
|
1108
|
+
success: true,
|
|
1109
|
+
products: mappingResult.data as Product[],
|
|
1110
|
+
};
|
|
1111
|
+
} catch (error: unknown) {
|
|
1112
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1113
|
+
return {
|
|
1114
|
+
success: false,
|
|
1115
|
+
products: [],
|
|
1116
|
+
error: errorMessage,
|
|
1117
|
+
};
|
|
1118
|
+
} finally {
|
|
1119
|
+
// ✅ CRITICAL: Ensure all large objects are cleared even if error occurs
|
|
1120
|
+
csvContent = null;
|
|
1121
|
+
parsed = null;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
---
|
|
1128
|
+
|
|
1129
|
+
## 5. Service: Event Sender (`src/services/event-sender.service.ts`)
|
|
1130
|
+
|
|
1131
|
+
```typescript
|
|
1132
|
+
/**
|
|
1133
|
+
* Event Sender Service
|
|
1134
|
+
*
|
|
1135
|
+
* Sends product events to Fluent Commerce Event API with per-record error handling.
|
|
1136
|
+
* Continues processing on individual failures (Event API best practice).
|
|
1137
|
+
* Supports configurable concurrency (sequential or parallel).
|
|
1138
|
+
*/
|
|
1139
|
+
|
|
1140
|
+
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1141
|
+
import type { EventResult, EventConfig, Product } from '../types/product-ingestion.types';
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Service for sending events to Fluent Commerce Event API
|
|
1145
|
+
*
|
|
1146
|
+
* ✅ PRODUCTION ENHANCEMENT: Optional logger for detailed progress tracking
|
|
1147
|
+
*/
|
|
1148
|
+
export class EventSenderService {
|
|
1149
|
+
constructor(
|
|
1150
|
+
private client: FluentClient,
|
|
1151
|
+
private log?: any // ✅ Optional logger for progress tracking
|
|
1152
|
+
) {}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Send events to Fluent Commerce Event API with configurable concurrency
|
|
1156
|
+
*
|
|
1157
|
+
* **Performance Characteristics:**
|
|
1158
|
+
* - `concurrency: 1` → Sequential processing (safe default, ~1 event/sec)
|
|
1159
|
+
* - `concurrency: 3-5` → Balanced throughput (~3-5 events/sec, good for most cases)
|
|
1160
|
+
* - `concurrency: 10` → High-volume processing (~10 events/sec, 100+ products)
|
|
1161
|
+
*
|
|
1162
|
+
* **Implementation Strategy:**
|
|
1163
|
+
* - Concurrency = 1: Optimized sequential loop (no Promise.allSettled overhead)
|
|
1164
|
+
* - Concurrency > 1: Chunked parallel processing with bounded concurrency
|
|
1165
|
+
* - Both modes: Per-record error tracking (failures don't block other events)
|
|
1166
|
+
*
|
|
1167
|
+
* @param products - Array of products to send as events
|
|
1168
|
+
* @param eventConfig - Event configuration (name, catalogue ref, mode)
|
|
1169
|
+
* @param concurrency - Number of concurrent event requests (default: 1, min: 1)
|
|
1170
|
+
* @returns EventResult with counts (eventsSent/eventsFailed) and error details
|
|
1171
|
+
*/
|
|
1172
|
+
async sendEvents(
|
|
1173
|
+
products: Product[],
|
|
1174
|
+
eventConfig: EventConfig,
|
|
1175
|
+
concurrency: number = 1
|
|
1176
|
+
): Promise<EventResult> {
|
|
1177
|
+
// Validate concurrency (guard against invalid values)
|
|
1178
|
+
const safeConc = Math.max(1, Math.floor(concurrency));
|
|
1179
|
+
|
|
1180
|
+
// Result accumulators
|
|
1181
|
+
let eventsSent = 0;
|
|
1182
|
+
let eventsFailed = 0;
|
|
1183
|
+
const errors: Array<{ productRef: string; error: string }> = [];
|
|
1184
|
+
|
|
1185
|
+
// ✅ PRODUCTION ENHANCEMENT: Log event sending start
|
|
1186
|
+
if (this.log) {
|
|
1187
|
+
this.log.info('📤 Starting event sending', {
|
|
1188
|
+
totalProducts: products.length,
|
|
1189
|
+
concurrency: safeConc,
|
|
1190
|
+
processingMode: safeConc === 1 ? 'sequential (one at a time)' : `parallel (${safeConc} concurrently)`,
|
|
1191
|
+
eventName: eventConfig.eventName,
|
|
1192
|
+
catalogueRef: eventConfig.catalogueRef
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Helper: Build event payload (DRY - reused in both modes)
|
|
1197
|
+
const buildPayload = (product: Product) => ({
|
|
1198
|
+
name: eventConfig.eventName,
|
|
1199
|
+
entityRef: eventConfig.catalogueRef,
|
|
1200
|
+
entityType: 'PRODUCT_CATALOGUE' as const,
|
|
1201
|
+
entitySubtype: eventConfig.catalogueType,
|
|
1202
|
+
rootEntityRef: eventConfig.catalogueRef,
|
|
1203
|
+
rootEntityType: 'PRODUCT_CATALOGUE' as const,
|
|
1204
|
+
attributes: product,
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
// ============================================================================
|
|
1208
|
+
// SEQUENTIAL MODE (concurrency === 1)
|
|
1209
|
+
// ============================================================================
|
|
1210
|
+
if (safeConc === 1) {
|
|
1211
|
+
for (let i = 0; i < products.length; i++) {
|
|
1212
|
+
const product = products[i];
|
|
1213
|
+
|
|
1214
|
+
// ✅ PRODUCTION ENHANCEMENT: Log progress every 10 products
|
|
1215
|
+
if (this.log && i % 10 === 0) {
|
|
1216
|
+
this.log.info(`📤 Sending product ${i + 1}/${products.length}`, {
|
|
1217
|
+
productRef: product.ref,
|
|
1218
|
+
progress: `${((i / products.length) * 100).toFixed(1)}%`
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
try {
|
|
1223
|
+
await this.client.sendEvent(buildPayload(product), eventConfig.eventMode);
|
|
1224
|
+
eventsSent++;
|
|
1225
|
+
} catch (err: unknown) {
|
|
1226
|
+
eventsFailed++;
|
|
1227
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1228
|
+
errors.push({ productRef: product?.ref || 'unknown', error: errorMsg });
|
|
1229
|
+
// Continue processing (failure doesn't block other products)
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// ✅ PRODUCTION ENHANCEMENT: Log completion
|
|
1234
|
+
if (this.log) {
|
|
1235
|
+
this.log.info('✅ Sequential event sending completed', {
|
|
1236
|
+
totalProducts: products.length,
|
|
1237
|
+
eventsSent,
|
|
1238
|
+
eventsFailed
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return { eventsSent, eventsFailed, errors };
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// ============================================================================
|
|
1246
|
+
// PARALLEL MODE (concurrency > 1)
|
|
1247
|
+
// ============================================================================
|
|
1248
|
+
const totalChunks = Math.ceil(products.length / safeConc);
|
|
1249
|
+
|
|
1250
|
+
for (let i = 0; i < products.length; i += safeConc) {
|
|
1251
|
+
const chunk = products.slice(i, i + safeConc);
|
|
1252
|
+
const chunkNumber = Math.floor(i / safeConc) + 1;
|
|
1253
|
+
|
|
1254
|
+
// ✅ PRODUCTION ENHANCEMENT: Log chunk progress
|
|
1255
|
+
if (this.log) {
|
|
1256
|
+
this.log.info(`📦 Processing chunk ${chunkNumber}/${totalChunks}`, {
|
|
1257
|
+
chunkNumber,
|
|
1258
|
+
totalChunks,
|
|
1259
|
+
productsInChunk: chunk.length,
|
|
1260
|
+
productRange: `${i + 1}-${i + chunk.length}`,
|
|
1261
|
+
totalProducts: products.length,
|
|
1262
|
+
progress: `${((i / products.length) * 100).toFixed(1)}%`
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Fire all requests in chunk concurrently
|
|
1267
|
+
const results = await Promise.allSettled(
|
|
1268
|
+
chunk.map(product =>
|
|
1269
|
+
this.client
|
|
1270
|
+
.sendEvent(buildPayload(product), eventConfig.eventMode)
|
|
1271
|
+
.then(() => ({ success: true as const, product }))
|
|
1272
|
+
.catch(error => ({ success: false as const, product, error }))
|
|
1273
|
+
)
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
// Aggregate chunk results into totals
|
|
1277
|
+
let chunkSuccess = 0;
|
|
1278
|
+
let chunkFailed = 0;
|
|
1279
|
+
|
|
1280
|
+
for (const result of results) {
|
|
1281
|
+
if (result.status === 'fulfilled' && result.value.success) {
|
|
1282
|
+
eventsSent++;
|
|
1283
|
+
chunkSuccess++;
|
|
1284
|
+
} else {
|
|
1285
|
+
eventsFailed++;
|
|
1286
|
+
chunkFailed++;
|
|
1287
|
+
const error = result.status === 'fulfilled' ? result.value.error : result.reason;
|
|
1288
|
+
const product = result.status === 'fulfilled' ? result.value.product : null;
|
|
1289
|
+
errors.push({
|
|
1290
|
+
productRef: product?.ref || 'unknown',
|
|
1291
|
+
error: error?.message || String(error) || 'unknown error',
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// ✅ PRODUCTION ENHANCEMENT: Log chunk completion
|
|
1297
|
+
if (this.log) {
|
|
1298
|
+
this.log.info(`✅ Chunk ${chunkNumber}/${totalChunks} completed`, {
|
|
1299
|
+
chunkNumber,
|
|
1300
|
+
totalChunks,
|
|
1301
|
+
chunkSuccess,
|
|
1302
|
+
chunkFailed,
|
|
1303
|
+
totalSentSoFar: eventsSent,
|
|
1304
|
+
totalFailedSoFar: eventsFailed,
|
|
1305
|
+
progress: `${(((i + chunk.length) / products.length) * 100).toFixed(1)}%`
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ✅ PRODUCTION ENHANCEMENT: Log parallel completion
|
|
1311
|
+
if (this.log) {
|
|
1312
|
+
this.log.info('✅ Parallel event sending completed', {
|
|
1313
|
+
totalProducts: products.length,
|
|
1314
|
+
totalChunks,
|
|
1315
|
+
concurrency: safeConc,
|
|
1316
|
+
eventsSent,
|
|
1317
|
+
eventsFailed
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
return { eventsSent, eventsFailed, errors };
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
---
|
|
1327
|
+
|
|
1328
|
+
## 6. Service: Event Logger (`src/services/event-logger.service.ts`)
|
|
1329
|
+
|
|
1330
|
+
```typescript
|
|
1331
|
+
/**
|
|
1332
|
+
* Event Logger Service
|
|
1333
|
+
*
|
|
1334
|
+
* Writes event processing logs to S3 for audit/debugging.
|
|
1335
|
+
* Optional - can be used to create rejection reports.
|
|
1336
|
+
*/
|
|
1337
|
+
|
|
1338
|
+
import { Buffer } from 'node:buffer';
|
|
1339
|
+
import { S3DataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Service for writing event logs to S3
|
|
1343
|
+
*/
|
|
1344
|
+
export class EventLoggerService {
|
|
1345
|
+
constructor(private s3: S3DataSource) {}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Write event log to S3
|
|
1349
|
+
*
|
|
1350
|
+
* @param objectKey - S3 object key for log file
|
|
1351
|
+
* @param logData - Log data to write (JSON format)
|
|
1352
|
+
*/
|
|
1353
|
+
async writeEventLog(objectKey: string, logData: unknown): Promise<void> {
|
|
1354
|
+
try {
|
|
1355
|
+
const logContent = JSON.stringify(logData, null, 2);
|
|
1356
|
+
await this.s3.uploadFile(objectKey, logContent);
|
|
1357
|
+
} catch (error: unknown) {
|
|
1358
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1359
|
+
throw new Error(`Failed to write event log: ${errorMessage}`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
## 7. Main Orchestration Service (src/services/product-ingestion.service.ts)
|
|
1366
|
+
|
|
1367
|
+
```typescript
|
|
1368
|
+
/**
|
|
1369
|
+
* ═══════════════════════════════════════════════════════════════
|
|
1370
|
+
* 📦 MAIN INGESTION ORCHESTRATION SERVICE
|
|
1371
|
+
* ═══════════════════════════════════════════════════════════════
|
|
1372
|
+
*
|
|
1373
|
+
* This is the heart of the ingestion workflow. It coordinates all steps:
|
|
1374
|
+
* 1. Initialize clients and services
|
|
1375
|
+
* 2. Discover files on S3
|
|
1376
|
+
* 3. Download and parse CSV files
|
|
1377
|
+
* 4. Transform data with UniversalMapper
|
|
1378
|
+
* 5. Send events to Fluent Commerce
|
|
1379
|
+
* 6. Archive processed files
|
|
1380
|
+
* 7. Track file processing state (S3 uses moveObject for deduplication)
|
|
1381
|
+
* 8. Track job progress with JobTracker
|
|
1382
|
+
*
|
|
1383
|
+
* KEY DIFFERENCES FOR S3 CSV:
|
|
1384
|
+
* - S3DataSource instead of SftpDataSource
|
|
1385
|
+
* - CSVParserService instead of XMLParserService
|
|
1386
|
+
* - No VersoriFileTracker (S3 uses moveObject for deduplication)
|
|
1387
|
+
* - S3 listObjects instead of SFTP listFiles
|
|
1388
|
+
* - S3 moveObject instead of SFTP moveFile
|
|
1389
|
+
* - CSV returns array directly (no normalization needed)
|
|
1390
|
+
* - CSV spread pattern for context injection
|
|
1391
|
+
*
|
|
1392
|
+
* ═══════════════════════════════════════════════════════════════
|
|
1393
|
+
*/
|
|
1394
|
+
|
|
1395
|
+
import { Buffer } from 'node:buffer';
|
|
1396
|
+
import {
|
|
1397
|
+
createClient,
|
|
1398
|
+
S3DataSource,
|
|
1399
|
+
CSVParserService,
|
|
1400
|
+
UniversalMapper,
|
|
1401
|
+
JobTracker,
|
|
1402
|
+
type FluentClient,
|
|
1403
|
+
type JobStatus,
|
|
1404
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1405
|
+
import type {
|
|
1406
|
+
ProductIngestionParams,
|
|
1407
|
+
ProductIngestionResult,
|
|
1408
|
+
FileProcessingResult,
|
|
1409
|
+
EventConfig,
|
|
1410
|
+
} from '../types/product-ingestion.types';
|
|
1411
|
+
import { ProductFileProcessorService } from './product-file-processor.service';
|
|
1412
|
+
import { EventSenderService } from './event-sender.service';
|
|
1413
|
+
import { EventLoggerService } from './event-logger.service';
|
|
1414
|
+
import { timestampedName } from '../utils/s3-path.utils';
|
|
1415
|
+
|
|
1416
|
+
import mappingConfig from '../../config/products.import.csv.json' with { type: 'json' };
|
|
1417
|
+
|
|
1418
|
+
// Custom resolvers for CSV mapping (prices, categories, taxType)
|
|
1419
|
+
const customResolvers = {
|
|
1420
|
+
'custom.splitByPipe': (value: string) => {
|
|
1421
|
+
if (!value || value.trim() === '') return [];
|
|
1422
|
+
return value
|
|
1423
|
+
.split('|')
|
|
1424
|
+
.map(v => v.trim())
|
|
1425
|
+
.filter(v => v !== '');
|
|
1426
|
+
},
|
|
1427
|
+
'custom.buildPriceArray': (value: any, sourceData: any) => {
|
|
1428
|
+
const prices = [];
|
|
1429
|
+
if (sourceData.price_default_value) {
|
|
1430
|
+
prices.push({
|
|
1431
|
+
type: 'DEFAULT',
|
|
1432
|
+
currency: sourceData.price_default_currency || 'USD',
|
|
1433
|
+
value: sourceData.price_default_value,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
if (sourceData.price_special_value) {
|
|
1437
|
+
prices.push({
|
|
1438
|
+
type: 'SPECIAL',
|
|
1439
|
+
currency: sourceData.price_special_currency || 'USD',
|
|
1440
|
+
value: sourceData.price_special_value,
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
return prices;
|
|
1444
|
+
},
|
|
1445
|
+
'custom.buildTaxType': (value: any, sourceData: any) => {
|
|
1446
|
+
if (!sourceData.tax_country) return undefined;
|
|
1447
|
+
return {
|
|
1448
|
+
country: sourceData.tax_country,
|
|
1449
|
+
group: sourceData.tax_group,
|
|
1450
|
+
tariff: sourceData.tax_tariff,
|
|
1451
|
+
};
|
|
1452
|
+
},
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Query job status from KV store
|
|
1457
|
+
*/
|
|
1458
|
+
export async function getJobStatus(
|
|
1459
|
+
kv: ReturnType<VersoriContext['openKv']>,
|
|
1460
|
+
jobId: string,
|
|
1461
|
+
log: VersoriContext['log']
|
|
1462
|
+
): Promise<JobStatus | undefined> {
|
|
1463
|
+
try {
|
|
1464
|
+
const tracker = new JobTracker(kv, log);
|
|
1465
|
+
return await tracker.getJob(jobId);
|
|
1466
|
+
} catch (error: unknown) {
|
|
1467
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1468
|
+
log.error('Failed to get job status', {
|
|
1469
|
+
jobId,
|
|
1470
|
+
message: errorMessage,
|
|
1471
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1472
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1473
|
+
});
|
|
1474
|
+
return undefined;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* MAIN ORCHESTRATION FUNCTION
|
|
1480
|
+
*
|
|
1481
|
+
* This function implements the complete ingestion workflow in 8 steps.
|
|
1482
|
+
*/
|
|
1483
|
+
export async function executeProductIngestion(
|
|
1484
|
+
ctx: VersoriContext,
|
|
1485
|
+
params: ProductIngestionParams
|
|
1486
|
+
): Promise<ProductIngestionResult> {
|
|
1487
|
+
|
|
1488
|
+
const { log, openKv, activation } = ctx;
|
|
1489
|
+
const { jobId, triggeredBy, filePattern, maxFiles, forceReprocess } = params;
|
|
1490
|
+
|
|
1491
|
+
const kv = openKv(':project:');
|
|
1492
|
+
const tracker = new JobTracker(kv, log);
|
|
1493
|
+
|
|
1494
|
+
const startTime = Date.now();
|
|
1495
|
+
const fileResults: FileProcessingResult[] = [];
|
|
1496
|
+
|
|
1497
|
+
// ⚠️ CRITICAL: Declare S3 outside try block for disposal pattern
|
|
1498
|
+
let s3: S3DataSource | undefined;
|
|
1499
|
+
|
|
1500
|
+
try {
|
|
1501
|
+
// ═══════════════════════════════════════════════════════════
|
|
1502
|
+
// 🚀 STEP 1/8: Initialize Job Tracking
|
|
1503
|
+
// ═══════════════════════════════════════════════════════════
|
|
1504
|
+
log.info('🚀 [STEP 1/8] Initializing job tracking', { jobId });
|
|
1505
|
+
|
|
1506
|
+
await tracker.createJob(jobId, {
|
|
1507
|
+
triggeredBy,
|
|
1508
|
+
filePattern: filePattern || 'default',
|
|
1509
|
+
maxFiles: maxFiles || 'unlimited',
|
|
1510
|
+
forceReprocess: !!forceReprocess,
|
|
1511
|
+
startTime,
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
// ═══════════════════════════════════════════════════════════
|
|
1515
|
+
// 🔧 STEP 2/8: Initialize Fluent Client & S3 Connection
|
|
1516
|
+
// ═══════════════════════════════════════════════════════════
|
|
1517
|
+
log.info('🔧 [STEP 2/8] Initializing Fluent Commerce client and S3', { jobId });
|
|
1518
|
+
|
|
1519
|
+
const client = await createClient(ctx);
|
|
1520
|
+
|
|
1521
|
+
if (!client) {
|
|
1522
|
+
const error = 'Failed to create Fluent Commerce client';
|
|
1523
|
+
log.error(`❌ ${error}`);
|
|
1524
|
+
throw new Error(error);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
log.info('✅ [Client] Fluent Commerce client created successfully');
|
|
1528
|
+
|
|
1529
|
+
// ✅ CRITICAL: Set retailerId for Event API calls
|
|
1530
|
+
const fluentRetailerId = activation.getVariable('fluentRetailerId');
|
|
1531
|
+
if (!fluentRetailerId) {
|
|
1532
|
+
const error = 'fluentRetailerId is required for Event API calls';
|
|
1533
|
+
log.error(`❌ ${error}`, {
|
|
1534
|
+
recommendation: 'Set fluentRetailerId in activation variables',
|
|
1535
|
+
variable: 'fluentRetailerId',
|
|
1536
|
+
});
|
|
1537
|
+
throw new Error(error);
|
|
1538
|
+
}
|
|
1539
|
+
client.setRetailerId(fluentRetailerId);
|
|
1540
|
+
log.info('✅ [Client] RetailerId set for Event API', { retailerId: fluentRetailerId });
|
|
1541
|
+
|
|
1542
|
+
// Get S3 configuration from activation variables
|
|
1543
|
+
const s3Config = {
|
|
1544
|
+
bucket: activation.getVariable('s3Bucket'),
|
|
1545
|
+
region: activation.getVariable('s3Region') || 'us-east-1',
|
|
1546
|
+
accessKeyId: activation.getVariable('s3AccessKeyId'),
|
|
1547
|
+
secretAccessKey: activation.getVariable('s3SecretAccessKey'),
|
|
1548
|
+
incomingPrefix: activation.getVariable('s3IncomingPrefix') || 'products/incoming/',
|
|
1549
|
+
processedPrefix: activation.getVariable('s3ProcessedPrefix') || 'products/processed/',
|
|
1550
|
+
errorPrefix: activation.getVariable('s3ErrorPrefix') || 'products/errors/',
|
|
1551
|
+
logsPrefix: activation.getVariable('s3LogsPrefix') || 'products/logs/',
|
|
1552
|
+
filePattern: filePattern || activation.getVariable('filePattern') || 'products_*.csv'
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
// Validate S3 config
|
|
1556
|
+
if (!s3Config.bucket) {
|
|
1557
|
+
const error = 'S3 configuration incomplete: missing bucket';
|
|
1558
|
+
log.error(`❌ ${error}`, {
|
|
1559
|
+
recommendation: 'Set s3Bucket in activation variables',
|
|
1560
|
+
variable: 's3Bucket',
|
|
1561
|
+
});
|
|
1562
|
+
throw new Error(error);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
if (!s3Config.accessKeyId || !s3Config.secretAccessKey) {
|
|
1566
|
+
const error = 'S3 configuration incomplete: missing accessKeyId or secretAccessKey';
|
|
1567
|
+
log.error(`❌ ${error}`, {
|
|
1568
|
+
recommendation: 'Set s3AccessKeyId and s3SecretAccessKey in activation variables',
|
|
1569
|
+
variables: ['s3AccessKeyId', 's3SecretAccessKey'],
|
|
1570
|
+
});
|
|
1571
|
+
throw new Error(error);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Initialize S3 data source
|
|
1575
|
+
s3 = new S3DataSource(
|
|
1576
|
+
{
|
|
1577
|
+
type: 'S3_CSV',
|
|
1578
|
+
connectionId: 's3-product-ingestion',
|
|
1579
|
+
name: 'Product Ingestion S3',
|
|
1580
|
+
s3Config: {
|
|
1581
|
+
bucket: s3Config.bucket,
|
|
1582
|
+
region: s3Config.region,
|
|
1583
|
+
accessKeyId: s3Config.accessKeyId,
|
|
1584
|
+
secretAccessKey: s3Config.secretAccessKey,
|
|
1585
|
+
},
|
|
1586
|
+
validateConnection: true, // ✅ Enable connection validation
|
|
1587
|
+
},
|
|
1588
|
+
log
|
|
1589
|
+
);
|
|
1590
|
+
|
|
1591
|
+
try {
|
|
1592
|
+
// Validate S3 connection
|
|
1593
|
+
log.info('🔍 [S3] Validating S3 connection...');
|
|
1594
|
+
await s3.validateConnection();
|
|
1595
|
+
log.info('✅ [S3] S3 connection validated successfully');
|
|
1596
|
+
|
|
1597
|
+
// ═══════════════════════════════════════════════════════════
|
|
1598
|
+
// 🔍 STEP 3/8: Discover Files on S3
|
|
1599
|
+
// ═══════════════════════════════════════════════════════════
|
|
1600
|
+
log.info('🔍 [STEP 3/8] Discovering files on S3', {
|
|
1601
|
+
jobId,
|
|
1602
|
+
prefix: s3Config.incomingPrefix,
|
|
1603
|
+
filePattern: s3Config.filePattern
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
await tracker.updateJob(jobId, {
|
|
1607
|
+
status: 'processing',
|
|
1608
|
+
stage: 'file_discovery',
|
|
1609
|
+
message: 'Discovering files on S3'
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
// List objects from S3
|
|
1613
|
+
const allFiles = await s3.listObjects({
|
|
1614
|
+
prefix: s3Config.incomingPrefix,
|
|
1615
|
+
pattern: s3Config.filePattern
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
log.info(`✅ [Discovery] Found ${allFiles.length} files matching pattern`);
|
|
1619
|
+
|
|
1620
|
+
// Apply maxFiles limit if specified
|
|
1621
|
+
const filesToProcess = maxFiles ? allFiles.slice(0, maxFiles) : allFiles;
|
|
1622
|
+
|
|
1623
|
+
if (filesToProcess.length === 0) {
|
|
1624
|
+
log.info('⚠️ [Discovery] No files to process');
|
|
1625
|
+
|
|
1626
|
+
await tracker.markCompleted(jobId, {
|
|
1627
|
+
filesProcessed: 0,
|
|
1628
|
+
message: 'No files found to process',
|
|
1629
|
+
duration: Date.now() - startTime,
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
return {
|
|
1633
|
+
success: true,
|
|
1634
|
+
jobId,
|
|
1635
|
+
filesProcessed: 0,
|
|
1636
|
+
filesFailed: 0,
|
|
1637
|
+
recordsProcessed: 0,
|
|
1638
|
+
eventsSent: 0,
|
|
1639
|
+
eventsFailed: 0,
|
|
1640
|
+
fileResults: [],
|
|
1641
|
+
duration: Date.now() - startTime,
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
log.info(`📄 [Discovery] Processing ${filesToProcess.length} files`);
|
|
1646
|
+
|
|
1647
|
+
// Initialize services
|
|
1648
|
+
const csvParser = new CSVParserService();
|
|
1649
|
+
const mapper = new UniversalMapper(mappingConfig, { customResolvers });
|
|
1650
|
+
|
|
1651
|
+
// Get event configuration
|
|
1652
|
+
const eventConfig: EventConfig = {
|
|
1653
|
+
catalogueRef: params.catalogueRef || activation.getVariable('catalogueRef') || 'PC:MASTER:2',
|
|
1654
|
+
catalogueType: activation.getVariable('catalogueType') || 'MASTER',
|
|
1655
|
+
eventName: activation.getVariable('eventName') || 'UPSERT_PRODUCT',
|
|
1656
|
+
eventMode: (activation.getVariable('eventMode') || 'async') as 'async' | 'sync',
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
// Get event sending configuration
|
|
1660
|
+
const eventConcurrency = Math.max(
|
|
1661
|
+
1,
|
|
1662
|
+
parseInt(activation.getVariable('eventConcurrency') || '1', 10)
|
|
1663
|
+
);
|
|
1664
|
+
|
|
1665
|
+
log.info(`Event concurrency: ${eventConcurrency}`, {
|
|
1666
|
+
mode: eventConcurrency === 1 ? 'sequential' : 'parallel',
|
|
1667
|
+
concurrentRequests: eventConcurrency === 1 ? 'N/A' : eventConcurrency,
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
// Initialize class-based services
|
|
1671
|
+
const fileProcessor = new ProductFileProcessorService(
|
|
1672
|
+
s3,
|
|
1673
|
+
csvParser,
|
|
1674
|
+
mapper,
|
|
1675
|
+
eventConfig.catalogueRef
|
|
1676
|
+
);
|
|
1677
|
+
// ✅ PRODUCTION ENHANCEMENT: Pass log to EventSenderService for detailed progress tracking
|
|
1678
|
+
const eventSender = new EventSenderService(client, log);
|
|
1679
|
+
const eventLogger = new EventLoggerService(s3);
|
|
1680
|
+
|
|
1681
|
+
// ═══════════════════════════════════════════════════════════
|
|
1682
|
+
// STEP 4-7: Process Each File (Download → Parse → Transform → Send → Archive)
|
|
1683
|
+
// ═══════════════════════════════════════════════════════════
|
|
1684
|
+
|
|
1685
|
+
for (let fileIndex = 0; fileIndex < filesToProcess.length; fileIndex++) {
|
|
1686
|
+
const file = filesToProcess[fileIndex];
|
|
1687
|
+
const fileStartTime = Date.now();
|
|
1688
|
+
|
|
1689
|
+
// Use object key from S3 listing
|
|
1690
|
+
const objectKey = file.key || (file as any).name;
|
|
1691
|
+
const baseName = objectKey.split('/').pop() || objectKey;
|
|
1692
|
+
|
|
1693
|
+
// ═══════════════════════════════════════════════════════════
|
|
1694
|
+
log.info(`\n${'═'.repeat(60)}`);
|
|
1695
|
+
log.info(`📄 [FILE ${fileIndex + 1}/${filesToProcess.length}] Processing: ${baseName}`);
|
|
1696
|
+
log.info(`${'═'.repeat(60)}`);
|
|
1697
|
+
// ═══════════════════════════════════════════════════════════
|
|
1698
|
+
|
|
1699
|
+
try {
|
|
1700
|
+
await tracker.updateJob(jobId, {
|
|
1701
|
+
status: 'processing',
|
|
1702
|
+
stage: 'downloading',
|
|
1703
|
+
message: `Processing file ${fileIndex + 1} of ${filesToProcess.length}: ${baseName}`
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
// ═══════════════════════════════════════════════════════════
|
|
1707
|
+
// 📥 STEP 4: Process File (Download + Parse + Map)
|
|
1708
|
+
// ═══════════════════════════════════════════════════════════
|
|
1709
|
+
log.info(`📥 [STEP 4/8] Processing file: ${baseName}`);
|
|
1710
|
+
|
|
1711
|
+
// Process file: download → parse → transform
|
|
1712
|
+
const fileResult = await fileProcessor.downloadParseAndTransform(objectKey);
|
|
1713
|
+
|
|
1714
|
+
if (!fileResult.success || fileResult.products.length === 0) {
|
|
1715
|
+
// Move failed file to errors and record result
|
|
1716
|
+
const archivedName = timestampedName(baseName);
|
|
1717
|
+
const errorKey = `${s3Config.errorPrefix}${archivedName}`;
|
|
1718
|
+
await s3.moveObject(objectKey, errorKey);
|
|
1719
|
+
|
|
1720
|
+
fileResults.push({
|
|
1721
|
+
fileName: baseName,
|
|
1722
|
+
success: false,
|
|
1723
|
+
recordsProcessed: fileResult.products.length,
|
|
1724
|
+
eventsSent: 0,
|
|
1725
|
+
eventsFailed: 0,
|
|
1726
|
+
duration: Date.now() - fileStartTime,
|
|
1727
|
+
error: fileResult.error || 'Processing failed'
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// ═══════════════════════════════════════════════════════════
|
|
1734
|
+
// 🚀 STEP 5: Send Events
|
|
1735
|
+
// ═══════════════════════════════════════════════════════════
|
|
1736
|
+
log.info(`🚀 [STEP 5/8] Sending events for ${baseName}`);
|
|
1737
|
+
|
|
1738
|
+
// Extract context for progress logging
|
|
1739
|
+
const sampleProductRefs = fileResult.products.slice(0, 5).map((p: any) => p.ref || p.skuRef);
|
|
1740
|
+
const eventType = eventConfig.eventName || 'UPSERT_PRODUCT';
|
|
1741
|
+
|
|
1742
|
+
log.info(`[EventSender] Sending events for file "${baseName}"`, {
|
|
1743
|
+
totalProducts: fileResult.products.length,
|
|
1744
|
+
eventType,
|
|
1745
|
+
concurrency: eventConcurrency === 1 ? 'sequential' : `parallel (${eventConcurrency})`,
|
|
1746
|
+
sampleProductRefs: sampleProductRefs.join(', '),
|
|
1747
|
+
eventMode: eventConfig.eventMode || 'async'
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
const eventResult = await eventSender.sendEvents(
|
|
1751
|
+
fileResult.products,
|
|
1752
|
+
eventConfig,
|
|
1753
|
+
eventConcurrency
|
|
1754
|
+
);
|
|
1755
|
+
|
|
1756
|
+
const eventsSent = eventResult.eventsSent;
|
|
1757
|
+
const eventsFailed = eventResult.eventsFailed;
|
|
1758
|
+
|
|
1759
|
+
log.info(`✅ [Events] Sent ${eventsSent} events for ${baseName}`, {
|
|
1760
|
+
successful: eventsSent,
|
|
1761
|
+
failed: eventsFailed
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
// Completion logging with summary
|
|
1765
|
+
log.info(`[EventSender] Event submission completed for file "${baseName}"`, {
|
|
1766
|
+
totalProducts: fileResult.products.length,
|
|
1767
|
+
eventsSent,
|
|
1768
|
+
eventsFailed,
|
|
1769
|
+
successRate: fileResult.products.length > 0 ? `${Math.round((eventsSent / fileResult.products.length) * 100)}%` : '0%',
|
|
1770
|
+
eventType,
|
|
1771
|
+
duration: Date.now() - fileStartTime,
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
// ═══════════════════════════════════════════════════════════
|
|
1775
|
+
// 📦 STEP 6: Archive File
|
|
1776
|
+
// ═══════════════════════════════════════════════════════════
|
|
1777
|
+
log.info(`📦 [STEP 6/8] Archiving file: ${baseName}`);
|
|
1778
|
+
|
|
1779
|
+
await tracker.updateJob(jobId, {
|
|
1780
|
+
status: 'processing',
|
|
1781
|
+
stage: 'archiving',
|
|
1782
|
+
message: `Archiving ${baseName}`
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
// Conditionally archive: errors → errorPrefix, else → processedPrefix (timestamped)
|
|
1786
|
+
// ✅ S3: Uses moveObject for deduplication (no VersoriFileTracker needed)
|
|
1787
|
+
const archivedName = timestampedName(baseName);
|
|
1788
|
+
const targetPrefix = eventsFailed > 0 ? s3Config.errorPrefix : s3Config.processedPrefix;
|
|
1789
|
+
const targetKey = `${targetPrefix}${archivedName}`;
|
|
1790
|
+
await s3.moveObject(objectKey, targetKey);
|
|
1791
|
+
|
|
1792
|
+
log.info(`✅ [Archive] File archived successfully: ${baseName}`, { targetKey });
|
|
1793
|
+
|
|
1794
|
+
// Optional: Write event log for audit/debugging
|
|
1795
|
+
if (eventsFailed > 0) {
|
|
1796
|
+
const logKey = `${s3Config.logsPrefix}${baseName.replace(/\.csv$/i, '')}-event-log.json`;
|
|
1797
|
+
await eventLogger.writeEventLog(logKey, {
|
|
1798
|
+
fileName: baseName,
|
|
1799
|
+
totalRecords: fileResult.products.length,
|
|
1800
|
+
eventsSent,
|
|
1801
|
+
eventsFailed,
|
|
1802
|
+
errors: eventResult.errors,
|
|
1803
|
+
processedAt: new Date().toISOString()
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Store file result
|
|
1808
|
+
fileResults.push({
|
|
1809
|
+
fileName: baseName,
|
|
1810
|
+
success: true,
|
|
1811
|
+
recordsProcessed: fileResult.products.length,
|
|
1812
|
+
eventsSent,
|
|
1813
|
+
eventsFailed,
|
|
1814
|
+
duration: Date.now() - fileStartTime
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
} catch (error: unknown) {
|
|
1818
|
+
// ❌ Enhanced error logging with recommendations
|
|
1819
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1820
|
+
const errorDetails = {
|
|
1821
|
+
message: errorMessage,
|
|
1822
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1823
|
+
fileName: (error as any)?.fileName,
|
|
1824
|
+
lineNumber: (error as any)?.lineNumber,
|
|
1825
|
+
originalError: (error as any)?.context?.originalError?.message,
|
|
1826
|
+
errorType: error instanceof Error ? error.name : 'Error',
|
|
1827
|
+
recommendation: 'Check file format, S3 permissions, and mapping configuration',
|
|
1828
|
+
};
|
|
1829
|
+
log.error(`❌ [Error] Failed to process file ${baseName}:`, errorDetails);
|
|
1830
|
+
|
|
1831
|
+
// Best-effort: move to error archive on unexpected failure
|
|
1832
|
+
try {
|
|
1833
|
+
const archivedName = timestampedName(baseName);
|
|
1834
|
+
const errorKey = `${s3Config.errorPrefix}${archivedName}`;
|
|
1835
|
+
await s3.moveObject(objectKey, errorKey);
|
|
1836
|
+
} catch (moveError: unknown) {
|
|
1837
|
+
const moveErrorMessage = moveError instanceof Error ? moveError.message : String(moveError);
|
|
1838
|
+
log.error('Could not archive failed file', {
|
|
1839
|
+
file: baseName,
|
|
1840
|
+
message: moveErrorMessage,
|
|
1841
|
+
stack: moveError instanceof Error ? moveError.stack : undefined,
|
|
1842
|
+
errorType: moveError instanceof Error ? moveError.constructor.name : 'Error'
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
fileResults.push({
|
|
1847
|
+
fileName: baseName,
|
|
1848
|
+
success: false,
|
|
1849
|
+
recordsProcessed: 0,
|
|
1850
|
+
eventsSent: 0,
|
|
1851
|
+
eventsFailed: 0,
|
|
1852
|
+
duration: Date.now() - fileStartTime,
|
|
1853
|
+
error: errorMessage
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// ═══════════════════════════════════════════════════════════
|
|
1859
|
+
// ✅ STEP 8: Complete Job & Calculate Totals
|
|
1860
|
+
// ═══════════════════════════════════════════════════════════
|
|
1861
|
+
log.info('✅ [STEP 8/8] Completing job and calculating totals', { jobId });
|
|
1862
|
+
|
|
1863
|
+
const filesProcessed = fileResults.filter(r => r.success).length;
|
|
1864
|
+
const filesFailed = fileResults.filter(r => !r.success).length;
|
|
1865
|
+
const totalRecordsProcessed = fileResults.reduce((sum, r) => sum + r.recordsProcessed, 0);
|
|
1866
|
+
const totalEventsSent = fileResults.reduce((sum, r) => sum + r.eventsSent, 0);
|
|
1867
|
+
const totalEventsFailed = fileResults.reduce((sum, r) => sum + r.eventsFailed, 0);
|
|
1868
|
+
const totalDuration = Date.now() - startTime;
|
|
1869
|
+
|
|
1870
|
+
await tracker.markCompleted(jobId, {
|
|
1871
|
+
filesProcessed,
|
|
1872
|
+
filesFailed,
|
|
1873
|
+
recordsProcessed: totalRecordsProcessed,
|
|
1874
|
+
eventsSent: totalEventsSent,
|
|
1875
|
+
eventsFailed: totalEventsFailed,
|
|
1876
|
+
duration: totalDuration
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
// ═══════════════════════════════════════════════════════════
|
|
1880
|
+
log.info(`\n${'═'.repeat(60)}`);
|
|
1881
|
+
log.info('✅ [COMPLETE] Ingestion completed successfully');
|
|
1882
|
+
log.info(`${'═'.repeat(60)}`);
|
|
1883
|
+
// ═══════════════════════════════════════════════════════════
|
|
1884
|
+
|
|
1885
|
+
log.info('[Summary]', {
|
|
1886
|
+
filesProcessed,
|
|
1887
|
+
filesFailed,
|
|
1888
|
+
recordsProcessed: totalRecordsProcessed,
|
|
1889
|
+
eventsSent: totalEventsSent,
|
|
1890
|
+
eventsFailed: totalEventsFailed,
|
|
1891
|
+
duration: `${totalDuration}ms`,
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
return {
|
|
1895
|
+
success: true,
|
|
1896
|
+
jobId,
|
|
1897
|
+
filesProcessed,
|
|
1898
|
+
filesFailed,
|
|
1899
|
+
recordsProcessed: totalRecordsProcessed,
|
|
1900
|
+
eventsSent: totalEventsSent,
|
|
1901
|
+
eventsFailed: totalEventsFailed,
|
|
1902
|
+
fileResults
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
} finally {
|
|
1906
|
+
// ⚠️ CRITICAL: Always dispose S3 connection
|
|
1907
|
+
if (s3) {
|
|
1908
|
+
await s3.dispose();
|
|
1909
|
+
log.info('✅ [Cleanup] S3 connection disposed');
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
} catch (error: unknown) {
|
|
1914
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1915
|
+
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
1916
|
+
log.error('❌ [FATAL] Ingestion workflow failed', {
|
|
1917
|
+
jobId,
|
|
1918
|
+
error: errorMessage,
|
|
1919
|
+
stack: errorStack,
|
|
1920
|
+
recommendation: 'Check logs for detailed error information and verify all activation variables',
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
// Try to mark job as failed, but don't let tracking errors mask workflow error
|
|
1924
|
+
try {
|
|
1925
|
+
await tracker.markFailed(jobId, error);
|
|
1926
|
+
} catch (trackingError: unknown) {
|
|
1927
|
+
const trackingErrorMessage =
|
|
1928
|
+
trackingError instanceof Error ? trackingError.message : String(trackingError);
|
|
1929
|
+
log.warn('Failed to mark job as failed in tracker', {
|
|
1930
|
+
jobId,
|
|
1931
|
+
trackingError: trackingErrorMessage,
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
return {
|
|
1936
|
+
success: false,
|
|
1937
|
+
jobId,
|
|
1938
|
+
filesProcessed: fileResults.filter(r => r.success && !r.skipped).length,
|
|
1939
|
+
filesFailed: fileResults.filter(r => !r.success).length,
|
|
1940
|
+
filesSkipped: fileResults.filter(r => r.skipped).length,
|
|
1941
|
+
recordsProcessed: fileResults.reduce((sum, r) => sum + r.recordsProcessed, 0),
|
|
1942
|
+
eventsSent: fileResults.reduce((sum, r) => sum + r.eventsSent, 0),
|
|
1943
|
+
eventsFailed: fileResults.reduce((sum, r) => sum + r.eventsFailed, 0),
|
|
1944
|
+
fileResults,
|
|
1945
|
+
error: errorMessage,
|
|
1946
|
+
};
|
|
1947
|
+
} finally {
|
|
1948
|
+
// ⚠️ CRITICAL: Ensure S3 is disposed even if outer error occurs
|
|
1949
|
+
if (s3) {
|
|
1950
|
+
try {
|
|
1951
|
+
await s3.dispose();
|
|
1952
|
+
log.info('✅ [Cleanup] S3 connection disposed (outer finally)');
|
|
1953
|
+
} catch (disposeError: unknown) {
|
|
1954
|
+
const disposeErrorMessage =
|
|
1955
|
+
disposeError instanceof Error ? disposeError.message : String(disposeError);
|
|
1956
|
+
log.warn('⚠️ [Warning] Error disposing S3 in outer finally', { error: disposeErrorMessage });
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
```
|
|
1962
|
+
|
|
1963
|
+
---
|
|
1964
|
+
|
|
1965
|
+
## 8. Utility Functions (src/utils/)
|
|
1966
|
+
|
|
1967
|
+
### S3 Path Helpers (src/utils/s3-path.utils.ts)
|
|
1968
|
+
|
|
1969
|
+
```typescript
|
|
1970
|
+
/**
|
|
1971
|
+
* S3 Path Utilities
|
|
1972
|
+
*
|
|
1973
|
+
* Helper functions for S3 path operations.
|
|
1974
|
+
*/
|
|
1975
|
+
|
|
1976
|
+
/**
|
|
1977
|
+
* Generate timestamped filename for archival
|
|
1978
|
+
*
|
|
1979
|
+
* Adds ISO timestamp to filename before extension for unique archival.
|
|
1980
|
+
* Handles CSV files specifically but can be adapted for other formats.
|
|
1981
|
+
*
|
|
1982
|
+
* @param name - Original filename
|
|
1983
|
+
* @returns Filename with timestamp appended
|
|
1984
|
+
*
|
|
1985
|
+
* @example
|
|
1986
|
+
* ```typescript
|
|
1987
|
+
* timestampedName('products.csv')
|
|
1988
|
+
* // Returns: 'products-2025-11-01T18-30-45-123Z.csv'
|
|
1989
|
+
* ```
|
|
1990
|
+
*/
|
|
1991
|
+
export function timestampedName(name: string): string {
|
|
1992
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1993
|
+
const base = name.replace(/\.csv$/i, '');
|
|
1994
|
+
return `${base}-${ts}.csv`;
|
|
1995
|
+
}
|
|
1996
|
+
```
|
|
1997
|
+
|
|
1998
|
+
### Job ID Generator (src/utils/job-id-generator.ts)
|
|
1999
|
+
|
|
2000
|
+
```typescript
|
|
2001
|
+
/**
|
|
2002
|
+
* Job ID Generator
|
|
2003
|
+
*
|
|
2004
|
+
* Generates unique job IDs for tracking ingestion runs.
|
|
2005
|
+
* Format: {PREFIX}_{ENTITY}_{DATE}_{TIME}_{RANDOM}
|
|
2006
|
+
* Example: SCHEDULED_PROD_20250124_183045_abc123
|
|
2007
|
+
*/
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Generate unique job ID
|
|
2011
|
+
*
|
|
2012
|
+
* @param prefix - Job prefix (SCHEDULED, ADHOC, etc.)
|
|
2013
|
+
* @param entity - Entity type (PROD, INV, etc.)
|
|
2014
|
+
* @returns Unique job ID string
|
|
2015
|
+
*/
|
|
2016
|
+
export function generateJobId(prefix: string, entity: string): string {
|
|
2017
|
+
const now = new Date();
|
|
2018
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
2019
|
+
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
|
|
2020
|
+
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
2021
|
+
return `${prefix}_${entity}_${date}_${time}_${random}`;
|
|
2022
|
+
}
|
|
2023
|
+
```
|
|
2024
|
+
|
|
2025
|
+
---
|
|
2026
|
+
|
|
2027
|
+
## 📄 Mapping Configuration
|
|
2028
|
+
|
|
2029
|
+
**File:** `config/products.import.csv.json`
|
|
2030
|
+
|
|
2031
|
+
```json
|
|
2032
|
+
{
|
|
2033
|
+
"name": "products.import.csv",
|
|
2034
|
+
"version": "1.2.0",
|
|
2035
|
+
"description": "CSV → Product Event Mapping",
|
|
2036
|
+
"fields": {
|
|
2037
|
+
"ref": {
|
|
2038
|
+
"source": "ref",
|
|
2039
|
+
"required": true,
|
|
2040
|
+
"resolver": "sdk.trim",
|
|
2041
|
+
"comment": "Product reference from CSV"
|
|
2042
|
+
},
|
|
2043
|
+
"type": {
|
|
2044
|
+
"source": "type",
|
|
2045
|
+
"required": true,
|
|
2046
|
+
"resolver": "sdk.uppercase",
|
|
2047
|
+
"comment": "Product type (STANDARD, VARIANT)"
|
|
2048
|
+
},
|
|
2049
|
+
"status": {
|
|
2050
|
+
"source": "status",
|
|
2051
|
+
"required": true,
|
|
2052
|
+
"resolver": "sdk.uppercase",
|
|
2053
|
+
"comment": "Product status (ACTIVE, INACTIVE)"
|
|
2054
|
+
},
|
|
2055
|
+
"name": {
|
|
2056
|
+
"source": "name",
|
|
2057
|
+
"required": true,
|
|
2058
|
+
"resolver": "sdk.trim",
|
|
2059
|
+
"comment": "Product name/title"
|
|
2060
|
+
},
|
|
2061
|
+
"summary": {
|
|
2062
|
+
"source": "summary",
|
|
2063
|
+
"required": false,
|
|
2064
|
+
"comment": "Product short description"
|
|
2065
|
+
},
|
|
2066
|
+
"gtin": {
|
|
2067
|
+
"source": "gtin",
|
|
2068
|
+
"required": false,
|
|
2069
|
+
"resolver": "sdk.trim",
|
|
2070
|
+
"comment": "Global Trade Item Number (barcode)"
|
|
2071
|
+
},
|
|
2072
|
+
"catalogue.ref": {
|
|
2073
|
+
"source": "$context.catalogueRef",
|
|
2074
|
+
"required": false,
|
|
2075
|
+
"comment": "Catalogue reference (injected from context, NOT in CSV)"
|
|
2076
|
+
},
|
|
2077
|
+
"metadata.source": {
|
|
2078
|
+
"value": "S3_CSV",
|
|
2079
|
+
"required": false,
|
|
2080
|
+
"comment": "Static value - identifies data source (NOT in CSV)"
|
|
2081
|
+
},
|
|
2082
|
+
"categoryRefs": {
|
|
2083
|
+
"source": "category_refs",
|
|
2084
|
+
"resolver": "custom.splitByPipe",
|
|
2085
|
+
"comment": "Pipe-separated categories (custom resolver)"
|
|
2086
|
+
},
|
|
2087
|
+
"price": {
|
|
2088
|
+
"resolver": "custom.buildPriceArray",
|
|
2089
|
+
"comment": "Build price array from default/special fields (custom resolver)"
|
|
2090
|
+
},
|
|
2091
|
+
"taxType": {
|
|
2092
|
+
"resolver": "custom.buildTaxType",
|
|
2093
|
+
"comment": "Build taxType object from country/group/tariff (custom resolver)"
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
```
|
|
2098
|
+
|
|
2099
|
+
**Note:** Custom resolvers (`custom.splitByPipe`, `custom.buildPriceArray`, `custom.buildTaxType`) are defined in the Main Orchestration Service.
|
|
2100
|
+
|
|
2101
|
+
---
|
|
2102
|
+
|
|
2103
|
+
## 9. Package Configuration
|
|
2104
|
+
|
|
2105
|
+
### `package.json`
|
|
2106
|
+
|
|
2107
|
+
```json
|
|
2108
|
+
{
|
|
2109
|
+
"name": "s3-csv-product-event-ingestion",
|
|
2110
|
+
"version": "1.2.0",
|
|
2111
|
+
"description": "S3 CSV Product Event Ingestion Connector",
|
|
2112
|
+
"type": "module",
|
|
2113
|
+
"main": "src/index.ts",
|
|
2114
|
+
"dependencies": {
|
|
2115
|
+
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
2116
|
+
"@versori/run": "^1.0.0"
|
|
2117
|
+
},
|
|
2118
|
+
"devDependencies": {
|
|
2119
|
+
"@types/node": "^20.0.0",
|
|
2120
|
+
"typescript": "^5.0.0"
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
```
|
|
2124
|
+
|
|
2125
|
+
---
|
|
2126
|
+
|
|
2127
|
+
## 6. Deployment Instructions
|
|
2128
|
+
|
|
2129
|
+
### Deploy to Versori
|
|
2130
|
+
|
|
2131
|
+
```bash
|
|
2132
|
+
# 1. Install dependencies
|
|
2133
|
+
npm install
|
|
2134
|
+
|
|
2135
|
+
# 2. Test locally (if using Versori CLI)
|
|
2136
|
+
npm run dev
|
|
2137
|
+
|
|
2138
|
+
# 3. Deploy to Versori platform
|
|
2139
|
+
npm run deploy
|
|
2140
|
+
```
|
|
2141
|
+
|
|
2142
|
+
### Configure Activation Variables
|
|
2143
|
+
|
|
2144
|
+
In Versori platform settings, configure all variables listed in the Activation Variables section above.
|
|
2145
|
+
|
|
2146
|
+
---
|
|
2147
|
+
|
|
2148
|
+
## 7. Testing
|
|
2149
|
+
|
|
2150
|
+
### Test Scheduled Ingestion
|
|
2151
|
+
|
|
2152
|
+
Upload a test CSV file to S3 incoming prefix and wait for the scheduled run.
|
|
2153
|
+
|
|
2154
|
+
**Check logs:**
|
|
2155
|
+
|
|
2156
|
+
```
|
|
2157
|
+
[STEP 1/8] Initializing job tracking
|
|
2158
|
+
[STEP 2/8] Initializing Fluent Commerce client and S3
|
|
2159
|
+
[STEP 3/8] Discovering files on S3
|
|
2160
|
+
[FILE 1/1] Processing file: products_20250124.csv
|
|
2161
|
+
[STEP 4/8] Downloading and parsing: products_20250124.csv
|
|
2162
|
+
[STEP 5/8] Transforming 5 products from products_20250124.csv
|
|
2163
|
+
[STEP 6/8] Sending 5 events to Fluent Commerce
|
|
2164
|
+
[STEP 7/8] Archiving file: products_20250124.csv
|
|
2165
|
+
[STEP 8/8] Completing job and calculating totals
|
|
2166
|
+
```
|
|
2167
|
+
|
|
2168
|
+
### Test Ad hoc Ingestion
|
|
2169
|
+
|
|
2170
|
+
```bash
|
|
2171
|
+
# Process all pending files
|
|
2172
|
+
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2173
|
+
-H "X-API-Key: your-secret-key" \
|
|
2174
|
+
-H "Content-Type: application/json" \
|
|
2175
|
+
-d '{}'
|
|
2176
|
+
|
|
2177
|
+
# Process specific pattern
|
|
2178
|
+
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2179
|
+
-H "X-API-Key: your-secret-key" \
|
|
2180
|
+
-H "Content-Type: application/json" \
|
|
2181
|
+
-d '{
|
|
2182
|
+
"filePattern": "urgent_*.csv", }'
|
|
2183
|
+
```
|
|
2184
|
+
|
|
2185
|
+
### Test Job Status Query
|
|
2186
|
+
|
|
2187
|
+
```bash
|
|
2188
|
+
curl -X POST https://api.versori.com/webhooks/product-ingestion-job-status \
|
|
2189
|
+
-H "X-API-Key: your-secret-key" \
|
|
2190
|
+
-H "Content-Type: application/json" \
|
|
2191
|
+
-d '{
|
|
2192
|
+
"jobId": "ADHOC_PROD_20251024_183045_abc123"
|
|
2193
|
+
}'
|
|
2194
|
+
```
|
|
2195
|
+
|
|
2196
|
+
---
|
|
2197
|
+
|
|
2198
|
+
## Monitoring
|
|
2199
|
+
|
|
2200
|
+
### Success Response
|
|
2201
|
+
|
|
2202
|
+
```json
|
|
2203
|
+
{
|
|
2204
|
+
"success": true,
|
|
2205
|
+
"filesProcessed": 1,
|
|
2206
|
+
"filesSkipped": 0,
|
|
2207
|
+
"filesFailed": 0,
|
|
2208
|
+
"totalRecords": 50,
|
|
2209
|
+
"eventsSent": 50,
|
|
2210
|
+
"eventsFailed": 0,
|
|
2211
|
+
"results": [
|
|
2212
|
+
{
|
|
2213
|
+
"file": "products_2025-01-22.csv",
|
|
2214
|
+
"success": true,
|
|
2215
|
+
"recordsProcessed": 50,
|
|
2216
|
+
"eventsSent": 50,
|
|
2217
|
+
"eventsFailed": 0
|
|
2218
|
+
}
|
|
2219
|
+
],
|
|
2220
|
+
"duration": 12345
|
|
2221
|
+
}
|
|
2222
|
+
```
|
|
2223
|
+
|
|
2224
|
+
### Partial Success Response
|
|
2225
|
+
|
|
2226
|
+
```json
|
|
2227
|
+
{
|
|
2228
|
+
"success": true,
|
|
2229
|
+
"filesProcessed": 1,
|
|
2230
|
+
"filesSkipped": 0,
|
|
2231
|
+
"filesFailed": 0,
|
|
2232
|
+
"totalRecords": 50,
|
|
2233
|
+
"eventsSent": 45,
|
|
2234
|
+
"eventsFailed": 5,
|
|
2235
|
+
"results": [
|
|
2236
|
+
{
|
|
2237
|
+
"file": "products_2025-01-22.csv",
|
|
2238
|
+
"success": true,
|
|
2239
|
+
"recordsProcessed": 50,
|
|
2240
|
+
"eventsSent": 45,
|
|
2241
|
+
"eventsFailed": 5,
|
|
2242
|
+
"errors": ["PRD-001: Invalid SKU format", "PRD-002: Missing required field"]
|
|
2243
|
+
}
|
|
2244
|
+
],
|
|
2245
|
+
"duration": 12345
|
|
2246
|
+
}
|
|
2247
|
+
```
|
|
2248
|
+
|
|
2249
|
+
### Error Response
|
|
2250
|
+
|
|
2251
|
+
```json
|
|
2252
|
+
{
|
|
2253
|
+
"success": false,
|
|
2254
|
+
"filesProcessed": 0,
|
|
2255
|
+
"filesFailed": 1,
|
|
2256
|
+
"totalRecords": 0,
|
|
2257
|
+
"eventsSent": 0,
|
|
2258
|
+
"eventsFailed": 0,
|
|
2259
|
+
"results": [
|
|
2260
|
+
{
|
|
2261
|
+
"file": "products_2025-01-22.csv",
|
|
2262
|
+
"success": false,
|
|
2263
|
+
"error": "CSV parse error: Invalid structure"
|
|
2264
|
+
}
|
|
2265
|
+
],
|
|
2266
|
+
"duration": 876
|
|
2267
|
+
}
|
|
2268
|
+
```
|
|
2269
|
+
|
|
2270
|
+
### Monitoring Metrics
|
|
2271
|
+
|
|
2272
|
+
Monitor these metrics via Versori logs filtered by `jobId` or `stage`:
|
|
2273
|
+
|
|
2274
|
+
- **Files Processed** - Total files successfully processed
|
|
2275
|
+
- **Events Sent** - Total events sent to Fluent Commerce
|
|
2276
|
+
- **Events Failed** - Events that failed (check rejection reports)
|
|
2277
|
+
- **Processing Duration** - Time taken for complete workflow
|
|
2278
|
+
- **Rate Limiting** - Watch for 429 errors indicating throttling
|
|
2279
|
+
|
|
2280
|
+
Use the status webhook for dashboards and automated monitoring.
|
|
2281
|
+
|
|
2282
|
+
---
|
|
2283
|
+
|
|
2284
|
+
- **No files found:** Check `s3IncomingPrefix` and `filePattern` (object keys only)
|
|
2285
|
+
- **CSV parse failed:** Move to errors/; validate CSV structure and encoding
|
|
2286
|
+
- **High event failures:** Inspect logs; consider Batch API for very high volumes
|
|
2287
|
+
- **429 throttling:** Add small backoff/delay or use Batch API
|
|
2288
|
+
- **S3 connection errors:** Verify credentials, bucket, region, and permissions
|
|
2289
|
+
|
|
2290
|
+
---
|
|
2291
|
+
|
|
2292
|
+
## 9. Key Takeaways
|
|
2293
|
+
|
|
2294
|
+
- ✅ **Modular service functions** - processFile(), sendEvents(), writeEventLog() keep code composable
|
|
2295
|
+
- ✅ **Native Versori logs** - Use `log` from context (LoggingService removed - use native log)
|
|
2296
|
+
- ✅ **Buffer import required** - `import { Buffer } from 'node:buffer';` for Deno/Versori compatibility
|
|
2297
|
+
- ✅ **3 workflows** - Scheduled, ad hoc webhook, job status webhook
|
|
2298
|
+
- ✅ **JobTracker** - Track job lifecycle with KV persistence
|
|
2299
|
+
- ❌ **NO VersoriFileTracker** - S3 uses moveObject for deduplication (SFTP-specific only)
|
|
2300
|
+
- ❌ **NO IngestionOrchestrator** - Removed from SDK - use inline modular services instead
|
|
2301
|
+
- ✅ **S3 deduplication** - Physical file movement to processed/errors prefixes
|
|
2302
|
+
- ✅ **S3 dispose()** - Always cleanup in finally block
|
|
2303
|
+
- ✅ **S3 method names** - listObjects, downloadObject, moveObject (not \*File)
|
|
2304
|
+
- ✅ **Per-record error handling** - Continue on individual failures
|
|
2305
|
+
- ✅ **Externalized mapping** - Use JSON config file for field mappings
|
|
2306
|
+
- ✅ **File-level error handling** - Don't stop on single file failure
|
|
2307
|
+
- ✅ **S3 vs SFTP** - Different deduplication strategies (moveObject vs KV state)
|
|
2308
|
+
- ✅ **uploadFile signature** - Uses Buffer.from(content, 'utf8') for text content
|
|
2309
|
+
|
|
2310
|
+
---
|
|
2311
|
+
|
|
2312
|
+
[← Back to Versori Event API Templates](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) | [Versori Platform Guide →](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md)
|