@fluentcommerce/fc-connect-sdk 0.1.54 → 0.1.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/cjs/clients/fluent-client.js +13 -6
- package/dist/cjs/utils/pagination-helpers.js +38 -2
- package/dist/cjs/versori/fluent-versori-client.js +11 -5
- package/dist/esm/clients/fluent-client.js +13 -6
- package/dist/esm/utils/pagination-helpers.js +38 -2
- package/dist/esm/versori/fluent-versori-client.js +11 -5
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/tsconfig.types.tsbuildinfo +1 -1
- package/docs/00-START-HERE/EXPORT-VALIDATION.md +158 -158
- package/docs/00-START-HERE/cli-analyze-source-structure-guide.md +655 -655
- package/docs/00-START-HERE/cli-documentation-index.md +202 -202
- package/docs/00-START-HERE/cli-quick-reference.md +252 -252
- package/docs/00-START-HERE/decision-tree.md +552 -552
- package/docs/00-START-HERE/getting-started.md +1070 -1070
- package/docs/00-START-HERE/mapper-quick-decision-guide.md +235 -235
- package/docs/00-START-HERE/readme.md +237 -237
- package/docs/00-START-HERE/retailerid-configuration.md +404 -404
- package/docs/00-START-HERE/sdk-philosophy.md +794 -794
- package/docs/00-START-HERE/troubleshooting-quick-reference.md +1086 -1086
- package/docs/01-TEMPLATES/faq.md +686 -686
- package/docs/01-TEMPLATES/patterns/pattern-templates-guide.md +68 -68
- package/docs/01-TEMPLATES/patterns/patterns-csv-schema-validation-and-rejection-report.md +233 -233
- package/docs/01-TEMPLATES/patterns/patterns-custom-resolvers.md +407 -407
- package/docs/01-TEMPLATES/patterns/patterns-error-handling-retry.md +511 -511
- package/docs/01-TEMPLATES/patterns/patterns-field-mapping-universal.md +701 -701
- package/docs/01-TEMPLATES/patterns/patterns-large-file-splitting.md +1430 -1430
- package/docs/01-TEMPLATES/patterns/patterns-master-data-etl.md +2399 -2399
- package/docs/01-TEMPLATES/patterns/patterns-pagination-streaming.md +447 -447
- package/docs/01-TEMPLATES/patterns/patterns-state-duplicate-prevention.md +385 -385
- package/docs/01-TEMPLATES/readme.md +957 -957
- package/docs/01-TEMPLATES/standalone/standalone-asn-inbound-processing.md +1209 -1209
- package/docs/01-TEMPLATES/standalone/standalone-graphql-query-export.md +1140 -1140
- package/docs/01-TEMPLATES/standalone/standalone-graphql-to-parquet-partitioned-s3.md +432 -432
- package/docs/01-TEMPLATES/standalone/standalone-multi-channel-inventory-sync.md +1185 -1185
- package/docs/01-TEMPLATES/standalone/standalone-multi-source-aggregation.md +1462 -1462
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-batch-api.md +1390 -1390
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-inventory-to-batch.md +330 -330
- package/docs/01-TEMPLATES/standalone/standalone-scripts-guide.md +87 -87
- package/docs/01-TEMPLATES/standalone/standalone-sftp-xml-graphql.md +1444 -1444
- package/docs/01-TEMPLATES/standalone/standalone-webhook-payload-processing.md +688 -688
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-dropship-order-routing.md +193 -193
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-graphql-parquet-extraction.md +518 -518
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-inter-location-transfers.md +2162 -2162
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-pre-order-allocation.md +2226 -2226
- package/docs/01-TEMPLATES/versori/business-examples/business-scenarios-guide.md +87 -87
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-connection-validation-pattern.md +656 -656
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-dual-workflow-connector.md +835 -835
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-guide.md +108 -108
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-kv-state-management.md +1533 -1533
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-xml-response-patterns.md +1160 -1160
- package/docs/01-TEMPLATES/versori/versori-platform-guide.md +201 -201
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-asn-purchase-order.md +1906 -1906
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-dropship-routing.md +1074 -1074
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-flash-sale-reserve.md +1395 -1395
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-generic-xml-order.md +888 -888
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-payment-gateway-integration.md +2478 -2478
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-rma-returns-comprehensive.md +2240 -2240
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-xml-order-ingestion.md +2029 -2029
- package/docs/01-TEMPLATES/versori/webhooks/webhook-templates-guide.md +140 -140
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/inventory-mapping.json +20 -20
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/products_2025-01-22.csv +11 -11
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/sample-data-guide.md +34 -34
- package/docs/01-TEMPLATES/versori/workflows/_examples/workflow-examples-guide.md +36 -36
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-modes-guide.md +1038 -1038
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-workflows-guide.md +138 -138
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/graphql-extraction-guide.md +63 -63
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-csv.md +2062 -2062
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-xml.md +2294 -2294
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-s3-csv.md +2461 -2461
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-sftp-xml.md +2529 -2529
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-csv.md +2464 -2464
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-json.md +1959 -1959
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-s3-csv.md +1953 -1953
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-sftp-xml.md +2541 -2541
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-s3-json.md +2384 -2384
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-sftp-xml.md +2445 -2445
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-csv.md +2355 -2355
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-json.md +2042 -2042
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-sftp-xml.md +2726 -2726
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/batch-api-guide.md +206 -206
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-cycle-count-reconciliation.md +2030 -2030
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-multi-channel-inventory-sync.md +1882 -1882
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-csv-inventory-batch.md +2827 -2827
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-json-inventory-batch.md +1952 -1952
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-xml-inventory-batch.md +3289 -3289
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-csv-inventory-batch.md +3064 -3064
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-json-inventory-batch.md +3238 -3238
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-xml-inventory-batch.md +2977 -2977
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/event-api-guide.md +321 -321
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-json-order-cancel-event.md +959 -959
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-xml-order-cancel-event.md +1170 -1170
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-csv-product-event.md +2312 -2312
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-json-product-event.md +2999 -2999
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-parquet-product-event.md +2836 -2836
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-xml-product-event.md +2395 -2395
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-csv-product-event.md +2295 -2295
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-json-product-event.md +2602 -2602
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-parquet-product-event.md +2589 -2589
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-xml-product-event.md +3578 -3578
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/graphql-mutations-guide.md +93 -93
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-json-order-update-graphql.md +1260 -1260
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-xml-order-update-graphql.md +1472 -1472
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-control-graphql.md +2417 -2417
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-location-graphql.md +2811 -2811
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-price-graphql.md +2619 -2619
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-json-location-graphql.md +2807 -2807
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-xml-location-graphql.md +2373 -2373
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-control-graphql.md +2740 -2740
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-location-graphql.md +2760 -2760
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-json-location-graphql.md +1710 -1710
- package/docs/01-TEMPLATES/versori/workflows/ingestion/ingestion-workflows-guide.md +136 -136
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/rubix-webhooks-guide.md +520 -520
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-inline.md +1418 -1418
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-universal-mapper.md +1785 -1785
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-order-attribute-update.md +824 -824
- package/docs/01-TEMPLATES/versori/workflows/workflows-overview-guide.md +646 -646
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-batch-archival.md +724 -724
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-job-tracker.md +627 -627
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-partial-batch-recovery.md +561 -561
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-quick-reference.md +367 -367
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-readme.md +407 -407
- package/docs/02-CORE-GUIDES/advanced-services/readme.md +49 -49
- package/docs/02-CORE-GUIDES/api-reference/api-reference-quick-reference.md +548 -548
- package/docs/02-CORE-GUIDES/api-reference/event-api-input-output-reference.md +702 -1171
- package/docs/02-CORE-GUIDES/api-reference/examples/client-initialization.ts +286 -286
- package/docs/02-CORE-GUIDES/api-reference/graphql-error-classification.md +337 -337
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-01-client-api.md +399 -520
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-03-authentication.md +199 -199
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-04-graphql-mapping.md +925 -925
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-05-services.md +1198 -1198
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-06-data-sources.md +1083 -1083
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-07-parsers.md +1097 -1097
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-pagination.md +513 -513
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-types.md +545 -597
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-error-handling.md +527 -527
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-webhook-validation.md +514 -514
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-extraction.md +557 -557
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-utilities.md +412 -412
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-cli-tools.md +423 -423
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-error-handling.md +716 -716
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-analyze-source-structure.md +518 -518
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-partial-responses.md +212 -212
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-testing.md +300 -300
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-13-resolver-builder.md +322 -322
- package/docs/02-CORE-GUIDES/api-reference/readme.md +279 -279
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-quick-reference.md +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-readme.md +277 -277
- package/docs/02-CORE-GUIDES/auto-pagination/examples/auto-pagination-readme.md +178 -178
- package/docs/02-CORE-GUIDES/auto-pagination/examples/common-patterns.ts +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-products.ts +384 -384
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-virtual-positions.ts +308 -308
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-01-foundations.md +470 -470
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-02-quick-start.md +713 -713
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-03-configuration.md +754 -754
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-04-advanced-patterns.md +732 -732
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-05-sdk-integration.md +847 -847
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-06-troubleshooting.md +359 -359
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-07-api-reference.md +462 -462
- package/docs/02-CORE-GUIDES/auto-pagination/readme.md +54 -54
- package/docs/02-CORE-GUIDES/data-sources/data-sources-file-operations-error-handling.md +1487 -1487
- package/docs/02-CORE-GUIDES/data-sources/data-sources-quick-reference.md +836 -836
- package/docs/02-CORE-GUIDES/data-sources/data-sources-readme.md +276 -276
- package/docs/02-CORE-GUIDES/data-sources/data-sources-sftp-credential-access-security.md +553 -553
- package/docs/02-CORE-GUIDES/data-sources/examples/common-patterns.ts +409 -409
- package/docs/02-CORE-GUIDES/data-sources/examples/data-sources-readme.md +178 -178
- package/docs/02-CORE-GUIDES/data-sources/examples/s3-operations.ts +308 -308
- package/docs/02-CORE-GUIDES/data-sources/examples/sftp-operations.ts +371 -371
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-01-foundations.md +735 -735
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-02-s3-operations.md +1302 -1302
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-03-sftp-operations.md +1379 -1379
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-04-file-patterns.md +941 -941
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-05-advanced-topics.md +813 -813
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-06-integration-patterns.md +486 -486
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-07-troubleshooting.md +387 -387
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-08-api-reference.md +417 -417
- package/docs/02-CORE-GUIDES/data-sources/readme.md +77 -77
- package/docs/02-CORE-GUIDES/error-handling-guide.md +936 -936
- package/docs/02-CORE-GUIDES/extraction/examples/02-core-guides-extraction-readme.md +116 -116
- package/docs/02-CORE-GUIDES/extraction/examples/common-patterns.ts +428 -428
- package/docs/02-CORE-GUIDES/extraction/examples/extract-inventory-basic.ts +187 -187
- package/docs/02-CORE-GUIDES/extraction/extraction-quick-reference.md +596 -596
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-01-foundations.md +514 -514
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-02-basic-extraction.md +823 -823
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-03-parquet-processing.md +507 -507
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-04-data-enrichment.md +546 -546
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-05-transformation.md +494 -494
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-export-formats.md +458 -458
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-performance.md +138 -138
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-api-reference.md +148 -148
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-optimization.md +692 -692
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-08-extraction-orchestrator.md +1008 -1008
- package/docs/02-CORE-GUIDES/extraction/readme.md +151 -151
- package/docs/02-CORE-GUIDES/ingestion/examples/_simple-kv-store.ts +40 -40
- package/docs/02-CORE-GUIDES/ingestion/examples/error-recovery.ts +728 -728
- package/docs/02-CORE-GUIDES/ingestion/examples/event-driven.ts +501 -501
- package/docs/02-CORE-GUIDES/ingestion/examples/local-file-ingestion.ts +88 -88
- package/docs/02-CORE-GUIDES/ingestion/examples/parquet-ingestion.ts +117 -117
- package/docs/02-CORE-GUIDES/ingestion/examples/performance-optimized.ts +647 -647
- package/docs/02-CORE-GUIDES/ingestion/examples/s3-csv-ingestion.ts +169 -169
- package/docs/02-CORE-GUIDES/ingestion/examples/sftp-csv-ingestion.ts +134 -134
- package/docs/02-CORE-GUIDES/ingestion/ingestion-quick-reference.md +546 -546
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-01-introduction.md +626 -626
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-02-quick-start.md +658 -658
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-03-data-sources.md +1052 -1052
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-04-field-mapping.md +763 -763
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-05-advanced-parsers.md +676 -676
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-06-batch-api.md +1295 -1295
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-api-reference.md +138 -138
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-state-management.md +1037 -1037
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-08-performance-optimization.md +1349 -1349
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-09-best-practices.md +1893 -1893
- package/docs/02-CORE-GUIDES/ingestion/readme.md +160 -160
- package/docs/02-CORE-GUIDES/logging-guide.md +585 -585
- package/docs/02-CORE-GUIDES/mapping/error-handling-patterns.md +401 -401
- package/docs/02-CORE-GUIDES/mapping/examples/02-core-guides-mapping-readme.md +128 -128
- package/docs/02-CORE-GUIDES/mapping/examples/common-patterns.ts +273 -273
- package/docs/02-CORE-GUIDES/mapping/examples/csv-location-ingestion.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/csv-mapping.ts +242 -242
- package/docs/02-CORE-GUIDES/mapping/examples/graphql-to-parquet-extraction.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/json-mapping.ts +213 -213
- package/docs/02-CORE-GUIDES/mapping/examples/json-product-to-mutation.json +48 -48
- package/docs/02-CORE-GUIDES/mapping/examples/xml-mapping.ts +291 -291
- package/docs/02-CORE-GUIDES/mapping/examples/xml-order-to-mutation.json +45 -45
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-quick-reference.md +463 -463
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-readme.md +227 -227
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-01-introduction.md +222 -222
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-02-quick-start.md +351 -351
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-03-schema-validation.md +569 -569
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-04-mapping-patterns.md +471 -471
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-05-configuration-reference.md +611 -611
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-advanced-xpath.md +148 -148
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-path-syntax.md +464 -464
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-api-reference.md +94 -94
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-array-handling.md +307 -307
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-08-custom-resolvers.md +544 -544
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-09-advanced-patterns.md +427 -427
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-10-hooks-and-variables.md +336 -336
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-11-error-handling.md +488 -488
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-12-arguments-vs-nodes.md +383 -383
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-13-best-practices.md +477 -477
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/readme.md +62 -62
- package/docs/02-CORE-GUIDES/mapping/mapping-format-decision-tree.md +480 -480
- package/docs/02-CORE-GUIDES/mapping/mapping-graphql-alias-batching-guide.md +820 -820
- package/docs/02-CORE-GUIDES/mapping/mapping-javascript-objects.md +2369 -2369
- package/docs/02-CORE-GUIDES/mapping/mapping-mapper-comparison-guide.md +682 -682
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-07-api-reference.md +1327 -1327
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-08-error-handling.md +1142 -1142
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-04-use-cases.md +891 -891
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-helpers-resolvers.md +1126 -1126
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-sdk-resolvers.md +199 -199
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-07-api-reference.md +1319 -1319
- package/docs/02-CORE-GUIDES/mapping/readme.md +178 -178
- package/docs/02-CORE-GUIDES/mapping/resolver-registration.md +410 -410
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/common-patterns.ts +226 -226
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/custom-resolvers.ts +227 -227
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/sdk-resolvers-usage.ts +203 -203
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-readme.md +274 -274
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-api-reference.md +679 -679
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-cookbook.md +826 -826
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-guide.md +1330 -1330
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-helpers-reference.md +1437 -1437
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-parameters-reference.md +553 -553
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-troubleshooting.md +854 -854
- package/docs/02-CORE-GUIDES/mapping/resolvers/readme.md +75 -75
- package/docs/02-CORE-GUIDES/parsers/examples/02-core-guides-parsers-readme.md +161 -161
- package/docs/02-CORE-GUIDES/parsers/examples/csv-parser-examples.ts +110 -110
- package/docs/02-CORE-GUIDES/parsers/examples/json-parser-examples.ts +33 -33
- package/docs/02-CORE-GUIDES/parsers/examples/parquet-parser-examples.ts +47 -47
- package/docs/02-CORE-GUIDES/parsers/examples/xml-parser-examples.ts +38 -38
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-01-foundations.md +355 -355
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-02-csv-parser.md +772 -772
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-03-json-parser.md +789 -789
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-04-xml-parser.md +857 -857
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-05-parquet-parser.md +603 -603
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-integration-patterns.md +702 -702
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-streaming.md +121 -121
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-api-reference.md +89 -89
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-troubleshooting.md +727 -727
- package/docs/02-CORE-GUIDES/parsers/parsers-quick-reference.md +482 -482
- package/docs/02-CORE-GUIDES/parsers/parsers-readme.md +258 -258
- package/docs/02-CORE-GUIDES/parsers/readme.md +65 -65
- package/docs/02-CORE-GUIDES/readme.md +194 -194
- package/docs/02-CORE-GUIDES/webhook-validation/examples/basic-validation.ts +108 -108
- package/docs/02-CORE-GUIDES/webhook-validation/examples/common-patterns.ts +316 -316
- package/docs/02-CORE-GUIDES/webhook-validation/examples/webhook-validation-readme.md +61 -61
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-01-foundations.md +440 -440
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-02-quick-start.md +525 -525
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-03-versori-integration.md +741 -741
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-04-platform-integration.md +629 -629
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-05-configuration.md +535 -535
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-error-handling.md +611 -611
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-troubleshooting.md +124 -124
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-07-api-reference.md +511 -511
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-08-rubix-webhooks.md +590 -590
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-09-rubix-event-vs-http-call.md +432 -432
- package/docs/02-CORE-GUIDES/webhook-validation/readme.md +239 -239
- package/docs/02-CORE-GUIDES/webhook-validation/webhook-validation-quick-reference.md +392 -392
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-quick-reference.md +498 -498
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-readme.md +313 -313
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/common-patterns.ts +612 -612
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/connector-scenarios-readme.md +253 -253
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-01-foundations.md +452 -452
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-02-simple-scenarios.md +681 -681
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-03-intermediate-scenarios.md +637 -637
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-04-advanced-scenarios.md +650 -650
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-05-bidirectional-sync.md +233 -233
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-06-production-patterns.md +442 -442
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-07-reference.md +445 -445
- package/docs/03-PATTERN-GUIDES/connector-scenarios/readme.md +31 -31
- package/docs/03-PATTERN-GUIDES/enterprise-integration-patterns.md +1528 -1528
- package/docs/03-PATTERN-GUIDES/error-handling/comprehensive-error-handling-guide.md +1437 -1437
- package/docs/03-PATTERN-GUIDES/error-handling/error-handling-quick-reference.md +390 -390
- package/docs/03-PATTERN-GUIDES/error-handling/examples/common-patterns.ts +438 -438
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-01-foundations.md +362 -362
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-02-error-types.md +850 -850
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-03-utf8-handling.md +456 -456
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-04-error-scenarios.md +658 -658
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-05-calling-patterns.md +671 -671
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-06-retry-strategies.md +1034 -1034
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-07-monitoring.md +653 -653
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-08-api-reference.md +847 -847
- package/docs/03-PATTERN-GUIDES/error-handling/readme.md +36 -36
- package/docs/03-PATTERN-GUIDES/examples/__tests__/readme.md +40 -40
- package/docs/03-PATTERN-GUIDES/examples/__tests__/resolver-examples.test.js +282 -282
- package/docs/03-PATTERN-GUIDES/examples/test-data/03-pattern-guides-readme.md +110 -110
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-inventory.json +123 -123
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-order.json +171 -171
- package/docs/03-PATTERN-GUIDES/examples/test-data/readme.md +28 -28
- package/docs/03-PATTERN-GUIDES/extraction/extraction-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/extraction/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/file-operations/examples/common-patterns.ts +407 -407
- package/docs/03-PATTERN-GUIDES/file-operations/examples/file-operations-readme.md +142 -142
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-quick-reference.md +462 -462
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-readme.md +379 -379
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-01-foundations.md +430 -430
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-02-quick-start.md +484 -484
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-03-s3-operations.md +507 -507
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-04-sftp-operations.md +963 -963
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-05-streaming-performance.md +503 -503
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-archive-patterns.md +386 -386
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-error-handling.md +117 -117
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-api-reference.md +78 -78
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-testing-troubleshooting.md +567 -567
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-08-api-reference.md +1055 -1055
- package/docs/03-PATTERN-GUIDES/file-operations/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/ingestion/ingestion-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/ingestion/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/batch-processing.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/common-patterns.ts +360 -360
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/delta-sync.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/integration-patterns-readme.md +100 -100
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/real-time-webhook.ts +398 -398
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-quick-reference.md +962 -962
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-readme.md +134 -134
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-01-real-time-processing.md +991 -991
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-02-batch-processing.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-03-delta-sync.md +1108 -1108
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-04-webhook-patterns.md +1181 -1181
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-05-error-handling.md +1061 -1061
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-advanced-integration-services.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-performance.md +109 -109
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-07-api-reference.md +34 -34
- package/docs/03-PATTERN-GUIDES/integration-patterns/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/logging-minimal-mode.md +128 -128
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/common-patterns.ts +380 -380
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/multiple-connections-readme.md +139 -139
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/parallel-root-connections.ts +149 -149
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/real-world-scenarios.ts +405 -405
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-01-foundations.md +378 -378
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-02-quick-start.md +566 -566
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-03-targeting-connections.md +659 -659
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-04-parallel-queries.md +656 -656
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-05-best-practices.md +624 -624
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-api-reference.md +824 -824
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-versori.md +119 -119
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-07-api-reference.md +87 -87
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-quick-reference.md +353 -353
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-readme.md +270 -270
- package/docs/03-PATTERN-GUIDES/multiple-connections/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/pagination/pagination-readme.md +14 -14
- package/docs/03-PATTERN-GUIDES/pagination/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/parquet/examples/common-patterns.ts +180 -180
- package/docs/03-PATTERN-GUIDES/parquet/examples/read-parquet.ts +48 -48
- package/docs/03-PATTERN-GUIDES/parquet/examples/write-parquet.ts +65 -65
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-01-introduction.md +393 -393
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-02-quick-start.md +572 -572
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-03-reading-parquet.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-04-writing-parquet.md +554 -554
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-05-graphql-extraction.md +405 -405
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-performance.md +104 -104
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-s3-integration.md +511 -511
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-api-reference.md +90 -90
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-performance-optimization.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-08-best-practices.md +712 -712
- package/docs/03-PATTERN-GUIDES/parquet/parquet-quick-reference.md +683 -683
- package/docs/03-PATTERN-GUIDES/parquet/parquet-readme.md +248 -248
- package/docs/03-PATTERN-GUIDES/parquet/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/parsers/parsers-readme.md +12 -12
- package/docs/03-PATTERN-GUIDES/parsers/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/readme.md +159 -159
- package/docs/03-PATTERN-GUIDES/webhooks/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/webhooks/webhooks-readme.md +8 -8
- package/docs/04-REFERENCE/architecture/architecture-01-overview.md +427 -427
- package/docs/04-REFERENCE/architecture/architecture-02-client-architecture.md +424 -424
- package/docs/04-REFERENCE/architecture/architecture-03-data-flow.md +690 -690
- package/docs/04-REFERENCE/architecture/architecture-04-service-layer.md +834 -834
- package/docs/04-REFERENCE/architecture/architecture-05-integration-architecture.md +655 -655
- package/docs/04-REFERENCE/architecture/architecture-06-state-management.md +653 -653
- package/docs/04-REFERENCE/architecture/architecture-adding-new-data-sources.md +686 -686
- package/docs/04-REFERENCE/architecture/readme.md +279 -279
- package/docs/04-REFERENCE/platforms/deno/readme.md +117 -117
- package/docs/04-REFERENCE/platforms/nodejs/readme.md +146 -146
- package/docs/04-REFERENCE/platforms/readme.md +135 -135
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-01-introduction.md +398 -398
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-02-quick-start.md +560 -560
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-03-authentication.md +757 -757
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-04-workflows.md +2476 -2476
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-05-connections.md +1167 -1167
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-kv-storage.md +990 -990
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-state-management.md +121 -121
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-api-reference.md +68 -68
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-deployment.md +731 -731
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-08-best-practices.md +1111 -1111
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-09-signature-reference.md +766 -766
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-readme.md +299 -299
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-s3-sftp-configuration-guide.md +1425 -1425
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-api-key-security.md +816 -816
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-connection-security.md +681 -681
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-workflow-task-types.md +708 -708
- package/docs/04-REFERENCE/platforms/versori/readme.md +108 -108
- package/docs/04-REFERENCE/readme.md +148 -148
- package/docs/04-REFERENCE/resolver-signature/examples/advanced-resolvers.ts +482 -482
- package/docs/04-REFERENCE/resolver-signature/examples/async-resolvers.ts +496 -496
- package/docs/04-REFERENCE/resolver-signature/examples/basic-resolvers.ts +343 -343
- package/docs/04-REFERENCE/resolver-signature/examples/resolver-signature-readme.md +188 -188
- package/docs/04-REFERENCE/resolver-signature/examples/testing-resolvers.ts +463 -463
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-01-foundations.md +286 -286
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-02-parameter-reference.md +643 -643
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-03-basic-examples.md +521 -521
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-04-advanced-patterns.md +739 -739
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-05-sdk-resolvers.md +531 -531
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-migration-guide.md +650 -650
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-testing.md +125 -125
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-07-api-reference.md +794 -794
- package/docs/04-REFERENCE/resolver-signature/readme.md +64 -64
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-quick-reference.md +270 -270
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-readme.md +351 -351
- package/docs/04-REFERENCE/schema/fluent-commerce-schema.json +764 -764
- package/docs/04-REFERENCE/schema/readme.md +141 -141
- package/docs/04-REFERENCE/testing/examples/04-reference-testing-readme.md +158 -158
- package/docs/04-REFERENCE/testing/examples/fluent-testing.ts +62 -62
- package/docs/04-REFERENCE/testing/examples/health-check.ts +155 -155
- package/docs/04-REFERENCE/testing/examples/integration-test.ts +119 -119
- package/docs/04-REFERENCE/testing/examples/performance-test.ts +183 -183
- package/docs/04-REFERENCE/testing/examples/s3-testing.ts +127 -127
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-01-foundations.md +267 -267
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-02-s3-testing.md +599 -599
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-03-fluent-testing.md +589 -589
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-04-integration-testing.md +699 -699
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-05-debugging.md +478 -478
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-cicd-integration.md +463 -463
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-preflight-validation.md +131 -131
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-best-practices.md +499 -499
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-coverage-ci.md +165 -165
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-08-api-reference.md +634 -634
- package/docs/04-REFERENCE/testing/readme.md +86 -86
- package/docs/04-REFERENCE/testing/testing-quick-reference.md +667 -667
- package/docs/04-REFERENCE/testing/testing-readme.md +286 -286
- package/docs/04-REFERENCE/troubleshooting/readme.md +144 -144
- package/docs/04-REFERENCE/troubleshooting/troubleshooting-deno-sftp-compatibility.md +392 -392
- package/docs/template-loading-matrix.md +242 -242
- package/package.json +5 -3
- package/docs/02-CORE-GUIDES/api-reference/cli-profile-integration.md +0 -377
|
@@ -1,2602 +1,2602 @@
|
|
|
1
|
-
---
|
|
2
|
-
template_id: tpl-ingest-sftp-json-to-product-event
|
|
3
|
-
canonical_filename: template-ingestion-sftp-json-product-event.md
|
|
4
|
-
sdk_version: ^0.1.39
|
|
5
|
-
runtime: versori
|
|
6
|
-
direction: ingestion
|
|
7
|
-
source: sftp-json
|
|
8
|
-
destination: fluent-event-api
|
|
9
|
-
entity: product
|
|
10
|
-
format: json
|
|
11
|
-
logging: versori
|
|
12
|
-
status: stable
|
|
13
|
-
features:
|
|
14
|
-
- batched-events
|
|
15
|
-
- attribute-transformation
|
|
16
|
-
- memory-management
|
|
17
|
-
- json-serialization-handling
|
|
18
|
-
- enhanced-logging
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
# Template: Ingestion - SFTP JSON to Product Event
|
|
22
|
-
|
|
23
|
-
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
24
|
-
**Last Updated:** 2025-01-24
|
|
25
|
-
**Deployment Target:** Versori Platform
|
|
26
|
-
|
|
27
|
-
---
|
|
28
|
-
|
|
29
|
-
## 📋 Implementation Prompt
|
|
30
|
-
|
|
31
|
-
```
|
|
32
|
-
I need a Versori scheduled ingestion that:
|
|
33
|
-
|
|
34
|
-
1) Discovers JSON files on SFTP with file tracking to skip duplicates
|
|
35
|
-
2) Downloads and parses JSON (standard or JSON Lines format) - NO array normalization
|
|
36
|
-
3) Transforms records with UniversalMapper per mapping JSON
|
|
37
|
-
4) Sends UPSERT_PRODUCT events (async) to Fluent Commerce with per-record error handling
|
|
38
|
-
5) Archives files to processed/ or errors/ and writes optional rejection report
|
|
39
|
-
6) Tracks progress with JobTracker and exposes a job-status webhook
|
|
40
|
-
7) Uses native Versori log from context
|
|
41
|
-
8) **Refactored with 3 service functions**: processFile(), sendEvents(), writeEventLog()
|
|
42
|
-
|
|
43
|
-
Use the loaded docs to fill in SDK specifics and best practices.
|
|
44
|
-
Keep the structure identical to the template; only adapt where needed.
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## 📋 Template Overview
|
|
50
|
-
|
|
51
|
-
This connector runs on the Versori platform. Most operational settings (Fluent account/connection, SFTP connection, schedule, file patterns/limits) are configured via activation variables. Data shape and logic (mapping JSON, JSON structure, parsing rules, per-record handling) are adjusted in code as needed. It reads product data from SFTP JSON, transforms it, and sends events to the Fluent Commerce Event API.
|
|
52
|
-
|
|
53
|
-
### What This Template Does
|
|
54
|
-
|
|
55
|
-
```
|
|
56
|
-
┌────────────────────────────────────────────────────────────────┐
|
|
57
|
-
│ INGESTION WORKFLOW │
|
|
58
|
-
└────────────────────────────────────────────────────────────────┘
|
|
59
|
-
|
|
60
|
-
1. TRIGGER
|
|
61
|
-
├─ Scheduled (Cron): Runs automatically every hour
|
|
62
|
-
├─ Ad hoc (Webhook): Manual trigger for immediate processing
|
|
63
|
-
└─ Status Query (Webhook): Check job progress
|
|
64
|
-
|
|
65
|
-
2. DISCOVER FILES (SftpDataSource)
|
|
66
|
-
├─ List files from SFTP directory
|
|
67
|
-
├─ Filter by pattern (products_*.json)
|
|
68
|
-
├─ Check file tracking (skip processed)
|
|
69
|
-
└─ Sort by oldest first
|
|
70
|
-
|
|
71
|
-
3. DOWNLOAD & PARSE (JSONParserService)
|
|
72
|
-
├─ Download file from SFTP
|
|
73
|
-
├─ Parse JSON to JavaScript object
|
|
74
|
-
├─ Auto-detect format (JSON vs JSON Lines)
|
|
75
|
-
└─ Normalize to array structure
|
|
76
|
-
|
|
77
|
-
4. TRANSFORM (UniversalMapper)
|
|
78
|
-
├─ Map JSON fields to Fluent schema
|
|
79
|
-
├─ Apply SDK resolvers (trim, uppercase, etc.)
|
|
80
|
-
├─ Direct passthrough for nested objects
|
|
81
|
-
├─ Direct passthrough for arrays
|
|
82
|
-
└─ Collect transformation errors
|
|
83
|
-
|
|
84
|
-
5. SEND EVENTS (Event API)
|
|
85
|
-
├─ Loop through transformed products
|
|
86
|
-
├─ Send UPSERT_PRODUCT event (async)
|
|
87
|
-
├─ Track success/failure count
|
|
88
|
-
└─ Continue on individual failures
|
|
89
|
-
|
|
90
|
-
6. ARCHIVE (SftpDataSource)
|
|
91
|
-
├─ Move file to processed/ folder
|
|
92
|
-
├─ Or move to errors/ if validation failed
|
|
93
|
-
├─ Generate timestamped archive name
|
|
94
|
-
└─ Verify archive success
|
|
95
|
-
|
|
96
|
-
7. TRACK STATE (VersoriFileTracker)
|
|
97
|
-
├─ Mark file as processed
|
|
98
|
-
├─ Store processing metadata
|
|
99
|
-
├─ Prevent duplicate processing
|
|
100
|
-
└─ Track record counts
|
|
101
|
-
|
|
102
|
-
8. TRACK JOB (JobTracker)
|
|
103
|
-
├─ Update job status at each step
|
|
104
|
-
├─ Store final result in KV
|
|
105
|
-
├─ Enable status queries via webhook
|
|
106
|
-
└─ Handle errors gracefully
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
### Key Features
|
|
110
|
-
|
|
111
|
-
- **Refactored Architecture**: 3 service functions for clean separation of concerns
|
|
112
|
-
- `processFile()`: SftpDataSource + JSONParserService + UniversalMapper (NO array normalization)
|
|
113
|
-
- `sendEvents()`: Loop records, `client.sendEvent()` with per-record error handling
|
|
114
|
-
- `writeEventLog()`: Write logs to SFTP with Buffer
|
|
115
|
-
- **Per-file Processing**: Download → Parse → Map → Send → Archive (one file at a time)
|
|
116
|
-
- **Job tracking with status queries**: JobTracker persists to Versori KV
|
|
117
|
-
- **Execution modes**: scheduled, ad hoc, status query
|
|
118
|
-
- **Uses SftpDataSource, JSONParserService, UniversalMapper, JobTracker**
|
|
119
|
-
- **Error handling, retry logic, and SFTP cleanup**
|
|
120
|
-
- **SFTP Deduplication:** VersoriFileTracker (KV-based state tracking)
|
|
121
|
-
- **Key Difference from S3:** KV state tracking vs physical file movement
|
|
122
|
-
- **File tracking**: VersoriFileTracker prevents duplicates; `forceReprocess` bypasses
|
|
123
|
-
- **Event API**: Per-record failures don't block other records; rejection reports written to `errors/`
|
|
124
|
-
- **SFTP cleanup in finally block**
|
|
125
|
-
- **Auto-detection of JSON vs JSON Lines format**
|
|
126
|
-
- **Buffer import for Deno/Versori compatibility**
|
|
127
|
-
|
|
128
|
-
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.
|
|
129
|
-
|
|
130
|
-
### 📦 Package Information
|
|
131
|
-
|
|
132
|
-
**SDK:** [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk) `^0.1.30`
|
|
133
|
-
|
|
134
|
-
```bash
|
|
135
|
-
npm install @fluentcommerce/fc-connect-sdk
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
---
|
|
139
|
-
|
|
140
|
-
**Templates are designed for direct deployment; customize via activation variables.**
|
|
141
|
-
|
|
142
|
-
---
|
|
143
|
-
|
|
144
|
-
## 📦 SDK Imports (Verified - Versori Optimized)
|
|
145
|
-
|
|
146
|
-
```typescript
|
|
147
|
-
// ✅ VERIFIED IMPORTS - These match actual SDK exports
|
|
148
|
-
import { Buffer } from 'node:buffer';
|
|
149
|
-
import {
|
|
150
|
-
createClient, // Universal client factory
|
|
151
|
-
SftpDataSource, // SFTP operations with connection pooling
|
|
152
|
-
JSONParserService, // JSON parsing with format auto-detection
|
|
153
|
-
UniversalMapper, // Field mapping with SDK resolvers
|
|
154
|
-
VersoriFileTracker, // File processing state tracker
|
|
155
|
-
JobTracker, // Job status tracking
|
|
156
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
157
|
-
|
|
158
|
-
import type { FluentClient, SftpDataSourceConfig } from '@fluentcommerce/fc-connect-sdk';
|
|
159
|
-
|
|
160
|
-
// Versori platform imports
|
|
161
|
-
import { schedule, webhook, http, fn } from '@versori/run';
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
**Note:** All imports are from actual SDK exports - this code compiles and runs as-is.
|
|
165
|
-
|
|
166
|
-
**✅ VERSORI PLATFORM - Use Native Logs:**
|
|
167
|
-
|
|
168
|
-
- Use `log` from context: `const { log } = ctx;`
|
|
169
|
-
- Native Versori logs are simpler and automatically integrated with platform monitoring
|
|
170
|
-
|
|
171
|
-
---
|
|
172
|
-
|
|
173
|
-
## 🔐 SFTP Connection Setup (Recommended)
|
|
174
|
-
|
|
175
|
-
**✅ BEST PRACTICE:** Store SFTP credentials in a Versori connection object with Basic Auth:
|
|
176
|
-
|
|
177
|
-
**Connection Configuration:**
|
|
178
|
-
|
|
179
|
-
1. In Versori platform, create a connection named `SFTP`
|
|
180
|
-
2. Set **Authentication Type**: `Basic Auth`
|
|
181
|
-
3. Enter **Username**: Your SFTP username
|
|
182
|
-
4. Enter **Password**: Your SFTP password
|
|
183
|
-
|
|
184
|
-
### Code Implementation
|
|
185
|
-
|
|
186
|
-
The workflow retrieves credentials programmatically from the Versori connection:
|
|
187
|
-
|
|
188
|
-
```typescript
|
|
189
|
-
import { Buffer } from 'node:buffer';
|
|
190
|
-
|
|
191
|
-
// Retrieve SFTP credentials from connection configuration
|
|
192
|
-
log.info('Retrieving SFTP credentials from connection configuration');
|
|
193
|
-
|
|
194
|
-
let sftpUsername: string;
|
|
195
|
-
let sftpPassword: string;
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
// Retrieve credentials from the 'SFTP' connection
|
|
199
|
-
const sftpCred = await ctx.credentials().getAccessToken('SFTP');
|
|
200
|
-
|
|
201
|
-
if (!sftpCred?.accessToken) {
|
|
202
|
-
throw new Error('No SFTP credentials found in connection configuration');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Decode base64 accessToken to get "username:password"
|
|
206
|
-
const rawBasicAuth = Buffer.from(sftpCred.accessToken, 'base64').toString('utf-8');
|
|
207
|
-
|
|
208
|
-
// Split on ':' to extract username and password
|
|
209
|
-
const parts = rawBasicAuth.split(':');
|
|
210
|
-
|
|
211
|
-
if (parts.length !== 2) {
|
|
212
|
-
throw new Error('Invalid SFTP credential format - expected username:password');
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
sftpUsername = parts[0];
|
|
216
|
-
sftpPassword = parts[1];
|
|
217
|
-
|
|
218
|
-
log.info('SFTP credentials retrieved successfully', {
|
|
219
|
-
hasUsername: !!sftpUsername,
|
|
220
|
-
hasPassword: !!sftpPassword,
|
|
221
|
-
usernameLength: sftpUsername.length,
|
|
222
|
-
passwordLength: sftpPassword.length,
|
|
223
|
-
});
|
|
224
|
-
} catch (error: any) {
|
|
225
|
-
log.error('Failed to retrieve SFTP credentials', {
|
|
226
|
-
message: error instanceof Error ? error.message : String(error),
|
|
227
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
228
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
success: false,
|
|
233
|
-
error: 'Failed to retrieve SFTP credentials from connection configuration',
|
|
234
|
-
details: error?.message,
|
|
235
|
-
recommendation: 'Please ensure the SFTP connection is configured in the Connections section with Basic Authentication (username and password)',
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Use credentials in SftpDataSource
|
|
240
|
-
const sftp = new SftpDataSource({
|
|
241
|
-
type: 'SFTP_JSON',
|
|
242
|
-
connectionId: 'sftp-product-sync',
|
|
243
|
-
name: 'Product Sync SFTP',
|
|
244
|
-
settings: {
|
|
245
|
-
host: activation.getVariable('sftpHost'),
|
|
246
|
-
port: parseInt(activation.getVariable('sftpPort') || '22', 10),
|
|
247
|
-
username: sftpUsername, // From connection
|
|
248
|
-
password: sftpPassword, // From connection
|
|
249
|
-
remotePath: activation.getVariable('sftpRemotePath'),
|
|
250
|
-
filePattern: activation.getVariable('filePattern'),
|
|
251
|
-
encoding: 'utf8',
|
|
252
|
-
},
|
|
253
|
-
}, log);
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
### Accessing SFTP Credentials (Alternative Methods)
|
|
257
|
-
|
|
258
|
-
Versori provides multiple methods to access connection credentials. The template uses `ctx.credentials().getAccessToken('SFTP')` which matches batch-api templates. Alternative methods include:
|
|
259
|
-
|
|
260
|
-
#### Method 1: activation.connections (Simpler Syntax)
|
|
261
|
-
|
|
262
|
-
**Alternative approach** - Direct access via `activation.connections`:
|
|
263
|
-
|
|
264
|
-
```typescript
|
|
265
|
-
const { activation } = ctx;
|
|
266
|
-
|
|
267
|
-
// Access connection object
|
|
268
|
-
const conn = activation.connections.SFTP;
|
|
269
|
-
|
|
270
|
-
// Decode Basic Auth credentials
|
|
271
|
-
const rawBasicAuth = Buffer.from(conn.accessToken, 'base64').toString('utf-8');
|
|
272
|
-
const [username, password] = rawBasicAuth.split(':');
|
|
273
|
-
|
|
274
|
-
log.info('SFTP credentials loaded', { username, host: activation.getVariable('sftpHost') });
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
**When to use:** Simple synchronous access, single connection scenarios
|
|
278
|
-
|
|
279
|
-
#### Method 2: credentials().get() (Dynamic Selection)
|
|
280
|
-
|
|
281
|
-
**Alternative approach** - Use `credentials().get()` for dynamic credential selection:
|
|
282
|
-
|
|
283
|
-
```typescript
|
|
284
|
-
const { credentials } = ctx;
|
|
285
|
-
|
|
286
|
-
// Get specific connection by name
|
|
287
|
-
const sftpCred = await credentials().get('SFTP');
|
|
288
|
-
|
|
289
|
-
// Decode Basic Auth credentials
|
|
290
|
-
const rawBasicAuth = Buffer.from(sftpCred.accessToken, 'base64').toString('utf-8');
|
|
291
|
-
const [username, password] = rawBasicAuth.split(':');
|
|
292
|
-
|
|
293
|
-
log.info('SFTP credentials loaded via credentials()', { username });
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
**When to use:**
|
|
297
|
-
- Multiple SFTP connections (dev/prod, multiple partners)
|
|
298
|
-
- Dynamic credential selection based on runtime logic
|
|
299
|
-
- Need to list all available connections
|
|
300
|
-
|
|
301
|
-
---
|
|
302
|
-
|
|
303
|
-
## ⚙️ Activation Variables
|
|
304
|
-
|
|
305
|
-
**Configuration is driven by activation variables - modify these instead of code:**
|
|
306
|
-
|
|
307
|
-
> **Note:** `sftpUsername` and `sftpPassword` are fetched from the `SFTP` Basic Auth connection (see SFTP Connection Setup above).
|
|
308
|
-
|
|
309
|
-
```bash
|
|
310
|
-
# SFTP Configuration
|
|
311
|
-
SFTP_HOST=sftp.partner.com
|
|
312
|
-
SFTP_PORT=22
|
|
313
|
-
|
|
314
|
-
# SFTP Paths
|
|
315
|
-
SFTP_REMOTE_PATH=/products/incoming
|
|
316
|
-
SFTP_ARCHIVE_PATH=/products/processed
|
|
317
|
-
SFTP_ERROR_PATH=/products/errors
|
|
318
|
-
|
|
319
|
-
# File Processing
|
|
320
|
-
FILE_PATTERN=products_*.json
|
|
321
|
-
JSON_FORMAT=json # json or jsonlines
|
|
322
|
-
MAX_FILES_PER_RUN=10
|
|
323
|
-
|
|
324
|
-
# Product Configuration
|
|
325
|
-
CATALOGUE_REF=PC:MASTER:2
|
|
326
|
-
CATALOGUE_TYPE=MASTER
|
|
327
|
-
|
|
328
|
-
# Event API Configuration
|
|
329
|
-
EVENT_NAME=UPSERT_PRODUCT
|
|
330
|
-
EVENT_MODE=async
|
|
331
|
-
EVENT_CONCURRENCY=1
|
|
332
|
-
|
|
333
|
-
# Fluent Configuration (via Versori connection)
|
|
334
|
-
# Connection: fluent_commerce (OAuth2)
|
|
335
|
-
FLUENT_RETAILER_ID=1
|
|
336
|
-
|
|
337
|
-
# Feature Toggles
|
|
338
|
-
REQUIRE_ABSOLUTE_PATHS=true # "true" for AWS Transfer Family, "false" for standard OpenSSH
|
|
339
|
-
ENABLE_FILE_TRACKING=true # Enable/disable file tracking (default: true)
|
|
340
|
-
|
|
341
|
-
# Event API Performance
|
|
342
|
-
USE_BATCHED_EVENTS=false # Use batched UPSERT_PRODUCTS (10x faster for 100+ products)
|
|
343
|
-
MAX_PRODUCTS_UNDER_BATCHED_EVENT=100 # Products per batch (only used when USE_BATCHED_EVENTS=true)
|
|
344
|
-
MEMORY_BATCH_SIZE=250 # Products per mapping batch (prevents OOM on large files)
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
**Security:**
|
|
348
|
-
- **SFTP Authentication:** Credentials stored in Versori connection vault (not in activation variables). See [SFTP Connection Setup](#-sftp-connection-setup-recommended) section below.
|
|
349
|
-
- **Webhook Authentication:** Enforced by Versori connection configuration. Configure your webhook connection with API key authentication in the Versori Dashboard, then reference it in `webhook({ connection: 'webhook-auth' })`.
|
|
350
|
-
|
|
351
|
-
### Variable Explanations
|
|
352
|
-
|
|
353
|
-
| Variable | Purpose | Default | Customization Hints |
|
|
354
|
-
| ----------------------- | -------------------------------- | ------------------------- | ----------------------------------- |
|
|
355
|
-
| `SFTP_HOST` | SFTP server hostname | - | Required - SFTP server address |
|
|
356
|
-
| `SFTP_PORT` | SFTP server port | `22` | Standard SFTP port |
|
|
357
|
-
| `SFTP_REMOTE_PATH` | Incoming files directory | `/products/incoming` | Where new files arrive |
|
|
358
|
-
| `SFTP_ARCHIVE_PATH` | Processed files archive | `/products/processed` | Where files move after success |
|
|
359
|
-
| `SFTP_ERROR_PATH` | Failed files directory | `/products/errors` | Where files move after errors |
|
|
360
|
-
| `FILE_PATTERN` | File name filter | `products_*.json` | Glob pattern for matching files |
|
|
361
|
-
| `JSON_FORMAT` | JSON format type | `json` | `json` or `jsonlines` |
|
|
362
|
-
| `CATALOGUE_REF` | Product catalogue reference | `PC:MASTER:2` | Target catalogue in Fluent |
|
|
363
|
-
| `CATALOGUE_TYPE` | Catalogue type | `MASTER` | Usually MASTER or STANDARD |
|
|
364
|
-
| `EVENT_NAME` | Event to send | `UPSERT_PRODUCT` | Event name from Rubix |
|
|
365
|
-
| `EVENT_MODE` | Event processing mode | `async` | async (recommended) or sync |
|
|
366
|
-
| `MAX_FILES_PER_RUN` | Max files per execution | `10` | Prevent timeout on large batches |
|
|
367
|
-
| `FLUENT_RETAILER_ID` | Retailer ID for Event API | - | Required - Fluent retailer ID |
|
|
368
|
-
| `EVENT_CONCURRENCY` | Event sending concurrency | `1` | `1` = sequential, `>1` = parallel (3-10 recommended) |
|
|
369
|
-
| `REQUIRE_ABSOLUTE_PATHS` | Require absolute SFTP paths | `true` | Recommended for clarity |
|
|
370
|
-
| `ENABLE_FILE_TRACKING` | Enable KV-based file deduplication | `true` | Set to `false` to disable file tracking |
|
|
371
|
-
| `USE_BATCHED_EVENTS` | Use batched UPSERT_PRODUCTS | `false` | `true` = batched events (10x faster for 100+ products), `false` = individual events |
|
|
372
|
-
| `MAX_PRODUCTS_UNDER_BATCHED_EVENT` | Products per batch | `100` | Only used when `USE_BATCHED_EVENTS=true` (default: 100) |
|
|
373
|
-
| `MEMORY_BATCH_SIZE` | Products per mapping batch | `250` | Processes products in batches during mapping (prevents OOM on large files) |
|
|
374
|
-
|
|
375
|
-
---
|
|
376
|
-
|
|
377
|
-
## 🔒 SDK Automatic Behaviors (v0.1.40+)
|
|
378
|
-
|
|
379
|
-
**The SDK automatically validates and retries for improved reliability:**
|
|
380
|
-
|
|
381
|
-
### retailerId Validation
|
|
382
|
-
- **SDK validates** `retailerId` before calling `sendEvent()`
|
|
383
|
-
- **Checks:** `event.retailerId || client.retailerId`
|
|
384
|
-
- **If missing:** Throws `"retailerId is required for Event API..."`
|
|
385
|
-
- **Configuration:** Set via `FLUENT_RETAILER_ID` activation variable (recommended)
|
|
386
|
-
|
|
387
|
-
### 401 Auth Retry
|
|
388
|
-
- **Automatic retry** for platform auth failures (3 attempts)
|
|
389
|
-
- **Delay:** Exponential backoff (1s → 2s → 4s)
|
|
390
|
-
- **Applies to:** All `sendEvent()` calls (async and sync modes)
|
|
391
|
-
- **Log:** `"[fc-connect-sdk:auth] Platform auth failure (401), retrying..."`
|
|
392
|
-
|
|
393
|
-
### 5xx Server Retry
|
|
394
|
-
- **Automatic retry** for transient server errors (3 attempts)
|
|
395
|
-
- **Delay:** Exponential backoff (1s → 2s → 4s, capped at 10s)
|
|
396
|
-
- **Protects:** Against Fluent API transient failures
|
|
397
|
-
|
|
398
|
-
### No Code Changes Required
|
|
399
|
-
- All templates remain compatible
|
|
400
|
-
- Retry logic is automatic and transparent
|
|
401
|
-
- Better error messages guide configuration
|
|
402
|
-
|
|
403
|
-
**See:** [Event API Guide](./event-api-guide.md) for complete details
|
|
404
|
-
|
|
405
|
-
---
|
|
406
|
-
|
|
407
|
-
### 📁 SFTP Deduplication Pattern (VersoriFileTracker)
|
|
408
|
-
|
|
409
|
-
**CRITICAL: SFTP uses VersoriFileTracker (KV state), NOT physical file movement**
|
|
410
|
-
|
|
411
|
-
VersoriFileTracker is **SFTP-specific** and tracks processed files in Versori KV storage. This prevents reprocessing even if files remain in the incoming directory.
|
|
412
|
-
|
|
413
|
-
#### How SFTP Deduplication Works
|
|
414
|
-
|
|
415
|
-
**4-Step Process:**
|
|
416
|
-
|
|
417
|
-
1. **Discovery:** List files from `sftpIncomingPath` matching `filePattern`
|
|
418
|
-
2. **Filter:** Check VersoriFileTracker - skip files already processed
|
|
419
|
-
3. **Process:** Download, parse, transform, and send events
|
|
420
|
-
4. **Track State:** Mark file as processed in KV (prevents future reprocessing)
|
|
421
|
-
|
|
422
|
-
#### File Lifecycle Example
|
|
423
|
-
|
|
424
|
-
```
|
|
425
|
-
INITIAL STATE:
|
|
426
|
-
sftp://host/products/incoming/products_20250124_001.json
|
|
427
|
-
|
|
428
|
-
↓ (workflow discovers file)
|
|
429
|
-
|
|
430
|
-
CHECKING STATE:
|
|
431
|
-
- List files from "/products/incoming/"
|
|
432
|
-
- VersoriFileTracker.isProcessed("products_20250124_001.json") → false
|
|
433
|
-
|
|
434
|
-
↓ (not processed before)
|
|
435
|
-
|
|
436
|
-
PROCESSING:
|
|
437
|
-
- Download: products_20250124_001.json
|
|
438
|
-
- Parse JSON → Map to events → Send to Event API
|
|
439
|
-
|
|
440
|
-
↓ (success)
|
|
441
|
-
|
|
442
|
-
STATE TRACKING:
|
|
443
|
-
- VersoriFileTracker.markProcessed("products_20250124_001.json")
|
|
444
|
-
- Move file to "/products/processed/products_20250124_001-20250124T183045.json"
|
|
445
|
-
|
|
446
|
-
↓ (next run)
|
|
447
|
-
|
|
448
|
-
DEDUPLICATION:
|
|
449
|
-
- List files from "/products/incoming/" (file may or may not still be there)
|
|
450
|
-
- VersoriFileTracker.isProcessed("products_20250124_001.json") → true
|
|
451
|
-
- Skip file automatically
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
#### Key Differences: SFTP vs S3
|
|
455
|
-
|
|
456
|
-
| Aspect | SFTP (This Template) | S3 (Other Templates) |
|
|
457
|
-
| ------------------------ | -------------------------------- | ----------------------------------- |
|
|
458
|
-
| **Deduplication Method** | VersoriFileTracker (KV state) | Physical file movement |
|
|
459
|
-
| **State Storage** | KV store tracks processed files | None needed |
|
|
460
|
-
| **File Tracking** | ✅ VersoriFileTracker required | ❌ NO VersoriFileTracker |
|
|
461
|
-
| **Reprocessing** | Clear KV state or forceReprocess | Move files back to incoming/ |
|
|
462
|
-
| **Complexity** | More complex (state management) | Simpler (no state management) |
|
|
463
|
-
| **Files After Process** | May remain in incoming/ | Physically moved to processed/ |
|
|
464
|
-
|
|
465
|
-
#### Why SFTP Needs VersoriFileTracker
|
|
466
|
-
|
|
467
|
-
**SFTP file operations are less atomic than S3:**
|
|
468
|
-
|
|
469
|
-
- Files may remain in incoming directory even after processing
|
|
470
|
-
- Partner systems may not clean up files immediately
|
|
471
|
-
- Multiple workflows may access the same SFTP directory
|
|
472
|
-
- Network issues can leave files in inconsistent state
|
|
473
|
-
- **KV state tracking** provides reliable deduplication regardless of file state
|
|
474
|
-
|
|
475
|
-
#### Reprocessing Files (If Needed)
|
|
476
|
-
|
|
477
|
-
To reprocess a file that was already tracked:
|
|
478
|
-
|
|
479
|
-
**Option 1: Clear KV State (Manual)**
|
|
480
|
-
|
|
481
|
-
```typescript
|
|
482
|
-
// In Versori console or workflow
|
|
483
|
-
const kv = openKv(':project:');
|
|
484
|
-
const tracker = new VersoriFileTracker(kv, 'product-ingestion');
|
|
485
|
-
await tracker.clearFile('products_20250124_001.json');
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
**Option 2: Webhook with forceReprocess**
|
|
489
|
-
|
|
490
|
-
```bash
|
|
491
|
-
# Set forceReprocess: true in webhook payload
|
|
492
|
-
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
493
|
-
-H "X-API-Key: your-secret-key" \
|
|
494
|
-
-H "Content-Type: application/json" \
|
|
495
|
-
-d '{
|
|
496
|
-
"forceReprocess": true,
|
|
497
|
-
"filePattern": "products_20250124_001.json"
|
|
498
|
-
}'
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
**Use cases for reprocessing:**
|
|
502
|
-
|
|
503
|
-
- Fixing bad data that was previously ingested
|
|
504
|
-
- Reingesting after mapping config changes
|
|
505
|
-
- Testing with production files
|
|
506
|
-
- Recovering from partial failures
|
|
507
|
-
|
|
508
|
-
#### Summary
|
|
509
|
-
|
|
510
|
-
✅ **DO:** Use VersoriFileTracker for SFTP deduplication
|
|
511
|
-
✅ **DO:** Move files to processed/ or errors/ directories for housekeeping
|
|
512
|
-
✅ **DO:** Import and use VersoriFileTracker in SFTP templates
|
|
513
|
-
✅ **DO:** Use KV state tracking for reliable deduplication
|
|
514
|
-
❌ **DON'T:** Rely on physical file movement alone (files may remain)
|
|
515
|
-
❌ **DON'T:** Use VersoriFileTracker for S3 templates (different use case)
|
|
516
|
-
|
|
517
|
-
---
|
|
518
|
-
|
|
519
|
-
### 📁 SFTP Path vs File Pattern
|
|
520
|
-
|
|
521
|
-
**Critical:** Directory paths and file patterns are configured separately.
|
|
522
|
-
|
|
523
|
-
- **Directory Paths:** Configured via activation variables:
|
|
524
|
-
- `sftpIncomingPath` - Where new files arrive (e.g., `/products/incoming`)
|
|
525
|
-
- `sftpArchivePath` - Where successful files are archived (e.g., `/products/processed`)
|
|
526
|
-
- `sftpErrorPath` - Where failed files are moved (e.g., `/products/errors`)
|
|
527
|
-
|
|
528
|
-
- **File Pattern:** Configured via `filePattern` activation variable
|
|
529
|
-
- Matches file names (not full paths)
|
|
530
|
-
- Supports wildcards: `*.json`, `products_*.json`, `urgent_*.json`
|
|
531
|
-
|
|
532
|
-
**How it works:**
|
|
533
|
-
|
|
534
|
-
```typescript
|
|
535
|
-
// The workflow lists files like this:
|
|
536
|
-
sftp.listFiles({
|
|
537
|
-
path: '/products/incoming', // ← Path from activation variable
|
|
538
|
-
pattern: 'products_*.json', // ← Pattern matches file names
|
|
539
|
-
});
|
|
540
|
-
```
|
|
541
|
-
|
|
542
|
-
**Example:** With these settings:
|
|
543
|
-
|
|
544
|
-
```json
|
|
545
|
-
{
|
|
546
|
-
"sftpIncomingPath": "/products/incoming",
|
|
547
|
-
"filePattern": "products_*.json"
|
|
548
|
-
}
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
The workflow will find files like:
|
|
552
|
-
|
|
553
|
-
- ✅ `/products/incoming/products_20250124.json`
|
|
554
|
-
- ✅ `/products/incoming/products_urgent.json`
|
|
555
|
-
- ❌ `/products/incoming/orders_20250124.json` (doesn't match pattern)
|
|
556
|
-
- ❌ `/orders/incoming/products_20250124.json` (wrong path)
|
|
557
|
-
|
|
558
|
-
**Note:** To process files from different paths, change `sftpIncomingPath` (not `filePattern`).
|
|
559
|
-
|
|
560
|
-
---
|
|
561
|
-
|
|
562
|
-
## 📄 Mapping Configuration
|
|
563
|
-
|
|
564
|
-
**File:** `config/products.import.json`
|
|
565
|
-
|
|
566
|
-
```json
|
|
567
|
-
{
|
|
568
|
-
"name": "products.import.json",
|
|
569
|
-
"version": "1.0.0",
|
|
570
|
-
"description": "JSON → Product Event Mapping",
|
|
571
|
-
"fields": {
|
|
572
|
-
"ref": {
|
|
573
|
-
"source": "ref",
|
|
574
|
-
"required": true,
|
|
575
|
-
"resolver": "sdk.trim"
|
|
576
|
-
},
|
|
577
|
-
"type": {
|
|
578
|
-
"source": "type",
|
|
579
|
-
"resolver": "sdk.uppercase",
|
|
580
|
-
"defaultValue": "STANDARD"
|
|
581
|
-
},
|
|
582
|
-
"status": {
|
|
583
|
-
"source": "status",
|
|
584
|
-
"resolver": "sdk.uppercase",
|
|
585
|
-
"defaultValue": "ACTIVE"
|
|
586
|
-
},
|
|
587
|
-
"gtin": {
|
|
588
|
-
"source": "gtin",
|
|
589
|
-
"resolver": "sdk.trim"
|
|
590
|
-
},
|
|
591
|
-
"name": {
|
|
592
|
-
"source": "name",
|
|
593
|
-
"required": true,
|
|
594
|
-
"resolver": "sdk.trim"
|
|
595
|
-
},
|
|
596
|
-
"summary": {
|
|
597
|
-
"source": "summary"
|
|
598
|
-
},
|
|
599
|
-
"categoryRefs": {
|
|
600
|
-
"source": "categoryRefs"
|
|
601
|
-
},
|
|
602
|
-
"price": {
|
|
603
|
-
"source": "price"
|
|
604
|
-
},
|
|
605
|
-
"taxType": {
|
|
606
|
-
"source": "taxType"
|
|
607
|
-
},
|
|
608
|
-
"attributes": {
|
|
609
|
-
"source": "attributes",
|
|
610
|
-
"defaultValue": []
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
```
|
|
615
|
-
|
|
616
|
-
**Note on JSON Mapping:**
|
|
617
|
-
|
|
618
|
-
Unlike CSV (flat structure), JSON often has nested objects and arrays that map directly to Fluent schema:
|
|
619
|
-
|
|
620
|
-
```json
|
|
621
|
-
{
|
|
622
|
-
"ref": "G_PROD_001",
|
|
623
|
-
"categoryRefs": ["CAT1", "CAT2"],
|
|
624
|
-
"price": [
|
|
625
|
-
{ "type": "DEFAULT", "currency": "USD", "value": 52.0 },
|
|
626
|
-
{ "type": "SPECIAL", "currency": "USD", "value": 9.0 }
|
|
627
|
-
],
|
|
628
|
-
"taxType": {
|
|
629
|
-
"country": "AU",
|
|
630
|
-
"group": "Tax Group",
|
|
631
|
-
"tariff": "Tax Tariff"
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
These fields can use **direct passthrough** (no custom resolvers needed):
|
|
637
|
-
|
|
638
|
-
```json
|
|
639
|
-
{
|
|
640
|
-
"categoryRefs": { "source": "categoryRefs" },
|
|
641
|
-
"price": { "source": "price" },
|
|
642
|
-
"taxType": { "source": "taxType" }
|
|
643
|
-
}
|
|
644
|
-
```
|
|
645
|
-
|
|
646
|
-
For advanced patterns, see SDK Universal Mapping guide.
|
|
647
|
-
|
|
648
|
-
---
|
|
649
|
-
|
|
650
|
-
## Expected JSON Format
|
|
651
|
-
|
|
652
|
-
### Standard JSON (Array of Objects)
|
|
653
|
-
|
|
654
|
-
```json
|
|
655
|
-
[
|
|
656
|
-
{
|
|
657
|
-
"ref": "G_PROD_WITH_NO_STANDARD",
|
|
658
|
-
"type": "VARIANT",
|
|
659
|
-
"status": "ACTIVE",
|
|
660
|
-
"gtin": "MH01-XS-Orange",
|
|
661
|
-
"name": "Chaz Kangeroo Hoodie-XS-Orange main",
|
|
662
|
-
"summary": "<p>test short description</p>",
|
|
663
|
-
"categoryRefs": ["STANDARD_CATEGORY"],
|
|
664
|
-
"price": [
|
|
665
|
-
{ "type": "DEFAULT", "currency": "USD", "value": 52.0 },
|
|
666
|
-
{ "type": "SPECIAL", "currency": "USD", "value": 9.0 }
|
|
667
|
-
],
|
|
668
|
-
"taxType": {
|
|
669
|
-
"country": "AU",
|
|
670
|
-
"group": "Tax Group",
|
|
671
|
-
"tariff": "Tax Tariff"
|
|
672
|
-
}
|
|
673
|
-
},
|
|
674
|
-
{
|
|
675
|
-
"ref": "G_PROD_002",
|
|
676
|
-
"type": "VARIANT",
|
|
677
|
-
"status": "ACTIVE",
|
|
678
|
-
"gtin": "MH02-M-Blue",
|
|
679
|
-
"name": "Kangeroo Hoodie-M-Blue",
|
|
680
|
-
"summary": "<p>Blue variant</p>",
|
|
681
|
-
"categoryRefs": ["STANDARD_CATEGORY", "SEASONAL"],
|
|
682
|
-
"price": [
|
|
683
|
-
{ "type": "DEFAULT", "currency": "USD", "value": 45.0 },
|
|
684
|
-
{ "type": "SPECIAL", "currency": "USD", "value": 35.0 }
|
|
685
|
-
],
|
|
686
|
-
"taxType": {
|
|
687
|
-
"country": "AU",
|
|
688
|
-
"group": "Tax Group",
|
|
689
|
-
"tariff": "Tax Tariff"
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
]
|
|
693
|
-
```
|
|
694
|
-
|
|
695
|
-
### JSON Lines Format (One JSON Object Per Line)
|
|
696
|
-
|
|
697
|
-
```json
|
|
698
|
-
{"ref":"G_PROD_WITH_NO_STANDARD","type":"VARIANT","status":"ACTIVE","gtin":"MH01-XS-Orange","name":"Chaz Kangeroo Hoodie-XS-Orange main","summary":"<p>test short description</p>","categoryRefs":["STANDARD_CATEGORY"],"price":[{"type":"DEFAULT","currency":"USD","value":52.0},{"type":"SPECIAL","currency":"USD","value":9.0}],"taxType":{"country":"AU","group":"Tax Group","tariff":"Tax Tariff"}}
|
|
699
|
-
{"ref":"G_PROD_002","type":"VARIANT","status":"ACTIVE","gtin":"MH02-M-Blue","name":"Kangeroo Hoodie-M-Blue","summary":"<p>Blue variant</p>","categoryRefs":["STANDARD_CATEGORY","SEASONAL"],"price":[{"type":"DEFAULT","currency":"USD","value":45.0},{"type":"SPECIAL","currency":"USD","value":35.0}],"taxType":{"country":"AU","group":"Tax Group","tariff":"Tax Tariff"}}
|
|
700
|
-
```
|
|
701
|
-
|
|
702
|
-
**JSONParserService auto-detects format** - NO array normalization, returns parsed data as-is.
|
|
703
|
-
|
|
704
|
-
---
|
|
705
|
-
|
|
706
|
-
## Event Sending Configuration
|
|
707
|
-
|
|
708
|
-
**Simple Configuration:** One variable controls everything
|
|
709
|
-
|
|
710
|
-
```json
|
|
711
|
-
{
|
|
712
|
-
"eventConcurrency": 1 // 1 = sequential, 3-10 = parallel
|
|
713
|
-
}
|
|
714
|
-
```
|
|
715
|
-
|
|
716
|
-
**How it works:**
|
|
717
|
-
|
|
718
|
-
- `eventConcurrency: 1` → Sequential (sends events one at a time, safe default)
|
|
719
|
-
- `eventConcurrency: 3` → Parallel (sends 3 events concurrently)
|
|
720
|
-
- `eventConcurrency: 5` → Parallel (sends 5 events concurrently)
|
|
721
|
-
- `eventConcurrency: 10` → Parallel (sends 10 events concurrently)
|
|
722
|
-
|
|
723
|
-
**Configuration Guidelines:**
|
|
724
|
-
|
|
725
|
-
- **Conservative:** `1` → Sequential (safe, predictable, ~1 req/sec)
|
|
726
|
-
- **Balanced:** `3-5` → Parallel (most common, ~3-5 req/sec)
|
|
727
|
-
- **Aggressive:** `10` → Parallel (high-volume, ~10 req/sec)
|
|
728
|
-
- **Note:** Fluent API supports concurrent requests - adjust based on your needs
|
|
729
|
-
|
|
730
|
-
**Why Single Variable?**
|
|
731
|
-
|
|
732
|
-
- **Simpler:** One variable instead of two (`mode` + `concurrency`)
|
|
733
|
-
- **Clearer:** `concurrency: 1` = sequential, `concurrency > 1` = parallel
|
|
734
|
-
- **Less config:** Fewer activation variables to manage
|
|
735
|
-
- **Flexible:** Easy to tune performance (just change the number)
|
|
736
|
-
|
|
737
|
-
---
|
|
738
|
-
|
|
739
|
-
## 🔧 Production Reference Implementation
|
|
740
|
-
|
|
741
|
-
### Processing Modes
|
|
742
|
-
|
|
743
|
-
| Mode | Default | What happens | When to use |
|
|
744
|
-
| ---------- | ------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
745
|
-
| `per-file` | ✅ | Process one file end-to-end (discover → parse → Event API → archive) before moving on | Recommended for production SFTP feeds and very large files |
|
|
746
|
-
| `chunked` | ➖ | Processes `fileChunkSize` files at a time, completing each chunk before the next | High-volume feeds where per-file would take too long |
|
|
747
|
-
| `batch` | ➖ | Loads every matched file into memory, produces a single Event API job, and archives when complete | Tiny datasets or smoke tests only |
|
|
748
|
-
|
|
749
|
-
Set the mode via activation variables:
|
|
750
|
-
|
|
751
|
-
```json
|
|
752
|
-
{
|
|
753
|
-
"processingMode": "per-file",
|
|
754
|
-
"fileChunkSize": "5",
|
|
755
|
-
"maxFilesPerRun": "25"
|
|
756
|
-
}
|
|
757
|
-
```
|
|
758
|
-
|
|
759
|
-
### Versori Workflows Structure
|
|
760
|
-
|
|
761
|
-
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
762
|
-
|
|
763
|
-
**Trigger Types:**
|
|
764
|
-
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
765
|
-
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
766
|
-
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
767
|
-
|
|
768
|
-
**Execution Steps (chained to triggers):**
|
|
769
|
-
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
770
|
-
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
771
|
-
|
|
772
|
-
### Recommended Project Structure
|
|
773
|
-
|
|
774
|
-
```
|
|
775
|
-
product-event-sync/
|
|
776
|
-
├── index.ts # Entry point - exports all workflows
|
|
777
|
-
└── src/
|
|
778
|
-
├── workflows/
|
|
779
|
-
│ ├── scheduled/
|
|
780
|
-
│ │ └── daily-product-sync.ts # Scheduled: Daily product sync
|
|
781
|
-
│ │
|
|
782
|
-
│ └── webhook/
|
|
783
|
-
│ ├── adhoc-product-sync.ts # Webhook: Manual trigger
|
|
784
|
-
│ └── job-status-check.ts # Webhook: Status query
|
|
785
|
-
│
|
|
786
|
-
├── services/
|
|
787
|
-
│ └── product-sync.service.ts # Shared orchestration logic (reusable)
|
|
788
|
-
│
|
|
789
|
-
└── types/
|
|
790
|
-
└── product.types.ts # Shared type definitions
|
|
791
|
-
```
|
|
792
|
-
|
|
793
|
-
**Benefits:**
|
|
794
|
-
- ✅ Clear trigger separation (`scheduled/` vs `webhook/`)
|
|
795
|
-
- ✅ Descriptive file names (easy to browse and understand)
|
|
796
|
-
- ✅ Scalable (add new workflows without cluttering)
|
|
797
|
-
- ✅ Reusable code in `services/` (DRY principle)
|
|
798
|
-
- ✅ Easy to modify individual workflows without affecting others
|
|
799
|
-
|
|
800
|
-
---
|
|
801
|
-
|
|
802
|
-
## Workflow Files
|
|
803
|
-
|
|
804
|
-
### 1. Scheduled Workflows (`src/workflows/scheduled/`)
|
|
805
|
-
|
|
806
|
-
All time-based triggers that run automatically on cron schedules.
|
|
807
|
-
|
|
808
|
-
#### `src/workflows/scheduled/daily-product-sync.ts`
|
|
809
|
-
|
|
810
|
-
**Purpose**: Automatic Daily product sync
|
|
811
|
-
**Trigger**: Cron schedule (`0 2 * * *`)
|
|
812
|
-
**Exposed as Endpoint**: ❌ NO - Runs automatically
|
|
813
|
-
|
|
814
|
-
```typescript
|
|
815
|
-
import { schedule, http } from '@versori/run';
|
|
816
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
817
|
-
import { executeProductIngestion } from '../../services/product-sync.service';
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* Scheduled Workflow: Daily Product Sync
|
|
821
|
-
*
|
|
822
|
-
* Runs automatically daily at 2 AM UTC
|
|
823
|
-
* NOT exposed as HTTP endpoint - Versori executes on schedule
|
|
824
|
-
*
|
|
825
|
-
* Uses shared service: product-sync.service.ts
|
|
826
|
-
*/
|
|
827
|
-
export const dailyProductSync = schedule(
|
|
828
|
-
'product-sync-scheduled',
|
|
829
|
-
'0 2 * * *' // Daily at 2 AM UTC
|
|
830
|
-
).then(
|
|
831
|
-
http('run-product-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
832
|
-
const { log, openKv } = ctx;
|
|
833
|
-
const jobId = `product-sync-${Date.now()}`;
|
|
834
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
835
|
-
|
|
836
|
-
log.info('🚀 [WORKFLOW START] Daily product sync workflow triggered', { jobId });
|
|
837
|
-
|
|
838
|
-
await tracker.createJob(jobId, { triggeredBy: 'schedule', stage: 'initialization' });
|
|
839
|
-
await tracker.updateJob(jobId, { status: 'processing' });
|
|
840
|
-
|
|
841
|
-
try {
|
|
842
|
-
// Reuse shared orchestration logic
|
|
843
|
-
const result = await executeProductIngestion(ctx, { jobId, triggeredBy: 'manual' }, tracker);
|
|
844
|
-
await tracker.markCompleted(jobId, result);
|
|
845
|
-
|
|
846
|
-
log.info('✅ [WORKFLOW END] Daily product sync completed successfully', {
|
|
847
|
-
jobId,
|
|
848
|
-
duration: result.duration,
|
|
849
|
-
filesProcessed: result.filesProcessed
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
return { success: true, jobId, ...result };
|
|
853
|
-
} catch (e: any) {
|
|
854
|
-
log.error('❌ [WORKFLOW ERROR] Daily product sync failed', {
|
|
855
|
-
jobId,
|
|
856
|
-
message: e?.message,
|
|
857
|
-
stack: e?.stack
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
await tracker.markFailed(jobId, e);
|
|
861
|
-
return { success: false, jobId, error: e?.message };
|
|
862
|
-
}
|
|
863
|
-
})
|
|
864
|
-
);
|
|
865
|
-
```
|
|
866
|
-
|
|
867
|
-
---
|
|
868
|
-
|
|
869
|
-
### 2. Webhook Workflows (`src/workflows/webhook/`)
|
|
870
|
-
|
|
871
|
-
All HTTP-based triggers that create webhook endpoints.
|
|
872
|
-
|
|
873
|
-
#### `src/workflows/webhook/adhoc-product-sync.ts`
|
|
874
|
-
|
|
875
|
-
**Purpose**: Manual product sync trigger (on-demand)
|
|
876
|
-
**Trigger**: HTTP POST
|
|
877
|
-
**Endpoint**: `POST https://{workspace}.versori.run/product-sync-adhoc`
|
|
878
|
-
**Use Cases**: Testing, priority processing, ad-hoc runs
|
|
879
|
-
|
|
880
|
-
```typescript
|
|
881
|
-
import { webhook, http } from '@versori/run';
|
|
882
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
883
|
-
import { executeProductIngestion } from '../../services/product-sync.service';
|
|
884
|
-
|
|
885
|
-
/**
|
|
886
|
-
* Webhook: Manual Product Sync Trigger
|
|
887
|
-
*
|
|
888
|
-
* Endpoint: POST https://{workspace}.versori.run/product-sync-adhoc
|
|
889
|
-
* Request body (optional): { filePattern: "urgent_*.json", maxFiles: 5 }
|
|
890
|
-
*
|
|
891
|
-
* Pattern: webhook().then(http()) - needs Fluent API access
|
|
892
|
-
* Uses shared service: product-sync.service.ts
|
|
893
|
-
*
|
|
894
|
-
* SECURITY: Authentication handled via connection parameter
|
|
895
|
-
* No manual API key validation needed - Versori manages this via connection auth
|
|
896
|
-
*/
|
|
897
|
-
export const adhocProductSync = webhook('product-sync-adhoc', {
|
|
898
|
-
response: { mode: 'sync' }, // ✅ Sync mode: response sent when handler returns
|
|
899
|
-
connection: 'product-sync-adhoc', // Versori validates API key
|
|
900
|
-
}).then(
|
|
901
|
-
http('run-product-sync-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
902
|
-
const { log, openKv, data } = ctx;
|
|
903
|
-
const jobId = `product-sync-adhoc-${Date.now()}`;
|
|
904
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
905
|
-
|
|
906
|
-
const filePattern = data?.filePattern as string;
|
|
907
|
-
const maxFiles = data?.maxFiles as number;
|
|
908
|
-
|
|
909
|
-
log.info('🚀 [WEBHOOK] Adhoc product sync triggered', {
|
|
910
|
-
jobId,
|
|
911
|
-
filePattern,
|
|
912
|
-
maxFiles,
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
// Create job entry FIRST (awaited to ensure job exists in KV)
|
|
916
|
-
await tracker.createJob(jobId, {
|
|
917
|
-
triggeredBy: 'manual',
|
|
918
|
-
stage: 'initialization',
|
|
919
|
-
status: 'queued',
|
|
920
|
-
options: { filePattern, maxFiles },
|
|
921
|
-
createdAt: new Date().toISOString(),
|
|
922
|
-
});
|
|
923
|
-
|
|
924
|
-
// ✅ Fire-and-forget: Start background processing WITHOUT await
|
|
925
|
-
// The promise continues execution after we return the response
|
|
926
|
-
executeProductIngestion(ctx, { jobId, triggeredBy: 'manual' }, tracker)
|
|
927
|
-
.then((result) => {
|
|
928
|
-
log.info('✅ [BACKGROUND] Product sync completed successfully', {
|
|
929
|
-
jobId,
|
|
930
|
-
filesProcessed: result.filesProcessed,
|
|
931
|
-
filesFailed: result.filesFailed,
|
|
932
|
-
recordsProcessed: result.recordsProcessed,
|
|
933
|
-
duration: result.duration,
|
|
934
|
-
});
|
|
935
|
-
return tracker.markCompleted(jobId, result);
|
|
936
|
-
})
|
|
937
|
-
.catch((error: unknown) => {
|
|
938
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
939
|
-
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
940
|
-
|
|
941
|
-
log.error('❌ [BACKGROUND] Product sync failed', {
|
|
942
|
-
jobId,
|
|
943
|
-
error: errorMessage,
|
|
944
|
-
stack: errorStack,
|
|
945
|
-
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
946
|
-
});
|
|
947
|
-
|
|
948
|
-
return tracker.markFailed(jobId, errorMessage);
|
|
949
|
-
});
|
|
950
|
-
|
|
951
|
-
// Return immediately with jobId (response sent with this return value)
|
|
952
|
-
return {
|
|
953
|
-
success: true,
|
|
954
|
-
jobId,
|
|
955
|
-
message: 'Product sync started in background',
|
|
956
|
-
statusEndpoint: `https://{workspace}.versori.run/product-sync-job-status`,
|
|
957
|
-
note: 'Poll the status endpoint with jobId to check progress',
|
|
958
|
-
};
|
|
959
|
-
})
|
|
960
|
-
);
|
|
961
|
-
```
|
|
962
|
-
|
|
963
|
-
#### `src/workflows/webhook/job-status-check.ts`
|
|
964
|
-
|
|
965
|
-
**Purpose**: Query job status
|
|
966
|
-
**Trigger**: HTTP POST
|
|
967
|
-
**Endpoint**: `POST https://{workspace}.versori.run/product-sync-job-status`
|
|
968
|
-
**Request body**: `{ "jobId": "product-sync-1234567890" }`
|
|
969
|
-
|
|
970
|
-
```typescript
|
|
971
|
-
import { webhook, fn } from '@versori/run';
|
|
972
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
973
|
-
|
|
974
|
-
/**
|
|
975
|
-
* Webhook: Job Status Check
|
|
976
|
-
*
|
|
977
|
-
* Endpoint: POST https://{workspace}.versori.run/product-sync-job-status
|
|
978
|
-
* Request body: { "jobId": "product-sync-1234567890" }
|
|
979
|
-
*
|
|
980
|
-
* Pattern: webhook().then(fn()) - no external API needed, only KV storage
|
|
981
|
-
* Lightweight: Only queries KV store, no Fluent API calls
|
|
982
|
-
*
|
|
983
|
-
* SECURITY: Authentication handled via connection parameter
|
|
984
|
-
* No manual API key validation needed - Versori manages this via connection auth
|
|
985
|
-
*/
|
|
986
|
-
export const productSyncJobStatus = webhook('product-sync-job-status', {
|
|
987
|
-
response: { mode: 'sync' },
|
|
988
|
-
connection: 'product-sync-job-status',
|
|
989
|
-
}).then(
|
|
990
|
-
fn('status', async ctx => {
|
|
991
|
-
const { data, log, openKv } = ctx;
|
|
992
|
-
const jobId = data?.jobId as string;
|
|
993
|
-
|
|
994
|
-
if (!jobId) {
|
|
995
|
-
return { success: false, error: 'jobId required' };
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
999
|
-
const status = await tracker.getJob(jobId);
|
|
1000
|
-
|
|
1001
|
-
return status
|
|
1002
|
-
? { success: true, jobId, ...status }
|
|
1003
|
-
: { success: false, error: 'Job not found', jobId };
|
|
1004
|
-
})
|
|
1005
|
-
);
|
|
1006
|
-
```
|
|
1007
|
-
|
|
1008
|
-
---
|
|
1009
|
-
|
|
1010
|
-
### 3. Entry Point (`index.ts`)
|
|
1011
|
-
|
|
1012
|
-
**Purpose**: Register all workflows with Versori platform
|
|
1013
|
-
|
|
1014
|
-
```typescript
|
|
1015
|
-
/**
|
|
1016
|
-
* Entry Point - Registers all workflows with Versori platform
|
|
1017
|
-
*
|
|
1018
|
-
* Versori automatically discovers and registers exported workflows
|
|
1019
|
-
*
|
|
1020
|
-
* File Structure:
|
|
1021
|
-
* - src/workflows/scheduled/ → Time-based triggers (cron)
|
|
1022
|
-
* - src/workflows/webhook/ → HTTP-based triggers (webhooks)
|
|
1023
|
-
* - src/services/ → Shared service logic (reusable across workflows)
|
|
1024
|
-
*/
|
|
1025
|
-
|
|
1026
|
-
// Import scheduled workflows
|
|
1027
|
-
import { dailyProductSync } from './src/workflows/scheduled/daily-product-sync';
|
|
1028
|
-
|
|
1029
|
-
// Import webhook workflows
|
|
1030
|
-
import { adhocProductSync } from './src/workflows/webhook/adhoc-product-sync';
|
|
1031
|
-
import { productSyncJobStatus } from './src/workflows/webhook/job-status-check';
|
|
1032
|
-
|
|
1033
|
-
// Register all workflows with Versori platform
|
|
1034
|
-
export {
|
|
1035
|
-
// Scheduled (time-based triggers)
|
|
1036
|
-
dailyProductSync,
|
|
1037
|
-
|
|
1038
|
-
// Webhooks (HTTP-based triggers)
|
|
1039
|
-
adhocProductSync,
|
|
1040
|
-
productSyncJobStatus,
|
|
1041
|
-
};
|
|
1042
|
-
```
|
|
1043
|
-
|
|
1044
|
-
**What Gets Exposed:**
|
|
1045
|
-
- ✅ `adhocProductSync` → `https://{workspace}.versori.run/product-sync-adhoc`
|
|
1046
|
-
- ✅ `productSyncJobStatus` → `https://{workspace}.versori.run/product-sync-job-status`
|
|
1047
|
-
- ❌ `dailyProductSync` → NOT exposed (runs automatically on cron)
|
|
1048
|
-
|
|
1049
|
-
---
|
|
1050
|
-
|
|
1051
|
-
### Adding New Workflows
|
|
1052
|
-
|
|
1053
|
-
**To add a scheduled workflow:**
|
|
1054
|
-
1. Create file in `src/workflows/scheduled/` with descriptive name (e.g., `hourly-delta-sync.ts`)
|
|
1055
|
-
2. Export the workflow from the file
|
|
1056
|
-
3. Import and re-export in `index.ts`
|
|
1057
|
-
|
|
1058
|
-
**To add a webhook workflow:**
|
|
1059
|
-
1. Create file in `src/workflows/webhook/` with descriptive name (e.g., `error-notification.ts`)
|
|
1060
|
-
2. Export the workflow from the file
|
|
1061
|
-
3. Import and re-export in `index.ts`
|
|
1062
|
-
|
|
1063
|
-
**Example - Adding hourly delta sync:**
|
|
1064
|
-
|
|
1065
|
-
```typescript
|
|
1066
|
-
// src/workflows/scheduled/hourly-delta-sync.ts
|
|
1067
|
-
export const hourlyDeltaSync = schedule(
|
|
1068
|
-
'product-delta-hourly',
|
|
1069
|
-
'0 * * * *' // Every hour
|
|
1070
|
-
).then(
|
|
1071
|
-
http('run-delta-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
1072
|
-
// Delta sync logic (skip BPP)
|
|
1073
|
-
const result = await runIngestion(ctx, jobId, tracker, { bppEnabled: false });
|
|
1074
|
-
return result;
|
|
1075
|
-
})
|
|
1076
|
-
);
|
|
1077
|
-
|
|
1078
|
-
// index.ts (add to imports and exports)
|
|
1079
|
-
import { hourlyDeltaSync } from './src/workflows/scheduled/hourly-delta-sync';
|
|
1080
|
-
export { daily_product_sync, hourlyDeltaSync, ... };
|
|
1081
|
-
```
|
|
1082
|
-
|
|
1083
|
-
---
|
|
1084
|
-
## 3. Type Definitions (src/types/product-ingestion.types.ts)
|
|
1085
|
-
|
|
1086
|
-
```typescript
|
|
1087
|
-
/**
|
|
1088
|
-
* Type Definitions for Product Ingestion
|
|
1089
|
-
*
|
|
1090
|
-
* Centralized type definitions for product ingestion workflow
|
|
1091
|
-
*/
|
|
1092
|
-
|
|
1093
|
-
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1094
|
-
|
|
1095
|
-
/**
|
|
1096
|
-
* Product interface - represents transformed product data
|
|
1097
|
-
*/
|
|
1098
|
-
export interface Product {
|
|
1099
|
-
ref: string;
|
|
1100
|
-
type?: string;
|
|
1101
|
-
status?: string;
|
|
1102
|
-
name: string;
|
|
1103
|
-
summary?: string;
|
|
1104
|
-
gtin?: string;
|
|
1105
|
-
catalogue?: { ref?: string };
|
|
1106
|
-
attributes?: Record<string, unknown>;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
/**
|
|
1110
|
-
* Event configuration
|
|
1111
|
-
*/
|
|
1112
|
-
export interface EventConfig {
|
|
1113
|
-
eventName: string;
|
|
1114
|
-
catalogueRef: string;
|
|
1115
|
-
catalogueType: string;
|
|
1116
|
-
eventMode: 'async' | 'sync';
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
/**
|
|
1120
|
-
* Event result - tracks success/failure counts
|
|
1121
|
-
*/
|
|
1122
|
-
export interface EventResult {
|
|
1123
|
-
eventsSent: number;
|
|
1124
|
-
eventsFailed: number;
|
|
1125
|
-
errors: Array<{ productRef: string; error: string }>;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
/**
|
|
1129
|
-
* Process file result
|
|
1130
|
-
*/
|
|
1131
|
-
export interface ProcessFileResult {
|
|
1132
|
-
success: boolean;
|
|
1133
|
-
products: Product[];
|
|
1134
|
-
error?: string;
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
/**
|
|
1138
|
-
* Versori Context Interface
|
|
1139
|
-
* Represents the Versori runtime context passed to workflow functions
|
|
1140
|
-
*/
|
|
1141
|
-
export interface VersoriContext {
|
|
1142
|
-
log: {
|
|
1143
|
-
info: (message: string, data?: Record<string, unknown>) => void;
|
|
1144
|
-
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
1145
|
-
error: (message: string, data?: Record<string, unknown>) => void;
|
|
1146
|
-
debug?: (message: string, data?: Record<string, unknown>) => void;
|
|
1147
|
-
};
|
|
1148
|
-
openKv: (namespace: string) => {
|
|
1149
|
-
get: (key: string) => Promise<unknown>;
|
|
1150
|
-
set: (key: string, value: unknown) => Promise<void>;
|
|
1151
|
-
delete: (key: string) => Promise<void>;
|
|
1152
|
-
};
|
|
1153
|
-
activation: {
|
|
1154
|
-
getVariable: (name: string) => string | undefined;
|
|
1155
|
-
connections?: Record<string, unknown>;
|
|
1156
|
-
};
|
|
1157
|
-
connections?: Record<string, unknown>;
|
|
1158
|
-
data?: unknown;
|
|
1159
|
-
fetch?: typeof fetch;
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
/**
|
|
1163
|
-
* Parameters for ingestion workflow
|
|
1164
|
-
*/
|
|
1165
|
-
export interface ProductIngestionParams {
|
|
1166
|
-
jobId: string;
|
|
1167
|
-
triggeredBy: 'schedule' | 'webhook';
|
|
1168
|
-
filePattern?: string;
|
|
1169
|
-
maxFiles?: number;
|
|
1170
|
-
forceReprocess?: boolean;
|
|
1171
|
-
catalogueRef?: string;
|
|
1172
|
-
priority?: 'normal' | 'high';
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
/**
|
|
1176
|
-
* Result from ingestion workflow
|
|
1177
|
-
*/
|
|
1178
|
-
export interface ProductIngestionResult {
|
|
1179
|
-
success: boolean;
|
|
1180
|
-
jobId: string;
|
|
1181
|
-
filesProcessed: number;
|
|
1182
|
-
filesFailed: number;
|
|
1183
|
-
filesSkipped?: number;
|
|
1184
|
-
recordsProcessed: number;
|
|
1185
|
-
eventsSent: number;
|
|
1186
|
-
eventsFailed: number;
|
|
1187
|
-
fileResults: FileProcessingResult[];
|
|
1188
|
-
error?: string;
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
/**
|
|
1192
|
-
* Per-file processing result
|
|
1193
|
-
*/
|
|
1194
|
-
export interface FileProcessingResult {
|
|
1195
|
-
fileName: string;
|
|
1196
|
-
success: boolean;
|
|
1197
|
-
skipped?: boolean;
|
|
1198
|
-
recordsProcessed: number;
|
|
1199
|
-
eventsSent: number;
|
|
1200
|
-
eventsFailed: number;
|
|
1201
|
-
duration: number;
|
|
1202
|
-
error?: string;
|
|
1203
|
-
}
|
|
1204
|
-
```
|
|
1205
|
-
|
|
1206
|
-
---
|
|
1207
|
-
|
|
1208
|
-
## 4. Service: Product File Processor (`src/services/product-file-processor.service.ts`)
|
|
1209
|
-
|
|
1210
|
-
```typescript
|
|
1211
|
-
/**
|
|
1212
|
-
* Product File Processor Service
|
|
1213
|
-
*
|
|
1214
|
-
* Downloads JSON files from SFTP, parses, and transforms with UniversalMapper.
|
|
1215
|
-
* JSON-specific: Handles both array format and object with products array.
|
|
1216
|
-
*/
|
|
1217
|
-
|
|
1218
|
-
import {
|
|
1219
|
-
SftpDataSource,
|
|
1220
|
-
JSONParserService,
|
|
1221
|
-
UniversalMapper,
|
|
1222
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1223
|
-
import type { ProcessFileResult, Product } from '../types/product-ingestion.types';
|
|
1224
|
-
|
|
1225
|
-
/**
|
|
1226
|
-
* Service for processing JSON product files
|
|
1227
|
-
*/
|
|
1228
|
-
export class ProductFileProcessorService {
|
|
1229
|
-
constructor(
|
|
1230
|
-
private sftp: SftpDataSource,
|
|
1231
|
-
private jsonParser: JSONParserService,
|
|
1232
|
-
private mapper: UniversalMapper,
|
|
1233
|
-
private catalogueRef: string
|
|
1234
|
-
) {}
|
|
1235
|
-
|
|
1236
|
-
/**
|
|
1237
|
-
* Download JSON file from SFTP, parse, and transform with UniversalMapper
|
|
1238
|
-
*
|
|
1239
|
-
* ✅ PRODUCTION ENHANCEMENT: Memory cleanup pattern
|
|
1240
|
-
* - Explicit null assignments after each step
|
|
1241
|
-
* - Finally block guarantees cleanup
|
|
1242
|
-
* - Prevents OOM errors on large JSON files
|
|
1243
|
-
*/
|
|
1244
|
-
async downloadParseAndTransform(remoteFilePath: string): Promise<ProcessFileResult> {
|
|
1245
|
-
// ✅ CRITICAL: Variables for cleanup tracking
|
|
1246
|
-
let jsonContent: string | null = null;
|
|
1247
|
-
let parsed: unknown | null = null;
|
|
1248
|
-
|
|
1249
|
-
try {
|
|
1250
|
-
// STEP 1: Download JSON content
|
|
1251
|
-
jsonContent = (await this.sftp.downloadFile(remoteFilePath, {
|
|
1252
|
-
encoding: 'utf8',
|
|
1253
|
-
})) as string;
|
|
1254
|
-
|
|
1255
|
-
// STEP 2: Parse JSON with type safety
|
|
1256
|
-
try {
|
|
1257
|
-
parsed = await this.jsonParser.parse(jsonContent);
|
|
1258
|
-
} catch (parseError: unknown) {
|
|
1259
|
-
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
|
1260
|
-
return {
|
|
1261
|
-
success: false,
|
|
1262
|
-
products: [],
|
|
1263
|
-
error: `JSON parse error: ${errorMessage}`,
|
|
1264
|
-
};
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
// ✅ Clear jsonContent from memory - no longer needed after parsing
|
|
1268
|
-
jsonContent = null;
|
|
1269
|
-
|
|
1270
|
-
// STEP 3: JSON-SPECIFIC - Normalize based on structure (array vs object with products array)
|
|
1271
|
-
const rawProducts = Array.isArray(parsed)
|
|
1272
|
-
? parsed
|
|
1273
|
-
: (parsed as any)?.products
|
|
1274
|
-
? Array.isArray((parsed as any).products)
|
|
1275
|
-
? (parsed as any).products
|
|
1276
|
-
: [(parsed as any).products]
|
|
1277
|
-
: [];
|
|
1278
|
-
|
|
1279
|
-
if (rawProducts.length === 0) {
|
|
1280
|
-
return {
|
|
1281
|
-
success: false,
|
|
1282
|
-
products: [],
|
|
1283
|
-
error: 'No products found in JSON',
|
|
1284
|
-
};
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// ✅ Clear parsed data from memory - only need rawProducts now
|
|
1288
|
-
parsed = null;
|
|
1289
|
-
|
|
1290
|
-
// STEP 4: Transform with UniversalMapper
|
|
1291
|
-
// JSON-SPECIFIC: Merge context using spread pattern (not { inventory: ... })
|
|
1292
|
-
const sourceDataWithContext = rawProducts.map(item => ({
|
|
1293
|
-
...item,
|
|
1294
|
-
$context: { catalogueRef: this.catalogueRef },
|
|
1295
|
-
}));
|
|
1296
|
-
|
|
1297
|
-
const mappingResult = await this.mapper.map(sourceDataWithContext);
|
|
1298
|
-
|
|
1299
|
-
if (!mappingResult.success) {
|
|
1300
|
-
return {
|
|
1301
|
-
success: false,
|
|
1302
|
-
products: [],
|
|
1303
|
-
error: `Mapping validation failed: ${mappingResult.errors?.join(', ')}`,
|
|
1304
|
-
};
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
1308
|
-
this.log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
1309
|
-
skippedFields: mappingResult.skippedFields,
|
|
1310
|
-
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
1311
|
-
});
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
return {
|
|
1315
|
-
success: true,
|
|
1316
|
-
products: mappingResult.data as Product[],
|
|
1317
|
-
};
|
|
1318
|
-
} catch (error: unknown) {
|
|
1319
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1320
|
-
return {
|
|
1321
|
-
success: false,
|
|
1322
|
-
products: [],
|
|
1323
|
-
error: errorMessage,
|
|
1324
|
-
};
|
|
1325
|
-
} finally {
|
|
1326
|
-
// ✅ CRITICAL: Ensure all large objects are cleared even if error occurs
|
|
1327
|
-
jsonContent = null;
|
|
1328
|
-
parsed = null;
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
```
|
|
1333
|
-
|
|
1334
|
-
---
|
|
1335
|
-
|
|
1336
|
-
## 5. Service: Event Sender (`src/services/event-sender.service.ts`)
|
|
1337
|
-
|
|
1338
|
-
```typescript
|
|
1339
|
-
/**
|
|
1340
|
-
* Event Sender Service
|
|
1341
|
-
*
|
|
1342
|
-
* Sends product events to Fluent Commerce Event API with per-record error handling.
|
|
1343
|
-
* Continues processing on individual failures (Event API best practice).
|
|
1344
|
-
* Supports configurable concurrency (sequential or parallel).
|
|
1345
|
-
*/
|
|
1346
|
-
|
|
1347
|
-
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1348
|
-
import type { EventResult, EventConfig, Product } from '../types/product-ingestion.types';
|
|
1349
|
-
|
|
1350
|
-
/**
|
|
1351
|
-
* Service for sending events to Fluent Commerce Event API
|
|
1352
|
-
*
|
|
1353
|
-
* ✅ PRODUCTION ENHANCEMENT: Optional logger for detailed progress tracking
|
|
1354
|
-
*/
|
|
1355
|
-
export class EventSenderService {
|
|
1356
|
-
constructor(
|
|
1357
|
-
private client: FluentClient,
|
|
1358
|
-
private log?: any // ✅ Optional logger for progress tracking
|
|
1359
|
-
) {}
|
|
1360
|
-
|
|
1361
|
-
/**
|
|
1362
|
-
* Send events to Fluent Commerce Event API with configurable concurrency
|
|
1363
|
-
*
|
|
1364
|
-
* **Performance Characteristics:**
|
|
1365
|
-
* - `concurrency: 1` → Sequential processing (safe default, ~1 event/sec)
|
|
1366
|
-
* - `concurrency: 3-5` → Balanced throughput (~3-5 events/sec, good for most cases)
|
|
1367
|
-
* - `concurrency: 10` → High-volume processing (~10 events/sec, 100+ products)
|
|
1368
|
-
*
|
|
1369
|
-
* **Implementation Strategy:**
|
|
1370
|
-
* - Concurrency = 1: Optimized sequential loop (no Promise.allSettled overhead)
|
|
1371
|
-
* - Concurrency > 1: Chunked parallel processing with bounded concurrency
|
|
1372
|
-
* - Both modes: Per-record error tracking (failures don't block other events)
|
|
1373
|
-
*
|
|
1374
|
-
* @param products - Array of products to send as events
|
|
1375
|
-
* @param eventConfig - Event configuration (name, catalogue ref, mode)
|
|
1376
|
-
* @param concurrency - Number of concurrent event requests (default: 1, min: 1)
|
|
1377
|
-
* @returns EventResult with counts (eventsSent/eventsFailed) and error details
|
|
1378
|
-
*/
|
|
1379
|
-
async sendEvents(
|
|
1380
|
-
products: Product[],
|
|
1381
|
-
eventConfig: EventConfig,
|
|
1382
|
-
concurrency: number = 1
|
|
1383
|
-
): Promise<EventResult> {
|
|
1384
|
-
// Validate concurrency (guard against invalid values)
|
|
1385
|
-
const safeConc = Math.max(1, Math.floor(concurrency));
|
|
1386
|
-
|
|
1387
|
-
// Result accumulators
|
|
1388
|
-
let eventsSent = 0;
|
|
1389
|
-
let eventsFailed = 0;
|
|
1390
|
-
const errors: Array<{ productRef: string; error: string }> = [];
|
|
1391
|
-
|
|
1392
|
-
// ✅ PRODUCTION ENHANCEMENT: Log event sending start
|
|
1393
|
-
if (this.log) {
|
|
1394
|
-
this.log.info('📤 Starting event sending', {
|
|
1395
|
-
totalProducts: products.length,
|
|
1396
|
-
concurrency: safeConc,
|
|
1397
|
-
processingMode: safeConc === 1 ? 'sequential (one at a time)' : `parallel (${safeConc} concurrently)`,
|
|
1398
|
-
eventName: eventConfig.eventName,
|
|
1399
|
-
catalogueRef: eventConfig.catalogueRef
|
|
1400
|
-
});
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
// Helper: Build event payload (DRY - reused in both modes)
|
|
1404
|
-
const buildPayload = (product: Product) => ({
|
|
1405
|
-
name: eventConfig.eventName,
|
|
1406
|
-
entityRef: eventConfig.catalogueRef,
|
|
1407
|
-
entityType: 'PRODUCT_CATALOGUE' as const,
|
|
1408
|
-
entitySubtype: eventConfig.catalogueType,
|
|
1409
|
-
rootEntityRef: eventConfig.catalogueRef,
|
|
1410
|
-
rootEntityType: 'PRODUCT_CATALOGUE' as const,
|
|
1411
|
-
attributes: product,
|
|
1412
|
-
});
|
|
1413
|
-
|
|
1414
|
-
// ============================================================================
|
|
1415
|
-
// SEQUENTIAL MODE (concurrency === 1)
|
|
1416
|
-
// ============================================================================
|
|
1417
|
-
if (safeConc === 1) {
|
|
1418
|
-
for (let i = 0; i < products.length; i++) {
|
|
1419
|
-
const product = products[i];
|
|
1420
|
-
|
|
1421
|
-
// ✅ PRODUCTION ENHANCEMENT: Log progress every 10 products
|
|
1422
|
-
if (this.log && i % 10 === 0) {
|
|
1423
|
-
this.log.info(`📤 Sending product ${i + 1}/${products.length}`, {
|
|
1424
|
-
productRef: product.ref,
|
|
1425
|
-
progress: `${((i / products.length) * 100).toFixed(1)}%`
|
|
1426
|
-
});
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
try {
|
|
1430
|
-
await this.client.sendEvent(buildPayload(product), eventConfig.eventMode);
|
|
1431
|
-
eventsSent++;
|
|
1432
|
-
} catch (err: unknown) {
|
|
1433
|
-
eventsFailed++;
|
|
1434
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1435
|
-
errors.push({ productRef: product?.ref || 'unknown', error: errorMsg });
|
|
1436
|
-
// Continue processing (failure doesn't block other products)
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
// ✅ PRODUCTION ENHANCEMENT: Log completion
|
|
1441
|
-
if (this.log) {
|
|
1442
|
-
this.log.info('✅ Sequential event sending completed', {
|
|
1443
|
-
totalProducts: products.length,
|
|
1444
|
-
eventsSent,
|
|
1445
|
-
eventsFailed
|
|
1446
|
-
});
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
return { eventsSent, eventsFailed, errors };
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
// ============================================================================
|
|
1453
|
-
// PARALLEL MODE (concurrency > 1)
|
|
1454
|
-
// ============================================================================
|
|
1455
|
-
const totalChunks = Math.ceil(products.length / safeConc);
|
|
1456
|
-
|
|
1457
|
-
for (let i = 0; i < products.length; i += safeConc) {
|
|
1458
|
-
const chunk = products.slice(i, i + safeConc);
|
|
1459
|
-
const chunkNumber = Math.floor(i / safeConc) + 1;
|
|
1460
|
-
|
|
1461
|
-
// ✅ PRODUCTION ENHANCEMENT: Log chunk progress
|
|
1462
|
-
if (this.log) {
|
|
1463
|
-
this.log.info(`📦 Processing chunk ${chunkNumber}/${totalChunks}`, {
|
|
1464
|
-
chunkNumber,
|
|
1465
|
-
totalChunks,
|
|
1466
|
-
productsInChunk: chunk.length,
|
|
1467
|
-
productRange: `${i + 1}-${i + chunk.length}`,
|
|
1468
|
-
totalProducts: products.length,
|
|
1469
|
-
progress: `${((i / products.length) * 100).toFixed(1)}%`
|
|
1470
|
-
});
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
// Fire all requests in chunk concurrently
|
|
1474
|
-
const results = await Promise.allSettled(
|
|
1475
|
-
chunk.map(product =>
|
|
1476
|
-
this.client
|
|
1477
|
-
.sendEvent(buildPayload(product), eventConfig.eventMode)
|
|
1478
|
-
.then(() => ({ success: true as const, product }))
|
|
1479
|
-
.catch(error => ({ success: false as const, product, error }))
|
|
1480
|
-
)
|
|
1481
|
-
);
|
|
1482
|
-
|
|
1483
|
-
// Aggregate chunk results into totals
|
|
1484
|
-
let chunkSuccess = 0;
|
|
1485
|
-
let chunkFailed = 0;
|
|
1486
|
-
|
|
1487
|
-
for (const result of results) {
|
|
1488
|
-
if (result.status === 'fulfilled' && result.value.success) {
|
|
1489
|
-
eventsSent++;
|
|
1490
|
-
chunkSuccess++;
|
|
1491
|
-
} else {
|
|
1492
|
-
eventsFailed++;
|
|
1493
|
-
chunkFailed++;
|
|
1494
|
-
const error = result.status === 'fulfilled' ? result.value.error : result.reason;
|
|
1495
|
-
const product = result.status === 'fulfilled' ? result.value.product : null;
|
|
1496
|
-
errors.push({
|
|
1497
|
-
productRef: product?.ref || 'unknown',
|
|
1498
|
-
error: error?.message || String(error) || 'unknown error',
|
|
1499
|
-
});
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
// ✅ PRODUCTION ENHANCEMENT: Log chunk completion
|
|
1504
|
-
if (this.log) {
|
|
1505
|
-
this.log.info(`✅ Chunk ${chunkNumber}/${totalChunks} completed`, {
|
|
1506
|
-
chunkNumber,
|
|
1507
|
-
totalChunks,
|
|
1508
|
-
chunkSuccess,
|
|
1509
|
-
chunkFailed,
|
|
1510
|
-
totalSentSoFar: eventsSent,
|
|
1511
|
-
totalFailedSoFar: eventsFailed,
|
|
1512
|
-
progress: `${(((i + chunk.length) / products.length) * 100).toFixed(1)}%`
|
|
1513
|
-
});
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
// ✅ PRODUCTION ENHANCEMENT: Log parallel completion
|
|
1518
|
-
if (this.log) {
|
|
1519
|
-
this.log.info('✅ Parallel event sending completed', {
|
|
1520
|
-
totalProducts: products.length,
|
|
1521
|
-
totalChunks,
|
|
1522
|
-
concurrency: safeConc,
|
|
1523
|
-
eventsSent,
|
|
1524
|
-
eventsFailed
|
|
1525
|
-
});
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
return { eventsSent, eventsFailed, errors };
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
```
|
|
1532
|
-
|
|
1533
|
-
---
|
|
1534
|
-
|
|
1535
|
-
## 6. Service: Event Logger (`src/services/event-logger.service.ts`)
|
|
1536
|
-
|
|
1537
|
-
```typescript
|
|
1538
|
-
/**
|
|
1539
|
-
* Event Logger Service
|
|
1540
|
-
*
|
|
1541
|
-
* Writes event processing logs to SFTP for audit/debugging.
|
|
1542
|
-
* Optional - can be used to create rejection reports.
|
|
1543
|
-
*/
|
|
1544
|
-
|
|
1545
|
-
import { Buffer } from 'node:buffer';
|
|
1546
|
-
import { SftpDataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
1547
|
-
|
|
1548
|
-
/**
|
|
1549
|
-
* Service for writing event logs to SFTP
|
|
1550
|
-
*/
|
|
1551
|
-
export class EventLoggerService {
|
|
1552
|
-
constructor(private sftp: SftpDataSource) {}
|
|
1553
|
-
|
|
1554
|
-
/**
|
|
1555
|
-
* Write event log to SFTP
|
|
1556
|
-
*
|
|
1557
|
-
* @param remotePath - SFTP path for log file
|
|
1558
|
-
* @param logData - Log data to write (JSON format)
|
|
1559
|
-
*/
|
|
1560
|
-
async writeEventLog(remotePath: string, logData: unknown): Promise<void> {
|
|
1561
|
-
try {
|
|
1562
|
-
const logContent = JSON.stringify(logData, null, 2);
|
|
1563
|
-
await this.sftp.uploadFile(remotePath, logContent);
|
|
1564
|
-
} catch (error: unknown) {
|
|
1565
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1566
|
-
throw new Error(`Failed to write event log: ${errorMessage}`);
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
```
|
|
1571
|
-
|
|
1572
|
-
---
|
|
1573
|
-
|
|
1574
|
-
## 7. Main Orchestration Service (src/services/product-ingestion.service.ts)
|
|
1575
|
-
|
|
1576
|
-
```typescript
|
|
1577
|
-
/**
|
|
1578
|
-
* MAIN INGESTION ORCHESTRATION SERVICE
|
|
1579
|
-
*
|
|
1580
|
-
* This is the heart of the ingestion workflow. It coordinates all steps:
|
|
1581
|
-
* 1. Initialize clients and services
|
|
1582
|
-
* 2. Discover files on SFTP
|
|
1583
|
-
* 3. Download and parse JSON files
|
|
1584
|
-
* 4. Transform data with UniversalMapper
|
|
1585
|
-
* 5. Send events to Fluent Commerce
|
|
1586
|
-
* 6. Archive processed files
|
|
1587
|
-
* 7. Track file processing state
|
|
1588
|
-
* 8. Track job progress with JobTracker
|
|
1589
|
-
*/
|
|
1590
|
-
|
|
1591
|
-
import { Buffer } from 'node:buffer';
|
|
1592
|
-
import {
|
|
1593
|
-
createClient,
|
|
1594
|
-
SftpDataSource,
|
|
1595
|
-
JSONParserService,
|
|
1596
|
-
UniversalMapper,
|
|
1597
|
-
VersoriFileTracker,
|
|
1598
|
-
JobTracker,
|
|
1599
|
-
type FluentClient,
|
|
1600
|
-
type JobStatus,
|
|
1601
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
1602
|
-
import type {
|
|
1603
|
-
ProductIngestionParams,
|
|
1604
|
-
ProductIngestionResult,
|
|
1605
|
-
FileProcessingResult,
|
|
1606
|
-
EventConfig,
|
|
1607
|
-
} from '../types/product-ingestion.types';
|
|
1608
|
-
import { ProductFileProcessorService } from './product-file-processor.service';
|
|
1609
|
-
import { EventSenderService } from './event-sender.service';
|
|
1610
|
-
import { EventLoggerService } from './event-logger.service';
|
|
1611
|
-
import { joinSftpPath, timestampedName, extractFileName } from '../utils/sftp-path.utils';
|
|
1612
|
-
|
|
1613
|
-
import mappingConfig from '../../config/products.import.json' with { type: 'json' };
|
|
1614
|
-
|
|
1615
|
-
// ✅ VERSORI PLATFORM: Use native log from context, not LoggingService
|
|
1616
|
-
|
|
1617
|
-
/**
|
|
1618
|
-
* Query job status from KV store
|
|
1619
|
-
*/
|
|
1620
|
-
export async function getJobStatus(
|
|
1621
|
-
kv: ReturnType<VersoriContext['openKv']>,
|
|
1622
|
-
jobId: string,
|
|
1623
|
-
log: VersoriContext['log']
|
|
1624
|
-
): Promise<JobStatus | undefined> {
|
|
1625
|
-
try {
|
|
1626
|
-
const tracker = new JobTracker(kv, log);
|
|
1627
|
-
return await tracker.getJob(jobId);
|
|
1628
|
-
} catch (error: unknown) {
|
|
1629
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1630
|
-
log.error('Failed to get job status', {
|
|
1631
|
-
jobId,
|
|
1632
|
-
message: errorMessage,
|
|
1633
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1634
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
1635
|
-
});
|
|
1636
|
-
return undefined;
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
/**
|
|
1641
|
-
* MAIN ORCHESTRATION FUNCTION
|
|
1642
|
-
*/
|
|
1643
|
-
export async function executeProductIngestion(
|
|
1644
|
-
ctx: VersoriContext,
|
|
1645
|
-
params: ProductIngestionParams
|
|
1646
|
-
): Promise<ProductIngestionResult> {
|
|
1647
|
-
|
|
1648
|
-
const { log, openKv, activation } = ctx;
|
|
1649
|
-
const { jobId, triggeredBy, filePattern, maxFiles, forceReprocess } = params;
|
|
1650
|
-
|
|
1651
|
-
const kv = openKv(':project:');
|
|
1652
|
-
const tracker = new JobTracker(kv, log);
|
|
1653
|
-
|
|
1654
|
-
const startTime = Date.now();
|
|
1655
|
-
const fileResults: FileProcessingResult[] = [];
|
|
1656
|
-
|
|
1657
|
-
// ⚠️ CRITICAL: Declare SFTP outside try block for double-finally pattern
|
|
1658
|
-
let sftp: SftpDataSource | undefined;
|
|
1659
|
-
|
|
1660
|
-
try {
|
|
1661
|
-
// ═══════════════════════════════════════════════════════════
|
|
1662
|
-
// STEP 1/8: Initialize Job Tracking
|
|
1663
|
-
// ═══════════════════════════════════════════════════════════
|
|
1664
|
-
log.info('🔧 [STEP 1/8] Initializing job tracking', { jobId });
|
|
1665
|
-
|
|
1666
|
-
await tracker.createJob(jobId, {
|
|
1667
|
-
triggeredBy,
|
|
1668
|
-
filePattern: filePattern || 'default',
|
|
1669
|
-
maxFiles: maxFiles || 'unlimited',
|
|
1670
|
-
forceReprocess: !!forceReprocess
|
|
1671
|
-
});
|
|
1672
|
-
|
|
1673
|
-
// ═══════════════════════════════════════════════════════════
|
|
1674
|
-
// STEP 2/8: Initialize Fluent Client & SFTP Connection
|
|
1675
|
-
// ═══════════════════════════════════════════════════════════
|
|
1676
|
-
log.info('🔌 [STEP 2/8] Initializing Fluent Commerce client and SFTP', { jobId });
|
|
1677
|
-
|
|
1678
|
-
const client = await createClient(ctx);
|
|
1679
|
-
|
|
1680
|
-
if (!client) {
|
|
1681
|
-
const errorMsg = 'Failed to create Fluent Commerce client';
|
|
1682
|
-
log.error('❌ ' + errorMsg, {
|
|
1683
|
-
recommendation: 'Verify fluent_commerce connection is configured with valid credentials'
|
|
1684
|
-
});
|
|
1685
|
-
throw new Error(errorMsg);
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
// ✅ CRITICAL: Set retailerId for Event API calls
|
|
1689
|
-
const fluentRetailerId = activation.getVariable('fluentRetailerId');
|
|
1690
|
-
if (!fluentRetailerId) {
|
|
1691
|
-
const errorMsg = 'fluentRetailerId is required for Event API calls';
|
|
1692
|
-
log.error('❌ ' + errorMsg, {
|
|
1693
|
-
recommendation: 'Add fluentRetailerId to activation variables (e.g., "1")'
|
|
1694
|
-
});
|
|
1695
|
-
throw new Error(errorMsg);
|
|
1696
|
-
}
|
|
1697
|
-
client.setRetailerId(fluentRetailerId);
|
|
1698
|
-
log.info('✅ RetailerId set for Event API', { retailerId: fluentRetailerId });
|
|
1699
|
-
|
|
1700
|
-
// Get SFTP credentials from connection (Basic Auth)
|
|
1701
|
-
log.info('🔐 Retrieving SFTP credentials from connection');
|
|
1702
|
-
|
|
1703
|
-
let sftpUsername: string;
|
|
1704
|
-
let sftpPassword: string;
|
|
1705
|
-
|
|
1706
|
-
try {
|
|
1707
|
-
// Retrieve credentials from the 'SFTP' connection
|
|
1708
|
-
const sftpCred = await ctx.credentials().getAccessToken('SFTP');
|
|
1709
|
-
|
|
1710
|
-
if (!sftpCred?.accessToken) {
|
|
1711
|
-
throw new Error('No SFTP credentials found in connection configuration');
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
// Decode base64 accessToken to get "username:password"
|
|
1715
|
-
const rawBasicAuth = Buffer.from(sftpCred.accessToken, 'base64').toString('utf-8');
|
|
1716
|
-
const parts = rawBasicAuth.split(':');
|
|
1717
|
-
|
|
1718
|
-
if (parts.length !== 2) {
|
|
1719
|
-
throw new Error('Invalid SFTP credential format - expected username:password');
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
sftpUsername = parts[0];
|
|
1723
|
-
sftpPassword = parts[1];
|
|
1724
|
-
|
|
1725
|
-
log.info('✅ SFTP credentials retrieved successfully', {
|
|
1726
|
-
hasUsername: !!sftpUsername,
|
|
1727
|
-
hasPassword: !!sftpPassword,
|
|
1728
|
-
usernameLength: sftpUsername.length,
|
|
1729
|
-
passwordLength: sftpPassword.length,
|
|
1730
|
-
});
|
|
1731
|
-
} catch (credError: any) {
|
|
1732
|
-
log.error('❌ Failed to retrieve SFTP credentials', {
|
|
1733
|
-
message: credError instanceof Error ? credError.message : String(credError),
|
|
1734
|
-
stack: credError instanceof Error ? credError.stack : undefined,
|
|
1735
|
-
errorType: credError instanceof Error ? credError.constructor.name : 'Error',
|
|
1736
|
-
recommendation: 'Ensure SFTP connection is configured with Basic Authentication'
|
|
1737
|
-
});
|
|
1738
|
-
throw credError;
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
// Get SFTP configuration from activation variables
|
|
1742
|
-
const sftpConfig = {
|
|
1743
|
-
host: activation.getVariable('sftpHost'),
|
|
1744
|
-
port: parseInt(activation.getVariable('sftpPort') || '22', 10),
|
|
1745
|
-
username: sftpUsername,
|
|
1746
|
-
password: sftpPassword,
|
|
1747
|
-
privateKey: activation.getVariable('sftpPrivateKey'),
|
|
1748
|
-
incomingPath: activation.getVariable('sftpIncomingPath') || '/products/incoming',
|
|
1749
|
-
archivePath: activation.getVariable('sftpArchivePath') || '/archive/products/',
|
|
1750
|
-
errorPath: activation.getVariable('sftpErrorPath') || '/errors/products/',
|
|
1751
|
-
filePattern: filePattern || activation.getVariable('filePattern') || 'products_*.json',
|
|
1752
|
-
enableFileTracking: activation.getVariable('enableFileTracking') !== 'false' // Default: true
|
|
1753
|
-
};
|
|
1754
|
-
|
|
1755
|
-
// Validate SFTP config
|
|
1756
|
-
if (!sftpConfig.host) {
|
|
1757
|
-
const errorMsg = 'SFTP configuration incomplete: missing sftpHost';
|
|
1758
|
-
log.error('❌ ' + errorMsg, {
|
|
1759
|
-
recommendation: 'Add sftpHost to activation variables (e.g., "sftp.partner.com")'
|
|
1760
|
-
});
|
|
1761
|
-
throw new Error(errorMsg);
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
if (!sftpConfig.password && !sftpConfig.privateKey) {
|
|
1765
|
-
const errorMsg = 'SFTP configuration incomplete: missing password or privateKey';
|
|
1766
|
-
log.error('❌ ' + errorMsg, {
|
|
1767
|
-
recommendation: 'Ensure SFTP connection has valid credentials OR set sftpPrivateKey'
|
|
1768
|
-
});
|
|
1769
|
-
throw new Error(errorMsg);
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
|
-
// Initialize SFTP data source
|
|
1773
|
-
log.info('🔧 Initializing SFTP data source', {
|
|
1774
|
-
host: sftpConfig.host,
|
|
1775
|
-
port: sftpConfig.port,
|
|
1776
|
-
remotePath: sftpConfig.incomingPath,
|
|
1777
|
-
filePattern: sftpConfig.filePattern,
|
|
1778
|
-
enableFileTracking: sftpConfig.enableFileTracking
|
|
1779
|
-
});
|
|
1780
|
-
|
|
1781
|
-
sftp = new SftpDataSource({
|
|
1782
|
-
type: 'SFTP_JSON',
|
|
1783
|
-
connectionId: 'sftp-product-ingestion',
|
|
1784
|
-
name: 'Product Ingestion SFTP',
|
|
1785
|
-
settings: {
|
|
1786
|
-
host: sftpConfig.host,
|
|
1787
|
-
port: sftpConfig.port,
|
|
1788
|
-
username: sftpConfig.username,
|
|
1789
|
-
password: sftpConfig.password,
|
|
1790
|
-
privateKey: sftpConfig.privateKey,
|
|
1791
|
-
remotePath: sftpConfig.incomingPath,
|
|
1792
|
-
filePattern: sftpConfig.filePattern,
|
|
1793
|
-
encoding: 'utf8'
|
|
1794
|
-
}
|
|
1795
|
-
}, log);
|
|
1796
|
-
|
|
1797
|
-
try {
|
|
1798
|
-
// Validate SFTP connection
|
|
1799
|
-
await sftp.validateConnection();
|
|
1800
|
-
log.info('✅ SFTP connection validated successfully');
|
|
1801
|
-
|
|
1802
|
-
// Ensure archive directories exist (recursive)
|
|
1803
|
-
await sftp.createDirectory(sftpConfig.archivePath, true);
|
|
1804
|
-
await sftp.createDirectory(sftpConfig.errorPath, true);
|
|
1805
|
-
|
|
1806
|
-
// ═══════════════════════════════════════════════════════════
|
|
1807
|
-
// STEP 3/8: Discover Files on SFTP
|
|
1808
|
-
// ═══════════════════════════════════════════════════════════
|
|
1809
|
-
log.info('📂 [STEP 3/8] Discovering files on SFTP', {
|
|
1810
|
-
jobId,
|
|
1811
|
-
remotePath: sftpConfig.incomingPath,
|
|
1812
|
-
filePattern: sftpConfig.filePattern,
|
|
1813
|
-
enableFileTracking: sftpConfig.enableFileTracking
|
|
1814
|
-
});
|
|
1815
|
-
|
|
1816
|
-
await tracker.updateJob(jobId, {
|
|
1817
|
-
status: 'processing',
|
|
1818
|
-
stage: 'file_discovery',
|
|
1819
|
-
message: 'Discovering files on SFTP'
|
|
1820
|
-
});
|
|
1821
|
-
|
|
1822
|
-
// List files from SFTP
|
|
1823
|
-
const allFiles = await sftp.listFiles({
|
|
1824
|
-
remotePath: sftpConfig.incomingPath,
|
|
1825
|
-
filePattern: sftpConfig.filePattern
|
|
1826
|
-
});
|
|
1827
|
-
|
|
1828
|
-
log.info(`📋 Discovered ${allFiles.length} files matching pattern`, {
|
|
1829
|
-
pattern: sftpConfig.filePattern,
|
|
1830
|
-
count: allFiles.length
|
|
1831
|
-
});
|
|
1832
|
-
|
|
1833
|
-
// Apply maxFiles limit if specified
|
|
1834
|
-
const filesToProcess = maxFiles ? allFiles.slice(0, maxFiles) : allFiles;
|
|
1835
|
-
|
|
1836
|
-
if (filesToProcess.length === 0) {
|
|
1837
|
-
log.info('ℹ️ No files to process', {
|
|
1838
|
-
remotePath: sftpConfig.incomingPath,
|
|
1839
|
-
filePattern: sftpConfig.filePattern
|
|
1840
|
-
});
|
|
1841
|
-
|
|
1842
|
-
await tracker.markCompleted(jobId, {
|
|
1843
|
-
filesProcessed: 0,
|
|
1844
|
-
message: 'No files found to process'
|
|
1845
|
-
});
|
|
1846
|
-
|
|
1847
|
-
return {
|
|
1848
|
-
success: true,
|
|
1849
|
-
jobId,
|
|
1850
|
-
filesProcessed: 0,
|
|
1851
|
-
filesFailed: 0,
|
|
1852
|
-
recordsProcessed: 0,
|
|
1853
|
-
eventsSent: 0,
|
|
1854
|
-
eventsFailed: 0,
|
|
1855
|
-
fileResults: []
|
|
1856
|
-
};
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
|
-
log.info(`🔄 Processing ${filesToProcess.length} files`, {
|
|
1860
|
-
totalDiscovered: allFiles.length,
|
|
1861
|
-
toProcess: filesToProcess.length,
|
|
1862
|
-
maxFilesLimit: maxFiles || 'unlimited'
|
|
1863
|
-
});
|
|
1864
|
-
|
|
1865
|
-
// Initialize services
|
|
1866
|
-
const jsonParser = new JSONParserService();
|
|
1867
|
-
const mapper = new UniversalMapper(mappingConfig);
|
|
1868
|
-
|
|
1869
|
-
// Get event configuration
|
|
1870
|
-
const eventConfig: EventConfig = {
|
|
1871
|
-
catalogueRef: params.catalogueRef || activation.getVariable('catalogueRef') || 'PC:MASTER:2',
|
|
1872
|
-
catalogueType: activation.getVariable('catalogueType') || 'MASTER',
|
|
1873
|
-
eventName: activation.getVariable('eventName') || 'UPSERT_PRODUCT',
|
|
1874
|
-
eventMode: (activation.getVariable('eventMode') || 'async') as 'async' | 'sync',
|
|
1875
|
-
};
|
|
1876
|
-
|
|
1877
|
-
// Get event sending configuration
|
|
1878
|
-
const eventConcurrency = Math.max(
|
|
1879
|
-
1,
|
|
1880
|
-
parseInt(activation.getVariable('eventConcurrency') || '1', 10)
|
|
1881
|
-
);
|
|
1882
|
-
|
|
1883
|
-
log.info(`Event concurrency: ${eventConcurrency}`, {
|
|
1884
|
-
mode: eventConcurrency === 1 ? 'sequential' : 'parallel',
|
|
1885
|
-
concurrentRequests: eventConcurrency === 1 ? 'N/A' : eventConcurrency,
|
|
1886
|
-
});
|
|
1887
|
-
|
|
1888
|
-
// Initialize class-based services
|
|
1889
|
-
const fileProcessor = new ProductFileProcessorService(
|
|
1890
|
-
sftp,
|
|
1891
|
-
jsonParser,
|
|
1892
|
-
mapper,
|
|
1893
|
-
eventConfig.catalogueRef
|
|
1894
|
-
);
|
|
1895
|
-
// ✅ PRODUCTION ENHANCEMENT: Pass log to EventSenderService for detailed progress tracking
|
|
1896
|
-
const eventSender = new EventSenderService(client, log);
|
|
1897
|
-
const eventLogger = new EventLoggerService(sftp);
|
|
1898
|
-
|
|
1899
|
-
// File tracker for deduplication
|
|
1900
|
-
const fileTracker = new VersoriFileTracker(kv, 'product-ingestion', log);
|
|
1901
|
-
|
|
1902
|
-
// Use direct KV with dot-separated keys (NATS KV safe)
|
|
1903
|
-
const processedKeyBase = 'fc_sdk.processed_files';
|
|
1904
|
-
|
|
1905
|
-
// Get SFTP path configuration
|
|
1906
|
-
const requireAbsolutePaths = activation.getVariable('requireAbsolutePaths') === 'true';
|
|
1907
|
-
|
|
1908
|
-
for (let fileIndex = 0; fileIndex < filesToProcess.length; fileIndex++) {
|
|
1909
|
-
const file = filesToProcess[fileIndex];
|
|
1910
|
-
const fileStartTime = Date.now();
|
|
1911
|
-
|
|
1912
|
-
// Extract file name using SDK utility
|
|
1913
|
-
const remoteFilePath = (file as any).path || file.name;
|
|
1914
|
-
const baseName = extractFileName(remoteFilePath);
|
|
1915
|
-
|
|
1916
|
-
log.info(`📄 [FILE ${fileIndex + 1}/${filesToProcess.length}] Processing file: ${baseName}`, {
|
|
1917
|
-
remotePath: remoteFilePath,
|
|
1918
|
-
fileSize: (file as any).size || 'unknown'
|
|
1919
|
-
});
|
|
1920
|
-
|
|
1921
|
-
try {
|
|
1922
|
-
// Check if file already processed (unless force reprocess)
|
|
1923
|
-
if (!forceReprocess && sftpConfig.enableFileTracking) {
|
|
1924
|
-
const isProcessed = await fileTracker.isProcessed(baseName);
|
|
1925
|
-
if (isProcessed) {
|
|
1926
|
-
log.info(`⏭️ Skipping already processed file: ${baseName}`, {
|
|
1927
|
-
reason: 'File tracking enabled and file previously processed'
|
|
1928
|
-
});
|
|
1929
|
-
fileResults.push({
|
|
1930
|
-
fileName: baseName,
|
|
1931
|
-
success: true,
|
|
1932
|
-
skipped: true,
|
|
1933
|
-
recordsProcessed: 0,
|
|
1934
|
-
eventsSent: 0,
|
|
1935
|
-
eventsFailed: 0,
|
|
1936
|
-
duration: 0,
|
|
1937
|
-
});
|
|
1938
|
-
continue;
|
|
1939
|
-
}
|
|
1940
|
-
}
|
|
1941
|
-
|
|
1942
|
-
await tracker.updateJob(jobId, {
|
|
1943
|
-
status: 'processing',
|
|
1944
|
-
stage: 'downloading',
|
|
1945
|
-
message: `Processing file ${fileIndex + 1} of ${filesToProcess.length}: ${baseName}`
|
|
1946
|
-
});
|
|
1947
|
-
|
|
1948
|
-
// ═══════════════════════════════════════════════════════════
|
|
1949
|
-
// STEP 4/8: Process File (Download + Parse + Map)
|
|
1950
|
-
// ═══════════════════════════════════════════════════════════
|
|
1951
|
-
log.info(`📥 [STEP 4/8] Processing file: ${baseName}`);
|
|
1952
|
-
|
|
1953
|
-
const fileResult = await fileProcessor.downloadParseAndTransform(remoteFilePath);
|
|
1954
|
-
|
|
1955
|
-
log.info(`✅ File processing completed: ${baseName}`, {
|
|
1956
|
-
success: fileResult.success,
|
|
1957
|
-
recordCount: fileResult.products.length,
|
|
1958
|
-
error: fileResult.error
|
|
1959
|
-
});
|
|
1960
|
-
|
|
1961
|
-
if (!fileResult.success || fileResult.products.length === 0) {
|
|
1962
|
-
const archivedName = timestampedName(baseName);
|
|
1963
|
-
await sftp.moveFile(
|
|
1964
|
-
remoteFilePath,
|
|
1965
|
-
joinSftpPath(requireAbsolutePaths, sftpConfig.errorPath, archivedName)
|
|
1966
|
-
);
|
|
1967
|
-
|
|
1968
|
-
fileResults.push({
|
|
1969
|
-
fileName: baseName,
|
|
1970
|
-
success: false,
|
|
1971
|
-
recordsProcessed: fileResult.products.length,
|
|
1972
|
-
eventsSent: 0,
|
|
1973
|
-
eventsFailed: 0,
|
|
1974
|
-
duration: Date.now() - fileStartTime,
|
|
1975
|
-
error: fileResult.error || 'Processing failed'
|
|
1976
|
-
});
|
|
1977
|
-
|
|
1978
|
-
continue;
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
// ═══════════════════════════════════════════════════════════
|
|
1982
|
-
// STEP 5/8: Send Events
|
|
1983
|
-
// ═══════════════════════════════════════════════════════════
|
|
1984
|
-
log.info(`📤 [STEP 5/8] Sending events for ${baseName}`);
|
|
1985
|
-
|
|
1986
|
-
const sampleProductRefs = fileResult.products.slice(0, 5).map((p: any) => p.ref || p.skuRef);
|
|
1987
|
-
|
|
1988
|
-
log.info(`🚀 [EventSender] Starting event submission for file "${baseName}"`, {
|
|
1989
|
-
totalProducts: fileResult.products.length,
|
|
1990
|
-
eventName: eventConfig.eventName,
|
|
1991
|
-
concurrency: eventConcurrency === 1 ? 'sequential' : `parallel (${eventConcurrency})`,
|
|
1992
|
-
sampleProductRefs: sampleProductRefs.join(', '),
|
|
1993
|
-
eventMode: eventConfig.eventMode
|
|
1994
|
-
});
|
|
1995
|
-
|
|
1996
|
-
const eventResult = await eventSender.sendEvents(
|
|
1997
|
-
fileResult.products,
|
|
1998
|
-
eventConfig,
|
|
1999
|
-
eventConcurrency
|
|
2000
|
-
);
|
|
2001
|
-
|
|
2002
|
-
const eventsSent = eventResult.eventsSent;
|
|
2003
|
-
const eventsFailed = eventResult.eventsFailed;
|
|
2004
|
-
const eventDuration = Date.now() - fileStartTime;
|
|
2005
|
-
|
|
2006
|
-
log.info(`✅ [EventSender] Event submission completed for file "${baseName}"`, {
|
|
2007
|
-
totalProducts: fileResult.products.length,
|
|
2008
|
-
eventsSent,
|
|
2009
|
-
eventsFailed,
|
|
2010
|
-
successRate: fileResult.products.length > 0 ? `${Math.round((eventsSent / fileResult.products.length) * 100)}%` : '0%',
|
|
2011
|
-
duration: eventDuration,
|
|
2012
|
-
avgTimePerEvent: eventsSent > 0 ? Math.round(eventDuration / eventsSent) + 'ms' : 'N/A'
|
|
2013
|
-
});
|
|
2014
|
-
|
|
2015
|
-
// ═══════════════════════════════════════════════════════════
|
|
2016
|
-
// STEP 6/8: Archive File & Track State
|
|
2017
|
-
// ═══════════════════════════════════════════════════════════
|
|
2018
|
-
log.info(`📦 [STEP 6/8] Archiving file: ${baseName}`);
|
|
2019
|
-
|
|
2020
|
-
await tracker.updateJob(jobId, {
|
|
2021
|
-
status: 'processing',
|
|
2022
|
-
stage: 'archiving',
|
|
2023
|
-
message: `Archiving ${baseName}`
|
|
2024
|
-
});
|
|
2025
|
-
|
|
2026
|
-
const archivedName = timestampedName(baseName);
|
|
2027
|
-
const targetDir = eventsFailed > 0 ? sftpConfig.errorPath : sftpConfig.archivePath;
|
|
2028
|
-
|
|
2029
|
-
log.info(`📁 Moving file to ${eventsFailed > 0 ? 'error' : 'processed'} directory`, {
|
|
2030
|
-
sourcePath: remoteFilePath,
|
|
2031
|
-
targetPath: joinSftpPath(requireAbsolutePaths, targetDir, archivedName)
|
|
2032
|
-
});
|
|
2033
|
-
|
|
2034
|
-
await sftp.moveFile(
|
|
2035
|
-
remoteFilePath,
|
|
2036
|
-
joinSftpPath(requireAbsolutePaths, targetDir, archivedName)
|
|
2037
|
-
);
|
|
2038
|
-
|
|
2039
|
-
// Mark as processed in file tracker (if enabled)
|
|
2040
|
-
if (sftpConfig.enableFileTracking) {
|
|
2041
|
-
await fileTracker.markProcessed(baseName, {
|
|
2042
|
-
recordsProcessed: fileResult.products.length,
|
|
2043
|
-
eventsSent,
|
|
2044
|
-
eventsFailed,
|
|
2045
|
-
});
|
|
2046
|
-
log.info(`✅ File tracking state updated for: ${baseName}`);
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
log.info(`✅ File archived successfully: ${baseName}`, {
|
|
2050
|
-
archivedName,
|
|
2051
|
-
targetDir,
|
|
2052
|
-
duration: Date.now() - fileStartTime
|
|
2053
|
-
});
|
|
2054
|
-
|
|
2055
|
-
// Optional: Write event log for audit/debugging
|
|
2056
|
-
if (eventsFailed > 0) {
|
|
2057
|
-
const logPath = joinSftpPath(
|
|
2058
|
-
requireAbsolutePaths,
|
|
2059
|
-
sftpConfig.errorPath,
|
|
2060
|
-
`${baseName.replace(/\.json$/i, '')}-event-log.json`
|
|
2061
|
-
);
|
|
2062
|
-
await eventLogger.writeEventLog(logPath, {
|
|
2063
|
-
fileName: baseName,
|
|
2064
|
-
totalRecords: fileResult.products.length,
|
|
2065
|
-
eventsSent,
|
|
2066
|
-
eventsFailed,
|
|
2067
|
-
errors: eventResult.errors,
|
|
2068
|
-
processedAt: new Date().toISOString()
|
|
2069
|
-
});
|
|
2070
|
-
}
|
|
2071
|
-
|
|
2072
|
-
fileResults.push({
|
|
2073
|
-
fileName: baseName,
|
|
2074
|
-
success: true,
|
|
2075
|
-
recordsProcessed: fileResult.products.length,
|
|
2076
|
-
eventsSent,
|
|
2077
|
-
eventsFailed,
|
|
2078
|
-
duration: Date.now() - fileStartTime
|
|
2079
|
-
});
|
|
2080
|
-
|
|
2081
|
-
} catch (error: unknown) {
|
|
2082
|
-
// ✅ Enhanced error logging: Extract all error details for visibility + recommendations
|
|
2083
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2084
|
-
const errorDetails = {
|
|
2085
|
-
message: errorMessage,
|
|
2086
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
2087
|
-
fileName: (error as any)?.fileName,
|
|
2088
|
-
lineNumber: (error as any)?.lineNumber,
|
|
2089
|
-
originalError: (error as any)?.context?.originalError?.message,
|
|
2090
|
-
errorType: error instanceof Error ? error.name : 'Error',
|
|
2091
|
-
};
|
|
2092
|
-
|
|
2093
|
-
// Provide actionable recommendations based on error type
|
|
2094
|
-
let recommendation = 'Check error details above for specific issue';
|
|
2095
|
-
if (errorMessage.includes('SFTP')) {
|
|
2096
|
-
recommendation = 'Verify SFTP connection: check host, credentials, and network access';
|
|
2097
|
-
} else if (errorMessage.includes('parse') || errorMessage.includes('JSON')) {
|
|
2098
|
-
recommendation = 'Verify JSON file format: check for valid JSON syntax and encoding';
|
|
2099
|
-
} else if (errorMessage.includes('mapping') || errorMessage.includes('validation')) {
|
|
2100
|
-
recommendation = 'Review mapping configuration: ensure all required fields are present';
|
|
2101
|
-
} else if (errorMessage.includes('retailerId') || errorMessage.includes('Event API')) {
|
|
2102
|
-
recommendation = 'Verify fluentRetailerId activation variable and Event API connection';
|
|
2103
|
-
}
|
|
2104
|
-
|
|
2105
|
-
log.error(`❌ Error processing file ${baseName}:`, {
|
|
2106
|
-
...errorDetails,
|
|
2107
|
-
recommendation
|
|
2108
|
-
});
|
|
2109
|
-
|
|
2110
|
-
try {
|
|
2111
|
-
const archivedName = timestampedName(baseName);
|
|
2112
|
-
await sftp.moveFile(
|
|
2113
|
-
remoteFilePath,
|
|
2114
|
-
joinSftpPath(requireAbsolutePaths, sftpConfig.errorPath, archivedName)
|
|
2115
|
-
);
|
|
2116
|
-
} catch (moveError: unknown) {
|
|
2117
|
-
const moveErrorMessage = moveError instanceof Error ? moveError.message : String(moveError);
|
|
2118
|
-
log.error('Could not archive failed file', {
|
|
2119
|
-
file: baseName,
|
|
2120
|
-
message: moveErrorMessage,
|
|
2121
|
-
stack: moveError instanceof Error ? moveError.stack : undefined,
|
|
2122
|
-
errorType: moveError instanceof Error ? moveError.constructor.name : 'Error'
|
|
2123
|
-
});
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
fileResults.push({
|
|
2127
|
-
fileName: baseName,
|
|
2128
|
-
success: false,
|
|
2129
|
-
recordsProcessed: 0,
|
|
2130
|
-
eventsSent: 0,
|
|
2131
|
-
eventsFailed: 0,
|
|
2132
|
-
duration: Date.now() - fileStartTime,
|
|
2133
|
-
error: errorMessage
|
|
2134
|
-
});
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
// ═══════════════════════════════════════════════════════════
|
|
2139
|
-
// STEP 8/8: Complete Job & Calculate Totals
|
|
2140
|
-
// ═══════════════════════════════════════════════════════════
|
|
2141
|
-
const totalDuration = Date.now() - startTime;
|
|
2142
|
-
|
|
2143
|
-
log.info('🏁 [STEP 8/8] Completing job and calculating totals', { jobId });
|
|
2144
|
-
|
|
2145
|
-
const filesProcessed = fileResults.filter(r => r.success && !r.skipped).length;
|
|
2146
|
-
const filesFailed = fileResults.filter(r => !r.success).length;
|
|
2147
|
-
const filesSkipped = fileResults.filter(r => r.skipped).length;
|
|
2148
|
-
const totalRecordsProcessed = fileResults.reduce((sum, r) => sum + r.recordsProcessed, 0);
|
|
2149
|
-
const totalEventsSent = fileResults.reduce((sum, r) => sum + r.eventsSent, 0);
|
|
2150
|
-
const totalEventsFailed = fileResults.reduce((sum, r) => sum + r.eventsFailed, 0);
|
|
2151
|
-
|
|
2152
|
-
await tracker.markCompleted(jobId, {
|
|
2153
|
-
filesProcessed,
|
|
2154
|
-
filesFailed,
|
|
2155
|
-
filesSkipped,
|
|
2156
|
-
recordsProcessed: totalRecordsProcessed,
|
|
2157
|
-
eventsSent: totalEventsSent,
|
|
2158
|
-
eventsFailed: totalEventsFailed,
|
|
2159
|
-
duration: totalDuration
|
|
2160
|
-
});
|
|
2161
|
-
|
|
2162
|
-
log.info('✅ Ingestion completed successfully', {
|
|
2163
|
-
jobId,
|
|
2164
|
-
filesProcessed,
|
|
2165
|
-
filesFailed,
|
|
2166
|
-
filesSkipped,
|
|
2167
|
-
recordsProcessed: totalRecordsProcessed,
|
|
2168
|
-
eventsSent: totalEventsSent,
|
|
2169
|
-
eventsFailed: totalEventsFailed,
|
|
2170
|
-
duration: totalDuration,
|
|
2171
|
-
avgFileTime: filesProcessed > 0 ? Math.round(totalDuration / filesProcessed) + 'ms' : 'N/A'
|
|
2172
|
-
});
|
|
2173
|
-
|
|
2174
|
-
return {
|
|
2175
|
-
success: true,
|
|
2176
|
-
jobId,
|
|
2177
|
-
filesProcessed,
|
|
2178
|
-
filesFailed,
|
|
2179
|
-
recordsProcessed: totalRecordsProcessed,
|
|
2180
|
-
eventsSent: totalEventsSent,
|
|
2181
|
-
eventsFailed: totalEventsFailed,
|
|
2182
|
-
fileResults
|
|
2183
|
-
};
|
|
2184
|
-
} finally {
|
|
2185
|
-
// ⚠️ CRITICAL: Always dispose SFTP connection (safe null check)
|
|
2186
|
-
if (sftp) {
|
|
2187
|
-
await sftp.dispose();
|
|
2188
|
-
log.info('SFTP connection disposed');
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
} catch (error: unknown) {
|
|
2192
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2193
|
-
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
2194
|
-
log.error('Ingestion workflow failed', {
|
|
2195
|
-
jobId,
|
|
2196
|
-
message: errorMessage,
|
|
2197
|
-
stack: errorStack,
|
|
2198
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
2199
|
-
});
|
|
2200
|
-
|
|
2201
|
-
try {
|
|
2202
|
-
await tracker.markFailed(jobId, error);
|
|
2203
|
-
} catch (trackingError: unknown) {
|
|
2204
|
-
const trackingErrorMessage =
|
|
2205
|
-
trackingError instanceof Error ? trackingError.message : String(trackingError);
|
|
2206
|
-
log.warn('Failed to mark job as failed in tracker', {
|
|
2207
|
-
jobId,
|
|
2208
|
-
trackingError: trackingErrorMessage,
|
|
2209
|
-
});
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
return {
|
|
2213
|
-
success: false,
|
|
2214
|
-
jobId,
|
|
2215
|
-
filesProcessed: fileResults.filter(r => r.success && !r.skipped).length,
|
|
2216
|
-
filesFailed: fileResults.filter(r => !r.success).length,
|
|
2217
|
-
filesSkipped: fileResults.filter(r => r.skipped).length,
|
|
2218
|
-
recordsProcessed: fileResults.reduce((sum, r) => sum + r.recordsProcessed, 0),
|
|
2219
|
-
eventsSent: fileResults.reduce((sum, r) => sum + r.eventsSent, 0),
|
|
2220
|
-
eventsFailed: fileResults.reduce((sum, r) => sum + r.eventsFailed, 0),
|
|
2221
|
-
fileResults,
|
|
2222
|
-
error: errorMessage,
|
|
2223
|
-
};
|
|
2224
|
-
} finally {
|
|
2225
|
-
// ⚠️ CRITICAL: Ensure SFTP is disposed even if outer error occurs
|
|
2226
|
-
if (sftp) {
|
|
2227
|
-
try {
|
|
2228
|
-
await sftp.dispose();
|
|
2229
|
-
log.info('SFTP connection disposed (outer finally)');
|
|
2230
|
-
} catch (disposeError: unknown) {
|
|
2231
|
-
const disposeErrorMessage =
|
|
2232
|
-
disposeError instanceof Error ? disposeError.message : String(disposeError);
|
|
2233
|
-
log.warn('Error disposing SFTP in outer finally', { error: disposeErrorMessage });
|
|
2234
|
-
}
|
|
2235
|
-
}
|
|
2236
|
-
}
|
|
2237
|
-
}
|
|
2238
|
-
```
|
|
2239
|
-
|
|
2240
|
-
---
|
|
2241
|
-
|
|
2242
|
-
## 8. Utility Functions (src/utils/)
|
|
2243
|
-
|
|
2244
|
-
### SFTP Path Helpers (src/utils/sftp-path.utils.ts)
|
|
2245
|
-
|
|
2246
|
-
```typescript
|
|
2247
|
-
/**
|
|
2248
|
-
* SFTP Path Utilities
|
|
2249
|
-
*
|
|
2250
|
-
* Helper functions for SFTP path operations with AWS Transfer Family support.
|
|
2251
|
-
* Handles both absolute paths (AWS Transfer Family) and relative paths (standard OpenSSH).
|
|
2252
|
-
*/
|
|
2253
|
-
|
|
2254
|
-
/**
|
|
2255
|
-
* Join SFTP path segments safely
|
|
2256
|
-
*
|
|
2257
|
-
* Handles different SFTP server path requirements:
|
|
2258
|
-
* - AWS Transfer Family: Requires absolute paths (leading /)
|
|
2259
|
-
* - Standard OpenSSH: Supports relative paths
|
|
2260
|
-
*
|
|
2261
|
-
* @param requireAbsolutePath - true for AWS Transfer Family, false for standard OpenSSH
|
|
2262
|
-
* @param parts - Path segments to join
|
|
2263
|
-
* @returns Properly formatted SFTP path
|
|
2264
|
-
*/
|
|
2265
|
-
export function joinSftpPath(requireAbsolutePath: boolean, ...parts: string[]): string {
|
|
2266
|
-
const cleaned = parts
|
|
2267
|
-
.filter(Boolean)
|
|
2268
|
-
.map(p => String(p).replace(/^\/+|\/+$/g, ''))
|
|
2269
|
-
.join('/');
|
|
2270
|
-
|
|
2271
|
-
return requireAbsolutePath && !cleaned.startsWith('/') ? `/${cleaned}` : cleaned;
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
/**
|
|
2275
|
-
* Generate timestamped filename for archival
|
|
2276
|
-
*
|
|
2277
|
-
* Adds ISO timestamp to filename before extension for unique archival.
|
|
2278
|
-
* Handles JSON files specifically but can be adapted for other formats.
|
|
2279
|
-
*
|
|
2280
|
-
* @param name - Original filename
|
|
2281
|
-
* @returns Filename with timestamp appended
|
|
2282
|
-
*/
|
|
2283
|
-
export function timestampedName(name: string): string {
|
|
2284
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
2285
|
-
const base = name.replace(/\.json$/i, '');
|
|
2286
|
-
return `${base}-${ts}.json`;
|
|
2287
|
-
}
|
|
2288
|
-
|
|
2289
|
-
/**
|
|
2290
|
-
* Extract base filename from full SFTP path
|
|
2291
|
-
*
|
|
2292
|
-
* @param filePath - Full SFTP path (e.g., '/products/incoming/file.json')
|
|
2293
|
-
* @returns Base filename only (e.g., 'file.json')
|
|
2294
|
-
*/
|
|
2295
|
-
export function extractFileName(filePath: string): string {
|
|
2296
|
-
return filePath.split('/').pop() || filePath;
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
// Alias for backward compatibility
|
|
2300
|
-
export const getBaseName = extractFileName;
|
|
2301
|
-
```
|
|
2302
|
-
|
|
2303
|
-
---
|
|
2304
|
-
|
|
2305
|
-
### Job ID Generator (src/utils/job-id-generator.ts)
|
|
2306
|
-
|
|
2307
|
-
```typescript
|
|
2308
|
-
/**
|
|
2309
|
-
* Job ID Generator
|
|
2310
|
-
*
|
|
2311
|
-
* Generates unique job IDs for tracking ingestion workflows
|
|
2312
|
-
*
|
|
2313
|
-
* FORMAT: {TYPE}_{ENTITY}_{YYYYMMDD}_{HHMMSS}_{RANDOM}
|
|
2314
|
-
* Example: SCHEDULED_PRD_20251024_183045_a1b2c3
|
|
2315
|
-
*/
|
|
2316
|
-
export function generateJobId(type: string, entity: string): string {
|
|
2317
|
-
const now = new Date();
|
|
2318
|
-
|
|
2319
|
-
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
2320
|
-
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '');
|
|
2321
|
-
const randomStr = Math.random().toString(36).substring(2, 8);
|
|
2322
|
-
|
|
2323
|
-
return `${type}_${entity}_${dateStr}_${timeStr}_${randomStr}`;
|
|
2324
|
-
}
|
|
2325
|
-
|
|
2326
|
-
/**
|
|
2327
|
-
* Parse job ID components
|
|
2328
|
-
*/
|
|
2329
|
-
export function parseJobId(jobId: string): {
|
|
2330
|
-
type: string;
|
|
2331
|
-
entity: string;
|
|
2332
|
-
date: string;
|
|
2333
|
-
time: string;
|
|
2334
|
-
random: string;
|
|
2335
|
-
} | null {
|
|
2336
|
-
const parts = jobId.split('_');
|
|
2337
|
-
|
|
2338
|
-
if (parts.length !== 5) {
|
|
2339
|
-
return null;
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
return {
|
|
2343
|
-
type: parts[0],
|
|
2344
|
-
entity: parts[1],
|
|
2345
|
-
date: parts[2],
|
|
2346
|
-
time: parts[3],
|
|
2347
|
-
random: parts[4],
|
|
2348
|
-
};
|
|
2349
|
-
}
|
|
2350
|
-
```
|
|
2351
|
-
|
|
2352
|
-
---
|
|
2353
|
-
|
|
2354
|
-
## 9. Package Configuration
|
|
2355
|
-
|
|
2356
|
-
#### `package.json`
|
|
2357
|
-
|
|
2358
|
-
```json
|
|
2359
|
-
{
|
|
2360
|
-
"name": "sftp-json-product-event",
|
|
2361
|
-
"version": "1.2.0",
|
|
2362
|
-
"type": "module",
|
|
2363
|
-
"main": "src/index.ts",
|
|
2364
|
-
"scripts": {
|
|
2365
|
-
"dev": "versori dev",
|
|
2366
|
-
"build": "versori build",
|
|
2367
|
-
"deploy": "versori deploy"
|
|
2368
|
-
},
|
|
2369
|
-
"dependencies": {
|
|
2370
|
-
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
2371
|
-
"@versori/run": "latest"
|
|
2372
|
-
},
|
|
2373
|
-
"devDependencies": {
|
|
2374
|
-
"@types/node": "^20.0.0",
|
|
2375
|
-
"typescript": "^5.0.0"
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2378
|
-
```
|
|
2379
|
-
|
|
2380
|
-
#### `tsconfig.json`
|
|
2381
|
-
|
|
2382
|
-
```json
|
|
2383
|
-
{
|
|
2384
|
-
"compilerOptions": {
|
|
2385
|
-
"lib": ["ES2022", "DOM"],
|
|
2386
|
-
"module": "ES2022",
|
|
2387
|
-
"target": "ES2022",
|
|
2388
|
-
"moduleResolution": "bundler",
|
|
2389
|
-
"strict": true,
|
|
2390
|
-
"esModuleInterop": true,
|
|
2391
|
-
"skipLibCheck": true,
|
|
2392
|
-
"resolveJsonModule": true,
|
|
2393
|
-
"isolatedModules": true
|
|
2394
|
-
}
|
|
2395
|
-
}
|
|
2396
|
-
```
|
|
2397
|
-
|
|
2398
|
-
**Note:** This hybrid configuration works for both local development and Versori deployment. It provides proper TypeScript checking while maintaining compatibility with Versori's Deno runtime.
|
|
2399
|
-
|
|
2400
|
-
---
|
|
2401
|
-
|
|
2402
|
-
## 10. Deployment Instructions
|
|
2403
|
-
|
|
2404
|
-
### Deploy to Versori
|
|
2405
|
-
|
|
2406
|
-
```bash
|
|
2407
|
-
# 1. Install dependencies
|
|
2408
|
-
npm install
|
|
2409
|
-
|
|
2410
|
-
# 2. Test locally (if using Versori CLI)
|
|
2411
|
-
npm run dev
|
|
2412
|
-
|
|
2413
|
-
# 3. Deploy to Versori platform
|
|
2414
|
-
npm run deploy
|
|
2415
|
-
```
|
|
2416
|
-
|
|
2417
|
-
### Configure Activation Variables
|
|
2418
|
-
|
|
2419
|
-
In Versori platform settings, configure all variables listed in the Activation Variables section above.
|
|
2420
|
-
|
|
2421
|
-
---
|
|
2422
|
-
|
|
2423
|
-
## 11. Testing
|
|
2424
|
-
|
|
2425
|
-
### Test Scheduled Ingestion
|
|
2426
|
-
|
|
2427
|
-
Upload a test JSON file to SFTP incoming path and wait for the scheduled run.
|
|
2428
|
-
|
|
2429
|
-
**Check logs:**
|
|
2430
|
-
|
|
2431
|
-
```
|
|
2432
|
-
[STEP 1/8] Initializing job tracking
|
|
2433
|
-
[STEP 2/8] Initializing Fluent Commerce client and SFTP
|
|
2434
|
-
[STEP 3/8] Discovering files on SFTP
|
|
2435
|
-
[FILE 1/1] Processing file: products_20250124.json
|
|
2436
|
-
[STEP 4/8] Downloading and parsing: products_20250124.json
|
|
2437
|
-
[STEP 5/8] Transforming 5 products from products_20250124.json
|
|
2438
|
-
[STEP 6/8] Sending 5 events to Fluent Commerce
|
|
2439
|
-
[STEP 7/8] Archiving file: products_20250124.json
|
|
2440
|
-
[STEP 8/8] Completing job and calculating totals
|
|
2441
|
-
```
|
|
2442
|
-
|
|
2443
|
-
### Test Ad hoc Ingestion
|
|
2444
|
-
|
|
2445
|
-
```bash
|
|
2446
|
-
# Process all pending files
|
|
2447
|
-
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2448
|
-
-H "X-API-Key: your-secret-key" \
|
|
2449
|
-
-H "Content-Type: application/json" \
|
|
2450
|
-
-d '{}'
|
|
2451
|
-
|
|
2452
|
-
# Process specific pattern
|
|
2453
|
-
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2454
|
-
-H "X-API-Key: your-secret-key" \
|
|
2455
|
-
-H "Content-Type: application/json" \
|
|
2456
|
-
-d '{
|
|
2457
|
-
"filePattern": "urgent_*.json", }'
|
|
2458
|
-
|
|
2459
|
-
# Force reprocess
|
|
2460
|
-
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2461
|
-
-H "X-API-Key: your-secret-key" \
|
|
2462
|
-
-H "Content-Type: application/json" \
|
|
2463
|
-
-d '{
|
|
2464
|
-
"forceReprocess": true,
|
|
2465
|
-
"filePattern": "products_20250124.json"
|
|
2466
|
-
}'
|
|
2467
|
-
```
|
|
2468
|
-
|
|
2469
|
-
### Test Job Status Query
|
|
2470
|
-
|
|
2471
|
-
```bash
|
|
2472
|
-
curl -X POST https://api.versori.com/webhooks/product-ingestion-job-status \
|
|
2473
|
-
-H "X-API-Key: your-secret-key" \
|
|
2474
|
-
-H "Content-Type: application/json" \
|
|
2475
|
-
-d '{
|
|
2476
|
-
"jobId": "ADHOC_PROD_20251024_183045_abc123"
|
|
2477
|
-
}'
|
|
2478
|
-
```
|
|
2479
|
-
|
|
2480
|
-
---
|
|
2481
|
-
|
|
2482
|
-
## Monitoring
|
|
2483
|
-
|
|
2484
|
-
### Success Response
|
|
2485
|
-
|
|
2486
|
-
```json
|
|
2487
|
-
{
|
|
2488
|
-
"success": true,
|
|
2489
|
-
"filesProcessed": 1,
|
|
2490
|
-
"filesSkipped": 0,
|
|
2491
|
-
"filesFailed": 0,
|
|
2492
|
-
"totalRecords": 50,
|
|
2493
|
-
"eventsSent": 50,
|
|
2494
|
-
"eventsFailed": 0,
|
|
2495
|
-
"results": [
|
|
2496
|
-
{
|
|
2497
|
-
"file": "products_2025-01-22.json",
|
|
2498
|
-
"success": true,
|
|
2499
|
-
"recordsProcessed": 50,
|
|
2500
|
-
"eventsSent": 50,
|
|
2501
|
-
"eventsFailed": 0
|
|
2502
|
-
}
|
|
2503
|
-
],
|
|
2504
|
-
"duration": 12345
|
|
2505
|
-
}
|
|
2506
|
-
```
|
|
2507
|
-
|
|
2508
|
-
### Partial Success Response
|
|
2509
|
-
|
|
2510
|
-
```json
|
|
2511
|
-
{
|
|
2512
|
-
"success": true,
|
|
2513
|
-
"filesProcessed": 1,
|
|
2514
|
-
"filesSkipped": 0,
|
|
2515
|
-
"filesFailed": 0,
|
|
2516
|
-
"totalRecords": 50,
|
|
2517
|
-
"eventsSent": 45,
|
|
2518
|
-
"eventsFailed": 5,
|
|
2519
|
-
"results": [
|
|
2520
|
-
{
|
|
2521
|
-
"file": "products_2025-01-22.json",
|
|
2522
|
-
"success": true,
|
|
2523
|
-
"recordsProcessed": 50,
|
|
2524
|
-
"eventsSent": 45,
|
|
2525
|
-
"eventsFailed": 5,
|
|
2526
|
-
"errors": ["PRD-001: Invalid SKU format", "PRD-002: Missing required field"]
|
|
2527
|
-
}
|
|
2528
|
-
],
|
|
2529
|
-
"duration": 12345
|
|
2530
|
-
}
|
|
2531
|
-
```
|
|
2532
|
-
|
|
2533
|
-
### Error Response
|
|
2534
|
-
|
|
2535
|
-
```json
|
|
2536
|
-
{
|
|
2537
|
-
"success": false,
|
|
2538
|
-
"filesProcessed": 0,
|
|
2539
|
-
"filesFailed": 1,
|
|
2540
|
-
"totalRecords": 0,
|
|
2541
|
-
"eventsSent": 0,
|
|
2542
|
-
"eventsFailed": 0,
|
|
2543
|
-
"results": [
|
|
2544
|
-
{
|
|
2545
|
-
"file": "products_2025-01-22.json",
|
|
2546
|
-
"success": false,
|
|
2547
|
-
"error": "JSON parse error: Invalid structure"
|
|
2548
|
-
}
|
|
2549
|
-
],
|
|
2550
|
-
"duration": 876
|
|
2551
|
-
}
|
|
2552
|
-
```
|
|
2553
|
-
|
|
2554
|
-
### Monitoring Metrics
|
|
2555
|
-
|
|
2556
|
-
Monitor these metrics via Versori logs filtered by `jobId` or `stage`:
|
|
2557
|
-
|
|
2558
|
-
- **Files Processed** - Total files successfully processed
|
|
2559
|
-
- **Events Sent** - Total events sent to Fluent Commerce
|
|
2560
|
-
- **Events Failed** - Events that failed (check rejection reports)
|
|
2561
|
-
- **Processing Duration** - Time taken for complete workflow
|
|
2562
|
-
- **Rate Limiting** - Watch for 429 errors indicating throttling
|
|
2563
|
-
|
|
2564
|
-
Use the status webhook for dashboards and automated monitoring.
|
|
2565
|
-
|
|
2566
|
-
---
|
|
2567
|
-
|
|
2568
|
-
- **No files found:** Check `sftpIncomingPath` and `filePattern` (file names only)
|
|
2569
|
-
- **JSON parse failed:** Move to errors/; validate JSON structure and encoding
|
|
2570
|
-
- **Files skipped (already processed):** Check VersoriFileTracker KV state; use `forceReprocess: true` if needed
|
|
2571
|
-
- **High event failures:** Inspect logs; consider Batch API for very high volumes
|
|
2572
|
-
- **429 throttling:** Add small backoff/delay or use Batch API
|
|
2573
|
-
- **SFTP connection errors:** Verify credentials (connection object), host, port, and permissions
|
|
2574
|
-
- **Connection pool exhausted:** Check connection cleanup in finally blocks
|
|
2575
|
-
|
|
2576
|
-
---
|
|
2577
|
-
|
|
2578
|
-
## 13. Key Takeaways
|
|
2579
|
-
|
|
2580
|
-
- ✅ **Refactored Architecture (v2.0.0)** - 3 service functions for clean separation:
|
|
2581
|
-
- `processFile()`: Download + Parse + Map (NO array normalization)
|
|
2582
|
-
- `sendEvents()`: Loop records, send events with per-record error handling
|
|
2583
|
-
- `writeEventLog()`: Write rejection logs to SFTP with Buffer
|
|
2584
|
-
- ✅ **Per-file Processing** - One file at a time for memory efficiency
|
|
2585
|
-
- ✅ **Native Versori logs** - Use `log` from context (LoggingService removed - use native log)
|
|
2586
|
-
- ✅ **Buffer import** - Always import Buffer: `import { Buffer } from 'node:buffer';`
|
|
2587
|
-
- ✅ **3 workflows** - Scheduled, ad hoc webhook, job status webhook
|
|
2588
|
-
- ✅ **JobTracker** - Track job lifecycle with KV persistence
|
|
2589
|
-
- ✅ **VersoriFileTracker** - KV-based deduplication for SFTP (required)
|
|
2590
|
-
- ✅ **SFTP deduplication** - KV state tracking prevents reprocessing
|
|
2591
|
-
- ✅ **SFTP dispose()** - Always cleanup in finally block
|
|
2592
|
-
- ✅ **SFTP method names** - listFiles, downloadFile, moveFile (not \*Object)
|
|
2593
|
-
- ✅ **Connection credentials** - Use `activation.connections` for Basic Auth
|
|
2594
|
-
- ✅ **Per-record error handling** - Continue on individual failures
|
|
2595
|
-
- ✅ **Externalized mapping** - Use JSON config file for field mappings
|
|
2596
|
-
- ✅ **File-level error handling** - Don't stop on single file failure
|
|
2597
|
-
- ✅ **JSON auto-detection** - Supports standard JSON and JSON Lines (NO normalization)
|
|
2598
|
-
- ✅ **SFTP vs S3** - Different deduplication strategies (KV state vs physical movement)
|
|
2599
|
-
|
|
2600
|
-
---
|
|
2601
|
-
|
|
2602
|
-
[← Back to Versori Event API Templates](../../readme.md) | [Versori Platform Guide →](../../../../../04-REFERENCE/platforms/versori/platforms-versori-readme.md)
|
|
1
|
+
---
|
|
2
|
+
template_id: tpl-ingest-sftp-json-to-product-event
|
|
3
|
+
canonical_filename: template-ingestion-sftp-json-product-event.md
|
|
4
|
+
sdk_version: ^0.1.39
|
|
5
|
+
runtime: versori
|
|
6
|
+
direction: ingestion
|
|
7
|
+
source: sftp-json
|
|
8
|
+
destination: fluent-event-api
|
|
9
|
+
entity: product
|
|
10
|
+
format: json
|
|
11
|
+
logging: versori
|
|
12
|
+
status: stable
|
|
13
|
+
features:
|
|
14
|
+
- batched-events
|
|
15
|
+
- attribute-transformation
|
|
16
|
+
- memory-management
|
|
17
|
+
- json-serialization-handling
|
|
18
|
+
- enhanced-logging
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# Template: Ingestion - SFTP JSON to Product Event
|
|
22
|
+
|
|
23
|
+
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
24
|
+
**Last Updated:** 2025-01-24
|
|
25
|
+
**Deployment Target:** Versori Platform
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 📋 Implementation Prompt
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
I need a Versori scheduled ingestion that:
|
|
33
|
+
|
|
34
|
+
1) Discovers JSON files on SFTP with file tracking to skip duplicates
|
|
35
|
+
2) Downloads and parses JSON (standard or JSON Lines format) - NO array normalization
|
|
36
|
+
3) Transforms records with UniversalMapper per mapping JSON
|
|
37
|
+
4) Sends UPSERT_PRODUCT events (async) to Fluent Commerce with per-record error handling
|
|
38
|
+
5) Archives files to processed/ or errors/ and writes optional rejection report
|
|
39
|
+
6) Tracks progress with JobTracker and exposes a job-status webhook
|
|
40
|
+
7) Uses native Versori log from context
|
|
41
|
+
8) **Refactored with 3 service functions**: processFile(), sendEvents(), writeEventLog()
|
|
42
|
+
|
|
43
|
+
Use the loaded docs to fill in SDK specifics and best practices.
|
|
44
|
+
Keep the structure identical to the template; only adapt where needed.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 📋 Template Overview
|
|
50
|
+
|
|
51
|
+
This connector runs on the Versori platform. Most operational settings (Fluent account/connection, SFTP connection, schedule, file patterns/limits) are configured via activation variables. Data shape and logic (mapping JSON, JSON structure, parsing rules, per-record handling) are adjusted in code as needed. It reads product data from SFTP JSON, transforms it, and sends events to the Fluent Commerce Event API.
|
|
52
|
+
|
|
53
|
+
### What This Template Does
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
57
|
+
│ INGESTION WORKFLOW │
|
|
58
|
+
└────────────────────────────────────────────────────────────────┘
|
|
59
|
+
|
|
60
|
+
1. TRIGGER
|
|
61
|
+
├─ Scheduled (Cron): Runs automatically every hour
|
|
62
|
+
├─ Ad hoc (Webhook): Manual trigger for immediate processing
|
|
63
|
+
└─ Status Query (Webhook): Check job progress
|
|
64
|
+
|
|
65
|
+
2. DISCOVER FILES (SftpDataSource)
|
|
66
|
+
├─ List files from SFTP directory
|
|
67
|
+
├─ Filter by pattern (products_*.json)
|
|
68
|
+
├─ Check file tracking (skip processed)
|
|
69
|
+
└─ Sort by oldest first
|
|
70
|
+
|
|
71
|
+
3. DOWNLOAD & PARSE (JSONParserService)
|
|
72
|
+
├─ Download file from SFTP
|
|
73
|
+
├─ Parse JSON to JavaScript object
|
|
74
|
+
├─ Auto-detect format (JSON vs JSON Lines)
|
|
75
|
+
└─ Normalize to array structure
|
|
76
|
+
|
|
77
|
+
4. TRANSFORM (UniversalMapper)
|
|
78
|
+
├─ Map JSON fields to Fluent schema
|
|
79
|
+
├─ Apply SDK resolvers (trim, uppercase, etc.)
|
|
80
|
+
├─ Direct passthrough for nested objects
|
|
81
|
+
├─ Direct passthrough for arrays
|
|
82
|
+
└─ Collect transformation errors
|
|
83
|
+
|
|
84
|
+
5. SEND EVENTS (Event API)
|
|
85
|
+
├─ Loop through transformed products
|
|
86
|
+
├─ Send UPSERT_PRODUCT event (async)
|
|
87
|
+
├─ Track success/failure count
|
|
88
|
+
└─ Continue on individual failures
|
|
89
|
+
|
|
90
|
+
6. ARCHIVE (SftpDataSource)
|
|
91
|
+
├─ Move file to processed/ folder
|
|
92
|
+
├─ Or move to errors/ if validation failed
|
|
93
|
+
├─ Generate timestamped archive name
|
|
94
|
+
└─ Verify archive success
|
|
95
|
+
|
|
96
|
+
7. TRACK STATE (VersoriFileTracker)
|
|
97
|
+
├─ Mark file as processed
|
|
98
|
+
├─ Store processing metadata
|
|
99
|
+
├─ Prevent duplicate processing
|
|
100
|
+
└─ Track record counts
|
|
101
|
+
|
|
102
|
+
8. TRACK JOB (JobTracker)
|
|
103
|
+
├─ Update job status at each step
|
|
104
|
+
├─ Store final result in KV
|
|
105
|
+
├─ Enable status queries via webhook
|
|
106
|
+
└─ Handle errors gracefully
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Key Features
|
|
110
|
+
|
|
111
|
+
- **Refactored Architecture**: 3 service functions for clean separation of concerns
|
|
112
|
+
- `processFile()`: SftpDataSource + JSONParserService + UniversalMapper (NO array normalization)
|
|
113
|
+
- `sendEvents()`: Loop records, `client.sendEvent()` with per-record error handling
|
|
114
|
+
- `writeEventLog()`: Write logs to SFTP with Buffer
|
|
115
|
+
- **Per-file Processing**: Download → Parse → Map → Send → Archive (one file at a time)
|
|
116
|
+
- **Job tracking with status queries**: JobTracker persists to Versori KV
|
|
117
|
+
- **Execution modes**: scheduled, ad hoc, status query
|
|
118
|
+
- **Uses SftpDataSource, JSONParserService, UniversalMapper, JobTracker**
|
|
119
|
+
- **Error handling, retry logic, and SFTP cleanup**
|
|
120
|
+
- **SFTP Deduplication:** VersoriFileTracker (KV-based state tracking)
|
|
121
|
+
- **Key Difference from S3:** KV state tracking vs physical file movement
|
|
122
|
+
- **File tracking**: VersoriFileTracker prevents duplicates; `forceReprocess` bypasses
|
|
123
|
+
- **Event API**: Per-record failures don't block other records; rejection reports written to `errors/`
|
|
124
|
+
- **SFTP cleanup in finally block**
|
|
125
|
+
- **Auto-detection of JSON vs JSON Lines format**
|
|
126
|
+
- **Buffer import for Deno/Versori compatibility**
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
### 📦 Package Information
|
|
131
|
+
|
|
132
|
+
**SDK:** [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk) `^0.1.30`
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm install @fluentcommerce/fc-connect-sdk
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
**Templates are designed for direct deployment; customize via activation variables.**
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 📦 SDK Imports (Verified - Versori Optimized)
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// ✅ VERIFIED IMPORTS - These match actual SDK exports
|
|
148
|
+
import { Buffer } from 'node:buffer';
|
|
149
|
+
import {
|
|
150
|
+
createClient, // Universal client factory
|
|
151
|
+
SftpDataSource, // SFTP operations with connection pooling
|
|
152
|
+
JSONParserService, // JSON parsing with format auto-detection
|
|
153
|
+
UniversalMapper, // Field mapping with SDK resolvers
|
|
154
|
+
VersoriFileTracker, // File processing state tracker
|
|
155
|
+
JobTracker, // Job status tracking
|
|
156
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
157
|
+
|
|
158
|
+
import type { FluentClient, SftpDataSourceConfig } from '@fluentcommerce/fc-connect-sdk';
|
|
159
|
+
|
|
160
|
+
// Versori platform imports
|
|
161
|
+
import { schedule, webhook, http, fn } from '@versori/run';
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Note:** All imports are from actual SDK exports - this code compiles and runs as-is.
|
|
165
|
+
|
|
166
|
+
**✅ VERSORI PLATFORM - Use Native Logs:**
|
|
167
|
+
|
|
168
|
+
- Use `log` from context: `const { log } = ctx;`
|
|
169
|
+
- Native Versori logs are simpler and automatically integrated with platform monitoring
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 🔐 SFTP Connection Setup (Recommended)
|
|
174
|
+
|
|
175
|
+
**✅ BEST PRACTICE:** Store SFTP credentials in a Versori connection object with Basic Auth:
|
|
176
|
+
|
|
177
|
+
**Connection Configuration:**
|
|
178
|
+
|
|
179
|
+
1. In Versori platform, create a connection named `SFTP`
|
|
180
|
+
2. Set **Authentication Type**: `Basic Auth`
|
|
181
|
+
3. Enter **Username**: Your SFTP username
|
|
182
|
+
4. Enter **Password**: Your SFTP password
|
|
183
|
+
|
|
184
|
+
### Code Implementation
|
|
185
|
+
|
|
186
|
+
The workflow retrieves credentials programmatically from the Versori connection:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
import { Buffer } from 'node:buffer';
|
|
190
|
+
|
|
191
|
+
// Retrieve SFTP credentials from connection configuration
|
|
192
|
+
log.info('Retrieving SFTP credentials from connection configuration');
|
|
193
|
+
|
|
194
|
+
let sftpUsername: string;
|
|
195
|
+
let sftpPassword: string;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
// Retrieve credentials from the 'SFTP' connection
|
|
199
|
+
const sftpCred = await ctx.credentials().getAccessToken('SFTP');
|
|
200
|
+
|
|
201
|
+
if (!sftpCred?.accessToken) {
|
|
202
|
+
throw new Error('No SFTP credentials found in connection configuration');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Decode base64 accessToken to get "username:password"
|
|
206
|
+
const rawBasicAuth = Buffer.from(sftpCred.accessToken, 'base64').toString('utf-8');
|
|
207
|
+
|
|
208
|
+
// Split on ':' to extract username and password
|
|
209
|
+
const parts = rawBasicAuth.split(':');
|
|
210
|
+
|
|
211
|
+
if (parts.length !== 2) {
|
|
212
|
+
throw new Error('Invalid SFTP credential format - expected username:password');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
sftpUsername = parts[0];
|
|
216
|
+
sftpPassword = parts[1];
|
|
217
|
+
|
|
218
|
+
log.info('SFTP credentials retrieved successfully', {
|
|
219
|
+
hasUsername: !!sftpUsername,
|
|
220
|
+
hasPassword: !!sftpPassword,
|
|
221
|
+
usernameLength: sftpUsername.length,
|
|
222
|
+
passwordLength: sftpPassword.length,
|
|
223
|
+
});
|
|
224
|
+
} catch (error: any) {
|
|
225
|
+
log.error('Failed to retrieve SFTP credentials', {
|
|
226
|
+
message: error instanceof Error ? error.message : String(error),
|
|
227
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
228
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
error: 'Failed to retrieve SFTP credentials from connection configuration',
|
|
234
|
+
details: error?.message,
|
|
235
|
+
recommendation: 'Please ensure the SFTP connection is configured in the Connections section with Basic Authentication (username and password)',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Use credentials in SftpDataSource
|
|
240
|
+
const sftp = new SftpDataSource({
|
|
241
|
+
type: 'SFTP_JSON',
|
|
242
|
+
connectionId: 'sftp-product-sync',
|
|
243
|
+
name: 'Product Sync SFTP',
|
|
244
|
+
settings: {
|
|
245
|
+
host: activation.getVariable('sftpHost'),
|
|
246
|
+
port: parseInt(activation.getVariable('sftpPort') || '22', 10),
|
|
247
|
+
username: sftpUsername, // From connection
|
|
248
|
+
password: sftpPassword, // From connection
|
|
249
|
+
remotePath: activation.getVariable('sftpRemotePath'),
|
|
250
|
+
filePattern: activation.getVariable('filePattern'),
|
|
251
|
+
encoding: 'utf8',
|
|
252
|
+
},
|
|
253
|
+
}, log);
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Accessing SFTP Credentials (Alternative Methods)
|
|
257
|
+
|
|
258
|
+
Versori provides multiple methods to access connection credentials. The template uses `ctx.credentials().getAccessToken('SFTP')` which matches batch-api templates. Alternative methods include:
|
|
259
|
+
|
|
260
|
+
#### Method 1: activation.connections (Simpler Syntax)
|
|
261
|
+
|
|
262
|
+
**Alternative approach** - Direct access via `activation.connections`:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
const { activation } = ctx;
|
|
266
|
+
|
|
267
|
+
// Access connection object
|
|
268
|
+
const conn = activation.connections.SFTP;
|
|
269
|
+
|
|
270
|
+
// Decode Basic Auth credentials
|
|
271
|
+
const rawBasicAuth = Buffer.from(conn.accessToken, 'base64').toString('utf-8');
|
|
272
|
+
const [username, password] = rawBasicAuth.split(':');
|
|
273
|
+
|
|
274
|
+
log.info('SFTP credentials loaded', { username, host: activation.getVariable('sftpHost') });
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**When to use:** Simple synchronous access, single connection scenarios
|
|
278
|
+
|
|
279
|
+
#### Method 2: credentials().get() (Dynamic Selection)
|
|
280
|
+
|
|
281
|
+
**Alternative approach** - Use `credentials().get()` for dynamic credential selection:
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
const { credentials } = ctx;
|
|
285
|
+
|
|
286
|
+
// Get specific connection by name
|
|
287
|
+
const sftpCred = await credentials().get('SFTP');
|
|
288
|
+
|
|
289
|
+
// Decode Basic Auth credentials
|
|
290
|
+
const rawBasicAuth = Buffer.from(sftpCred.accessToken, 'base64').toString('utf-8');
|
|
291
|
+
const [username, password] = rawBasicAuth.split(':');
|
|
292
|
+
|
|
293
|
+
log.info('SFTP credentials loaded via credentials()', { username });
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**When to use:**
|
|
297
|
+
- Multiple SFTP connections (dev/prod, multiple partners)
|
|
298
|
+
- Dynamic credential selection based on runtime logic
|
|
299
|
+
- Need to list all available connections
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## ⚙️ Activation Variables
|
|
304
|
+
|
|
305
|
+
**Configuration is driven by activation variables - modify these instead of code:**
|
|
306
|
+
|
|
307
|
+
> **Note:** `sftpUsername` and `sftpPassword` are fetched from the `SFTP` Basic Auth connection (see SFTP Connection Setup above).
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
# SFTP Configuration
|
|
311
|
+
SFTP_HOST=sftp.partner.com
|
|
312
|
+
SFTP_PORT=22
|
|
313
|
+
|
|
314
|
+
# SFTP Paths
|
|
315
|
+
SFTP_REMOTE_PATH=/products/incoming
|
|
316
|
+
SFTP_ARCHIVE_PATH=/products/processed
|
|
317
|
+
SFTP_ERROR_PATH=/products/errors
|
|
318
|
+
|
|
319
|
+
# File Processing
|
|
320
|
+
FILE_PATTERN=products_*.json
|
|
321
|
+
JSON_FORMAT=json # json or jsonlines
|
|
322
|
+
MAX_FILES_PER_RUN=10
|
|
323
|
+
|
|
324
|
+
# Product Configuration
|
|
325
|
+
CATALOGUE_REF=PC:MASTER:2
|
|
326
|
+
CATALOGUE_TYPE=MASTER
|
|
327
|
+
|
|
328
|
+
# Event API Configuration
|
|
329
|
+
EVENT_NAME=UPSERT_PRODUCT
|
|
330
|
+
EVENT_MODE=async
|
|
331
|
+
EVENT_CONCURRENCY=1
|
|
332
|
+
|
|
333
|
+
# Fluent Configuration (via Versori connection)
|
|
334
|
+
# Connection: fluent_commerce (OAuth2)
|
|
335
|
+
FLUENT_RETAILER_ID=1
|
|
336
|
+
|
|
337
|
+
# Feature Toggles
|
|
338
|
+
REQUIRE_ABSOLUTE_PATHS=true # "true" for AWS Transfer Family, "false" for standard OpenSSH
|
|
339
|
+
ENABLE_FILE_TRACKING=true # Enable/disable file tracking (default: true)
|
|
340
|
+
|
|
341
|
+
# Event API Performance
|
|
342
|
+
USE_BATCHED_EVENTS=false # Use batched UPSERT_PRODUCTS (10x faster for 100+ products)
|
|
343
|
+
MAX_PRODUCTS_UNDER_BATCHED_EVENT=100 # Products per batch (only used when USE_BATCHED_EVENTS=true)
|
|
344
|
+
MEMORY_BATCH_SIZE=250 # Products per mapping batch (prevents OOM on large files)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**Security:**
|
|
348
|
+
- **SFTP Authentication:** Credentials stored in Versori connection vault (not in activation variables). See [SFTP Connection Setup](#-sftp-connection-setup-recommended) section below.
|
|
349
|
+
- **Webhook Authentication:** Enforced by Versori connection configuration. Configure your webhook connection with API key authentication in the Versori Dashboard, then reference it in `webhook({ connection: 'webhook-auth' })`.
|
|
350
|
+
|
|
351
|
+
### Variable Explanations
|
|
352
|
+
|
|
353
|
+
| Variable | Purpose | Default | Customization Hints |
|
|
354
|
+
| ----------------------- | -------------------------------- | ------------------------- | ----------------------------------- |
|
|
355
|
+
| `SFTP_HOST` | SFTP server hostname | - | Required - SFTP server address |
|
|
356
|
+
| `SFTP_PORT` | SFTP server port | `22` | Standard SFTP port |
|
|
357
|
+
| `SFTP_REMOTE_PATH` | Incoming files directory | `/products/incoming` | Where new files arrive |
|
|
358
|
+
| `SFTP_ARCHIVE_PATH` | Processed files archive | `/products/processed` | Where files move after success |
|
|
359
|
+
| `SFTP_ERROR_PATH` | Failed files directory | `/products/errors` | Where files move after errors |
|
|
360
|
+
| `FILE_PATTERN` | File name filter | `products_*.json` | Glob pattern for matching files |
|
|
361
|
+
| `JSON_FORMAT` | JSON format type | `json` | `json` or `jsonlines` |
|
|
362
|
+
| `CATALOGUE_REF` | Product catalogue reference | `PC:MASTER:2` | Target catalogue in Fluent |
|
|
363
|
+
| `CATALOGUE_TYPE` | Catalogue type | `MASTER` | Usually MASTER or STANDARD |
|
|
364
|
+
| `EVENT_NAME` | Event to send | `UPSERT_PRODUCT` | Event name from Rubix |
|
|
365
|
+
| `EVENT_MODE` | Event processing mode | `async` | async (recommended) or sync |
|
|
366
|
+
| `MAX_FILES_PER_RUN` | Max files per execution | `10` | Prevent timeout on large batches |
|
|
367
|
+
| `FLUENT_RETAILER_ID` | Retailer ID for Event API | - | Required - Fluent retailer ID |
|
|
368
|
+
| `EVENT_CONCURRENCY` | Event sending concurrency | `1` | `1` = sequential, `>1` = parallel (3-10 recommended) |
|
|
369
|
+
| `REQUIRE_ABSOLUTE_PATHS` | Require absolute SFTP paths | `true` | Recommended for clarity |
|
|
370
|
+
| `ENABLE_FILE_TRACKING` | Enable KV-based file deduplication | `true` | Set to `false` to disable file tracking |
|
|
371
|
+
| `USE_BATCHED_EVENTS` | Use batched UPSERT_PRODUCTS | `false` | `true` = batched events (10x faster for 100+ products), `false` = individual events |
|
|
372
|
+
| `MAX_PRODUCTS_UNDER_BATCHED_EVENT` | Products per batch | `100` | Only used when `USE_BATCHED_EVENTS=true` (default: 100) |
|
|
373
|
+
| `MEMORY_BATCH_SIZE` | Products per mapping batch | `250` | Processes products in batches during mapping (prevents OOM on large files) |
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## 🔒 SDK Automatic Behaviors (v0.1.40+)
|
|
378
|
+
|
|
379
|
+
**The SDK automatically validates and retries for improved reliability:**
|
|
380
|
+
|
|
381
|
+
### retailerId Validation
|
|
382
|
+
- **SDK validates** `retailerId` before calling `sendEvent()`
|
|
383
|
+
- **Checks:** `event.retailerId || client.retailerId`
|
|
384
|
+
- **If missing:** Throws `"retailerId is required for Event API..."`
|
|
385
|
+
- **Configuration:** Set via `FLUENT_RETAILER_ID` activation variable (recommended)
|
|
386
|
+
|
|
387
|
+
### 401 Auth Retry
|
|
388
|
+
- **Automatic retry** for platform auth failures (3 attempts)
|
|
389
|
+
- **Delay:** Exponential backoff (1s → 2s → 4s)
|
|
390
|
+
- **Applies to:** All `sendEvent()` calls (async and sync modes)
|
|
391
|
+
- **Log:** `"[fc-connect-sdk:auth] Platform auth failure (401), retrying..."`
|
|
392
|
+
|
|
393
|
+
### 5xx Server Retry
|
|
394
|
+
- **Automatic retry** for transient server errors (3 attempts)
|
|
395
|
+
- **Delay:** Exponential backoff (1s → 2s → 4s, capped at 10s)
|
|
396
|
+
- **Protects:** Against Fluent API transient failures
|
|
397
|
+
|
|
398
|
+
### No Code Changes Required
|
|
399
|
+
- All templates remain compatible
|
|
400
|
+
- Retry logic is automatic and transparent
|
|
401
|
+
- Better error messages guide configuration
|
|
402
|
+
|
|
403
|
+
**See:** [Event API Guide](./event-api-guide.md) for complete details
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
### 📁 SFTP Deduplication Pattern (VersoriFileTracker)
|
|
408
|
+
|
|
409
|
+
**CRITICAL: SFTP uses VersoriFileTracker (KV state), NOT physical file movement**
|
|
410
|
+
|
|
411
|
+
VersoriFileTracker is **SFTP-specific** and tracks processed files in Versori KV storage. This prevents reprocessing even if files remain in the incoming directory.
|
|
412
|
+
|
|
413
|
+
#### How SFTP Deduplication Works
|
|
414
|
+
|
|
415
|
+
**4-Step Process:**
|
|
416
|
+
|
|
417
|
+
1. **Discovery:** List files from `sftpIncomingPath` matching `filePattern`
|
|
418
|
+
2. **Filter:** Check VersoriFileTracker - skip files already processed
|
|
419
|
+
3. **Process:** Download, parse, transform, and send events
|
|
420
|
+
4. **Track State:** Mark file as processed in KV (prevents future reprocessing)
|
|
421
|
+
|
|
422
|
+
#### File Lifecycle Example
|
|
423
|
+
|
|
424
|
+
```
|
|
425
|
+
INITIAL STATE:
|
|
426
|
+
sftp://host/products/incoming/products_20250124_001.json
|
|
427
|
+
|
|
428
|
+
↓ (workflow discovers file)
|
|
429
|
+
|
|
430
|
+
CHECKING STATE:
|
|
431
|
+
- List files from "/products/incoming/"
|
|
432
|
+
- VersoriFileTracker.isProcessed("products_20250124_001.json") → false
|
|
433
|
+
|
|
434
|
+
↓ (not processed before)
|
|
435
|
+
|
|
436
|
+
PROCESSING:
|
|
437
|
+
- Download: products_20250124_001.json
|
|
438
|
+
- Parse JSON → Map to events → Send to Event API
|
|
439
|
+
|
|
440
|
+
↓ (success)
|
|
441
|
+
|
|
442
|
+
STATE TRACKING:
|
|
443
|
+
- VersoriFileTracker.markProcessed("products_20250124_001.json")
|
|
444
|
+
- Move file to "/products/processed/products_20250124_001-20250124T183045.json"
|
|
445
|
+
|
|
446
|
+
↓ (next run)
|
|
447
|
+
|
|
448
|
+
DEDUPLICATION:
|
|
449
|
+
- List files from "/products/incoming/" (file may or may not still be there)
|
|
450
|
+
- VersoriFileTracker.isProcessed("products_20250124_001.json") → true
|
|
451
|
+
- Skip file automatically
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
#### Key Differences: SFTP vs S3
|
|
455
|
+
|
|
456
|
+
| Aspect | SFTP (This Template) | S3 (Other Templates) |
|
|
457
|
+
| ------------------------ | -------------------------------- | ----------------------------------- |
|
|
458
|
+
| **Deduplication Method** | VersoriFileTracker (KV state) | Physical file movement |
|
|
459
|
+
| **State Storage** | KV store tracks processed files | None needed |
|
|
460
|
+
| **File Tracking** | ✅ VersoriFileTracker required | ❌ NO VersoriFileTracker |
|
|
461
|
+
| **Reprocessing** | Clear KV state or forceReprocess | Move files back to incoming/ |
|
|
462
|
+
| **Complexity** | More complex (state management) | Simpler (no state management) |
|
|
463
|
+
| **Files After Process** | May remain in incoming/ | Physically moved to processed/ |
|
|
464
|
+
|
|
465
|
+
#### Why SFTP Needs VersoriFileTracker
|
|
466
|
+
|
|
467
|
+
**SFTP file operations are less atomic than S3:**
|
|
468
|
+
|
|
469
|
+
- Files may remain in incoming directory even after processing
|
|
470
|
+
- Partner systems may not clean up files immediately
|
|
471
|
+
- Multiple workflows may access the same SFTP directory
|
|
472
|
+
- Network issues can leave files in inconsistent state
|
|
473
|
+
- **KV state tracking** provides reliable deduplication regardless of file state
|
|
474
|
+
|
|
475
|
+
#### Reprocessing Files (If Needed)
|
|
476
|
+
|
|
477
|
+
To reprocess a file that was already tracked:
|
|
478
|
+
|
|
479
|
+
**Option 1: Clear KV State (Manual)**
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
// In Versori console or workflow
|
|
483
|
+
const kv = openKv(':project:');
|
|
484
|
+
const tracker = new VersoriFileTracker(kv, 'product-ingestion');
|
|
485
|
+
await tracker.clearFile('products_20250124_001.json');
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**Option 2: Webhook with forceReprocess**
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
# Set forceReprocess: true in webhook payload
|
|
492
|
+
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
493
|
+
-H "X-API-Key: your-secret-key" \
|
|
494
|
+
-H "Content-Type: application/json" \
|
|
495
|
+
-d '{
|
|
496
|
+
"forceReprocess": true,
|
|
497
|
+
"filePattern": "products_20250124_001.json"
|
|
498
|
+
}'
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
**Use cases for reprocessing:**
|
|
502
|
+
|
|
503
|
+
- Fixing bad data that was previously ingested
|
|
504
|
+
- Reingesting after mapping config changes
|
|
505
|
+
- Testing with production files
|
|
506
|
+
- Recovering from partial failures
|
|
507
|
+
|
|
508
|
+
#### Summary
|
|
509
|
+
|
|
510
|
+
✅ **DO:** Use VersoriFileTracker for SFTP deduplication
|
|
511
|
+
✅ **DO:** Move files to processed/ or errors/ directories for housekeeping
|
|
512
|
+
✅ **DO:** Import and use VersoriFileTracker in SFTP templates
|
|
513
|
+
✅ **DO:** Use KV state tracking for reliable deduplication
|
|
514
|
+
❌ **DON'T:** Rely on physical file movement alone (files may remain)
|
|
515
|
+
❌ **DON'T:** Use VersoriFileTracker for S3 templates (different use case)
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
### 📁 SFTP Path vs File Pattern
|
|
520
|
+
|
|
521
|
+
**Critical:** Directory paths and file patterns are configured separately.
|
|
522
|
+
|
|
523
|
+
- **Directory Paths:** Configured via activation variables:
|
|
524
|
+
- `sftpIncomingPath` - Where new files arrive (e.g., `/products/incoming`)
|
|
525
|
+
- `sftpArchivePath` - Where successful files are archived (e.g., `/products/processed`)
|
|
526
|
+
- `sftpErrorPath` - Where failed files are moved (e.g., `/products/errors`)
|
|
527
|
+
|
|
528
|
+
- **File Pattern:** Configured via `filePattern` activation variable
|
|
529
|
+
- Matches file names (not full paths)
|
|
530
|
+
- Supports wildcards: `*.json`, `products_*.json`, `urgent_*.json`
|
|
531
|
+
|
|
532
|
+
**How it works:**
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
// The workflow lists files like this:
|
|
536
|
+
sftp.listFiles({
|
|
537
|
+
path: '/products/incoming', // ← Path from activation variable
|
|
538
|
+
pattern: 'products_*.json', // ← Pattern matches file names
|
|
539
|
+
});
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
**Example:** With these settings:
|
|
543
|
+
|
|
544
|
+
```json
|
|
545
|
+
{
|
|
546
|
+
"sftpIncomingPath": "/products/incoming",
|
|
547
|
+
"filePattern": "products_*.json"
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
The workflow will find files like:
|
|
552
|
+
|
|
553
|
+
- ✅ `/products/incoming/products_20250124.json`
|
|
554
|
+
- ✅ `/products/incoming/products_urgent.json`
|
|
555
|
+
- ❌ `/products/incoming/orders_20250124.json` (doesn't match pattern)
|
|
556
|
+
- ❌ `/orders/incoming/products_20250124.json` (wrong path)
|
|
557
|
+
|
|
558
|
+
**Note:** To process files from different paths, change `sftpIncomingPath` (not `filePattern`).
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
## 📄 Mapping Configuration
|
|
563
|
+
|
|
564
|
+
**File:** `config/products.import.json`
|
|
565
|
+
|
|
566
|
+
```json
|
|
567
|
+
{
|
|
568
|
+
"name": "products.import.json",
|
|
569
|
+
"version": "1.0.0",
|
|
570
|
+
"description": "JSON → Product Event Mapping",
|
|
571
|
+
"fields": {
|
|
572
|
+
"ref": {
|
|
573
|
+
"source": "ref",
|
|
574
|
+
"required": true,
|
|
575
|
+
"resolver": "sdk.trim"
|
|
576
|
+
},
|
|
577
|
+
"type": {
|
|
578
|
+
"source": "type",
|
|
579
|
+
"resolver": "sdk.uppercase",
|
|
580
|
+
"defaultValue": "STANDARD"
|
|
581
|
+
},
|
|
582
|
+
"status": {
|
|
583
|
+
"source": "status",
|
|
584
|
+
"resolver": "sdk.uppercase",
|
|
585
|
+
"defaultValue": "ACTIVE"
|
|
586
|
+
},
|
|
587
|
+
"gtin": {
|
|
588
|
+
"source": "gtin",
|
|
589
|
+
"resolver": "sdk.trim"
|
|
590
|
+
},
|
|
591
|
+
"name": {
|
|
592
|
+
"source": "name",
|
|
593
|
+
"required": true,
|
|
594
|
+
"resolver": "sdk.trim"
|
|
595
|
+
},
|
|
596
|
+
"summary": {
|
|
597
|
+
"source": "summary"
|
|
598
|
+
},
|
|
599
|
+
"categoryRefs": {
|
|
600
|
+
"source": "categoryRefs"
|
|
601
|
+
},
|
|
602
|
+
"price": {
|
|
603
|
+
"source": "price"
|
|
604
|
+
},
|
|
605
|
+
"taxType": {
|
|
606
|
+
"source": "taxType"
|
|
607
|
+
},
|
|
608
|
+
"attributes": {
|
|
609
|
+
"source": "attributes",
|
|
610
|
+
"defaultValue": []
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
**Note on JSON Mapping:**
|
|
617
|
+
|
|
618
|
+
Unlike CSV (flat structure), JSON often has nested objects and arrays that map directly to Fluent schema:
|
|
619
|
+
|
|
620
|
+
```json
|
|
621
|
+
{
|
|
622
|
+
"ref": "G_PROD_001",
|
|
623
|
+
"categoryRefs": ["CAT1", "CAT2"],
|
|
624
|
+
"price": [
|
|
625
|
+
{ "type": "DEFAULT", "currency": "USD", "value": 52.0 },
|
|
626
|
+
{ "type": "SPECIAL", "currency": "USD", "value": 9.0 }
|
|
627
|
+
],
|
|
628
|
+
"taxType": {
|
|
629
|
+
"country": "AU",
|
|
630
|
+
"group": "Tax Group",
|
|
631
|
+
"tariff": "Tax Tariff"
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
These fields can use **direct passthrough** (no custom resolvers needed):
|
|
637
|
+
|
|
638
|
+
```json
|
|
639
|
+
{
|
|
640
|
+
"categoryRefs": { "source": "categoryRefs" },
|
|
641
|
+
"price": { "source": "price" },
|
|
642
|
+
"taxType": { "source": "taxType" }
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
For advanced patterns, see SDK Universal Mapping guide.
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## Expected JSON Format
|
|
651
|
+
|
|
652
|
+
### Standard JSON (Array of Objects)
|
|
653
|
+
|
|
654
|
+
```json
|
|
655
|
+
[
|
|
656
|
+
{
|
|
657
|
+
"ref": "G_PROD_WITH_NO_STANDARD",
|
|
658
|
+
"type": "VARIANT",
|
|
659
|
+
"status": "ACTIVE",
|
|
660
|
+
"gtin": "MH01-XS-Orange",
|
|
661
|
+
"name": "Chaz Kangeroo Hoodie-XS-Orange main",
|
|
662
|
+
"summary": "<p>test short description</p>",
|
|
663
|
+
"categoryRefs": ["STANDARD_CATEGORY"],
|
|
664
|
+
"price": [
|
|
665
|
+
{ "type": "DEFAULT", "currency": "USD", "value": 52.0 },
|
|
666
|
+
{ "type": "SPECIAL", "currency": "USD", "value": 9.0 }
|
|
667
|
+
],
|
|
668
|
+
"taxType": {
|
|
669
|
+
"country": "AU",
|
|
670
|
+
"group": "Tax Group",
|
|
671
|
+
"tariff": "Tax Tariff"
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
"ref": "G_PROD_002",
|
|
676
|
+
"type": "VARIANT",
|
|
677
|
+
"status": "ACTIVE",
|
|
678
|
+
"gtin": "MH02-M-Blue",
|
|
679
|
+
"name": "Kangeroo Hoodie-M-Blue",
|
|
680
|
+
"summary": "<p>Blue variant</p>",
|
|
681
|
+
"categoryRefs": ["STANDARD_CATEGORY", "SEASONAL"],
|
|
682
|
+
"price": [
|
|
683
|
+
{ "type": "DEFAULT", "currency": "USD", "value": 45.0 },
|
|
684
|
+
{ "type": "SPECIAL", "currency": "USD", "value": 35.0 }
|
|
685
|
+
],
|
|
686
|
+
"taxType": {
|
|
687
|
+
"country": "AU",
|
|
688
|
+
"group": "Tax Group",
|
|
689
|
+
"tariff": "Tax Tariff"
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
]
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### JSON Lines Format (One JSON Object Per Line)
|
|
696
|
+
|
|
697
|
+
```json
|
|
698
|
+
{"ref":"G_PROD_WITH_NO_STANDARD","type":"VARIANT","status":"ACTIVE","gtin":"MH01-XS-Orange","name":"Chaz Kangeroo Hoodie-XS-Orange main","summary":"<p>test short description</p>","categoryRefs":["STANDARD_CATEGORY"],"price":[{"type":"DEFAULT","currency":"USD","value":52.0},{"type":"SPECIAL","currency":"USD","value":9.0}],"taxType":{"country":"AU","group":"Tax Group","tariff":"Tax Tariff"}}
|
|
699
|
+
{"ref":"G_PROD_002","type":"VARIANT","status":"ACTIVE","gtin":"MH02-M-Blue","name":"Kangeroo Hoodie-M-Blue","summary":"<p>Blue variant</p>","categoryRefs":["STANDARD_CATEGORY","SEASONAL"],"price":[{"type":"DEFAULT","currency":"USD","value":45.0},{"type":"SPECIAL","currency":"USD","value":35.0}],"taxType":{"country":"AU","group":"Tax Group","tariff":"Tax Tariff"}}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
**JSONParserService auto-detects format** - NO array normalization, returns parsed data as-is.
|
|
703
|
+
|
|
704
|
+
---
|
|
705
|
+
|
|
706
|
+
## Event Sending Configuration
|
|
707
|
+
|
|
708
|
+
**Simple Configuration:** One variable controls everything
|
|
709
|
+
|
|
710
|
+
```json
|
|
711
|
+
{
|
|
712
|
+
"eventConcurrency": 1 // 1 = sequential, 3-10 = parallel
|
|
713
|
+
}
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
**How it works:**
|
|
717
|
+
|
|
718
|
+
- `eventConcurrency: 1` → Sequential (sends events one at a time, safe default)
|
|
719
|
+
- `eventConcurrency: 3` → Parallel (sends 3 events concurrently)
|
|
720
|
+
- `eventConcurrency: 5` → Parallel (sends 5 events concurrently)
|
|
721
|
+
- `eventConcurrency: 10` → Parallel (sends 10 events concurrently)
|
|
722
|
+
|
|
723
|
+
**Configuration Guidelines:**
|
|
724
|
+
|
|
725
|
+
- **Conservative:** `1` → Sequential (safe, predictable, ~1 req/sec)
|
|
726
|
+
- **Balanced:** `3-5` → Parallel (most common, ~3-5 req/sec)
|
|
727
|
+
- **Aggressive:** `10` → Parallel (high-volume, ~10 req/sec)
|
|
728
|
+
- **Note:** Fluent API supports concurrent requests - adjust based on your needs
|
|
729
|
+
|
|
730
|
+
**Why Single Variable?**
|
|
731
|
+
|
|
732
|
+
- **Simpler:** One variable instead of two (`mode` + `concurrency`)
|
|
733
|
+
- **Clearer:** `concurrency: 1` = sequential, `concurrency > 1` = parallel
|
|
734
|
+
- **Less config:** Fewer activation variables to manage
|
|
735
|
+
- **Flexible:** Easy to tune performance (just change the number)
|
|
736
|
+
|
|
737
|
+
---
|
|
738
|
+
|
|
739
|
+
## 🔧 Production Reference Implementation
|
|
740
|
+
|
|
741
|
+
### Processing Modes
|
|
742
|
+
|
|
743
|
+
| Mode | Default | What happens | When to use |
|
|
744
|
+
| ---------- | ------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
745
|
+
| `per-file` | ✅ | Process one file end-to-end (discover → parse → Event API → archive) before moving on | Recommended for production SFTP feeds and very large files |
|
|
746
|
+
| `chunked` | ➖ | Processes `fileChunkSize` files at a time, completing each chunk before the next | High-volume feeds where per-file would take too long |
|
|
747
|
+
| `batch` | ➖ | Loads every matched file into memory, produces a single Event API job, and archives when complete | Tiny datasets or smoke tests only |
|
|
748
|
+
|
|
749
|
+
Set the mode via activation variables:
|
|
750
|
+
|
|
751
|
+
```json
|
|
752
|
+
{
|
|
753
|
+
"processingMode": "per-file",
|
|
754
|
+
"fileChunkSize": "5",
|
|
755
|
+
"maxFilesPerRun": "25"
|
|
756
|
+
}
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
### Versori Workflows Structure
|
|
760
|
+
|
|
761
|
+
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
762
|
+
|
|
763
|
+
**Trigger Types:**
|
|
764
|
+
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
765
|
+
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
766
|
+
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
767
|
+
|
|
768
|
+
**Execution Steps (chained to triggers):**
|
|
769
|
+
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
770
|
+
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
771
|
+
|
|
772
|
+
### Recommended Project Structure
|
|
773
|
+
|
|
774
|
+
```
|
|
775
|
+
product-event-sync/
|
|
776
|
+
├── index.ts # Entry point - exports all workflows
|
|
777
|
+
└── src/
|
|
778
|
+
├── workflows/
|
|
779
|
+
│ ├── scheduled/
|
|
780
|
+
│ │ └── daily-product-sync.ts # Scheduled: Daily product sync
|
|
781
|
+
│ │
|
|
782
|
+
│ └── webhook/
|
|
783
|
+
│ ├── adhoc-product-sync.ts # Webhook: Manual trigger
|
|
784
|
+
│ └── job-status-check.ts # Webhook: Status query
|
|
785
|
+
│
|
|
786
|
+
├── services/
|
|
787
|
+
│ └── product-sync.service.ts # Shared orchestration logic (reusable)
|
|
788
|
+
│
|
|
789
|
+
└── types/
|
|
790
|
+
└── product.types.ts # Shared type definitions
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
**Benefits:**
|
|
794
|
+
- ✅ Clear trigger separation (`scheduled/` vs `webhook/`)
|
|
795
|
+
- ✅ Descriptive file names (easy to browse and understand)
|
|
796
|
+
- ✅ Scalable (add new workflows without cluttering)
|
|
797
|
+
- ✅ Reusable code in `services/` (DRY principle)
|
|
798
|
+
- ✅ Easy to modify individual workflows without affecting others
|
|
799
|
+
|
|
800
|
+
---
|
|
801
|
+
|
|
802
|
+
## Workflow Files
|
|
803
|
+
|
|
804
|
+
### 1. Scheduled Workflows (`src/workflows/scheduled/`)
|
|
805
|
+
|
|
806
|
+
All time-based triggers that run automatically on cron schedules.
|
|
807
|
+
|
|
808
|
+
#### `src/workflows/scheduled/daily-product-sync.ts`
|
|
809
|
+
|
|
810
|
+
**Purpose**: Automatic Daily product sync
|
|
811
|
+
**Trigger**: Cron schedule (`0 2 * * *`)
|
|
812
|
+
**Exposed as Endpoint**: ❌ NO - Runs automatically
|
|
813
|
+
|
|
814
|
+
```typescript
|
|
815
|
+
import { schedule, http } from '@versori/run';
|
|
816
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
817
|
+
import { executeProductIngestion } from '../../services/product-sync.service';
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Scheduled Workflow: Daily Product Sync
|
|
821
|
+
*
|
|
822
|
+
* Runs automatically daily at 2 AM UTC
|
|
823
|
+
* NOT exposed as HTTP endpoint - Versori executes on schedule
|
|
824
|
+
*
|
|
825
|
+
* Uses shared service: product-sync.service.ts
|
|
826
|
+
*/
|
|
827
|
+
export const dailyProductSync = schedule(
|
|
828
|
+
'product-sync-scheduled',
|
|
829
|
+
'0 2 * * *' // Daily at 2 AM UTC
|
|
830
|
+
).then(
|
|
831
|
+
http('run-product-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
832
|
+
const { log, openKv } = ctx;
|
|
833
|
+
const jobId = `product-sync-${Date.now()}`;
|
|
834
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
835
|
+
|
|
836
|
+
log.info('🚀 [WORKFLOW START] Daily product sync workflow triggered', { jobId });
|
|
837
|
+
|
|
838
|
+
await tracker.createJob(jobId, { triggeredBy: 'schedule', stage: 'initialization' });
|
|
839
|
+
await tracker.updateJob(jobId, { status: 'processing' });
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
// Reuse shared orchestration logic
|
|
843
|
+
const result = await executeProductIngestion(ctx, { jobId, triggeredBy: 'manual' }, tracker);
|
|
844
|
+
await tracker.markCompleted(jobId, result);
|
|
845
|
+
|
|
846
|
+
log.info('✅ [WORKFLOW END] Daily product sync completed successfully', {
|
|
847
|
+
jobId,
|
|
848
|
+
duration: result.duration,
|
|
849
|
+
filesProcessed: result.filesProcessed
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
return { success: true, jobId, ...result };
|
|
853
|
+
} catch (e: any) {
|
|
854
|
+
log.error('❌ [WORKFLOW ERROR] Daily product sync failed', {
|
|
855
|
+
jobId,
|
|
856
|
+
message: e?.message,
|
|
857
|
+
stack: e?.stack
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
await tracker.markFailed(jobId, e);
|
|
861
|
+
return { success: false, jobId, error: e?.message };
|
|
862
|
+
}
|
|
863
|
+
})
|
|
864
|
+
);
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
---
|
|
868
|
+
|
|
869
|
+
### 2. Webhook Workflows (`src/workflows/webhook/`)
|
|
870
|
+
|
|
871
|
+
All HTTP-based triggers that create webhook endpoints.
|
|
872
|
+
|
|
873
|
+
#### `src/workflows/webhook/adhoc-product-sync.ts`
|
|
874
|
+
|
|
875
|
+
**Purpose**: Manual product sync trigger (on-demand)
|
|
876
|
+
**Trigger**: HTTP POST
|
|
877
|
+
**Endpoint**: `POST https://{workspace}.versori.run/product-sync-adhoc`
|
|
878
|
+
**Use Cases**: Testing, priority processing, ad-hoc runs
|
|
879
|
+
|
|
880
|
+
```typescript
|
|
881
|
+
import { webhook, http } from '@versori/run';
|
|
882
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
883
|
+
import { executeProductIngestion } from '../../services/product-sync.service';
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Webhook: Manual Product Sync Trigger
|
|
887
|
+
*
|
|
888
|
+
* Endpoint: POST https://{workspace}.versori.run/product-sync-adhoc
|
|
889
|
+
* Request body (optional): { filePattern: "urgent_*.json", maxFiles: 5 }
|
|
890
|
+
*
|
|
891
|
+
* Pattern: webhook().then(http()) - needs Fluent API access
|
|
892
|
+
* Uses shared service: product-sync.service.ts
|
|
893
|
+
*
|
|
894
|
+
* SECURITY: Authentication handled via connection parameter
|
|
895
|
+
* No manual API key validation needed - Versori manages this via connection auth
|
|
896
|
+
*/
|
|
897
|
+
export const adhocProductSync = webhook('product-sync-adhoc', {
|
|
898
|
+
response: { mode: 'sync' }, // ✅ Sync mode: response sent when handler returns
|
|
899
|
+
connection: 'product-sync-adhoc', // Versori validates API key
|
|
900
|
+
}).then(
|
|
901
|
+
http('run-product-sync-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
902
|
+
const { log, openKv, data } = ctx;
|
|
903
|
+
const jobId = `product-sync-adhoc-${Date.now()}`;
|
|
904
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
905
|
+
|
|
906
|
+
const filePattern = data?.filePattern as string;
|
|
907
|
+
const maxFiles = data?.maxFiles as number;
|
|
908
|
+
|
|
909
|
+
log.info('🚀 [WEBHOOK] Adhoc product sync triggered', {
|
|
910
|
+
jobId,
|
|
911
|
+
filePattern,
|
|
912
|
+
maxFiles,
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// Create job entry FIRST (awaited to ensure job exists in KV)
|
|
916
|
+
await tracker.createJob(jobId, {
|
|
917
|
+
triggeredBy: 'manual',
|
|
918
|
+
stage: 'initialization',
|
|
919
|
+
status: 'queued',
|
|
920
|
+
options: { filePattern, maxFiles },
|
|
921
|
+
createdAt: new Date().toISOString(),
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// ✅ Fire-and-forget: Start background processing WITHOUT await
|
|
925
|
+
// The promise continues execution after we return the response
|
|
926
|
+
executeProductIngestion(ctx, { jobId, triggeredBy: 'manual' }, tracker)
|
|
927
|
+
.then((result) => {
|
|
928
|
+
log.info('✅ [BACKGROUND] Product sync completed successfully', {
|
|
929
|
+
jobId,
|
|
930
|
+
filesProcessed: result.filesProcessed,
|
|
931
|
+
filesFailed: result.filesFailed,
|
|
932
|
+
recordsProcessed: result.recordsProcessed,
|
|
933
|
+
duration: result.duration,
|
|
934
|
+
});
|
|
935
|
+
return tracker.markCompleted(jobId, result);
|
|
936
|
+
})
|
|
937
|
+
.catch((error: unknown) => {
|
|
938
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
939
|
+
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
940
|
+
|
|
941
|
+
log.error('❌ [BACKGROUND] Product sync failed', {
|
|
942
|
+
jobId,
|
|
943
|
+
error: errorMessage,
|
|
944
|
+
stack: errorStack,
|
|
945
|
+
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
return tracker.markFailed(jobId, errorMessage);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
// Return immediately with jobId (response sent with this return value)
|
|
952
|
+
return {
|
|
953
|
+
success: true,
|
|
954
|
+
jobId,
|
|
955
|
+
message: 'Product sync started in background',
|
|
956
|
+
statusEndpoint: `https://{workspace}.versori.run/product-sync-job-status`,
|
|
957
|
+
note: 'Poll the status endpoint with jobId to check progress',
|
|
958
|
+
};
|
|
959
|
+
})
|
|
960
|
+
);
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
#### `src/workflows/webhook/job-status-check.ts`
|
|
964
|
+
|
|
965
|
+
**Purpose**: Query job status
|
|
966
|
+
**Trigger**: HTTP POST
|
|
967
|
+
**Endpoint**: `POST https://{workspace}.versori.run/product-sync-job-status`
|
|
968
|
+
**Request body**: `{ "jobId": "product-sync-1234567890" }`
|
|
969
|
+
|
|
970
|
+
```typescript
|
|
971
|
+
import { webhook, fn } from '@versori/run';
|
|
972
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Webhook: Job Status Check
|
|
976
|
+
*
|
|
977
|
+
* Endpoint: POST https://{workspace}.versori.run/product-sync-job-status
|
|
978
|
+
* Request body: { "jobId": "product-sync-1234567890" }
|
|
979
|
+
*
|
|
980
|
+
* Pattern: webhook().then(fn()) - no external API needed, only KV storage
|
|
981
|
+
* Lightweight: Only queries KV store, no Fluent API calls
|
|
982
|
+
*
|
|
983
|
+
* SECURITY: Authentication handled via connection parameter
|
|
984
|
+
* No manual API key validation needed - Versori manages this via connection auth
|
|
985
|
+
*/
|
|
986
|
+
export const productSyncJobStatus = webhook('product-sync-job-status', {
|
|
987
|
+
response: { mode: 'sync' },
|
|
988
|
+
connection: 'product-sync-job-status',
|
|
989
|
+
}).then(
|
|
990
|
+
fn('status', async ctx => {
|
|
991
|
+
const { data, log, openKv } = ctx;
|
|
992
|
+
const jobId = data?.jobId as string;
|
|
993
|
+
|
|
994
|
+
if (!jobId) {
|
|
995
|
+
return { success: false, error: 'jobId required' };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
999
|
+
const status = await tracker.getJob(jobId);
|
|
1000
|
+
|
|
1001
|
+
return status
|
|
1002
|
+
? { success: true, jobId, ...status }
|
|
1003
|
+
: { success: false, error: 'Job not found', jobId };
|
|
1004
|
+
})
|
|
1005
|
+
);
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
---
|
|
1009
|
+
|
|
1010
|
+
### 3. Entry Point (`index.ts`)
|
|
1011
|
+
|
|
1012
|
+
**Purpose**: Register all workflows with Versori platform
|
|
1013
|
+
|
|
1014
|
+
```typescript
|
|
1015
|
+
/**
|
|
1016
|
+
* Entry Point - Registers all workflows with Versori platform
|
|
1017
|
+
*
|
|
1018
|
+
* Versori automatically discovers and registers exported workflows
|
|
1019
|
+
*
|
|
1020
|
+
* File Structure:
|
|
1021
|
+
* - src/workflows/scheduled/ → Time-based triggers (cron)
|
|
1022
|
+
* - src/workflows/webhook/ → HTTP-based triggers (webhooks)
|
|
1023
|
+
* - src/services/ → Shared service logic (reusable across workflows)
|
|
1024
|
+
*/
|
|
1025
|
+
|
|
1026
|
+
// Import scheduled workflows
|
|
1027
|
+
import { dailyProductSync } from './src/workflows/scheduled/daily-product-sync';
|
|
1028
|
+
|
|
1029
|
+
// Import webhook workflows
|
|
1030
|
+
import { adhocProductSync } from './src/workflows/webhook/adhoc-product-sync';
|
|
1031
|
+
import { productSyncJobStatus } from './src/workflows/webhook/job-status-check';
|
|
1032
|
+
|
|
1033
|
+
// Register all workflows with Versori platform
|
|
1034
|
+
export {
|
|
1035
|
+
// Scheduled (time-based triggers)
|
|
1036
|
+
dailyProductSync,
|
|
1037
|
+
|
|
1038
|
+
// Webhooks (HTTP-based triggers)
|
|
1039
|
+
adhocProductSync,
|
|
1040
|
+
productSyncJobStatus,
|
|
1041
|
+
};
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
**What Gets Exposed:**
|
|
1045
|
+
- ✅ `adhocProductSync` → `https://{workspace}.versori.run/product-sync-adhoc`
|
|
1046
|
+
- ✅ `productSyncJobStatus` → `https://{workspace}.versori.run/product-sync-job-status`
|
|
1047
|
+
- ❌ `dailyProductSync` → NOT exposed (runs automatically on cron)
|
|
1048
|
+
|
|
1049
|
+
---
|
|
1050
|
+
|
|
1051
|
+
### Adding New Workflows
|
|
1052
|
+
|
|
1053
|
+
**To add a scheduled workflow:**
|
|
1054
|
+
1. Create file in `src/workflows/scheduled/` with descriptive name (e.g., `hourly-delta-sync.ts`)
|
|
1055
|
+
2. Export the workflow from the file
|
|
1056
|
+
3. Import and re-export in `index.ts`
|
|
1057
|
+
|
|
1058
|
+
**To add a webhook workflow:**
|
|
1059
|
+
1. Create file in `src/workflows/webhook/` with descriptive name (e.g., `error-notification.ts`)
|
|
1060
|
+
2. Export the workflow from the file
|
|
1061
|
+
3. Import and re-export in `index.ts`
|
|
1062
|
+
|
|
1063
|
+
**Example - Adding hourly delta sync:**
|
|
1064
|
+
|
|
1065
|
+
```typescript
|
|
1066
|
+
// src/workflows/scheduled/hourly-delta-sync.ts
|
|
1067
|
+
export const hourlyDeltaSync = schedule(
|
|
1068
|
+
'product-delta-hourly',
|
|
1069
|
+
'0 * * * *' // Every hour
|
|
1070
|
+
).then(
|
|
1071
|
+
http('run-delta-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
1072
|
+
// Delta sync logic (skip BPP)
|
|
1073
|
+
const result = await runIngestion(ctx, jobId, tracker, { bppEnabled: false });
|
|
1074
|
+
return result;
|
|
1075
|
+
})
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
// index.ts (add to imports and exports)
|
|
1079
|
+
import { hourlyDeltaSync } from './src/workflows/scheduled/hourly-delta-sync';
|
|
1080
|
+
export { daily_product_sync, hourlyDeltaSync, ... };
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
---
|
|
1084
|
+
## 3. Type Definitions (src/types/product-ingestion.types.ts)
|
|
1085
|
+
|
|
1086
|
+
```typescript
|
|
1087
|
+
/**
|
|
1088
|
+
* Type Definitions for Product Ingestion
|
|
1089
|
+
*
|
|
1090
|
+
* Centralized type definitions for product ingestion workflow
|
|
1091
|
+
*/
|
|
1092
|
+
|
|
1093
|
+
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Product interface - represents transformed product data
|
|
1097
|
+
*/
|
|
1098
|
+
export interface Product {
|
|
1099
|
+
ref: string;
|
|
1100
|
+
type?: string;
|
|
1101
|
+
status?: string;
|
|
1102
|
+
name: string;
|
|
1103
|
+
summary?: string;
|
|
1104
|
+
gtin?: string;
|
|
1105
|
+
catalogue?: { ref?: string };
|
|
1106
|
+
attributes?: Record<string, unknown>;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Event configuration
|
|
1111
|
+
*/
|
|
1112
|
+
export interface EventConfig {
|
|
1113
|
+
eventName: string;
|
|
1114
|
+
catalogueRef: string;
|
|
1115
|
+
catalogueType: string;
|
|
1116
|
+
eventMode: 'async' | 'sync';
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Event result - tracks success/failure counts
|
|
1121
|
+
*/
|
|
1122
|
+
export interface EventResult {
|
|
1123
|
+
eventsSent: number;
|
|
1124
|
+
eventsFailed: number;
|
|
1125
|
+
errors: Array<{ productRef: string; error: string }>;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Process file result
|
|
1130
|
+
*/
|
|
1131
|
+
export interface ProcessFileResult {
|
|
1132
|
+
success: boolean;
|
|
1133
|
+
products: Product[];
|
|
1134
|
+
error?: string;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Versori Context Interface
|
|
1139
|
+
* Represents the Versori runtime context passed to workflow functions
|
|
1140
|
+
*/
|
|
1141
|
+
export interface VersoriContext {
|
|
1142
|
+
log: {
|
|
1143
|
+
info: (message: string, data?: Record<string, unknown>) => void;
|
|
1144
|
+
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
1145
|
+
error: (message: string, data?: Record<string, unknown>) => void;
|
|
1146
|
+
debug?: (message: string, data?: Record<string, unknown>) => void;
|
|
1147
|
+
};
|
|
1148
|
+
openKv: (namespace: string) => {
|
|
1149
|
+
get: (key: string) => Promise<unknown>;
|
|
1150
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
1151
|
+
delete: (key: string) => Promise<void>;
|
|
1152
|
+
};
|
|
1153
|
+
activation: {
|
|
1154
|
+
getVariable: (name: string) => string | undefined;
|
|
1155
|
+
connections?: Record<string, unknown>;
|
|
1156
|
+
};
|
|
1157
|
+
connections?: Record<string, unknown>;
|
|
1158
|
+
data?: unknown;
|
|
1159
|
+
fetch?: typeof fetch;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Parameters for ingestion workflow
|
|
1164
|
+
*/
|
|
1165
|
+
export interface ProductIngestionParams {
|
|
1166
|
+
jobId: string;
|
|
1167
|
+
triggeredBy: 'schedule' | 'webhook';
|
|
1168
|
+
filePattern?: string;
|
|
1169
|
+
maxFiles?: number;
|
|
1170
|
+
forceReprocess?: boolean;
|
|
1171
|
+
catalogueRef?: string;
|
|
1172
|
+
priority?: 'normal' | 'high';
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Result from ingestion workflow
|
|
1177
|
+
*/
|
|
1178
|
+
export interface ProductIngestionResult {
|
|
1179
|
+
success: boolean;
|
|
1180
|
+
jobId: string;
|
|
1181
|
+
filesProcessed: number;
|
|
1182
|
+
filesFailed: number;
|
|
1183
|
+
filesSkipped?: number;
|
|
1184
|
+
recordsProcessed: number;
|
|
1185
|
+
eventsSent: number;
|
|
1186
|
+
eventsFailed: number;
|
|
1187
|
+
fileResults: FileProcessingResult[];
|
|
1188
|
+
error?: string;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Per-file processing result
|
|
1193
|
+
*/
|
|
1194
|
+
export interface FileProcessingResult {
|
|
1195
|
+
fileName: string;
|
|
1196
|
+
success: boolean;
|
|
1197
|
+
skipped?: boolean;
|
|
1198
|
+
recordsProcessed: number;
|
|
1199
|
+
eventsSent: number;
|
|
1200
|
+
eventsFailed: number;
|
|
1201
|
+
duration: number;
|
|
1202
|
+
error?: string;
|
|
1203
|
+
}
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
---
|
|
1207
|
+
|
|
1208
|
+
## 4. Service: Product File Processor (`src/services/product-file-processor.service.ts`)
|
|
1209
|
+
|
|
1210
|
+
```typescript
|
|
1211
|
+
/**
|
|
1212
|
+
* Product File Processor Service
|
|
1213
|
+
*
|
|
1214
|
+
* Downloads JSON files from SFTP, parses, and transforms with UniversalMapper.
|
|
1215
|
+
* JSON-specific: Handles both array format and object with products array.
|
|
1216
|
+
*/
|
|
1217
|
+
|
|
1218
|
+
import {
|
|
1219
|
+
SftpDataSource,
|
|
1220
|
+
JSONParserService,
|
|
1221
|
+
UniversalMapper,
|
|
1222
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1223
|
+
import type { ProcessFileResult, Product } from '../types/product-ingestion.types';
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Service for processing JSON product files
|
|
1227
|
+
*/
|
|
1228
|
+
export class ProductFileProcessorService {
|
|
1229
|
+
constructor(
|
|
1230
|
+
private sftp: SftpDataSource,
|
|
1231
|
+
private jsonParser: JSONParserService,
|
|
1232
|
+
private mapper: UniversalMapper,
|
|
1233
|
+
private catalogueRef: string
|
|
1234
|
+
) {}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Download JSON file from SFTP, parse, and transform with UniversalMapper
|
|
1238
|
+
*
|
|
1239
|
+
* ✅ PRODUCTION ENHANCEMENT: Memory cleanup pattern
|
|
1240
|
+
* - Explicit null assignments after each step
|
|
1241
|
+
* - Finally block guarantees cleanup
|
|
1242
|
+
* - Prevents OOM errors on large JSON files
|
|
1243
|
+
*/
|
|
1244
|
+
async downloadParseAndTransform(remoteFilePath: string): Promise<ProcessFileResult> {
|
|
1245
|
+
// ✅ CRITICAL: Variables for cleanup tracking
|
|
1246
|
+
let jsonContent: string | null = null;
|
|
1247
|
+
let parsed: unknown | null = null;
|
|
1248
|
+
|
|
1249
|
+
try {
|
|
1250
|
+
// STEP 1: Download JSON content
|
|
1251
|
+
jsonContent = (await this.sftp.downloadFile(remoteFilePath, {
|
|
1252
|
+
encoding: 'utf8',
|
|
1253
|
+
})) as string;
|
|
1254
|
+
|
|
1255
|
+
// STEP 2: Parse JSON with type safety
|
|
1256
|
+
try {
|
|
1257
|
+
parsed = await this.jsonParser.parse(jsonContent);
|
|
1258
|
+
} catch (parseError: unknown) {
|
|
1259
|
+
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
|
1260
|
+
return {
|
|
1261
|
+
success: false,
|
|
1262
|
+
products: [],
|
|
1263
|
+
error: `JSON parse error: ${errorMessage}`,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// ✅ Clear jsonContent from memory - no longer needed after parsing
|
|
1268
|
+
jsonContent = null;
|
|
1269
|
+
|
|
1270
|
+
// STEP 3: JSON-SPECIFIC - Normalize based on structure (array vs object with products array)
|
|
1271
|
+
const rawProducts = Array.isArray(parsed)
|
|
1272
|
+
? parsed
|
|
1273
|
+
: (parsed as any)?.products
|
|
1274
|
+
? Array.isArray((parsed as any).products)
|
|
1275
|
+
? (parsed as any).products
|
|
1276
|
+
: [(parsed as any).products]
|
|
1277
|
+
: [];
|
|
1278
|
+
|
|
1279
|
+
if (rawProducts.length === 0) {
|
|
1280
|
+
return {
|
|
1281
|
+
success: false,
|
|
1282
|
+
products: [],
|
|
1283
|
+
error: 'No products found in JSON',
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// ✅ Clear parsed data from memory - only need rawProducts now
|
|
1288
|
+
parsed = null;
|
|
1289
|
+
|
|
1290
|
+
// STEP 4: Transform with UniversalMapper
|
|
1291
|
+
// JSON-SPECIFIC: Merge context using spread pattern (not { inventory: ... })
|
|
1292
|
+
const sourceDataWithContext = rawProducts.map(item => ({
|
|
1293
|
+
...item,
|
|
1294
|
+
$context: { catalogueRef: this.catalogueRef },
|
|
1295
|
+
}));
|
|
1296
|
+
|
|
1297
|
+
const mappingResult = await this.mapper.map(sourceDataWithContext);
|
|
1298
|
+
|
|
1299
|
+
if (!mappingResult.success) {
|
|
1300
|
+
return {
|
|
1301
|
+
success: false,
|
|
1302
|
+
products: [],
|
|
1303
|
+
error: `Mapping validation failed: ${mappingResult.errors?.join(', ')}`,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
1308
|
+
this.log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
1309
|
+
skippedFields: mappingResult.skippedFields,
|
|
1310
|
+
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
return {
|
|
1315
|
+
success: true,
|
|
1316
|
+
products: mappingResult.data as Product[],
|
|
1317
|
+
};
|
|
1318
|
+
} catch (error: unknown) {
|
|
1319
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1320
|
+
return {
|
|
1321
|
+
success: false,
|
|
1322
|
+
products: [],
|
|
1323
|
+
error: errorMessage,
|
|
1324
|
+
};
|
|
1325
|
+
} finally {
|
|
1326
|
+
// ✅ CRITICAL: Ensure all large objects are cleared even if error occurs
|
|
1327
|
+
jsonContent = null;
|
|
1328
|
+
parsed = null;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
---
|
|
1335
|
+
|
|
1336
|
+
## 5. Service: Event Sender (`src/services/event-sender.service.ts`)
|
|
1337
|
+
|
|
1338
|
+
```typescript
|
|
1339
|
+
/**
|
|
1340
|
+
* Event Sender Service
|
|
1341
|
+
*
|
|
1342
|
+
* Sends product events to Fluent Commerce Event API with per-record error handling.
|
|
1343
|
+
* Continues processing on individual failures (Event API best practice).
|
|
1344
|
+
* Supports configurable concurrency (sequential or parallel).
|
|
1345
|
+
*/
|
|
1346
|
+
|
|
1347
|
+
import type { FluentClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1348
|
+
import type { EventResult, EventConfig, Product } from '../types/product-ingestion.types';
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Service for sending events to Fluent Commerce Event API
|
|
1352
|
+
*
|
|
1353
|
+
* ✅ PRODUCTION ENHANCEMENT: Optional logger for detailed progress tracking
|
|
1354
|
+
*/
|
|
1355
|
+
export class EventSenderService {
|
|
1356
|
+
constructor(
|
|
1357
|
+
private client: FluentClient,
|
|
1358
|
+
private log?: any // ✅ Optional logger for progress tracking
|
|
1359
|
+
) {}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Send events to Fluent Commerce Event API with configurable concurrency
|
|
1363
|
+
*
|
|
1364
|
+
* **Performance Characteristics:**
|
|
1365
|
+
* - `concurrency: 1` → Sequential processing (safe default, ~1 event/sec)
|
|
1366
|
+
* - `concurrency: 3-5` → Balanced throughput (~3-5 events/sec, good for most cases)
|
|
1367
|
+
* - `concurrency: 10` → High-volume processing (~10 events/sec, 100+ products)
|
|
1368
|
+
*
|
|
1369
|
+
* **Implementation Strategy:**
|
|
1370
|
+
* - Concurrency = 1: Optimized sequential loop (no Promise.allSettled overhead)
|
|
1371
|
+
* - Concurrency > 1: Chunked parallel processing with bounded concurrency
|
|
1372
|
+
* - Both modes: Per-record error tracking (failures don't block other events)
|
|
1373
|
+
*
|
|
1374
|
+
* @param products - Array of products to send as events
|
|
1375
|
+
* @param eventConfig - Event configuration (name, catalogue ref, mode)
|
|
1376
|
+
* @param concurrency - Number of concurrent event requests (default: 1, min: 1)
|
|
1377
|
+
* @returns EventResult with counts (eventsSent/eventsFailed) and error details
|
|
1378
|
+
*/
|
|
1379
|
+
async sendEvents(
|
|
1380
|
+
products: Product[],
|
|
1381
|
+
eventConfig: EventConfig,
|
|
1382
|
+
concurrency: number = 1
|
|
1383
|
+
): Promise<EventResult> {
|
|
1384
|
+
// Validate concurrency (guard against invalid values)
|
|
1385
|
+
const safeConc = Math.max(1, Math.floor(concurrency));
|
|
1386
|
+
|
|
1387
|
+
// Result accumulators
|
|
1388
|
+
let eventsSent = 0;
|
|
1389
|
+
let eventsFailed = 0;
|
|
1390
|
+
const errors: Array<{ productRef: string; error: string }> = [];
|
|
1391
|
+
|
|
1392
|
+
// ✅ PRODUCTION ENHANCEMENT: Log event sending start
|
|
1393
|
+
if (this.log) {
|
|
1394
|
+
this.log.info('📤 Starting event sending', {
|
|
1395
|
+
totalProducts: products.length,
|
|
1396
|
+
concurrency: safeConc,
|
|
1397
|
+
processingMode: safeConc === 1 ? 'sequential (one at a time)' : `parallel (${safeConc} concurrently)`,
|
|
1398
|
+
eventName: eventConfig.eventName,
|
|
1399
|
+
catalogueRef: eventConfig.catalogueRef
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// Helper: Build event payload (DRY - reused in both modes)
|
|
1404
|
+
const buildPayload = (product: Product) => ({
|
|
1405
|
+
name: eventConfig.eventName,
|
|
1406
|
+
entityRef: eventConfig.catalogueRef,
|
|
1407
|
+
entityType: 'PRODUCT_CATALOGUE' as const,
|
|
1408
|
+
entitySubtype: eventConfig.catalogueType,
|
|
1409
|
+
rootEntityRef: eventConfig.catalogueRef,
|
|
1410
|
+
rootEntityType: 'PRODUCT_CATALOGUE' as const,
|
|
1411
|
+
attributes: product,
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
// ============================================================================
|
|
1415
|
+
// SEQUENTIAL MODE (concurrency === 1)
|
|
1416
|
+
// ============================================================================
|
|
1417
|
+
if (safeConc === 1) {
|
|
1418
|
+
for (let i = 0; i < products.length; i++) {
|
|
1419
|
+
const product = products[i];
|
|
1420
|
+
|
|
1421
|
+
// ✅ PRODUCTION ENHANCEMENT: Log progress every 10 products
|
|
1422
|
+
if (this.log && i % 10 === 0) {
|
|
1423
|
+
this.log.info(`📤 Sending product ${i + 1}/${products.length}`, {
|
|
1424
|
+
productRef: product.ref,
|
|
1425
|
+
progress: `${((i / products.length) * 100).toFixed(1)}%`
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
try {
|
|
1430
|
+
await this.client.sendEvent(buildPayload(product), eventConfig.eventMode);
|
|
1431
|
+
eventsSent++;
|
|
1432
|
+
} catch (err: unknown) {
|
|
1433
|
+
eventsFailed++;
|
|
1434
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1435
|
+
errors.push({ productRef: product?.ref || 'unknown', error: errorMsg });
|
|
1436
|
+
// Continue processing (failure doesn't block other products)
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ✅ PRODUCTION ENHANCEMENT: Log completion
|
|
1441
|
+
if (this.log) {
|
|
1442
|
+
this.log.info('✅ Sequential event sending completed', {
|
|
1443
|
+
totalProducts: products.length,
|
|
1444
|
+
eventsSent,
|
|
1445
|
+
eventsFailed
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return { eventsSent, eventsFailed, errors };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// ============================================================================
|
|
1453
|
+
// PARALLEL MODE (concurrency > 1)
|
|
1454
|
+
// ============================================================================
|
|
1455
|
+
const totalChunks = Math.ceil(products.length / safeConc);
|
|
1456
|
+
|
|
1457
|
+
for (let i = 0; i < products.length; i += safeConc) {
|
|
1458
|
+
const chunk = products.slice(i, i + safeConc);
|
|
1459
|
+
const chunkNumber = Math.floor(i / safeConc) + 1;
|
|
1460
|
+
|
|
1461
|
+
// ✅ PRODUCTION ENHANCEMENT: Log chunk progress
|
|
1462
|
+
if (this.log) {
|
|
1463
|
+
this.log.info(`📦 Processing chunk ${chunkNumber}/${totalChunks}`, {
|
|
1464
|
+
chunkNumber,
|
|
1465
|
+
totalChunks,
|
|
1466
|
+
productsInChunk: chunk.length,
|
|
1467
|
+
productRange: `${i + 1}-${i + chunk.length}`,
|
|
1468
|
+
totalProducts: products.length,
|
|
1469
|
+
progress: `${((i / products.length) * 100).toFixed(1)}%`
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Fire all requests in chunk concurrently
|
|
1474
|
+
const results = await Promise.allSettled(
|
|
1475
|
+
chunk.map(product =>
|
|
1476
|
+
this.client
|
|
1477
|
+
.sendEvent(buildPayload(product), eventConfig.eventMode)
|
|
1478
|
+
.then(() => ({ success: true as const, product }))
|
|
1479
|
+
.catch(error => ({ success: false as const, product, error }))
|
|
1480
|
+
)
|
|
1481
|
+
);
|
|
1482
|
+
|
|
1483
|
+
// Aggregate chunk results into totals
|
|
1484
|
+
let chunkSuccess = 0;
|
|
1485
|
+
let chunkFailed = 0;
|
|
1486
|
+
|
|
1487
|
+
for (const result of results) {
|
|
1488
|
+
if (result.status === 'fulfilled' && result.value.success) {
|
|
1489
|
+
eventsSent++;
|
|
1490
|
+
chunkSuccess++;
|
|
1491
|
+
} else {
|
|
1492
|
+
eventsFailed++;
|
|
1493
|
+
chunkFailed++;
|
|
1494
|
+
const error = result.status === 'fulfilled' ? result.value.error : result.reason;
|
|
1495
|
+
const product = result.status === 'fulfilled' ? result.value.product : null;
|
|
1496
|
+
errors.push({
|
|
1497
|
+
productRef: product?.ref || 'unknown',
|
|
1498
|
+
error: error?.message || String(error) || 'unknown error',
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// ✅ PRODUCTION ENHANCEMENT: Log chunk completion
|
|
1504
|
+
if (this.log) {
|
|
1505
|
+
this.log.info(`✅ Chunk ${chunkNumber}/${totalChunks} completed`, {
|
|
1506
|
+
chunkNumber,
|
|
1507
|
+
totalChunks,
|
|
1508
|
+
chunkSuccess,
|
|
1509
|
+
chunkFailed,
|
|
1510
|
+
totalSentSoFar: eventsSent,
|
|
1511
|
+
totalFailedSoFar: eventsFailed,
|
|
1512
|
+
progress: `${(((i + chunk.length) / products.length) * 100).toFixed(1)}%`
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// ✅ PRODUCTION ENHANCEMENT: Log parallel completion
|
|
1518
|
+
if (this.log) {
|
|
1519
|
+
this.log.info('✅ Parallel event sending completed', {
|
|
1520
|
+
totalProducts: products.length,
|
|
1521
|
+
totalChunks,
|
|
1522
|
+
concurrency: safeConc,
|
|
1523
|
+
eventsSent,
|
|
1524
|
+
eventsFailed
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
return { eventsSent, eventsFailed, errors };
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
---
|
|
1534
|
+
|
|
1535
|
+
## 6. Service: Event Logger (`src/services/event-logger.service.ts`)
|
|
1536
|
+
|
|
1537
|
+
```typescript
|
|
1538
|
+
/**
|
|
1539
|
+
* Event Logger Service
|
|
1540
|
+
*
|
|
1541
|
+
* Writes event processing logs to SFTP for audit/debugging.
|
|
1542
|
+
* Optional - can be used to create rejection reports.
|
|
1543
|
+
*/
|
|
1544
|
+
|
|
1545
|
+
import { Buffer } from 'node:buffer';
|
|
1546
|
+
import { SftpDataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* Service for writing event logs to SFTP
|
|
1550
|
+
*/
|
|
1551
|
+
export class EventLoggerService {
|
|
1552
|
+
constructor(private sftp: SftpDataSource) {}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Write event log to SFTP
|
|
1556
|
+
*
|
|
1557
|
+
* @param remotePath - SFTP path for log file
|
|
1558
|
+
* @param logData - Log data to write (JSON format)
|
|
1559
|
+
*/
|
|
1560
|
+
async writeEventLog(remotePath: string, logData: unknown): Promise<void> {
|
|
1561
|
+
try {
|
|
1562
|
+
const logContent = JSON.stringify(logData, null, 2);
|
|
1563
|
+
await this.sftp.uploadFile(remotePath, logContent);
|
|
1564
|
+
} catch (error: unknown) {
|
|
1565
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1566
|
+
throw new Error(`Failed to write event log: ${errorMessage}`);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
```
|
|
1571
|
+
|
|
1572
|
+
---
|
|
1573
|
+
|
|
1574
|
+
## 7. Main Orchestration Service (src/services/product-ingestion.service.ts)
|
|
1575
|
+
|
|
1576
|
+
```typescript
|
|
1577
|
+
/**
|
|
1578
|
+
* MAIN INGESTION ORCHESTRATION SERVICE
|
|
1579
|
+
*
|
|
1580
|
+
* This is the heart of the ingestion workflow. It coordinates all steps:
|
|
1581
|
+
* 1. Initialize clients and services
|
|
1582
|
+
* 2. Discover files on SFTP
|
|
1583
|
+
* 3. Download and parse JSON files
|
|
1584
|
+
* 4. Transform data with UniversalMapper
|
|
1585
|
+
* 5. Send events to Fluent Commerce
|
|
1586
|
+
* 6. Archive processed files
|
|
1587
|
+
* 7. Track file processing state
|
|
1588
|
+
* 8. Track job progress with JobTracker
|
|
1589
|
+
*/
|
|
1590
|
+
|
|
1591
|
+
import { Buffer } from 'node:buffer';
|
|
1592
|
+
import {
|
|
1593
|
+
createClient,
|
|
1594
|
+
SftpDataSource,
|
|
1595
|
+
JSONParserService,
|
|
1596
|
+
UniversalMapper,
|
|
1597
|
+
VersoriFileTracker,
|
|
1598
|
+
JobTracker,
|
|
1599
|
+
type FluentClient,
|
|
1600
|
+
type JobStatus,
|
|
1601
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
1602
|
+
import type {
|
|
1603
|
+
ProductIngestionParams,
|
|
1604
|
+
ProductIngestionResult,
|
|
1605
|
+
FileProcessingResult,
|
|
1606
|
+
EventConfig,
|
|
1607
|
+
} from '../types/product-ingestion.types';
|
|
1608
|
+
import { ProductFileProcessorService } from './product-file-processor.service';
|
|
1609
|
+
import { EventSenderService } from './event-sender.service';
|
|
1610
|
+
import { EventLoggerService } from './event-logger.service';
|
|
1611
|
+
import { joinSftpPath, timestampedName, extractFileName } from '../utils/sftp-path.utils';
|
|
1612
|
+
|
|
1613
|
+
import mappingConfig from '../../config/products.import.json' with { type: 'json' };
|
|
1614
|
+
|
|
1615
|
+
// ✅ VERSORI PLATFORM: Use native log from context, not LoggingService
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Query job status from KV store
|
|
1619
|
+
*/
|
|
1620
|
+
export async function getJobStatus(
|
|
1621
|
+
kv: ReturnType<VersoriContext['openKv']>,
|
|
1622
|
+
jobId: string,
|
|
1623
|
+
log: VersoriContext['log']
|
|
1624
|
+
): Promise<JobStatus | undefined> {
|
|
1625
|
+
try {
|
|
1626
|
+
const tracker = new JobTracker(kv, log);
|
|
1627
|
+
return await tracker.getJob(jobId);
|
|
1628
|
+
} catch (error: unknown) {
|
|
1629
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1630
|
+
log.error('Failed to get job status', {
|
|
1631
|
+
jobId,
|
|
1632
|
+
message: errorMessage,
|
|
1633
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1634
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
1635
|
+
});
|
|
1636
|
+
return undefined;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* MAIN ORCHESTRATION FUNCTION
|
|
1642
|
+
*/
|
|
1643
|
+
export async function executeProductIngestion(
|
|
1644
|
+
ctx: VersoriContext,
|
|
1645
|
+
params: ProductIngestionParams
|
|
1646
|
+
): Promise<ProductIngestionResult> {
|
|
1647
|
+
|
|
1648
|
+
const { log, openKv, activation } = ctx;
|
|
1649
|
+
const { jobId, triggeredBy, filePattern, maxFiles, forceReprocess } = params;
|
|
1650
|
+
|
|
1651
|
+
const kv = openKv(':project:');
|
|
1652
|
+
const tracker = new JobTracker(kv, log);
|
|
1653
|
+
|
|
1654
|
+
const startTime = Date.now();
|
|
1655
|
+
const fileResults: FileProcessingResult[] = [];
|
|
1656
|
+
|
|
1657
|
+
// ⚠️ CRITICAL: Declare SFTP outside try block for double-finally pattern
|
|
1658
|
+
let sftp: SftpDataSource | undefined;
|
|
1659
|
+
|
|
1660
|
+
try {
|
|
1661
|
+
// ═══════════════════════════════════════════════════════════
|
|
1662
|
+
// STEP 1/8: Initialize Job Tracking
|
|
1663
|
+
// ═══════════════════════════════════════════════════════════
|
|
1664
|
+
log.info('🔧 [STEP 1/8] Initializing job tracking', { jobId });
|
|
1665
|
+
|
|
1666
|
+
await tracker.createJob(jobId, {
|
|
1667
|
+
triggeredBy,
|
|
1668
|
+
filePattern: filePattern || 'default',
|
|
1669
|
+
maxFiles: maxFiles || 'unlimited',
|
|
1670
|
+
forceReprocess: !!forceReprocess
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
// ═══════════════════════════════════════════════════════════
|
|
1674
|
+
// STEP 2/8: Initialize Fluent Client & SFTP Connection
|
|
1675
|
+
// ═══════════════════════════════════════════════════════════
|
|
1676
|
+
log.info('🔌 [STEP 2/8] Initializing Fluent Commerce client and SFTP', { jobId });
|
|
1677
|
+
|
|
1678
|
+
const client = await createClient(ctx);
|
|
1679
|
+
|
|
1680
|
+
if (!client) {
|
|
1681
|
+
const errorMsg = 'Failed to create Fluent Commerce client';
|
|
1682
|
+
log.error('❌ ' + errorMsg, {
|
|
1683
|
+
recommendation: 'Verify fluent_commerce connection is configured with valid credentials'
|
|
1684
|
+
});
|
|
1685
|
+
throw new Error(errorMsg);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// ✅ CRITICAL: Set retailerId for Event API calls
|
|
1689
|
+
const fluentRetailerId = activation.getVariable('fluentRetailerId');
|
|
1690
|
+
if (!fluentRetailerId) {
|
|
1691
|
+
const errorMsg = 'fluentRetailerId is required for Event API calls';
|
|
1692
|
+
log.error('❌ ' + errorMsg, {
|
|
1693
|
+
recommendation: 'Add fluentRetailerId to activation variables (e.g., "1")'
|
|
1694
|
+
});
|
|
1695
|
+
throw new Error(errorMsg);
|
|
1696
|
+
}
|
|
1697
|
+
client.setRetailerId(fluentRetailerId);
|
|
1698
|
+
log.info('✅ RetailerId set for Event API', { retailerId: fluentRetailerId });
|
|
1699
|
+
|
|
1700
|
+
// Get SFTP credentials from connection (Basic Auth)
|
|
1701
|
+
log.info('🔐 Retrieving SFTP credentials from connection');
|
|
1702
|
+
|
|
1703
|
+
let sftpUsername: string;
|
|
1704
|
+
let sftpPassword: string;
|
|
1705
|
+
|
|
1706
|
+
try {
|
|
1707
|
+
// Retrieve credentials from the 'SFTP' connection
|
|
1708
|
+
const sftpCred = await ctx.credentials().getAccessToken('SFTP');
|
|
1709
|
+
|
|
1710
|
+
if (!sftpCred?.accessToken) {
|
|
1711
|
+
throw new Error('No SFTP credentials found in connection configuration');
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Decode base64 accessToken to get "username:password"
|
|
1715
|
+
const rawBasicAuth = Buffer.from(sftpCred.accessToken, 'base64').toString('utf-8');
|
|
1716
|
+
const parts = rawBasicAuth.split(':');
|
|
1717
|
+
|
|
1718
|
+
if (parts.length !== 2) {
|
|
1719
|
+
throw new Error('Invalid SFTP credential format - expected username:password');
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
sftpUsername = parts[0];
|
|
1723
|
+
sftpPassword = parts[1];
|
|
1724
|
+
|
|
1725
|
+
log.info('✅ SFTP credentials retrieved successfully', {
|
|
1726
|
+
hasUsername: !!sftpUsername,
|
|
1727
|
+
hasPassword: !!sftpPassword,
|
|
1728
|
+
usernameLength: sftpUsername.length,
|
|
1729
|
+
passwordLength: sftpPassword.length,
|
|
1730
|
+
});
|
|
1731
|
+
} catch (credError: any) {
|
|
1732
|
+
log.error('❌ Failed to retrieve SFTP credentials', {
|
|
1733
|
+
message: credError instanceof Error ? credError.message : String(credError),
|
|
1734
|
+
stack: credError instanceof Error ? credError.stack : undefined,
|
|
1735
|
+
errorType: credError instanceof Error ? credError.constructor.name : 'Error',
|
|
1736
|
+
recommendation: 'Ensure SFTP connection is configured with Basic Authentication'
|
|
1737
|
+
});
|
|
1738
|
+
throw credError;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// Get SFTP configuration from activation variables
|
|
1742
|
+
const sftpConfig = {
|
|
1743
|
+
host: activation.getVariable('sftpHost'),
|
|
1744
|
+
port: parseInt(activation.getVariable('sftpPort') || '22', 10),
|
|
1745
|
+
username: sftpUsername,
|
|
1746
|
+
password: sftpPassword,
|
|
1747
|
+
privateKey: activation.getVariable('sftpPrivateKey'),
|
|
1748
|
+
incomingPath: activation.getVariable('sftpIncomingPath') || '/products/incoming',
|
|
1749
|
+
archivePath: activation.getVariable('sftpArchivePath') || '/archive/products/',
|
|
1750
|
+
errorPath: activation.getVariable('sftpErrorPath') || '/errors/products/',
|
|
1751
|
+
filePattern: filePattern || activation.getVariable('filePattern') || 'products_*.json',
|
|
1752
|
+
enableFileTracking: activation.getVariable('enableFileTracking') !== 'false' // Default: true
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
// Validate SFTP config
|
|
1756
|
+
if (!sftpConfig.host) {
|
|
1757
|
+
const errorMsg = 'SFTP configuration incomplete: missing sftpHost';
|
|
1758
|
+
log.error('❌ ' + errorMsg, {
|
|
1759
|
+
recommendation: 'Add sftpHost to activation variables (e.g., "sftp.partner.com")'
|
|
1760
|
+
});
|
|
1761
|
+
throw new Error(errorMsg);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if (!sftpConfig.password && !sftpConfig.privateKey) {
|
|
1765
|
+
const errorMsg = 'SFTP configuration incomplete: missing password or privateKey';
|
|
1766
|
+
log.error('❌ ' + errorMsg, {
|
|
1767
|
+
recommendation: 'Ensure SFTP connection has valid credentials OR set sftpPrivateKey'
|
|
1768
|
+
});
|
|
1769
|
+
throw new Error(errorMsg);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// Initialize SFTP data source
|
|
1773
|
+
log.info('🔧 Initializing SFTP data source', {
|
|
1774
|
+
host: sftpConfig.host,
|
|
1775
|
+
port: sftpConfig.port,
|
|
1776
|
+
remotePath: sftpConfig.incomingPath,
|
|
1777
|
+
filePattern: sftpConfig.filePattern,
|
|
1778
|
+
enableFileTracking: sftpConfig.enableFileTracking
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
sftp = new SftpDataSource({
|
|
1782
|
+
type: 'SFTP_JSON',
|
|
1783
|
+
connectionId: 'sftp-product-ingestion',
|
|
1784
|
+
name: 'Product Ingestion SFTP',
|
|
1785
|
+
settings: {
|
|
1786
|
+
host: sftpConfig.host,
|
|
1787
|
+
port: sftpConfig.port,
|
|
1788
|
+
username: sftpConfig.username,
|
|
1789
|
+
password: sftpConfig.password,
|
|
1790
|
+
privateKey: sftpConfig.privateKey,
|
|
1791
|
+
remotePath: sftpConfig.incomingPath,
|
|
1792
|
+
filePattern: sftpConfig.filePattern,
|
|
1793
|
+
encoding: 'utf8'
|
|
1794
|
+
}
|
|
1795
|
+
}, log);
|
|
1796
|
+
|
|
1797
|
+
try {
|
|
1798
|
+
// Validate SFTP connection
|
|
1799
|
+
await sftp.validateConnection();
|
|
1800
|
+
log.info('✅ SFTP connection validated successfully');
|
|
1801
|
+
|
|
1802
|
+
// Ensure archive directories exist (recursive)
|
|
1803
|
+
await sftp.createDirectory(sftpConfig.archivePath, true);
|
|
1804
|
+
await sftp.createDirectory(sftpConfig.errorPath, true);
|
|
1805
|
+
|
|
1806
|
+
// ═══════════════════════════════════════════════════════════
|
|
1807
|
+
// STEP 3/8: Discover Files on SFTP
|
|
1808
|
+
// ═══════════════════════════════════════════════════════════
|
|
1809
|
+
log.info('📂 [STEP 3/8] Discovering files on SFTP', {
|
|
1810
|
+
jobId,
|
|
1811
|
+
remotePath: sftpConfig.incomingPath,
|
|
1812
|
+
filePattern: sftpConfig.filePattern,
|
|
1813
|
+
enableFileTracking: sftpConfig.enableFileTracking
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
await tracker.updateJob(jobId, {
|
|
1817
|
+
status: 'processing',
|
|
1818
|
+
stage: 'file_discovery',
|
|
1819
|
+
message: 'Discovering files on SFTP'
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
// List files from SFTP
|
|
1823
|
+
const allFiles = await sftp.listFiles({
|
|
1824
|
+
remotePath: sftpConfig.incomingPath,
|
|
1825
|
+
filePattern: sftpConfig.filePattern
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
log.info(`📋 Discovered ${allFiles.length} files matching pattern`, {
|
|
1829
|
+
pattern: sftpConfig.filePattern,
|
|
1830
|
+
count: allFiles.length
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
// Apply maxFiles limit if specified
|
|
1834
|
+
const filesToProcess = maxFiles ? allFiles.slice(0, maxFiles) : allFiles;
|
|
1835
|
+
|
|
1836
|
+
if (filesToProcess.length === 0) {
|
|
1837
|
+
log.info('ℹ️ No files to process', {
|
|
1838
|
+
remotePath: sftpConfig.incomingPath,
|
|
1839
|
+
filePattern: sftpConfig.filePattern
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
await tracker.markCompleted(jobId, {
|
|
1843
|
+
filesProcessed: 0,
|
|
1844
|
+
message: 'No files found to process'
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
return {
|
|
1848
|
+
success: true,
|
|
1849
|
+
jobId,
|
|
1850
|
+
filesProcessed: 0,
|
|
1851
|
+
filesFailed: 0,
|
|
1852
|
+
recordsProcessed: 0,
|
|
1853
|
+
eventsSent: 0,
|
|
1854
|
+
eventsFailed: 0,
|
|
1855
|
+
fileResults: []
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
log.info(`🔄 Processing ${filesToProcess.length} files`, {
|
|
1860
|
+
totalDiscovered: allFiles.length,
|
|
1861
|
+
toProcess: filesToProcess.length,
|
|
1862
|
+
maxFilesLimit: maxFiles || 'unlimited'
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
// Initialize services
|
|
1866
|
+
const jsonParser = new JSONParserService();
|
|
1867
|
+
const mapper = new UniversalMapper(mappingConfig);
|
|
1868
|
+
|
|
1869
|
+
// Get event configuration
|
|
1870
|
+
const eventConfig: EventConfig = {
|
|
1871
|
+
catalogueRef: params.catalogueRef || activation.getVariable('catalogueRef') || 'PC:MASTER:2',
|
|
1872
|
+
catalogueType: activation.getVariable('catalogueType') || 'MASTER',
|
|
1873
|
+
eventName: activation.getVariable('eventName') || 'UPSERT_PRODUCT',
|
|
1874
|
+
eventMode: (activation.getVariable('eventMode') || 'async') as 'async' | 'sync',
|
|
1875
|
+
};
|
|
1876
|
+
|
|
1877
|
+
// Get event sending configuration
|
|
1878
|
+
const eventConcurrency = Math.max(
|
|
1879
|
+
1,
|
|
1880
|
+
parseInt(activation.getVariable('eventConcurrency') || '1', 10)
|
|
1881
|
+
);
|
|
1882
|
+
|
|
1883
|
+
log.info(`Event concurrency: ${eventConcurrency}`, {
|
|
1884
|
+
mode: eventConcurrency === 1 ? 'sequential' : 'parallel',
|
|
1885
|
+
concurrentRequests: eventConcurrency === 1 ? 'N/A' : eventConcurrency,
|
|
1886
|
+
});
|
|
1887
|
+
|
|
1888
|
+
// Initialize class-based services
|
|
1889
|
+
const fileProcessor = new ProductFileProcessorService(
|
|
1890
|
+
sftp,
|
|
1891
|
+
jsonParser,
|
|
1892
|
+
mapper,
|
|
1893
|
+
eventConfig.catalogueRef
|
|
1894
|
+
);
|
|
1895
|
+
// ✅ PRODUCTION ENHANCEMENT: Pass log to EventSenderService for detailed progress tracking
|
|
1896
|
+
const eventSender = new EventSenderService(client, log);
|
|
1897
|
+
const eventLogger = new EventLoggerService(sftp);
|
|
1898
|
+
|
|
1899
|
+
// File tracker for deduplication
|
|
1900
|
+
const fileTracker = new VersoriFileTracker(kv, 'product-ingestion', log);
|
|
1901
|
+
|
|
1902
|
+
// Use direct KV with dot-separated keys (NATS KV safe)
|
|
1903
|
+
const processedKeyBase = 'fc_sdk.processed_files';
|
|
1904
|
+
|
|
1905
|
+
// Get SFTP path configuration
|
|
1906
|
+
const requireAbsolutePaths = activation.getVariable('requireAbsolutePaths') === 'true';
|
|
1907
|
+
|
|
1908
|
+
for (let fileIndex = 0; fileIndex < filesToProcess.length; fileIndex++) {
|
|
1909
|
+
const file = filesToProcess[fileIndex];
|
|
1910
|
+
const fileStartTime = Date.now();
|
|
1911
|
+
|
|
1912
|
+
// Extract file name using SDK utility
|
|
1913
|
+
const remoteFilePath = (file as any).path || file.name;
|
|
1914
|
+
const baseName = extractFileName(remoteFilePath);
|
|
1915
|
+
|
|
1916
|
+
log.info(`📄 [FILE ${fileIndex + 1}/${filesToProcess.length}] Processing file: ${baseName}`, {
|
|
1917
|
+
remotePath: remoteFilePath,
|
|
1918
|
+
fileSize: (file as any).size || 'unknown'
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
try {
|
|
1922
|
+
// Check if file already processed (unless force reprocess)
|
|
1923
|
+
if (!forceReprocess && sftpConfig.enableFileTracking) {
|
|
1924
|
+
const isProcessed = await fileTracker.isProcessed(baseName);
|
|
1925
|
+
if (isProcessed) {
|
|
1926
|
+
log.info(`⏭️ Skipping already processed file: ${baseName}`, {
|
|
1927
|
+
reason: 'File tracking enabled and file previously processed'
|
|
1928
|
+
});
|
|
1929
|
+
fileResults.push({
|
|
1930
|
+
fileName: baseName,
|
|
1931
|
+
success: true,
|
|
1932
|
+
skipped: true,
|
|
1933
|
+
recordsProcessed: 0,
|
|
1934
|
+
eventsSent: 0,
|
|
1935
|
+
eventsFailed: 0,
|
|
1936
|
+
duration: 0,
|
|
1937
|
+
});
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
await tracker.updateJob(jobId, {
|
|
1943
|
+
status: 'processing',
|
|
1944
|
+
stage: 'downloading',
|
|
1945
|
+
message: `Processing file ${fileIndex + 1} of ${filesToProcess.length}: ${baseName}`
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
// ═══════════════════════════════════════════════════════════
|
|
1949
|
+
// STEP 4/8: Process File (Download + Parse + Map)
|
|
1950
|
+
// ═══════════════════════════════════════════════════════════
|
|
1951
|
+
log.info(`📥 [STEP 4/8] Processing file: ${baseName}`);
|
|
1952
|
+
|
|
1953
|
+
const fileResult = await fileProcessor.downloadParseAndTransform(remoteFilePath);
|
|
1954
|
+
|
|
1955
|
+
log.info(`✅ File processing completed: ${baseName}`, {
|
|
1956
|
+
success: fileResult.success,
|
|
1957
|
+
recordCount: fileResult.products.length,
|
|
1958
|
+
error: fileResult.error
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
if (!fileResult.success || fileResult.products.length === 0) {
|
|
1962
|
+
const archivedName = timestampedName(baseName);
|
|
1963
|
+
await sftp.moveFile(
|
|
1964
|
+
remoteFilePath,
|
|
1965
|
+
joinSftpPath(requireAbsolutePaths, sftpConfig.errorPath, archivedName)
|
|
1966
|
+
);
|
|
1967
|
+
|
|
1968
|
+
fileResults.push({
|
|
1969
|
+
fileName: baseName,
|
|
1970
|
+
success: false,
|
|
1971
|
+
recordsProcessed: fileResult.products.length,
|
|
1972
|
+
eventsSent: 0,
|
|
1973
|
+
eventsFailed: 0,
|
|
1974
|
+
duration: Date.now() - fileStartTime,
|
|
1975
|
+
error: fileResult.error || 'Processing failed'
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
continue;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// ═══════════════════════════════════════════════════════════
|
|
1982
|
+
// STEP 5/8: Send Events
|
|
1983
|
+
// ═══════════════════════════════════════════════════════════
|
|
1984
|
+
log.info(`📤 [STEP 5/8] Sending events for ${baseName}`);
|
|
1985
|
+
|
|
1986
|
+
const sampleProductRefs = fileResult.products.slice(0, 5).map((p: any) => p.ref || p.skuRef);
|
|
1987
|
+
|
|
1988
|
+
log.info(`🚀 [EventSender] Starting event submission for file "${baseName}"`, {
|
|
1989
|
+
totalProducts: fileResult.products.length,
|
|
1990
|
+
eventName: eventConfig.eventName,
|
|
1991
|
+
concurrency: eventConcurrency === 1 ? 'sequential' : `parallel (${eventConcurrency})`,
|
|
1992
|
+
sampleProductRefs: sampleProductRefs.join(', '),
|
|
1993
|
+
eventMode: eventConfig.eventMode
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
const eventResult = await eventSender.sendEvents(
|
|
1997
|
+
fileResult.products,
|
|
1998
|
+
eventConfig,
|
|
1999
|
+
eventConcurrency
|
|
2000
|
+
);
|
|
2001
|
+
|
|
2002
|
+
const eventsSent = eventResult.eventsSent;
|
|
2003
|
+
const eventsFailed = eventResult.eventsFailed;
|
|
2004
|
+
const eventDuration = Date.now() - fileStartTime;
|
|
2005
|
+
|
|
2006
|
+
log.info(`✅ [EventSender] Event submission completed for file "${baseName}"`, {
|
|
2007
|
+
totalProducts: fileResult.products.length,
|
|
2008
|
+
eventsSent,
|
|
2009
|
+
eventsFailed,
|
|
2010
|
+
successRate: fileResult.products.length > 0 ? `${Math.round((eventsSent / fileResult.products.length) * 100)}%` : '0%',
|
|
2011
|
+
duration: eventDuration,
|
|
2012
|
+
avgTimePerEvent: eventsSent > 0 ? Math.round(eventDuration / eventsSent) + 'ms' : 'N/A'
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
// ═══════════════════════════════════════════════════════════
|
|
2016
|
+
// STEP 6/8: Archive File & Track State
|
|
2017
|
+
// ═══════════════════════════════════════════════════════════
|
|
2018
|
+
log.info(`📦 [STEP 6/8] Archiving file: ${baseName}`);
|
|
2019
|
+
|
|
2020
|
+
await tracker.updateJob(jobId, {
|
|
2021
|
+
status: 'processing',
|
|
2022
|
+
stage: 'archiving',
|
|
2023
|
+
message: `Archiving ${baseName}`
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
const archivedName = timestampedName(baseName);
|
|
2027
|
+
const targetDir = eventsFailed > 0 ? sftpConfig.errorPath : sftpConfig.archivePath;
|
|
2028
|
+
|
|
2029
|
+
log.info(`📁 Moving file to ${eventsFailed > 0 ? 'error' : 'processed'} directory`, {
|
|
2030
|
+
sourcePath: remoteFilePath,
|
|
2031
|
+
targetPath: joinSftpPath(requireAbsolutePaths, targetDir, archivedName)
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
await sftp.moveFile(
|
|
2035
|
+
remoteFilePath,
|
|
2036
|
+
joinSftpPath(requireAbsolutePaths, targetDir, archivedName)
|
|
2037
|
+
);
|
|
2038
|
+
|
|
2039
|
+
// Mark as processed in file tracker (if enabled)
|
|
2040
|
+
if (sftpConfig.enableFileTracking) {
|
|
2041
|
+
await fileTracker.markProcessed(baseName, {
|
|
2042
|
+
recordsProcessed: fileResult.products.length,
|
|
2043
|
+
eventsSent,
|
|
2044
|
+
eventsFailed,
|
|
2045
|
+
});
|
|
2046
|
+
log.info(`✅ File tracking state updated for: ${baseName}`);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
log.info(`✅ File archived successfully: ${baseName}`, {
|
|
2050
|
+
archivedName,
|
|
2051
|
+
targetDir,
|
|
2052
|
+
duration: Date.now() - fileStartTime
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
// Optional: Write event log for audit/debugging
|
|
2056
|
+
if (eventsFailed > 0) {
|
|
2057
|
+
const logPath = joinSftpPath(
|
|
2058
|
+
requireAbsolutePaths,
|
|
2059
|
+
sftpConfig.errorPath,
|
|
2060
|
+
`${baseName.replace(/\.json$/i, '')}-event-log.json`
|
|
2061
|
+
);
|
|
2062
|
+
await eventLogger.writeEventLog(logPath, {
|
|
2063
|
+
fileName: baseName,
|
|
2064
|
+
totalRecords: fileResult.products.length,
|
|
2065
|
+
eventsSent,
|
|
2066
|
+
eventsFailed,
|
|
2067
|
+
errors: eventResult.errors,
|
|
2068
|
+
processedAt: new Date().toISOString()
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
fileResults.push({
|
|
2073
|
+
fileName: baseName,
|
|
2074
|
+
success: true,
|
|
2075
|
+
recordsProcessed: fileResult.products.length,
|
|
2076
|
+
eventsSent,
|
|
2077
|
+
eventsFailed,
|
|
2078
|
+
duration: Date.now() - fileStartTime
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
} catch (error: unknown) {
|
|
2082
|
+
// ✅ Enhanced error logging: Extract all error details for visibility + recommendations
|
|
2083
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2084
|
+
const errorDetails = {
|
|
2085
|
+
message: errorMessage,
|
|
2086
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
2087
|
+
fileName: (error as any)?.fileName,
|
|
2088
|
+
lineNumber: (error as any)?.lineNumber,
|
|
2089
|
+
originalError: (error as any)?.context?.originalError?.message,
|
|
2090
|
+
errorType: error instanceof Error ? error.name : 'Error',
|
|
2091
|
+
};
|
|
2092
|
+
|
|
2093
|
+
// Provide actionable recommendations based on error type
|
|
2094
|
+
let recommendation = 'Check error details above for specific issue';
|
|
2095
|
+
if (errorMessage.includes('SFTP')) {
|
|
2096
|
+
recommendation = 'Verify SFTP connection: check host, credentials, and network access';
|
|
2097
|
+
} else if (errorMessage.includes('parse') || errorMessage.includes('JSON')) {
|
|
2098
|
+
recommendation = 'Verify JSON file format: check for valid JSON syntax and encoding';
|
|
2099
|
+
} else if (errorMessage.includes('mapping') || errorMessage.includes('validation')) {
|
|
2100
|
+
recommendation = 'Review mapping configuration: ensure all required fields are present';
|
|
2101
|
+
} else if (errorMessage.includes('retailerId') || errorMessage.includes('Event API')) {
|
|
2102
|
+
recommendation = 'Verify fluentRetailerId activation variable and Event API connection';
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
log.error(`❌ Error processing file ${baseName}:`, {
|
|
2106
|
+
...errorDetails,
|
|
2107
|
+
recommendation
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
try {
|
|
2111
|
+
const archivedName = timestampedName(baseName);
|
|
2112
|
+
await sftp.moveFile(
|
|
2113
|
+
remoteFilePath,
|
|
2114
|
+
joinSftpPath(requireAbsolutePaths, sftpConfig.errorPath, archivedName)
|
|
2115
|
+
);
|
|
2116
|
+
} catch (moveError: unknown) {
|
|
2117
|
+
const moveErrorMessage = moveError instanceof Error ? moveError.message : String(moveError);
|
|
2118
|
+
log.error('Could not archive failed file', {
|
|
2119
|
+
file: baseName,
|
|
2120
|
+
message: moveErrorMessage,
|
|
2121
|
+
stack: moveError instanceof Error ? moveError.stack : undefined,
|
|
2122
|
+
errorType: moveError instanceof Error ? moveError.constructor.name : 'Error'
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
fileResults.push({
|
|
2127
|
+
fileName: baseName,
|
|
2128
|
+
success: false,
|
|
2129
|
+
recordsProcessed: 0,
|
|
2130
|
+
eventsSent: 0,
|
|
2131
|
+
eventsFailed: 0,
|
|
2132
|
+
duration: Date.now() - fileStartTime,
|
|
2133
|
+
error: errorMessage
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// ═══════════════════════════════════════════════════════════
|
|
2139
|
+
// STEP 8/8: Complete Job & Calculate Totals
|
|
2140
|
+
// ═══════════════════════════════════════════════════════════
|
|
2141
|
+
const totalDuration = Date.now() - startTime;
|
|
2142
|
+
|
|
2143
|
+
log.info('🏁 [STEP 8/8] Completing job and calculating totals', { jobId });
|
|
2144
|
+
|
|
2145
|
+
const filesProcessed = fileResults.filter(r => r.success && !r.skipped).length;
|
|
2146
|
+
const filesFailed = fileResults.filter(r => !r.success).length;
|
|
2147
|
+
const filesSkipped = fileResults.filter(r => r.skipped).length;
|
|
2148
|
+
const totalRecordsProcessed = fileResults.reduce((sum, r) => sum + r.recordsProcessed, 0);
|
|
2149
|
+
const totalEventsSent = fileResults.reduce((sum, r) => sum + r.eventsSent, 0);
|
|
2150
|
+
const totalEventsFailed = fileResults.reduce((sum, r) => sum + r.eventsFailed, 0);
|
|
2151
|
+
|
|
2152
|
+
await tracker.markCompleted(jobId, {
|
|
2153
|
+
filesProcessed,
|
|
2154
|
+
filesFailed,
|
|
2155
|
+
filesSkipped,
|
|
2156
|
+
recordsProcessed: totalRecordsProcessed,
|
|
2157
|
+
eventsSent: totalEventsSent,
|
|
2158
|
+
eventsFailed: totalEventsFailed,
|
|
2159
|
+
duration: totalDuration
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
log.info('✅ Ingestion completed successfully', {
|
|
2163
|
+
jobId,
|
|
2164
|
+
filesProcessed,
|
|
2165
|
+
filesFailed,
|
|
2166
|
+
filesSkipped,
|
|
2167
|
+
recordsProcessed: totalRecordsProcessed,
|
|
2168
|
+
eventsSent: totalEventsSent,
|
|
2169
|
+
eventsFailed: totalEventsFailed,
|
|
2170
|
+
duration: totalDuration,
|
|
2171
|
+
avgFileTime: filesProcessed > 0 ? Math.round(totalDuration / filesProcessed) + 'ms' : 'N/A'
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
return {
|
|
2175
|
+
success: true,
|
|
2176
|
+
jobId,
|
|
2177
|
+
filesProcessed,
|
|
2178
|
+
filesFailed,
|
|
2179
|
+
recordsProcessed: totalRecordsProcessed,
|
|
2180
|
+
eventsSent: totalEventsSent,
|
|
2181
|
+
eventsFailed: totalEventsFailed,
|
|
2182
|
+
fileResults
|
|
2183
|
+
};
|
|
2184
|
+
} finally {
|
|
2185
|
+
// ⚠️ CRITICAL: Always dispose SFTP connection (safe null check)
|
|
2186
|
+
if (sftp) {
|
|
2187
|
+
await sftp.dispose();
|
|
2188
|
+
log.info('SFTP connection disposed');
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
} catch (error: unknown) {
|
|
2192
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2193
|
+
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
2194
|
+
log.error('Ingestion workflow failed', {
|
|
2195
|
+
jobId,
|
|
2196
|
+
message: errorMessage,
|
|
2197
|
+
stack: errorStack,
|
|
2198
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error'
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
try {
|
|
2202
|
+
await tracker.markFailed(jobId, error);
|
|
2203
|
+
} catch (trackingError: unknown) {
|
|
2204
|
+
const trackingErrorMessage =
|
|
2205
|
+
trackingError instanceof Error ? trackingError.message : String(trackingError);
|
|
2206
|
+
log.warn('Failed to mark job as failed in tracker', {
|
|
2207
|
+
jobId,
|
|
2208
|
+
trackingError: trackingErrorMessage,
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
return {
|
|
2213
|
+
success: false,
|
|
2214
|
+
jobId,
|
|
2215
|
+
filesProcessed: fileResults.filter(r => r.success && !r.skipped).length,
|
|
2216
|
+
filesFailed: fileResults.filter(r => !r.success).length,
|
|
2217
|
+
filesSkipped: fileResults.filter(r => r.skipped).length,
|
|
2218
|
+
recordsProcessed: fileResults.reduce((sum, r) => sum + r.recordsProcessed, 0),
|
|
2219
|
+
eventsSent: fileResults.reduce((sum, r) => sum + r.eventsSent, 0),
|
|
2220
|
+
eventsFailed: fileResults.reduce((sum, r) => sum + r.eventsFailed, 0),
|
|
2221
|
+
fileResults,
|
|
2222
|
+
error: errorMessage,
|
|
2223
|
+
};
|
|
2224
|
+
} finally {
|
|
2225
|
+
// ⚠️ CRITICAL: Ensure SFTP is disposed even if outer error occurs
|
|
2226
|
+
if (sftp) {
|
|
2227
|
+
try {
|
|
2228
|
+
await sftp.dispose();
|
|
2229
|
+
log.info('SFTP connection disposed (outer finally)');
|
|
2230
|
+
} catch (disposeError: unknown) {
|
|
2231
|
+
const disposeErrorMessage =
|
|
2232
|
+
disposeError instanceof Error ? disposeError.message : String(disposeError);
|
|
2233
|
+
log.warn('Error disposing SFTP in outer finally', { error: disposeErrorMessage });
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
```
|
|
2239
|
+
|
|
2240
|
+
---
|
|
2241
|
+
|
|
2242
|
+
## 8. Utility Functions (src/utils/)
|
|
2243
|
+
|
|
2244
|
+
### SFTP Path Helpers (src/utils/sftp-path.utils.ts)
|
|
2245
|
+
|
|
2246
|
+
```typescript
|
|
2247
|
+
/**
|
|
2248
|
+
* SFTP Path Utilities
|
|
2249
|
+
*
|
|
2250
|
+
* Helper functions for SFTP path operations with AWS Transfer Family support.
|
|
2251
|
+
* Handles both absolute paths (AWS Transfer Family) and relative paths (standard OpenSSH).
|
|
2252
|
+
*/
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* Join SFTP path segments safely
|
|
2256
|
+
*
|
|
2257
|
+
* Handles different SFTP server path requirements:
|
|
2258
|
+
* - AWS Transfer Family: Requires absolute paths (leading /)
|
|
2259
|
+
* - Standard OpenSSH: Supports relative paths
|
|
2260
|
+
*
|
|
2261
|
+
* @param requireAbsolutePath - true for AWS Transfer Family, false for standard OpenSSH
|
|
2262
|
+
* @param parts - Path segments to join
|
|
2263
|
+
* @returns Properly formatted SFTP path
|
|
2264
|
+
*/
|
|
2265
|
+
export function joinSftpPath(requireAbsolutePath: boolean, ...parts: string[]): string {
|
|
2266
|
+
const cleaned = parts
|
|
2267
|
+
.filter(Boolean)
|
|
2268
|
+
.map(p => String(p).replace(/^\/+|\/+$/g, ''))
|
|
2269
|
+
.join('/');
|
|
2270
|
+
|
|
2271
|
+
return requireAbsolutePath && !cleaned.startsWith('/') ? `/${cleaned}` : cleaned;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* Generate timestamped filename for archival
|
|
2276
|
+
*
|
|
2277
|
+
* Adds ISO timestamp to filename before extension for unique archival.
|
|
2278
|
+
* Handles JSON files specifically but can be adapted for other formats.
|
|
2279
|
+
*
|
|
2280
|
+
* @param name - Original filename
|
|
2281
|
+
* @returns Filename with timestamp appended
|
|
2282
|
+
*/
|
|
2283
|
+
export function timestampedName(name: string): string {
|
|
2284
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
2285
|
+
const base = name.replace(/\.json$/i, '');
|
|
2286
|
+
return `${base}-${ts}.json`;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/**
|
|
2290
|
+
* Extract base filename from full SFTP path
|
|
2291
|
+
*
|
|
2292
|
+
* @param filePath - Full SFTP path (e.g., '/products/incoming/file.json')
|
|
2293
|
+
* @returns Base filename only (e.g., 'file.json')
|
|
2294
|
+
*/
|
|
2295
|
+
export function extractFileName(filePath: string): string {
|
|
2296
|
+
return filePath.split('/').pop() || filePath;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Alias for backward compatibility
|
|
2300
|
+
export const getBaseName = extractFileName;
|
|
2301
|
+
```
|
|
2302
|
+
|
|
2303
|
+
---
|
|
2304
|
+
|
|
2305
|
+
### Job ID Generator (src/utils/job-id-generator.ts)
|
|
2306
|
+
|
|
2307
|
+
```typescript
|
|
2308
|
+
/**
|
|
2309
|
+
* Job ID Generator
|
|
2310
|
+
*
|
|
2311
|
+
* Generates unique job IDs for tracking ingestion workflows
|
|
2312
|
+
*
|
|
2313
|
+
* FORMAT: {TYPE}_{ENTITY}_{YYYYMMDD}_{HHMMSS}_{RANDOM}
|
|
2314
|
+
* Example: SCHEDULED_PRD_20251024_183045_a1b2c3
|
|
2315
|
+
*/
|
|
2316
|
+
export function generateJobId(type: string, entity: string): string {
|
|
2317
|
+
const now = new Date();
|
|
2318
|
+
|
|
2319
|
+
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
2320
|
+
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '');
|
|
2321
|
+
const randomStr = Math.random().toString(36).substring(2, 8);
|
|
2322
|
+
|
|
2323
|
+
return `${type}_${entity}_${dateStr}_${timeStr}_${randomStr}`;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/**
|
|
2327
|
+
* Parse job ID components
|
|
2328
|
+
*/
|
|
2329
|
+
export function parseJobId(jobId: string): {
|
|
2330
|
+
type: string;
|
|
2331
|
+
entity: string;
|
|
2332
|
+
date: string;
|
|
2333
|
+
time: string;
|
|
2334
|
+
random: string;
|
|
2335
|
+
} | null {
|
|
2336
|
+
const parts = jobId.split('_');
|
|
2337
|
+
|
|
2338
|
+
if (parts.length !== 5) {
|
|
2339
|
+
return null;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
return {
|
|
2343
|
+
type: parts[0],
|
|
2344
|
+
entity: parts[1],
|
|
2345
|
+
date: parts[2],
|
|
2346
|
+
time: parts[3],
|
|
2347
|
+
random: parts[4],
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
```
|
|
2351
|
+
|
|
2352
|
+
---
|
|
2353
|
+
|
|
2354
|
+
## 9. Package Configuration
|
|
2355
|
+
|
|
2356
|
+
#### `package.json`
|
|
2357
|
+
|
|
2358
|
+
```json
|
|
2359
|
+
{
|
|
2360
|
+
"name": "sftp-json-product-event",
|
|
2361
|
+
"version": "1.2.0",
|
|
2362
|
+
"type": "module",
|
|
2363
|
+
"main": "src/index.ts",
|
|
2364
|
+
"scripts": {
|
|
2365
|
+
"dev": "versori dev",
|
|
2366
|
+
"build": "versori build",
|
|
2367
|
+
"deploy": "versori deploy"
|
|
2368
|
+
},
|
|
2369
|
+
"dependencies": {
|
|
2370
|
+
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
2371
|
+
"@versori/run": "latest"
|
|
2372
|
+
},
|
|
2373
|
+
"devDependencies": {
|
|
2374
|
+
"@types/node": "^20.0.0",
|
|
2375
|
+
"typescript": "^5.0.0"
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
```
|
|
2379
|
+
|
|
2380
|
+
#### `tsconfig.json`
|
|
2381
|
+
|
|
2382
|
+
```json
|
|
2383
|
+
{
|
|
2384
|
+
"compilerOptions": {
|
|
2385
|
+
"lib": ["ES2022", "DOM"],
|
|
2386
|
+
"module": "ES2022",
|
|
2387
|
+
"target": "ES2022",
|
|
2388
|
+
"moduleResolution": "bundler",
|
|
2389
|
+
"strict": true,
|
|
2390
|
+
"esModuleInterop": true,
|
|
2391
|
+
"skipLibCheck": true,
|
|
2392
|
+
"resolveJsonModule": true,
|
|
2393
|
+
"isolatedModules": true
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
```
|
|
2397
|
+
|
|
2398
|
+
**Note:** This hybrid configuration works for both local development and Versori deployment. It provides proper TypeScript checking while maintaining compatibility with Versori's Deno runtime.
|
|
2399
|
+
|
|
2400
|
+
---
|
|
2401
|
+
|
|
2402
|
+
## 10. Deployment Instructions
|
|
2403
|
+
|
|
2404
|
+
### Deploy to Versori
|
|
2405
|
+
|
|
2406
|
+
```bash
|
|
2407
|
+
# 1. Install dependencies
|
|
2408
|
+
npm install
|
|
2409
|
+
|
|
2410
|
+
# 2. Test locally (if using Versori CLI)
|
|
2411
|
+
npm run dev
|
|
2412
|
+
|
|
2413
|
+
# 3. Deploy to Versori platform
|
|
2414
|
+
npm run deploy
|
|
2415
|
+
```
|
|
2416
|
+
|
|
2417
|
+
### Configure Activation Variables
|
|
2418
|
+
|
|
2419
|
+
In Versori platform settings, configure all variables listed in the Activation Variables section above.
|
|
2420
|
+
|
|
2421
|
+
---
|
|
2422
|
+
|
|
2423
|
+
## 11. Testing
|
|
2424
|
+
|
|
2425
|
+
### Test Scheduled Ingestion
|
|
2426
|
+
|
|
2427
|
+
Upload a test JSON file to SFTP incoming path and wait for the scheduled run.
|
|
2428
|
+
|
|
2429
|
+
**Check logs:**
|
|
2430
|
+
|
|
2431
|
+
```
|
|
2432
|
+
[STEP 1/8] Initializing job tracking
|
|
2433
|
+
[STEP 2/8] Initializing Fluent Commerce client and SFTP
|
|
2434
|
+
[STEP 3/8] Discovering files on SFTP
|
|
2435
|
+
[FILE 1/1] Processing file: products_20250124.json
|
|
2436
|
+
[STEP 4/8] Downloading and parsing: products_20250124.json
|
|
2437
|
+
[STEP 5/8] Transforming 5 products from products_20250124.json
|
|
2438
|
+
[STEP 6/8] Sending 5 events to Fluent Commerce
|
|
2439
|
+
[STEP 7/8] Archiving file: products_20250124.json
|
|
2440
|
+
[STEP 8/8] Completing job and calculating totals
|
|
2441
|
+
```
|
|
2442
|
+
|
|
2443
|
+
### Test Ad hoc Ingestion
|
|
2444
|
+
|
|
2445
|
+
```bash
|
|
2446
|
+
# Process all pending files
|
|
2447
|
+
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2448
|
+
-H "X-API-Key: your-secret-key" \
|
|
2449
|
+
-H "Content-Type: application/json" \
|
|
2450
|
+
-d '{}'
|
|
2451
|
+
|
|
2452
|
+
# Process specific pattern
|
|
2453
|
+
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2454
|
+
-H "X-API-Key: your-secret-key" \
|
|
2455
|
+
-H "Content-Type: application/json" \
|
|
2456
|
+
-d '{
|
|
2457
|
+
"filePattern": "urgent_*.json", }'
|
|
2458
|
+
|
|
2459
|
+
# Force reprocess
|
|
2460
|
+
curl -X POST https://api.versori.com/webhooks/product-ingestion-adhoc \
|
|
2461
|
+
-H "X-API-Key: your-secret-key" \
|
|
2462
|
+
-H "Content-Type: application/json" \
|
|
2463
|
+
-d '{
|
|
2464
|
+
"forceReprocess": true,
|
|
2465
|
+
"filePattern": "products_20250124.json"
|
|
2466
|
+
}'
|
|
2467
|
+
```
|
|
2468
|
+
|
|
2469
|
+
### Test Job Status Query
|
|
2470
|
+
|
|
2471
|
+
```bash
|
|
2472
|
+
curl -X POST https://api.versori.com/webhooks/product-ingestion-job-status \
|
|
2473
|
+
-H "X-API-Key: your-secret-key" \
|
|
2474
|
+
-H "Content-Type: application/json" \
|
|
2475
|
+
-d '{
|
|
2476
|
+
"jobId": "ADHOC_PROD_20251024_183045_abc123"
|
|
2477
|
+
}'
|
|
2478
|
+
```
|
|
2479
|
+
|
|
2480
|
+
---
|
|
2481
|
+
|
|
2482
|
+
## Monitoring
|
|
2483
|
+
|
|
2484
|
+
### Success Response
|
|
2485
|
+
|
|
2486
|
+
```json
|
|
2487
|
+
{
|
|
2488
|
+
"success": true,
|
|
2489
|
+
"filesProcessed": 1,
|
|
2490
|
+
"filesSkipped": 0,
|
|
2491
|
+
"filesFailed": 0,
|
|
2492
|
+
"totalRecords": 50,
|
|
2493
|
+
"eventsSent": 50,
|
|
2494
|
+
"eventsFailed": 0,
|
|
2495
|
+
"results": [
|
|
2496
|
+
{
|
|
2497
|
+
"file": "products_2025-01-22.json",
|
|
2498
|
+
"success": true,
|
|
2499
|
+
"recordsProcessed": 50,
|
|
2500
|
+
"eventsSent": 50,
|
|
2501
|
+
"eventsFailed": 0
|
|
2502
|
+
}
|
|
2503
|
+
],
|
|
2504
|
+
"duration": 12345
|
|
2505
|
+
}
|
|
2506
|
+
```
|
|
2507
|
+
|
|
2508
|
+
### Partial Success Response
|
|
2509
|
+
|
|
2510
|
+
```json
|
|
2511
|
+
{
|
|
2512
|
+
"success": true,
|
|
2513
|
+
"filesProcessed": 1,
|
|
2514
|
+
"filesSkipped": 0,
|
|
2515
|
+
"filesFailed": 0,
|
|
2516
|
+
"totalRecords": 50,
|
|
2517
|
+
"eventsSent": 45,
|
|
2518
|
+
"eventsFailed": 5,
|
|
2519
|
+
"results": [
|
|
2520
|
+
{
|
|
2521
|
+
"file": "products_2025-01-22.json",
|
|
2522
|
+
"success": true,
|
|
2523
|
+
"recordsProcessed": 50,
|
|
2524
|
+
"eventsSent": 45,
|
|
2525
|
+
"eventsFailed": 5,
|
|
2526
|
+
"errors": ["PRD-001: Invalid SKU format", "PRD-002: Missing required field"]
|
|
2527
|
+
}
|
|
2528
|
+
],
|
|
2529
|
+
"duration": 12345
|
|
2530
|
+
}
|
|
2531
|
+
```
|
|
2532
|
+
|
|
2533
|
+
### Error Response
|
|
2534
|
+
|
|
2535
|
+
```json
|
|
2536
|
+
{
|
|
2537
|
+
"success": false,
|
|
2538
|
+
"filesProcessed": 0,
|
|
2539
|
+
"filesFailed": 1,
|
|
2540
|
+
"totalRecords": 0,
|
|
2541
|
+
"eventsSent": 0,
|
|
2542
|
+
"eventsFailed": 0,
|
|
2543
|
+
"results": [
|
|
2544
|
+
{
|
|
2545
|
+
"file": "products_2025-01-22.json",
|
|
2546
|
+
"success": false,
|
|
2547
|
+
"error": "JSON parse error: Invalid structure"
|
|
2548
|
+
}
|
|
2549
|
+
],
|
|
2550
|
+
"duration": 876
|
|
2551
|
+
}
|
|
2552
|
+
```
|
|
2553
|
+
|
|
2554
|
+
### Monitoring Metrics
|
|
2555
|
+
|
|
2556
|
+
Monitor these metrics via Versori logs filtered by `jobId` or `stage`:
|
|
2557
|
+
|
|
2558
|
+
- **Files Processed** - Total files successfully processed
|
|
2559
|
+
- **Events Sent** - Total events sent to Fluent Commerce
|
|
2560
|
+
- **Events Failed** - Events that failed (check rejection reports)
|
|
2561
|
+
- **Processing Duration** - Time taken for complete workflow
|
|
2562
|
+
- **Rate Limiting** - Watch for 429 errors indicating throttling
|
|
2563
|
+
|
|
2564
|
+
Use the status webhook for dashboards and automated monitoring.
|
|
2565
|
+
|
|
2566
|
+
---
|
|
2567
|
+
|
|
2568
|
+
- **No files found:** Check `sftpIncomingPath` and `filePattern` (file names only)
|
|
2569
|
+
- **JSON parse failed:** Move to errors/; validate JSON structure and encoding
|
|
2570
|
+
- **Files skipped (already processed):** Check VersoriFileTracker KV state; use `forceReprocess: true` if needed
|
|
2571
|
+
- **High event failures:** Inspect logs; consider Batch API for very high volumes
|
|
2572
|
+
- **429 throttling:** Add small backoff/delay or use Batch API
|
|
2573
|
+
- **SFTP connection errors:** Verify credentials (connection object), host, port, and permissions
|
|
2574
|
+
- **Connection pool exhausted:** Check connection cleanup in finally blocks
|
|
2575
|
+
|
|
2576
|
+
---
|
|
2577
|
+
|
|
2578
|
+
## 13. Key Takeaways
|
|
2579
|
+
|
|
2580
|
+
- ✅ **Refactored Architecture (v2.0.0)** - 3 service functions for clean separation:
|
|
2581
|
+
- `processFile()`: Download + Parse + Map (NO array normalization)
|
|
2582
|
+
- `sendEvents()`: Loop records, send events with per-record error handling
|
|
2583
|
+
- `writeEventLog()`: Write rejection logs to SFTP with Buffer
|
|
2584
|
+
- ✅ **Per-file Processing** - One file at a time for memory efficiency
|
|
2585
|
+
- ✅ **Native Versori logs** - Use `log` from context (LoggingService removed - use native log)
|
|
2586
|
+
- ✅ **Buffer import** - Always import Buffer: `import { Buffer } from 'node:buffer';`
|
|
2587
|
+
- ✅ **3 workflows** - Scheduled, ad hoc webhook, job status webhook
|
|
2588
|
+
- ✅ **JobTracker** - Track job lifecycle with KV persistence
|
|
2589
|
+
- ✅ **VersoriFileTracker** - KV-based deduplication for SFTP (required)
|
|
2590
|
+
- ✅ **SFTP deduplication** - KV state tracking prevents reprocessing
|
|
2591
|
+
- ✅ **SFTP dispose()** - Always cleanup in finally block
|
|
2592
|
+
- ✅ **SFTP method names** - listFiles, downloadFile, moveFile (not \*Object)
|
|
2593
|
+
- ✅ **Connection credentials** - Use `activation.connections` for Basic Auth
|
|
2594
|
+
- ✅ **Per-record error handling** - Continue on individual failures
|
|
2595
|
+
- ✅ **Externalized mapping** - Use JSON config file for field mappings
|
|
2596
|
+
- ✅ **File-level error handling** - Don't stop on single file failure
|
|
2597
|
+
- ✅ **JSON auto-detection** - Supports standard JSON and JSON Lines (NO normalization)
|
|
2598
|
+
- ✅ **SFTP vs S3** - Different deduplication strategies (KV state vs physical movement)
|
|
2599
|
+
|
|
2600
|
+
---
|
|
2601
|
+
|
|
2602
|
+
[← Back to Versori Event API Templates](../../readme.md) | [Versori Platform Guide →](../../../../../04-REFERENCE/platforms/versori/platforms-versori-readme.md)
|