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