@fluentcommerce/fc-connect-sdk 0.1.54 → 0.1.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/cjs/clients/fluent-client.js +13 -6
- package/dist/cjs/utils/pagination-helpers.js +38 -2
- package/dist/cjs/versori/fluent-versori-client.js +11 -5
- package/dist/esm/clients/fluent-client.js +13 -6
- package/dist/esm/utils/pagination-helpers.js +38 -2
- package/dist/esm/versori/fluent-versori-client.js +11 -5
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/tsconfig.types.tsbuildinfo +1 -1
- package/docs/00-START-HERE/EXPORT-VALIDATION.md +158 -158
- package/docs/00-START-HERE/cli-analyze-source-structure-guide.md +655 -655
- package/docs/00-START-HERE/cli-documentation-index.md +202 -202
- package/docs/00-START-HERE/cli-quick-reference.md +252 -252
- package/docs/00-START-HERE/decision-tree.md +552 -552
- package/docs/00-START-HERE/getting-started.md +1070 -1070
- package/docs/00-START-HERE/mapper-quick-decision-guide.md +235 -235
- package/docs/00-START-HERE/readme.md +237 -237
- package/docs/00-START-HERE/retailerid-configuration.md +404 -404
- package/docs/00-START-HERE/sdk-philosophy.md +794 -794
- package/docs/00-START-HERE/troubleshooting-quick-reference.md +1086 -1086
- package/docs/01-TEMPLATES/faq.md +686 -686
- package/docs/01-TEMPLATES/patterns/pattern-templates-guide.md +68 -68
- package/docs/01-TEMPLATES/patterns/patterns-csv-schema-validation-and-rejection-report.md +233 -233
- package/docs/01-TEMPLATES/patterns/patterns-custom-resolvers.md +407 -407
- package/docs/01-TEMPLATES/patterns/patterns-error-handling-retry.md +511 -511
- package/docs/01-TEMPLATES/patterns/patterns-field-mapping-universal.md +701 -701
- package/docs/01-TEMPLATES/patterns/patterns-large-file-splitting.md +1430 -1430
- package/docs/01-TEMPLATES/patterns/patterns-master-data-etl.md +2399 -2399
- package/docs/01-TEMPLATES/patterns/patterns-pagination-streaming.md +447 -447
- package/docs/01-TEMPLATES/patterns/patterns-state-duplicate-prevention.md +385 -385
- package/docs/01-TEMPLATES/readme.md +957 -957
- package/docs/01-TEMPLATES/standalone/standalone-asn-inbound-processing.md +1209 -1209
- package/docs/01-TEMPLATES/standalone/standalone-graphql-query-export.md +1140 -1140
- package/docs/01-TEMPLATES/standalone/standalone-graphql-to-parquet-partitioned-s3.md +432 -432
- package/docs/01-TEMPLATES/standalone/standalone-multi-channel-inventory-sync.md +1185 -1185
- package/docs/01-TEMPLATES/standalone/standalone-multi-source-aggregation.md +1462 -1462
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-batch-api.md +1390 -1390
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-inventory-to-batch.md +330 -330
- package/docs/01-TEMPLATES/standalone/standalone-scripts-guide.md +87 -87
- package/docs/01-TEMPLATES/standalone/standalone-sftp-xml-graphql.md +1444 -1444
- package/docs/01-TEMPLATES/standalone/standalone-webhook-payload-processing.md +688 -688
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-dropship-order-routing.md +193 -193
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-graphql-parquet-extraction.md +518 -518
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-inter-location-transfers.md +2162 -2162
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-pre-order-allocation.md +2226 -2226
- package/docs/01-TEMPLATES/versori/business-examples/business-scenarios-guide.md +87 -87
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-connection-validation-pattern.md +656 -656
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-dual-workflow-connector.md +835 -835
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-guide.md +108 -108
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-kv-state-management.md +1533 -1533
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-xml-response-patterns.md +1160 -1160
- package/docs/01-TEMPLATES/versori/versori-platform-guide.md +201 -201
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-asn-purchase-order.md +1906 -1906
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-dropship-routing.md +1074 -1074
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-flash-sale-reserve.md +1395 -1395
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-generic-xml-order.md +888 -888
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-payment-gateway-integration.md +2478 -2478
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-rma-returns-comprehensive.md +2240 -2240
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-xml-order-ingestion.md +2029 -2029
- package/docs/01-TEMPLATES/versori/webhooks/webhook-templates-guide.md +140 -140
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/inventory-mapping.json +20 -20
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/products_2025-01-22.csv +11 -11
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/sample-data-guide.md +34 -34
- package/docs/01-TEMPLATES/versori/workflows/_examples/workflow-examples-guide.md +36 -36
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-modes-guide.md +1038 -1038
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-workflows-guide.md +138 -138
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/graphql-extraction-guide.md +63 -63
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-csv.md +2062 -2062
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-xml.md +2294 -2294
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-s3-csv.md +2461 -2461
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-sftp-xml.md +2529 -2529
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-csv.md +2464 -2464
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-json.md +1959 -1959
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-s3-csv.md +1953 -1953
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-sftp-xml.md +2541 -2541
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-s3-json.md +2384 -2384
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-sftp-xml.md +2445 -2445
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-csv.md +2355 -2355
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-json.md +2042 -2042
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-sftp-xml.md +2726 -2726
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/batch-api-guide.md +206 -206
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-cycle-count-reconciliation.md +2030 -2030
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-multi-channel-inventory-sync.md +1882 -1882
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-csv-inventory-batch.md +2827 -2827
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-json-inventory-batch.md +1952 -1952
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-xml-inventory-batch.md +3289 -3289
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-csv-inventory-batch.md +3064 -3064
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-json-inventory-batch.md +3238 -3238
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-xml-inventory-batch.md +2977 -2977
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/event-api-guide.md +321 -321
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-json-order-cancel-event.md +959 -959
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-xml-order-cancel-event.md +1170 -1170
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-csv-product-event.md +2312 -2312
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-json-product-event.md +2999 -2999
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-parquet-product-event.md +2836 -2836
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-xml-product-event.md +2395 -2395
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-csv-product-event.md +2295 -2295
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-json-product-event.md +2602 -2602
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-parquet-product-event.md +2589 -2589
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-xml-product-event.md +3578 -3578
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/graphql-mutations-guide.md +93 -93
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-json-order-update-graphql.md +1260 -1260
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-xml-order-update-graphql.md +1472 -1472
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-control-graphql.md +2417 -2417
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-location-graphql.md +2811 -2811
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-price-graphql.md +2619 -2619
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-json-location-graphql.md +2807 -2807
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-xml-location-graphql.md +2373 -2373
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-control-graphql.md +2740 -2740
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-location-graphql.md +2760 -2760
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-json-location-graphql.md +1710 -1710
- package/docs/01-TEMPLATES/versori/workflows/ingestion/ingestion-workflows-guide.md +136 -136
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/rubix-webhooks-guide.md +520 -520
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-inline.md +1418 -1418
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-universal-mapper.md +1785 -1785
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-order-attribute-update.md +824 -824
- package/docs/01-TEMPLATES/versori/workflows/workflows-overview-guide.md +646 -646
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-batch-archival.md +724 -724
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-job-tracker.md +627 -627
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-partial-batch-recovery.md +561 -561
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-quick-reference.md +367 -367
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-readme.md +407 -407
- package/docs/02-CORE-GUIDES/advanced-services/readme.md +49 -49
- package/docs/02-CORE-GUIDES/api-reference/api-reference-quick-reference.md +548 -548
- package/docs/02-CORE-GUIDES/api-reference/event-api-input-output-reference.md +702 -1171
- package/docs/02-CORE-GUIDES/api-reference/examples/client-initialization.ts +286 -286
- package/docs/02-CORE-GUIDES/api-reference/graphql-error-classification.md +337 -337
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-01-client-api.md +399 -520
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-03-authentication.md +199 -199
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-04-graphql-mapping.md +925 -925
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-05-services.md +1198 -1198
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-06-data-sources.md +1083 -1083
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-07-parsers.md +1097 -1097
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-pagination.md +513 -513
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-types.md +545 -597
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-error-handling.md +527 -527
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-webhook-validation.md +514 -514
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-extraction.md +557 -557
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-utilities.md +412 -412
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-cli-tools.md +423 -423
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-error-handling.md +716 -716
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-analyze-source-structure.md +518 -518
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-partial-responses.md +212 -212
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-testing.md +300 -300
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-13-resolver-builder.md +322 -322
- package/docs/02-CORE-GUIDES/api-reference/readme.md +279 -279
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-quick-reference.md +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-readme.md +277 -277
- package/docs/02-CORE-GUIDES/auto-pagination/examples/auto-pagination-readme.md +178 -178
- package/docs/02-CORE-GUIDES/auto-pagination/examples/common-patterns.ts +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-products.ts +384 -384
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-virtual-positions.ts +308 -308
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-01-foundations.md +470 -470
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-02-quick-start.md +713 -713
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-03-configuration.md +754 -754
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-04-advanced-patterns.md +732 -732
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-05-sdk-integration.md +847 -847
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-06-troubleshooting.md +359 -359
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-07-api-reference.md +462 -462
- package/docs/02-CORE-GUIDES/auto-pagination/readme.md +54 -54
- package/docs/02-CORE-GUIDES/data-sources/data-sources-file-operations-error-handling.md +1487 -1487
- package/docs/02-CORE-GUIDES/data-sources/data-sources-quick-reference.md +836 -836
- package/docs/02-CORE-GUIDES/data-sources/data-sources-readme.md +276 -276
- package/docs/02-CORE-GUIDES/data-sources/data-sources-sftp-credential-access-security.md +553 -553
- package/docs/02-CORE-GUIDES/data-sources/examples/common-patterns.ts +409 -409
- package/docs/02-CORE-GUIDES/data-sources/examples/data-sources-readme.md +178 -178
- package/docs/02-CORE-GUIDES/data-sources/examples/s3-operations.ts +308 -308
- package/docs/02-CORE-GUIDES/data-sources/examples/sftp-operations.ts +371 -371
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-01-foundations.md +735 -735
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-02-s3-operations.md +1302 -1302
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-03-sftp-operations.md +1379 -1379
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-04-file-patterns.md +941 -941
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-05-advanced-topics.md +813 -813
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-06-integration-patterns.md +486 -486
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-07-troubleshooting.md +387 -387
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-08-api-reference.md +417 -417
- package/docs/02-CORE-GUIDES/data-sources/readme.md +77 -77
- package/docs/02-CORE-GUIDES/error-handling-guide.md +936 -936
- package/docs/02-CORE-GUIDES/extraction/examples/02-core-guides-extraction-readme.md +116 -116
- package/docs/02-CORE-GUIDES/extraction/examples/common-patterns.ts +428 -428
- package/docs/02-CORE-GUIDES/extraction/examples/extract-inventory-basic.ts +187 -187
- package/docs/02-CORE-GUIDES/extraction/extraction-quick-reference.md +596 -596
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-01-foundations.md +514 -514
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-02-basic-extraction.md +823 -823
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-03-parquet-processing.md +507 -507
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-04-data-enrichment.md +546 -546
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-05-transformation.md +494 -494
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-export-formats.md +458 -458
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-performance.md +138 -138
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-api-reference.md +148 -148
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-optimization.md +692 -692
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-08-extraction-orchestrator.md +1008 -1008
- package/docs/02-CORE-GUIDES/extraction/readme.md +151 -151
- package/docs/02-CORE-GUIDES/ingestion/examples/_simple-kv-store.ts +40 -40
- package/docs/02-CORE-GUIDES/ingestion/examples/error-recovery.ts +728 -728
- package/docs/02-CORE-GUIDES/ingestion/examples/event-driven.ts +501 -501
- package/docs/02-CORE-GUIDES/ingestion/examples/local-file-ingestion.ts +88 -88
- package/docs/02-CORE-GUIDES/ingestion/examples/parquet-ingestion.ts +117 -117
- package/docs/02-CORE-GUIDES/ingestion/examples/performance-optimized.ts +647 -647
- package/docs/02-CORE-GUIDES/ingestion/examples/s3-csv-ingestion.ts +169 -169
- package/docs/02-CORE-GUIDES/ingestion/examples/sftp-csv-ingestion.ts +134 -134
- package/docs/02-CORE-GUIDES/ingestion/ingestion-quick-reference.md +546 -546
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-01-introduction.md +626 -626
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-02-quick-start.md +658 -658
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-03-data-sources.md +1052 -1052
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-04-field-mapping.md +763 -763
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-05-advanced-parsers.md +676 -676
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-06-batch-api.md +1295 -1295
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-api-reference.md +138 -138
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-state-management.md +1037 -1037
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-08-performance-optimization.md +1349 -1349
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-09-best-practices.md +1893 -1893
- package/docs/02-CORE-GUIDES/ingestion/readme.md +160 -160
- package/docs/02-CORE-GUIDES/logging-guide.md +585 -585
- package/docs/02-CORE-GUIDES/mapping/error-handling-patterns.md +401 -401
- package/docs/02-CORE-GUIDES/mapping/examples/02-core-guides-mapping-readme.md +128 -128
- package/docs/02-CORE-GUIDES/mapping/examples/common-patterns.ts +273 -273
- package/docs/02-CORE-GUIDES/mapping/examples/csv-location-ingestion.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/csv-mapping.ts +242 -242
- package/docs/02-CORE-GUIDES/mapping/examples/graphql-to-parquet-extraction.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/json-mapping.ts +213 -213
- package/docs/02-CORE-GUIDES/mapping/examples/json-product-to-mutation.json +48 -48
- package/docs/02-CORE-GUIDES/mapping/examples/xml-mapping.ts +291 -291
- package/docs/02-CORE-GUIDES/mapping/examples/xml-order-to-mutation.json +45 -45
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-quick-reference.md +463 -463
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-readme.md +227 -227
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-01-introduction.md +222 -222
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-02-quick-start.md +351 -351
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-03-schema-validation.md +569 -569
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-04-mapping-patterns.md +471 -471
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-05-configuration-reference.md +611 -611
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-advanced-xpath.md +148 -148
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-path-syntax.md +464 -464
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-api-reference.md +94 -94
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-array-handling.md +307 -307
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-08-custom-resolvers.md +544 -544
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-09-advanced-patterns.md +427 -427
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-10-hooks-and-variables.md +336 -336
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-11-error-handling.md +488 -488
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-12-arguments-vs-nodes.md +383 -383
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-13-best-practices.md +477 -477
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/readme.md +62 -62
- package/docs/02-CORE-GUIDES/mapping/mapping-format-decision-tree.md +480 -480
- package/docs/02-CORE-GUIDES/mapping/mapping-graphql-alias-batching-guide.md +820 -820
- package/docs/02-CORE-GUIDES/mapping/mapping-javascript-objects.md +2369 -2369
- package/docs/02-CORE-GUIDES/mapping/mapping-mapper-comparison-guide.md +682 -682
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-07-api-reference.md +1327 -1327
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-08-error-handling.md +1142 -1142
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-04-use-cases.md +891 -891
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-helpers-resolvers.md +1126 -1126
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-sdk-resolvers.md +199 -199
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-07-api-reference.md +1319 -1319
- package/docs/02-CORE-GUIDES/mapping/readme.md +178 -178
- package/docs/02-CORE-GUIDES/mapping/resolver-registration.md +410 -410
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/common-patterns.ts +226 -226
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/custom-resolvers.ts +227 -227
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/sdk-resolvers-usage.ts +203 -203
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-readme.md +274 -274
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-api-reference.md +679 -679
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-cookbook.md +826 -826
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-guide.md +1330 -1330
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-helpers-reference.md +1437 -1437
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-parameters-reference.md +553 -553
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-troubleshooting.md +854 -854
- package/docs/02-CORE-GUIDES/mapping/resolvers/readme.md +75 -75
- package/docs/02-CORE-GUIDES/parsers/examples/02-core-guides-parsers-readme.md +161 -161
- package/docs/02-CORE-GUIDES/parsers/examples/csv-parser-examples.ts +110 -110
- package/docs/02-CORE-GUIDES/parsers/examples/json-parser-examples.ts +33 -33
- package/docs/02-CORE-GUIDES/parsers/examples/parquet-parser-examples.ts +47 -47
- package/docs/02-CORE-GUIDES/parsers/examples/xml-parser-examples.ts +38 -38
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-01-foundations.md +355 -355
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-02-csv-parser.md +772 -772
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-03-json-parser.md +789 -789
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-04-xml-parser.md +857 -857
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-05-parquet-parser.md +603 -603
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-integration-patterns.md +702 -702
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-streaming.md +121 -121
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-api-reference.md +89 -89
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-troubleshooting.md +727 -727
- package/docs/02-CORE-GUIDES/parsers/parsers-quick-reference.md +482 -482
- package/docs/02-CORE-GUIDES/parsers/parsers-readme.md +258 -258
- package/docs/02-CORE-GUIDES/parsers/readme.md +65 -65
- package/docs/02-CORE-GUIDES/readme.md +194 -194
- package/docs/02-CORE-GUIDES/webhook-validation/examples/basic-validation.ts +108 -108
- package/docs/02-CORE-GUIDES/webhook-validation/examples/common-patterns.ts +316 -316
- package/docs/02-CORE-GUIDES/webhook-validation/examples/webhook-validation-readme.md +61 -61
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-01-foundations.md +440 -440
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-02-quick-start.md +525 -525
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-03-versori-integration.md +741 -741
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-04-platform-integration.md +629 -629
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-05-configuration.md +535 -535
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-error-handling.md +611 -611
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-troubleshooting.md +124 -124
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-07-api-reference.md +511 -511
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-08-rubix-webhooks.md +590 -590
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-09-rubix-event-vs-http-call.md +432 -432
- package/docs/02-CORE-GUIDES/webhook-validation/readme.md +239 -239
- package/docs/02-CORE-GUIDES/webhook-validation/webhook-validation-quick-reference.md +392 -392
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-quick-reference.md +498 -498
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-readme.md +313 -313
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/common-patterns.ts +612 -612
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/connector-scenarios-readme.md +253 -253
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-01-foundations.md +452 -452
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-02-simple-scenarios.md +681 -681
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-03-intermediate-scenarios.md +637 -637
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-04-advanced-scenarios.md +650 -650
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-05-bidirectional-sync.md +233 -233
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-06-production-patterns.md +442 -442
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-07-reference.md +445 -445
- package/docs/03-PATTERN-GUIDES/connector-scenarios/readme.md +31 -31
- package/docs/03-PATTERN-GUIDES/enterprise-integration-patterns.md +1528 -1528
- package/docs/03-PATTERN-GUIDES/error-handling/comprehensive-error-handling-guide.md +1437 -1437
- package/docs/03-PATTERN-GUIDES/error-handling/error-handling-quick-reference.md +390 -390
- package/docs/03-PATTERN-GUIDES/error-handling/examples/common-patterns.ts +438 -438
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-01-foundations.md +362 -362
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-02-error-types.md +850 -850
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-03-utf8-handling.md +456 -456
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-04-error-scenarios.md +658 -658
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-05-calling-patterns.md +671 -671
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-06-retry-strategies.md +1034 -1034
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-07-monitoring.md +653 -653
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-08-api-reference.md +847 -847
- package/docs/03-PATTERN-GUIDES/error-handling/readme.md +36 -36
- package/docs/03-PATTERN-GUIDES/examples/__tests__/readme.md +40 -40
- package/docs/03-PATTERN-GUIDES/examples/__tests__/resolver-examples.test.js +282 -282
- package/docs/03-PATTERN-GUIDES/examples/test-data/03-pattern-guides-readme.md +110 -110
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-inventory.json +123 -123
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-order.json +171 -171
- package/docs/03-PATTERN-GUIDES/examples/test-data/readme.md +28 -28
- package/docs/03-PATTERN-GUIDES/extraction/extraction-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/extraction/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/file-operations/examples/common-patterns.ts +407 -407
- package/docs/03-PATTERN-GUIDES/file-operations/examples/file-operations-readme.md +142 -142
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-quick-reference.md +462 -462
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-readme.md +379 -379
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-01-foundations.md +430 -430
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-02-quick-start.md +484 -484
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-03-s3-operations.md +507 -507
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-04-sftp-operations.md +963 -963
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-05-streaming-performance.md +503 -503
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-archive-patterns.md +386 -386
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-error-handling.md +117 -117
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-api-reference.md +78 -78
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-testing-troubleshooting.md +567 -567
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-08-api-reference.md +1055 -1055
- package/docs/03-PATTERN-GUIDES/file-operations/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/ingestion/ingestion-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/ingestion/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/batch-processing.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/common-patterns.ts +360 -360
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/delta-sync.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/integration-patterns-readme.md +100 -100
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/real-time-webhook.ts +398 -398
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-quick-reference.md +962 -962
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-readme.md +134 -134
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-01-real-time-processing.md +991 -991
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-02-batch-processing.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-03-delta-sync.md +1108 -1108
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-04-webhook-patterns.md +1181 -1181
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-05-error-handling.md +1061 -1061
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-advanced-integration-services.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-performance.md +109 -109
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-07-api-reference.md +34 -34
- package/docs/03-PATTERN-GUIDES/integration-patterns/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/logging-minimal-mode.md +128 -128
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/common-patterns.ts +380 -380
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/multiple-connections-readme.md +139 -139
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/parallel-root-connections.ts +149 -149
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/real-world-scenarios.ts +405 -405
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-01-foundations.md +378 -378
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-02-quick-start.md +566 -566
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-03-targeting-connections.md +659 -659
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-04-parallel-queries.md +656 -656
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-05-best-practices.md +624 -624
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-api-reference.md +824 -824
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-versori.md +119 -119
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-07-api-reference.md +87 -87
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-quick-reference.md +353 -353
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-readme.md +270 -270
- package/docs/03-PATTERN-GUIDES/multiple-connections/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/pagination/pagination-readme.md +14 -14
- package/docs/03-PATTERN-GUIDES/pagination/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/parquet/examples/common-patterns.ts +180 -180
- package/docs/03-PATTERN-GUIDES/parquet/examples/read-parquet.ts +48 -48
- package/docs/03-PATTERN-GUIDES/parquet/examples/write-parquet.ts +65 -65
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-01-introduction.md +393 -393
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-02-quick-start.md +572 -572
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-03-reading-parquet.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-04-writing-parquet.md +554 -554
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-05-graphql-extraction.md +405 -405
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-performance.md +104 -104
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-s3-integration.md +511 -511
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-api-reference.md +90 -90
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-performance-optimization.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-08-best-practices.md +712 -712
- package/docs/03-PATTERN-GUIDES/parquet/parquet-quick-reference.md +683 -683
- package/docs/03-PATTERN-GUIDES/parquet/parquet-readme.md +248 -248
- package/docs/03-PATTERN-GUIDES/parquet/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/parsers/parsers-readme.md +12 -12
- package/docs/03-PATTERN-GUIDES/parsers/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/readme.md +159 -159
- package/docs/03-PATTERN-GUIDES/webhooks/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/webhooks/webhooks-readme.md +8 -8
- package/docs/04-REFERENCE/architecture/architecture-01-overview.md +427 -427
- package/docs/04-REFERENCE/architecture/architecture-02-client-architecture.md +424 -424
- package/docs/04-REFERENCE/architecture/architecture-03-data-flow.md +690 -690
- package/docs/04-REFERENCE/architecture/architecture-04-service-layer.md +834 -834
- package/docs/04-REFERENCE/architecture/architecture-05-integration-architecture.md +655 -655
- package/docs/04-REFERENCE/architecture/architecture-06-state-management.md +653 -653
- package/docs/04-REFERENCE/architecture/architecture-adding-new-data-sources.md +686 -686
- package/docs/04-REFERENCE/architecture/readme.md +279 -279
- package/docs/04-REFERENCE/platforms/deno/readme.md +117 -117
- package/docs/04-REFERENCE/platforms/nodejs/readme.md +146 -146
- package/docs/04-REFERENCE/platforms/readme.md +135 -135
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-01-introduction.md +398 -398
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-02-quick-start.md +560 -560
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-03-authentication.md +757 -757
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-04-workflows.md +2476 -2476
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-05-connections.md +1167 -1167
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-kv-storage.md +990 -990
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-state-management.md +121 -121
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-api-reference.md +68 -68
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-deployment.md +731 -731
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-08-best-practices.md +1111 -1111
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-09-signature-reference.md +766 -766
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-readme.md +299 -299
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-s3-sftp-configuration-guide.md +1425 -1425
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-api-key-security.md +816 -816
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-connection-security.md +681 -681
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-workflow-task-types.md +708 -708
- package/docs/04-REFERENCE/platforms/versori/readme.md +108 -108
- package/docs/04-REFERENCE/readme.md +148 -148
- package/docs/04-REFERENCE/resolver-signature/examples/advanced-resolvers.ts +482 -482
- package/docs/04-REFERENCE/resolver-signature/examples/async-resolvers.ts +496 -496
- package/docs/04-REFERENCE/resolver-signature/examples/basic-resolvers.ts +343 -343
- package/docs/04-REFERENCE/resolver-signature/examples/resolver-signature-readme.md +188 -188
- package/docs/04-REFERENCE/resolver-signature/examples/testing-resolvers.ts +463 -463
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-01-foundations.md +286 -286
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-02-parameter-reference.md +643 -643
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-03-basic-examples.md +521 -521
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-04-advanced-patterns.md +739 -739
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-05-sdk-resolvers.md +531 -531
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-migration-guide.md +650 -650
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-testing.md +125 -125
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-07-api-reference.md +794 -794
- package/docs/04-REFERENCE/resolver-signature/readme.md +64 -64
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-quick-reference.md +270 -270
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-readme.md +351 -351
- package/docs/04-REFERENCE/schema/fluent-commerce-schema.json +764 -764
- package/docs/04-REFERENCE/schema/readme.md +141 -141
- package/docs/04-REFERENCE/testing/examples/04-reference-testing-readme.md +158 -158
- package/docs/04-REFERENCE/testing/examples/fluent-testing.ts +62 -62
- package/docs/04-REFERENCE/testing/examples/health-check.ts +155 -155
- package/docs/04-REFERENCE/testing/examples/integration-test.ts +119 -119
- package/docs/04-REFERENCE/testing/examples/performance-test.ts +183 -183
- package/docs/04-REFERENCE/testing/examples/s3-testing.ts +127 -127
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-01-foundations.md +267 -267
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-02-s3-testing.md +599 -599
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-03-fluent-testing.md +589 -589
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-04-integration-testing.md +699 -699
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-05-debugging.md +478 -478
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-cicd-integration.md +463 -463
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-preflight-validation.md +131 -131
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-best-practices.md +499 -499
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-coverage-ci.md +165 -165
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-08-api-reference.md +634 -634
- package/docs/04-REFERENCE/testing/readme.md +86 -86
- package/docs/04-REFERENCE/testing/testing-quick-reference.md +667 -667
- package/docs/04-REFERENCE/testing/testing-readme.md +286 -286
- package/docs/04-REFERENCE/troubleshooting/readme.md +144 -144
- package/docs/04-REFERENCE/troubleshooting/troubleshooting-deno-sftp-compatibility.md +392 -392
- package/docs/template-loading-matrix.md +242 -242
- package/package.json +5 -3
- package/docs/02-CORE-GUIDES/api-reference/cli-profile-integration.md +0 -377
|
@@ -1,2417 +1,2417 @@
|
|
|
1
|
-
---
|
|
2
|
-
template_id: tpl-ingest-s3-csv-to-control-graphql
|
|
3
|
-
canonical_filename: template-ingestion-s3-csv-control-graphql.md
|
|
4
|
-
version: 2.0.0
|
|
5
|
-
sdk_version: ^0.1.39
|
|
6
|
-
runtime: versori
|
|
7
|
-
direction: ingestion
|
|
8
|
-
source: s3-csv
|
|
9
|
-
destination: fluent-graphql
|
|
10
|
-
entity: control
|
|
11
|
-
format: csv
|
|
12
|
-
logging: versori
|
|
13
|
-
status: stable
|
|
14
|
-
compliance: gold-standard
|
|
15
|
-
features:
|
|
16
|
-
- graphql-mutation-mapper
|
|
17
|
-
- memory-management
|
|
18
|
-
- enhanced-logging
|
|
19
|
-
- attribute-transformation
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
# Template: Ingestion - S3 CSV to Control GraphQL
|
|
23
|
-
|
|
24
|
-
**Template Version:** 2.0.0
|
|
25
|
-
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
26
|
-
**Last Updated:** 2025-01-24
|
|
27
|
-
|
|
28
|
-
**🆕 Version 2.0.0 Enhancements:**
|
|
29
|
-
- ✅ **GraphQL Mutation Mapper** - Direct field mapping to mutation variables
|
|
30
|
-
- ✅ **Memory Management** - Clear large arrays after processing batches
|
|
31
|
-
- ✅ **Enhanced Logging** - Track mutation execution with emoji indicators
|
|
32
|
-
- ✅ **Attribute Transformation** - Handle complex nested data structures
|
|
33
|
-
|
|
34
|
-
## STEP 1: Read This Template As-Is (Do Not Execute Yet)
|
|
35
|
-
|
|
36
|
-
**IMPORTANT:** This is a template that requires customization before use.
|
|
37
|
-
|
|
38
|
-
**Purpose:** This template shows you how to build a scheduled Versori workflow that:
|
|
39
|
-
- Reads Control CSV files from S3
|
|
40
|
-
- Parses CSV records and validates them
|
|
41
|
-
- Creates/updates Fluent Commerce controls via GraphQL mutations (createControl, updateControl)
|
|
42
|
-
- Archives processed files and prevents duplicates via KV state
|
|
43
|
-
- Handles rate limiting and retries
|
|
44
|
-
- Tracks job status via JobTracker webhook
|
|
45
|
-
|
|
46
|
-
**What You'll Learn:**
|
|
47
|
-
- S3 file discovery with prefix filtering and archiving
|
|
48
|
-
- CSV parsing with CSVParserService
|
|
49
|
-
- Control entity structure (EXCLUSION vs QUANTITY_BUFFER types)
|
|
50
|
-
- Direct GraphQL mutations (NOT Batch API)
|
|
51
|
-
- Rate limiting (25-50 mutations at a time to prevent API throttling)
|
|
52
|
-
- Custom control ref building (catalog + type + entity combinations)
|
|
53
|
-
- Schema validation using CLI commands
|
|
54
|
-
- Error handling and partial failure recovery
|
|
55
|
-
- Versori KV state management for duplicate prevention
|
|
56
|
-
- JobTracker integration with job-status webhook
|
|
57
|
-
|
|
58
|
-
**Before You Begin:**
|
|
59
|
-
1. Read through the entire template to understand the workflow
|
|
60
|
-
2. Identify which parts need customization (marked with activation variables)
|
|
61
|
-
3. Have your Fluent API credentials ready
|
|
62
|
-
4. Have your S3 bucket details ready
|
|
63
|
-
5. Understand Control entity structure in Fluent (see Sample CSV section)
|
|
64
|
-
6. Review schema validation CLI commands (see Schema Validation section)
|
|
65
|
-
|
|
66
|
-
## STEP 2: AI Agent Customization Instructions
|
|
67
|
-
|
|
68
|
-
**If you are an AI agent helping a developer customize this template, follow these steps:**
|
|
69
|
-
|
|
70
|
-
### 1. Gather Requirements
|
|
71
|
-
|
|
72
|
-
Ask the developer:
|
|
73
|
-
- **S3 Configuration:**
|
|
74
|
-
- Bucket name and region
|
|
75
|
-
- Access credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
76
|
-
- Prefix for control files (e.g., `controls/` or `incoming/controls/`)
|
|
77
|
-
- Archive prefix (e.g., `processed/` or `archive/`)
|
|
78
|
-
- Error prefix (e.g., `errors/` or `failed/`)
|
|
79
|
-
|
|
80
|
-
- **CSV Structure:**
|
|
81
|
-
- Does CSV have pre-built control refs OR component fields?
|
|
82
|
-
- Which control types are used? (EXCLUSION, QUANTITY_BUFFER, THRESHOLD, EXPIRY)
|
|
83
|
-
- CSV column names and sample data
|
|
84
|
-
- Any custom validation rules beyond schema requirements
|
|
85
|
-
|
|
86
|
-
- **Processing Rules:**
|
|
87
|
-
- Cron schedule (e.g., `0 2 * * *` = daily at 2 AM)
|
|
88
|
-
- Max files to process per run (default: 10)
|
|
89
|
-
- Rate limiting (mutations per second, default: 10)
|
|
90
|
-
- File naming pattern (default: `.csv`)
|
|
91
|
-
- Enable archival? (default: true)
|
|
92
|
-
- Enable file tracking? (default: true) - prevents duplicate processing
|
|
93
|
-
- Validate S3 connection on startup? (default: true)
|
|
94
|
-
|
|
95
|
-
- **Fluent Configuration:**
|
|
96
|
-
- GraphQL endpoint URL (usually `https://api.fluentcommerce.com/graphql`)
|
|
97
|
-
- OAuth2 client credentials (will be in Versori connection)
|
|
98
|
-
- ⚠️ NOTE: retailerId is NOT needed for GraphQL mutations (only for Job/Event API)
|
|
99
|
-
|
|
100
|
-
### 2. Schema Validation (CRITICAL - Run First!)
|
|
101
|
-
|
|
102
|
-
**Before customizing any code, validate the Control schema:**
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
# 1. Introspect current Fluent GraphQL schema
|
|
106
|
-
npx @fluentcommerce/fc-connect-sdk introspect-schema \
|
|
107
|
-
--url https://api.fluentcommerce.com/graphql \
|
|
108
|
-
--client-id YOUR_CLIENT_ID \
|
|
109
|
-
--client-secret YOUR_CLIENT_SECRET \
|
|
110
|
-
--output fluent-schema.json
|
|
111
|
-
|
|
112
|
-
# 2. Create test mutation file for Control
|
|
113
|
-
cat > control-test-mutation.graphql << 'EOF'
|
|
114
|
-
mutation CreateControl(
|
|
115
|
-
$controlRef: String!
|
|
116
|
-
$catalogRef: String!
|
|
117
|
-
$controlType: String!
|
|
118
|
-
$executionOrder: Int!
|
|
119
|
-
$value: Json!
|
|
120
|
-
) {
|
|
121
|
-
createControl(input: {
|
|
122
|
-
type: $controlType
|
|
123
|
-
ref: $controlRef
|
|
124
|
-
name: $controlRef
|
|
125
|
-
values: { name: "CONTROL_VALUE", type: "INTEGER", value: $value }
|
|
126
|
-
controlGroup: { ref: $catalogRef }
|
|
127
|
-
executionOrder: $executionOrder
|
|
128
|
-
}) {
|
|
129
|
-
ref
|
|
130
|
-
type
|
|
131
|
-
status
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
EOF
|
|
135
|
-
|
|
136
|
-
# 3. Validate mutation against schema
|
|
137
|
-
npx @fluentcommerce/fc-connect-sdk validate-schema \
|
|
138
|
-
--mapping control-test-mutation.graphql \
|
|
139
|
-
--schema fluent-schema.json
|
|
140
|
-
|
|
141
|
-
# 4. Analyze field coverage
|
|
142
|
-
npx @fluentcommerce/fc-connect-sdk analyze-coverage \
|
|
143
|
-
--mapping control-test-mutation.graphql \
|
|
144
|
-
--schema fluent-schema.json
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
**Verify these Control fields exist in schema:**
|
|
148
|
-
- `ref` (String!, required) - Unique control reference
|
|
149
|
-
- `type` (String!, required) - Control type: EXCLUSION, QUANTITY_BUFFER, THRESHOLD, EXPIRY
|
|
150
|
-
- `executionOrder` (Int!, required) - Priority: 1-999 (lower = higher priority)
|
|
151
|
-
- `values` (Json!, required) - Control value object with name, type, value
|
|
152
|
-
- `status` (String) - ACTIVE or INACTIVE
|
|
153
|
-
- `controlGroup` (reference) - For EXCLUSION controls (uses catalogRef)
|
|
154
|
-
- `virtualCatalogue` (reference) - For QUANTITY_BUFFER controls (uses catalogRef)
|
|
155
|
-
|
|
156
|
-
### 3. Customize CSV Mapping
|
|
157
|
-
|
|
158
|
-
**Update the `mappingConfig` in the template:**
|
|
159
|
-
|
|
160
|
-
```typescript
|
|
161
|
-
// ✅ CORRECT: GraphQLMutationMapper configuration structure
|
|
162
|
-
// File: src/config/control-mapping.json
|
|
163
|
-
{
|
|
164
|
-
"mutation": "createControl",
|
|
165
|
-
"arguments": {
|
|
166
|
-
"input": {
|
|
167
|
-
"type": { "source": "controlType", "required": true, "resolver": "uppercase" },
|
|
168
|
-
"ref": { "resolver": "custom.buildOrUseControlRef", "required": true },
|
|
169
|
-
"name": { "resolver": "custom.buildOrUseControlRef" },
|
|
170
|
-
"values": {
|
|
171
|
-
"value": [
|
|
172
|
-
{
|
|
173
|
-
"name": { "value": "CONTROL_VALUE" },
|
|
174
|
-
"type": { "value": "INTEGER" },
|
|
175
|
-
"value": { "source": "value", "required": true, "resolver": "parseInt" }
|
|
176
|
-
}
|
|
177
|
-
]
|
|
178
|
-
},
|
|
179
|
-
"executionOrder": { "source": "executionOrder", "required": true, "resolver": "parseInt" },
|
|
180
|
-
"controlGroup": {
|
|
181
|
-
"ref": { "resolver": "custom.resolveCatalogRef", "required": true }
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
**Save mapping to config file:**
|
|
188
|
-
```bash
|
|
189
|
-
mkdir -p config
|
|
190
|
-
cat > config/control.import.csv.json << 'EOF'
|
|
191
|
-
{
|
|
192
|
-
"version": "2.0.0",
|
|
193
|
-
"description": "CSV control to Fluent Commerce GraphQL mapping",
|
|
194
|
-
"fields": {
|
|
195
|
-
"controlRef": { "resolver": "custom.buildOrUseControlRef", "required": true },
|
|
196
|
-
"catalogRef": { "resolver": "custom.resolveCatalogRef", "required": true },
|
|
197
|
-
"controlType": { "source": "controlType", "required": true, "resolver": "sdk.uppercase" },
|
|
198
|
-
"executionOrder": { "source": "executionOrder", "required": true, "resolver": "sdk.parseInt" },
|
|
199
|
-
"value": { "source": "value", "required": true, "resolver": "sdk.parseInt" },
|
|
200
|
-
"status": { "source": "status", "resolver": "sdk.uppercase" }
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
EOF
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
### 4. Control Ref Building Rules
|
|
207
|
-
|
|
208
|
-
**EXCLUSION controls (catalog + type + product/category):**
|
|
209
|
-
```typescript
|
|
210
|
-
// Pattern: {controlGroupRef}:EXCLUSION:{productRef|categoryRef}
|
|
211
|
-
// Examples:
|
|
212
|
-
CG-RETAIL:EXCLUSION:CAT-SEASONAL // Category exclusion
|
|
213
|
-
CG-RETAIL:EXCLUSION:PROD-12345 // Product exclusion
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
**QUANTITY_BUFFER controls (catalog + type + product/category/location):**
|
|
217
|
-
```typescript
|
|
218
|
-
// Pattern: {virtualCatalogueRef}:QUANTITY_BUFFER:{productRef}[:{locationRef}]
|
|
219
|
-
// Examples:
|
|
220
|
-
VC-RETAIL:QUANTITY_BUFFER:PROD-98765:LOC-001 // Product at location
|
|
221
|
-
VC-RETAIL:QUANTITY_BUFFER:PROD-11111 // Product (all locations)
|
|
222
|
-
VC-RETAIL:QUANTITY_BUFFER:CAT-ELECTRONICS // Category
|
|
223
|
-
VC-RETAIL:QUANTITY_BUFFER:LOC-002 // Location (all products)
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
**Validation rules:**
|
|
227
|
-
- EXCLUSION must have `controlGroupRef` + (`productRef` OR `categoryRef`)
|
|
228
|
-
- EXCLUSION must have `value=0` (always zero)
|
|
229
|
-
- QUANTITY_BUFFER must have `virtualCatalogueRef` + (`productRef` OR `categoryRef` OR `locationRef`)
|
|
230
|
-
- QUANTITY_BUFFER must have `value >= 0`
|
|
231
|
-
- `executionOrder` must be 1-999 (lower = higher priority)
|
|
232
|
-
- `status` must be ACTIVE or INACTIVE (defaults to ACTIVE if not provided)
|
|
233
|
-
|
|
234
|
-
### 5. Rate Limiting Configuration
|
|
235
|
-
|
|
236
|
-
**Why rate limiting is critical:**
|
|
237
|
-
- Fluent GraphQL API has rate limits
|
|
238
|
-
- Direct mutations (NOT Batch API) hit GraphQL endpoint directly
|
|
239
|
-
- Recommended: 10-25 mutations per second
|
|
240
|
-
- Higher rates may cause 429 (Too Many Requests) errors
|
|
241
|
-
|
|
242
|
-
**Configure in activation variables:**
|
|
243
|
-
```bash
|
|
244
|
-
# Conservative (10 mutations/sec = 100ms delay between mutations)
|
|
245
|
-
mutationRateLimit=10
|
|
246
|
-
|
|
247
|
-
# Aggressive (25 mutations/sec = 40ms delay)
|
|
248
|
-
mutationRateLimit=25
|
|
249
|
-
|
|
250
|
-
# Very aggressive (50 mutations/sec = 20ms delay) - use with caution
|
|
251
|
-
mutationRateLimit=50
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
**Implementation in template:**
|
|
255
|
-
```typescript
|
|
256
|
-
const rateLimit = parseInt(ctx.activation?.getVariable('mutationRateLimit') || '10', 10);
|
|
257
|
-
const delayMs = rateLimit > 0 ? Math.floor(1000 / rateLimit) : 0;
|
|
258
|
-
|
|
259
|
-
// Apply delay after each mutation
|
|
260
|
-
await rateLimitedMutation(
|
|
261
|
-
() => client.graphql({ query: createMutation, variables }),
|
|
262
|
-
delayMs
|
|
263
|
-
);
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
### 6. NO Batch API Guidance
|
|
267
|
-
|
|
268
|
-
**This template uses DIRECT GraphQL mutations, NOT the Batch API:**
|
|
269
|
-
|
|
270
|
-
**Why Direct Mutations:**
|
|
271
|
-
- Controls are typically small volume (100s, not 1000s)
|
|
272
|
-
- Need immediate feedback on create/update success
|
|
273
|
-
- Simpler error handling (per-control vs per-batch)
|
|
274
|
-
- No BPP (Batch Pre-Processing) needed
|
|
275
|
-
|
|
276
|
-
**What This Means:**
|
|
277
|
-
- Each control = 1 GraphQL mutation (createControl or updateControl)
|
|
278
|
-
- Rate limiting is CRITICAL to prevent API throttling
|
|
279
|
-
- No job/batch polling - mutations succeed or fail immediately
|
|
280
|
-
- Errors are handled per-control, not per-batch
|
|
281
|
-
|
|
282
|
-
**DO NOT use Batch API methods:**
|
|
283
|
-
```typescript
|
|
284
|
-
// ❌ WRONG - Don't use these for controls
|
|
285
|
-
await client.createJob({ ... });
|
|
286
|
-
await client.sendBatch(jobId, { ... });
|
|
287
|
-
await client.getBatchStatus(jobId, batchId);
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
**✅ CORRECT - Use direct mutations:**
|
|
291
|
-
```typescript
|
|
292
|
-
// Check if exists
|
|
293
|
-
const checkResult = await client.graphql({
|
|
294
|
-
query: checkQuery,
|
|
295
|
-
variables: { ref: control.controlRef }
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
// Create or update
|
|
299
|
-
if (exists) {
|
|
300
|
-
await client.graphql({ query: updateMutation, variables });
|
|
301
|
-
} else {
|
|
302
|
-
await client.graphql({ query: createMutation, variables });
|
|
303
|
-
}
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
### 7. JobTracker Integration
|
|
307
|
-
|
|
308
|
-
**This template includes JobTracker with job-status webhook:**
|
|
309
|
-
|
|
310
|
-
```typescript
|
|
311
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
312
|
-
|
|
313
|
-
// Start job
|
|
314
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
315
|
-
await tracker.startJob(jobId, { mode: 'scheduled' });
|
|
316
|
-
|
|
317
|
-
// Update progress (optional)
|
|
318
|
-
await tracker.updateJob(jobId, {
|
|
319
|
-
processed: results.processed,
|
|
320
|
-
controlsCreated: results.controlsCreated
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// Complete or fail
|
|
324
|
-
if (success) {
|
|
325
|
-
await tracker.completeJob(jobId, { result });
|
|
326
|
-
} else {
|
|
327
|
-
await tracker.failJob(jobId, { error: e?.message });
|
|
328
|
-
}
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
**Query job status via webhook:**
|
|
332
|
-
```bash
|
|
333
|
-
curl -X POST https://your-workspace.versori.run/control-csv-job-status \
|
|
334
|
-
-H "x-api-key: YOUR_WEBHOOK_API_KEY" \
|
|
335
|
-
-H "Content-Type: application/json" \
|
|
336
|
-
-d '{"jobId": "control-csv-1234567890"}'
|
|
337
|
-
```
|
|
338
|
-
|
|
339
|
-
### 8. Testing Checklist
|
|
340
|
-
|
|
341
|
-
Before deploying to production:
|
|
342
|
-
|
|
343
|
-
- [ ] Schema validation passed (introspect-schema, validate-schema)
|
|
344
|
-
- [ ] CSV mapping tested with sample data
|
|
345
|
-
- [ ] Control ref building logic verified (EXCLUSION vs QUANTITY_BUFFER)
|
|
346
|
-
- [ ] Rate limiting configured appropriately
|
|
347
|
-
- [ ] S3 credentials validated
|
|
348
|
-
- [ ] Activation variables configured in Versori
|
|
349
|
-
- [ ] Test file uploaded to S3
|
|
350
|
-
- [ ] Manual webhook trigger tested
|
|
351
|
-
- [ ] File archival working (processed and error directories)
|
|
352
|
-
- [ ] JobTracker status webhook tested
|
|
353
|
-
- [ ] Error handling tested with malformed CSV
|
|
354
|
-
- [ ] Duplicate prevention working via KV state
|
|
355
|
-
|
|
356
|
-
### 9. Common Customizations
|
|
357
|
-
|
|
358
|
-
**Change cron schedule:**
|
|
359
|
-
```typescript
|
|
360
|
-
// Daily at 2 AM
|
|
361
|
-
export const scheduledControlSync = schedule('control-csv-scheduled', '0 2 * * *')
|
|
362
|
-
|
|
363
|
-
// Every 6 hours
|
|
364
|
-
export const scheduledControlSync = schedule('control-csv-scheduled', '0 */6 * * *')
|
|
365
|
-
|
|
366
|
-
// Hourly
|
|
367
|
-
export const scheduledControlSync = schedule('control-csv-scheduled', '0 * * * *')
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
**Add custom validation:**
|
|
371
|
-
```typescript
|
|
372
|
-
// In ControlSchemaValidator class
|
|
373
|
-
validateCustomRules(control: any): { valid: boolean; errors: string[] } {
|
|
374
|
-
const errors: string[] = [];
|
|
375
|
-
|
|
376
|
-
// Example: Require description for all controls
|
|
377
|
-
if (!control.description?.trim()) {
|
|
378
|
-
errors.push('description is required');
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Example: Value range validation for QUANTITY_BUFFER
|
|
382
|
-
if (control.controlType === 'QUANTITY_BUFFER' && control.value > 1000) {
|
|
383
|
-
errors.push('QUANTITY_BUFFER value cannot exceed 1000');
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return { valid: errors.length === 0, errors };
|
|
387
|
-
}
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
**Change file pattern:**
|
|
391
|
-
```typescript
|
|
392
|
-
// Only process files starting with "control-"
|
|
393
|
-
const filePattern = 'control-.csv';
|
|
394
|
-
|
|
395
|
-
// Process multiple extensions
|
|
396
|
-
const filePattern = '.csv|.txt';
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
### 10. Deployment Steps
|
|
400
|
-
|
|
401
|
-
```bash
|
|
402
|
-
# 1. Install dependencies
|
|
403
|
-
npm install @fluentcommerce/fc-connect-sdk@^0.1.39 @versori/run
|
|
404
|
-
|
|
405
|
-
# 2. Configure activation variables in Versori
|
|
406
|
-
# (See Activation Variables section in template)
|
|
407
|
-
|
|
408
|
-
# 3. Deploy
|
|
409
|
-
npm run deploy
|
|
410
|
-
|
|
411
|
-
# 4. Test manual trigger
|
|
412
|
-
curl -X POST https://your-workspace.versori.run/control-csv-adhoc \
|
|
413
|
-
-H "x-api-key: YOUR_WEBHOOK_API_KEY"
|
|
414
|
-
|
|
415
|
-
# 5. Monitor logs
|
|
416
|
-
npm run logs
|
|
417
|
-
|
|
418
|
-
# 6. Check job status
|
|
419
|
-
curl -X POST https://your-workspace.versori.run/control-csv-job-status \
|
|
420
|
-
-H "x-api-key: YOUR_WEBHOOK_API_KEY" \
|
|
421
|
-
-d '{"jobId": "control-csv-1234567890"}'
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
---
|
|
425
|
-
|
|
426
|
-
# Template: Ingestion - S3 CSV to Control GraphQL
|
|
427
|
-
|
|
428
|
-
**FC Connect SDK Use Case Guide**
|
|
429
|
-
|
|
430
|
-
> **SDK**: [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk)
|
|
431
|
-
> **Version**: Use latest - `npm install @fluentcommerce/fc-connect-sdk@^0.1.39`
|
|
432
|
-
|
|
433
|
-
**Context**: Scheduled Versori workflow that reads control CSV files from S3 and creates/updates Fluent Commerce controls via GraphQL mutations
|
|
434
|
-
|
|
435
|
-
**Complexity**: Medium-High
|
|
436
|
-
|
|
437
|
-
**Runtime**: Versori Platform (Scheduled)
|
|
438
|
-
|
|
439
|
-
**Estimated Lines**: ~700 lines
|
|
440
|
-
|
|
441
|
-
## What You'll Build
|
|
442
|
-
|
|
443
|
-
- Versori scheduled workflow (cron trigger)
|
|
444
|
-
- S3 file listing and download with retry logic
|
|
445
|
-
- CSV parsing with validation
|
|
446
|
-
- UniversalMapper with custom control ref building
|
|
447
|
-
- GraphQL mutations for control upserts with rate limiting
|
|
448
|
-
- Versori KV state management (duplicate prevention)
|
|
449
|
-
- File archival after processing
|
|
450
|
-
- Schema validation for control-specific rules
|
|
451
|
-
|
|
452
|
-
## Versori Workflows Structure
|
|
453
|
-
|
|
454
|
-
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
455
|
-
|
|
456
|
-
**Trigger Types:**
|
|
457
|
-
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
458
|
-
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
459
|
-
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
460
|
-
|
|
461
|
-
**Execution Steps (chained to triggers):**
|
|
462
|
-
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
463
|
-
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
464
|
-
|
|
465
|
-
### Recommended Project Structure
|
|
466
|
-
|
|
467
|
-
```
|
|
468
|
-
s3-csv-control-graphql/
|
|
469
|
-
├── index.ts # Entry point - exports all workflows
|
|
470
|
-
└── src/
|
|
471
|
-
├── workflows/
|
|
472
|
-
│ ├── scheduled/
|
|
473
|
-
│ │ └── daily-control-sync.ts # Scheduled: Daily control sync
|
|
474
|
-
│ │
|
|
475
|
-
│ └── webhook/
|
|
476
|
-
│ ├── adhoc-control-sync.ts # Webhook: Manual trigger
|
|
477
|
-
│ └── job-status-check.ts # Webhook: Status query
|
|
478
|
-
│
|
|
479
|
-
├── services/
|
|
480
|
-
│ └── control-sync.service.ts # Shared orchestration logic (reusable)
|
|
481
|
-
│
|
|
482
|
-
└── config/
|
|
483
|
-
└── control-mapping.json # GraphQL mapping config
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
---
|
|
487
|
-
|
|
488
|
-
## Workflow Files
|
|
489
|
-
|
|
490
|
-
### 1. Scheduled Workflows (`src/workflows/scheduled/`)
|
|
491
|
-
|
|
492
|
-
All time-based triggers that run automatically on cron schedules.
|
|
493
|
-
|
|
494
|
-
#### `src/workflows/scheduled/daily-control-sync.ts`
|
|
495
|
-
|
|
496
|
-
**Purpose**: Automatic daily control sync
|
|
497
|
-
**Trigger**: Cron schedule (`0 2 * * *`)
|
|
498
|
-
**Exposed as Endpoint**: ❌ NO - Runs automatically
|
|
499
|
-
|
|
500
|
-
```typescript
|
|
501
|
-
import { schedule, http } from '@versori/run';
|
|
502
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
503
|
-
import { executeControlSync } from '../../services/control-sync.service';
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Scheduled Workflow: Daily Control Sync
|
|
507
|
-
*
|
|
508
|
-
* Runs automatically daily at 2 AM UTC
|
|
509
|
-
* NOT exposed as HTTP endpoint - Versori executes on schedule
|
|
510
|
-
*
|
|
511
|
-
* Uses shared service: control-sync.service.ts
|
|
512
|
-
*/
|
|
513
|
-
export const dailyControlSync = schedule(
|
|
514
|
-
'control-sync-scheduled',
|
|
515
|
-
'0 2 * * *' // Daily at 2 AM UTC
|
|
516
|
-
).then(
|
|
517
|
-
http('run-control-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
518
|
-
const { log, openKv } = ctx;
|
|
519
|
-
const executionStartTime = Date.now();
|
|
520
|
-
const jobId = `control-sync-${executionStartTime}`;
|
|
521
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
522
|
-
|
|
523
|
-
await tracker.createJob(jobId, {
|
|
524
|
-
triggeredBy: 'schedule',
|
|
525
|
-
stage: 'initialization',
|
|
526
|
-
startTime: executionStartTime,
|
|
527
|
-
});
|
|
528
|
-
await tracker.updateJob(jobId, { status: 'processing' });
|
|
529
|
-
|
|
530
|
-
try {
|
|
531
|
-
const result = await executeControlSync(ctx, jobId, tracker);
|
|
532
|
-
await tracker.markCompleted(jobId, result);
|
|
533
|
-
return { success: true, jobId, ...result };
|
|
534
|
-
} catch (e: any) {
|
|
535
|
-
await tracker.markFailed(jobId, e);
|
|
536
|
-
return { success: false, jobId, error: e?.message };
|
|
537
|
-
}
|
|
538
|
-
})
|
|
539
|
-
);
|
|
540
|
-
```
|
|
541
|
-
|
|
542
|
-
---
|
|
543
|
-
|
|
544
|
-
### 2. Webhook Workflows (`src/workflows/webhook/`)
|
|
545
|
-
|
|
546
|
-
All HTTP-based triggers that create webhook endpoints.
|
|
547
|
-
|
|
548
|
-
#### `src/workflows/webhook/adhoc-control-sync.ts`
|
|
549
|
-
|
|
550
|
-
**Purpose**: Manual control sync trigger (on-demand)
|
|
551
|
-
**Trigger**: HTTP POST
|
|
552
|
-
**Endpoint**: `POST https://{workspace}.versori.run/control-sync-adhoc`
|
|
553
|
-
**Use Cases**: Testing, priority processing, ad-hoc runs
|
|
554
|
-
|
|
555
|
-
```typescript
|
|
556
|
-
import { webhook, http } from '@versori/run';
|
|
557
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
558
|
-
import { executeControlSync } from '../../services/control-sync.service';
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
* Webhook: Manual Control Sync Trigger
|
|
562
|
-
*
|
|
563
|
-
* Endpoint: POST https://{workspace}.versori.run/control-sync-adhoc
|
|
564
|
-
* Request body (optional): { filePattern: "urgent_*.csv", maxFiles: 5 }
|
|
565
|
-
*
|
|
566
|
-
* Pattern: webhook().then(http()) - needs Fluent API access
|
|
567
|
-
* Uses shared service: control-sync.service.ts
|
|
568
|
-
*
|
|
569
|
-
* SECURITY: Authentication handled via connection parameter
|
|
570
|
-
* No manual API key validation needed - Versori manages this via connection auth
|
|
571
|
-
*/
|
|
572
|
-
export const adhocControlSync = webhook('control-sync-adhoc', {
|
|
573
|
-
response: { mode: 'sync' },
|
|
574
|
-
connection: 'control-sync-adhoc', // Versori validates API key
|
|
575
|
-
}).then(
|
|
576
|
-
http('run-control-sync-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
577
|
-
const { log, openKv, data } = ctx;
|
|
578
|
-
const executionStartTime = Date.now();
|
|
579
|
-
const jobId = `control-sync-adhoc-${executionStartTime}`;
|
|
580
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
581
|
-
|
|
582
|
-
await tracker.createJob(jobId, {
|
|
583
|
-
triggeredBy: 'manual',
|
|
584
|
-
stage: 'initialization',
|
|
585
|
-
startTime: executionStartTime,
|
|
586
|
-
options: data, // Optional: filePattern, maxFiles, etc.
|
|
587
|
-
});
|
|
588
|
-
await tracker.updateJob(jobId, { status: 'processing' });
|
|
589
|
-
|
|
590
|
-
try {
|
|
591
|
-
const result = await executeControlSync(ctx, jobId, tracker);
|
|
592
|
-
await tracker.markCompleted(jobId, result);
|
|
593
|
-
return { success: true, jobId, ...result };
|
|
594
|
-
} catch (e: any) {
|
|
595
|
-
await tracker.markFailed(jobId, e);
|
|
596
|
-
return { success: false, jobId, error: e?.message };
|
|
597
|
-
}
|
|
598
|
-
})
|
|
599
|
-
);
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
---
|
|
603
|
-
|
|
604
|
-
#### `src/workflows/webhook/job-status-check.ts`
|
|
605
|
-
|
|
606
|
-
**Purpose**: Query job status
|
|
607
|
-
**Trigger**: HTTP POST
|
|
608
|
-
**Endpoint**: `POST https://{workspace}.versori.run/control-sync-job-status`
|
|
609
|
-
**Request body**: `{ "jobId": "control-sync-1234567890" }`
|
|
610
|
-
|
|
611
|
-
```typescript
|
|
612
|
-
import { webhook, fn } from '@versori/run';
|
|
613
|
-
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Webhook: Job Status Check
|
|
617
|
-
*
|
|
618
|
-
* Endpoint: POST https://{workspace}.versori.run/control-sync-job-status
|
|
619
|
-
* Request body: { "jobId": "control-sync-1234567890" }
|
|
620
|
-
*
|
|
621
|
-
* Pattern: webhook().then(fn()) - no external API needed, only KV storage
|
|
622
|
-
* Lightweight: Only queries KV store, no Fluent API calls
|
|
623
|
-
*
|
|
624
|
-
* SECURITY: Authentication handled via connection parameter
|
|
625
|
-
* No manual API key validation needed - Versori manages this via connection auth
|
|
626
|
-
*/
|
|
627
|
-
export const controlSyncJobStatus = webhook('control-sync-job-status', {
|
|
628
|
-
response: { mode: 'sync' },
|
|
629
|
-
connection: 'control-sync-job-status',
|
|
630
|
-
}).then(
|
|
631
|
-
fn('status', async ctx => {
|
|
632
|
-
const { data, log, openKv } = ctx;
|
|
633
|
-
const jobId = data?.jobId as string;
|
|
634
|
-
|
|
635
|
-
if (!jobId) {
|
|
636
|
-
return { success: false, error: 'jobId required' };
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
640
|
-
const status = await tracker.getJob(jobId);
|
|
641
|
-
|
|
642
|
-
return status
|
|
643
|
-
? { success: true, jobId, ...status }
|
|
644
|
-
: { success: false, error: 'Job not found', jobId };
|
|
645
|
-
})
|
|
646
|
-
);
|
|
647
|
-
```
|
|
648
|
-
|
|
649
|
-
---
|
|
650
|
-
|
|
651
|
-
### 3. Entry Point (`index.ts`)
|
|
652
|
-
|
|
653
|
-
**Purpose**: Register all workflows with Versori platform
|
|
654
|
-
|
|
655
|
-
```typescript
|
|
656
|
-
/**
|
|
657
|
-
* Entry Point - Registers all workflows with Versori platform
|
|
658
|
-
*
|
|
659
|
-
* Versori automatically discovers and registers exported workflows
|
|
660
|
-
*
|
|
661
|
-
* File Structure:
|
|
662
|
-
* - src/workflows/scheduled/ → Time-based triggers (cron)
|
|
663
|
-
* - src/workflows/webhook/ → HTTP-based triggers (webhooks)
|
|
664
|
-
*/
|
|
665
|
-
|
|
666
|
-
// Import scheduled workflows
|
|
667
|
-
export { dailyControlSync } from './src/workflows/scheduled/daily-control-sync';
|
|
668
|
-
|
|
669
|
-
// Import webhook workflows
|
|
670
|
-
export { adhocControlSync } from './src/workflows/webhook/adhoc-control-sync';
|
|
671
|
-
export { controlSyncJobStatus } from './src/workflows/webhook/job-status-check';
|
|
672
|
-
```
|
|
673
|
-
|
|
674
|
-
**What Gets Exposed:**
|
|
675
|
-
- ✅ `adhocControlSync` → `https://{workspace}.versori.run/control-sync-adhoc`
|
|
676
|
-
- ✅ `controlSyncJobStatus` → `https://{workspace}.versori.run/control-sync-job-status`
|
|
677
|
-
- ❌ `dailyControlSync` → NOT exposed (runs automatically on cron)
|
|
678
|
-
|
|
679
|
-
---
|
|
680
|
-
|
|
681
|
-
## SDK Methods Used
|
|
682
|
-
|
|
683
|
-
- `createClient(ctx)` - Create Fluent client (auto-detects Versori context)
|
|
684
|
-
- `S3DataSource(config, log)` - S3 operations
|
|
685
|
-
- `CSVParserService()` - CSV parsing
|
|
686
|
-
- `GraphQLMutationMapper(mappingConfig, log, { fluentClient: client })` - Field mapping with schema validation
|
|
687
|
-
- `mapper.map(record)` - Transform single record
|
|
688
|
-
- `client.graphql({ query, variables })` - Execute GraphQL mutation
|
|
689
|
-
- `VersoriKVAdapter` - Direct KV access for state management
|
|
690
|
-
|
|
691
|
-
## Service Functions Architecture
|
|
692
|
-
|
|
693
|
-
This template uses **three dedicated service functions** for clean separation of concerns:
|
|
694
|
-
|
|
695
|
-
### 1. processFile()
|
|
696
|
-
**Purpose:** Download, parse, map, and validate CSV file
|
|
697
|
-
**Inputs:** S3DataSource, CSVParserService, GraphQLMutationMapper, ControlSchemaValidator, filePath, fileName, log
|
|
698
|
-
**Returns:** FileProcessingResult with valid records ready for mutation
|
|
699
|
-
**Responsibilities:**
|
|
700
|
-
- Download file from S3 with retry
|
|
701
|
-
- Parse CSV records
|
|
702
|
-
- Map each record using GraphQLMutationMapper
|
|
703
|
-
- Validate control schema
|
|
704
|
-
- Return validated records for mutation
|
|
705
|
-
|
|
706
|
-
### 2. executeMutations()
|
|
707
|
-
**Purpose:** Execute GraphQL mutations for control records
|
|
708
|
-
**Inputs:** FluentClient, ControlRecord[], delayMs (rate limit), log
|
|
709
|
-
**Returns:** MutationResult[] with success/failure per control
|
|
710
|
-
**Responsibilities:**
|
|
711
|
-
- Check if control exists (query)
|
|
712
|
-
- Create or update control (mutation)
|
|
713
|
-
- Apply rate limiting between mutations
|
|
714
|
-
- Track success/failure per control
|
|
715
|
-
|
|
716
|
-
### 3. writeMutationLog()
|
|
717
|
-
**Purpose:** Write detailed mutation results log to S3
|
|
718
|
-
**Inputs:** S3DataSource, fileName, FileProcessingResult, logPrefix, log
|
|
719
|
-
**Returns:** boolean (upload success)
|
|
720
|
-
**Responsibilities:**
|
|
721
|
-
- Create JSON log with processing summary
|
|
722
|
-
- Include mutation results (created, updated, failed)
|
|
723
|
-
- Upload to S3 logs directory using Buffer
|
|
724
|
-
- Handle upload errors gracefully
|
|
725
|
-
|
|
726
|
-
### Workflow Flow
|
|
727
|
-
```
|
|
728
|
-
Main Workflow (runControlSync)
|
|
729
|
-
↓
|
|
730
|
-
List S3 files → Filter CSV files
|
|
731
|
-
↓
|
|
732
|
-
For each file:
|
|
733
|
-
↓
|
|
734
|
-
1. processFile() → Download, parse, map, validate
|
|
735
|
-
↓
|
|
736
|
-
2. executeMutations() → Create/update controls with rate limiting
|
|
737
|
-
↓
|
|
738
|
-
3. writeMutationLog() → Write detailed logs to S3
|
|
739
|
-
↓
|
|
740
|
-
Archive file → Update KV state
|
|
741
|
-
↓
|
|
742
|
-
Return summary
|
|
743
|
-
```
|
|
744
|
-
|
|
745
|
-
## Sample CSV Input Data
|
|
746
|
-
|
|
747
|
-
### Option 1: Component-Based (Recommended)
|
|
748
|
-
|
|
749
|
-
**File**: `controls-20250122-001.csv`
|
|
750
|
-
|
|
751
|
-
```csv
|
|
752
|
-
controlGroupRef,virtualCatalogueRef,controlType,productRef,categoryRef,locationRef,executionOrder,value,status,description
|
|
753
|
-
CG-RETAIL,,EXCLUSION,,CAT-SEASONAL,,1,0,ACTIVE,Seasonal items - in-store only
|
|
754
|
-
CG-RETAIL,,EXCLUSION,PROD-12345,,,2,0,ACTIVE,Product recall active
|
|
755
|
-
,VC-RETAIL,QUANTITY_BUFFER,PROD-98765,,LOC-001,3,10,ACTIVE,NYC flagship - high foot traffic
|
|
756
|
-
,VC-RETAIL,QUANTITY_BUFFER,PROD-11111,,,4,5,ACTIVE,Fast-moving product safety stock
|
|
757
|
-
,VC-RETAIL,QUANTITY_BUFFER,,CAT-ELECTRONICS,,5,20,ACTIVE,All electronics buffer
|
|
758
|
-
,VC-RETAIL,QUANTITY_BUFFER,,,LOC-002,6,100,ACTIVE,Flagship store large buffer
|
|
759
|
-
```
|
|
760
|
-
|
|
761
|
-
**Note**: Use `controlGroupRef` for EXCLUSION controls, `virtualCatalogueRef` for QUANTITY_BUFFER controls.
|
|
762
|
-
|
|
763
|
-
### Option 2: Pre-Built Refs (Simple)
|
|
764
|
-
|
|
765
|
-
```csv
|
|
766
|
-
controlRef,catalogRef,controlType,executionOrder,value,status
|
|
767
|
-
CG-RETAIL:EXCLUSION:CAT-SEASONAL,CG-RETAIL,EXCLUSION,1,0,ACTIVE
|
|
768
|
-
CG-RETAIL:EXCLUSION:PROD-12345,CG-RETAIL,EXCLUSION,2,0,ACTIVE
|
|
769
|
-
VC-RETAIL:QUANTITY_BUFFER:PROD-98765:LOC-001,VC-RETAIL,QUANTITY_BUFFER,3,10,ACTIVE
|
|
770
|
-
VC-RETAIL:QUANTITY_BUFFER:PROD-11111,VC-RETAIL,QUANTITY_BUFFER,4,5,ACTIVE
|
|
771
|
-
```
|
|
772
|
-
|
|
773
|
-
**Field Mapping**:
|
|
774
|
-
|
|
775
|
-
- `controlRef` → Unique control reference (auto-built from components or pre-built)
|
|
776
|
-
- `controlGroupRef` → Control Group reference (for EXCLUSION controls)
|
|
777
|
-
- `virtualCatalogueRef` → Virtual Catalogue reference (for QUANTITY_BUFFER controls)
|
|
778
|
-
- `catalogRef` → Generic catalog reference (for pre-built format)
|
|
779
|
-
- `controlType` → Control type: EXCLUSION or QUANTITY_BUFFER (required)
|
|
780
|
-
- `productRef` → Product reference (optional, used in ref building)
|
|
781
|
-
- `categoryRef` → Category reference (optional, used in ref building)
|
|
782
|
-
- `locationRef` → Location reference (optional, used in ref building)
|
|
783
|
-
- `executionOrder` → Execution priority: 1-999 (lower = higher priority) (required)
|
|
784
|
-
- `value` → Control value: 0 for EXCLUSION, ≥0 for QUANTITY_BUFFER (required)
|
|
785
|
-
- `status` → Control status: ACTIVE or INACTIVE (defaults to ACTIVE)
|
|
786
|
-
|
|
787
|
-
## Project Setup
|
|
788
|
-
|
|
789
|
-
```bash
|
|
790
|
-
mkdir versori-s3-csv-control-sync && cd $_
|
|
791
|
-
npm init -y
|
|
792
|
-
npm install @fluentcommerce/fc-connect-sdk@^0.1.39 @versori/run
|
|
793
|
-
mkdir -p src
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
### Package Configuration (package.json)
|
|
797
|
-
|
|
798
|
-
```json
|
|
799
|
-
{
|
|
800
|
-
"name": "versori-s3-csv-control-sync",
|
|
801
|
-
"version": "1.0.0",
|
|
802
|
-
"description": "Versori workflow: S3 CSV control sync to Fluent GraphQL",
|
|
803
|
-
"versori": {
|
|
804
|
-
"workflows": "./src/index.ts"
|
|
805
|
-
},
|
|
806
|
-
"type": "module",
|
|
807
|
-
"scripts": {
|
|
808
|
-
"deploy": "versori deploy",
|
|
809
|
-
"logs": "versori logs"
|
|
810
|
-
},
|
|
811
|
-
"dependencies": {
|
|
812
|
-
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
813
|
-
"@versori/run": "latest"
|
|
814
|
-
},
|
|
815
|
-
"devDependencies": {
|
|
816
|
-
"typescript": "^5.0.0",
|
|
817
|
-
"@types/node": "^20.0.0"
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
```
|
|
821
|
-
|
|
822
|
-
### Activation Variables (Versori)
|
|
823
|
-
|
|
824
|
-
```bash
|
|
825
|
-
# Required Variables
|
|
826
|
-
s3BucketName=my-controls-bucket
|
|
827
|
-
awsAccessKeyId=AKIAXXXXXXXXXXXX
|
|
828
|
-
awsSecretAccessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
829
|
-
|
|
830
|
-
# Optional Variables (with defaults shown)
|
|
831
|
-
awsRegion=us-east-1
|
|
832
|
-
s3Prefix=controls/
|
|
833
|
-
archivePrefix=processed/
|
|
834
|
-
errorPrefix=errors/
|
|
835
|
-
logPrefix=logs/
|
|
836
|
-
maxFilesToProcess=10
|
|
837
|
-
filePattern=.csv
|
|
838
|
-
enableArchival=true
|
|
839
|
-
enableFileTracking=true
|
|
840
|
-
validateConnection=true
|
|
841
|
-
mutationRateLimit=10
|
|
842
|
-
mutationBatchSize=1
|
|
843
|
-
# mutationsPerAliasBatch=5 # Optional: Enable alias batching for high-volume scenarios
|
|
844
|
-
|
|
845
|
-
# ⚠️ NOTE: Check your GraphQL schema to determine retailerId handling:
|
|
846
|
-
# - Mandatory retailerId → Must pass it in mutation input
|
|
847
|
-
# - Optional retailerId → Can pass it if needed
|
|
848
|
-
# - No retailerId field → Don't pass it
|
|
849
|
-
# Standard createControl/updateControl do not have retailerId field - only use if your custom schema requires it
|
|
850
|
-
```
|
|
851
|
-
|
|
852
|
-
## Complete Workflow (src/index.ts)
|
|
853
|
-
|
|
854
|
-
```typescript
|
|
855
|
-
import { Buffer } from 'node:buffer'; // Required for Deno/Versori runtime
|
|
856
|
-
import { schedule, webhook, http, fn } from '@versori/run';
|
|
857
|
-
import {
|
|
858
|
-
createClient,
|
|
859
|
-
S3DataSource,
|
|
860
|
-
CSVParserService,
|
|
861
|
-
GraphQLMutationMapper,
|
|
862
|
-
VersoriKVAdapter,
|
|
863
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
864
|
-
|
|
865
|
-
// ============================================================================
|
|
866
|
-
// TYPES
|
|
867
|
-
// ============================================================================
|
|
868
|
-
|
|
869
|
-
interface FileProcessingResult {
|
|
870
|
-
success: boolean;
|
|
871
|
-
fileName: string;
|
|
872
|
-
recordsProcessed: number;
|
|
873
|
-
recordsSuccessful: number;
|
|
874
|
-
recordsFailed: number;
|
|
875
|
-
mutations: MutationResult[];
|
|
876
|
-
errors: string[];
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
interface MutationResult {
|
|
880
|
-
success: boolean;
|
|
881
|
-
controlRef: string;
|
|
882
|
-
operation: 'create' | 'update';
|
|
883
|
-
error?: string;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
interface ControlRecord {
|
|
887
|
-
controlRef: string;
|
|
888
|
-
catalogRef: string;
|
|
889
|
-
controlType: string;
|
|
890
|
-
executionOrder: number;
|
|
891
|
-
value: number;
|
|
892
|
-
status?: string;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
// ============================================================================
|
|
896
|
-
// UTILITY FUNCTIONS
|
|
897
|
-
// ============================================================================
|
|
898
|
-
|
|
899
|
-
/**
|
|
900
|
-
* Retry utility with exponential backoff
|
|
901
|
-
*/
|
|
902
|
-
async function retryWithBackoff<T>(
|
|
903
|
-
operation: () => Promise<T>,
|
|
904
|
-
maxRetries = 3,
|
|
905
|
-
baseDelayMs = 1000
|
|
906
|
-
): Promise<T> {
|
|
907
|
-
let lastError: any;
|
|
908
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
909
|
-
try {
|
|
910
|
-
return await operation();
|
|
911
|
-
} catch (error) {
|
|
912
|
-
lastError = error;
|
|
913
|
-
if (attempt < maxRetries - 1) {
|
|
914
|
-
const delay = baseDelayMs * Math.pow(2, attempt) + Math.floor(Math.random() * 200);
|
|
915
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
throw lastError;
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
/**
|
|
923
|
-
* Rate limiter with delay after operation
|
|
924
|
-
*/
|
|
925
|
-
async function rateLimitedMutation(operation: () => Promise<any>, delayMs: number): Promise<any> {
|
|
926
|
-
const result = await operation();
|
|
927
|
-
if (delayMs > 0) {
|
|
928
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
929
|
-
}
|
|
930
|
-
return result;
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
/**
|
|
934
|
-
* Build control ref from components
|
|
935
|
-
*/
|
|
936
|
-
function buildControlRef(data: {
|
|
937
|
-
controlGroupRef?: string;
|
|
938
|
-
virtualCatalogueRef?: string;
|
|
939
|
-
controlType: string;
|
|
940
|
-
productRef?: string;
|
|
941
|
-
categoryRef?: string;
|
|
942
|
-
locationRef?: string;
|
|
943
|
-
}): string {
|
|
944
|
-
const { controlGroupRef, virtualCatalogueRef, controlType, productRef, categoryRef, locationRef } =
|
|
945
|
-
data;
|
|
946
|
-
|
|
947
|
-
if (controlType === 'EXCLUSION') {
|
|
948
|
-
if (!controlGroupRef) throw new Error('EXCLUSION requires controlGroupRef');
|
|
949
|
-
if (categoryRef) return `${controlGroupRef}:EXCLUSION:${categoryRef}`;
|
|
950
|
-
else if (productRef) return `${controlGroupRef}:EXCLUSION:${productRef}`;
|
|
951
|
-
throw new Error('EXCLUSION requires either categoryRef or productRef');
|
|
952
|
-
} else if (controlType === 'QUANTITY_BUFFER') {
|
|
953
|
-
if (!virtualCatalogueRef) throw new Error('QUANTITY_BUFFER requires virtualCatalogueRef');
|
|
954
|
-
if (productRef && locationRef)
|
|
955
|
-
return `${virtualCatalogueRef}:QUANTITY_BUFFER:${productRef}:${locationRef}`;
|
|
956
|
-
else if (productRef) return `${virtualCatalogueRef}:QUANTITY_BUFFER:${productRef}`;
|
|
957
|
-
else if (categoryRef) return `${virtualCatalogueRef}:QUANTITY_BUFFER:${categoryRef}`;
|
|
958
|
-
else if (locationRef) return `${virtualCatalogueRef}:QUANTITY_BUFFER:${locationRef}`;
|
|
959
|
-
throw new Error('QUANTITY_BUFFER requires productRef, categoryRef, or locationRef');
|
|
960
|
-
}
|
|
961
|
-
throw new Error(`Unknown control type: ${controlType}`);
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
/**
|
|
965
|
-
* Schema validator for Control entities
|
|
966
|
-
*/
|
|
967
|
-
class ControlSchemaValidator {
|
|
968
|
-
private readonly VALID_CONTROL_TYPES = ['QUANTITY_BUFFER', 'EXCLUSION'];
|
|
969
|
-
private readonly VALID_STATUSES = ['ACTIVE', 'INACTIVE'];
|
|
970
|
-
private readonly MIN_EXECUTION_ORDER = 1;
|
|
971
|
-
private readonly MAX_EXECUTION_ORDER = 999;
|
|
972
|
-
|
|
973
|
-
validateForCreate(control: any): { valid: boolean; errors: string[] } {
|
|
974
|
-
const errors: string[] = [];
|
|
975
|
-
|
|
976
|
-
if (!control.controlRef?.trim()) errors.push('controlRef is required');
|
|
977
|
-
if (!control.catalogRef?.trim()) errors.push('catalogRef is required');
|
|
978
|
-
|
|
979
|
-
if (!control.controlType?.trim()) {
|
|
980
|
-
errors.push('controlType is required');
|
|
981
|
-
} else if (!this.VALID_CONTROL_TYPES.includes(control.controlType)) {
|
|
982
|
-
errors.push(`Invalid controlType: ${control.controlType}`);
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
if (control.executionOrder === undefined || control.executionOrder === null) {
|
|
986
|
-
errors.push('executionOrder is required');
|
|
987
|
-
} else {
|
|
988
|
-
const order = parseInt(control.executionOrder, 10);
|
|
989
|
-
if (isNaN(order) || order < this.MIN_EXECUTION_ORDER || order > this.MAX_EXECUTION_ORDER) {
|
|
990
|
-
errors.push(`executionOrder must be ${this.MIN_EXECUTION_ORDER}-${this.MAX_EXECUTION_ORDER}`);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
if (control.value === undefined || control.value === null) {
|
|
995
|
-
errors.push('value is required');
|
|
996
|
-
} else {
|
|
997
|
-
const value = parseInt(control.value, 10);
|
|
998
|
-
if (isNaN(value)) errors.push('value must be integer');
|
|
999
|
-
if (control.controlType === 'EXCLUSION' && value !== 0) {
|
|
1000
|
-
errors.push('EXCLUSION must have value=0');
|
|
1001
|
-
}
|
|
1002
|
-
if (control.controlType === 'QUANTITY_BUFFER' && value < 0) {
|
|
1003
|
-
errors.push('QUANTITY_BUFFER value cannot be negative');
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
if (control.status && !this.VALID_STATUSES.includes(control.status)) {
|
|
1008
|
-
errors.push(`Invalid status: ${control.status}`);
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
const ref = control.controlRef || '';
|
|
1012
|
-
const type = control.controlType || '';
|
|
1013
|
-
|
|
1014
|
-
if (type === 'EXCLUSION' && !ref.includes(':EXCLUSION:')) {
|
|
1015
|
-
errors.push('EXCLUSION ref must contain ":EXCLUSION:"');
|
|
1016
|
-
}
|
|
1017
|
-
if (type === 'QUANTITY_BUFFER' && !ref.includes(':QUANTITY_BUFFER:')) {
|
|
1018
|
-
errors.push('QUANTITY_BUFFER ref must contain ":QUANTITY_BUFFER:"');
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
return { valid: errors.length === 0, errors };
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// ============================================================================
|
|
1026
|
-
// SERVICE FUNCTIONS
|
|
1027
|
-
// ============================================================================
|
|
1028
|
-
|
|
1029
|
-
/**
|
|
1030
|
-
* SERVICE 1: Process a single CSV file
|
|
1031
|
-
* - Downloads file from S3
|
|
1032
|
-
* - Parses CSV records
|
|
1033
|
-
* - Maps records using UniversalMapper
|
|
1034
|
-
* - Validates control schema
|
|
1035
|
-
* - Returns processed records ready for mutation
|
|
1036
|
-
*/
|
|
1037
|
-
async function processFile(
|
|
1038
|
-
s3: S3DataSource,
|
|
1039
|
-
parser: CSVParserService,
|
|
1040
|
-
mapper: GraphQLMutationMapper,
|
|
1041
|
-
validator: ControlSchemaValidator,
|
|
1042
|
-
filePath: string,
|
|
1043
|
-
fileName: string,
|
|
1044
|
-
log: any
|
|
1045
|
-
): Promise<FileProcessingResult> {
|
|
1046
|
-
const result: FileProcessingResult = {
|
|
1047
|
-
success: false,
|
|
1048
|
-
fileName,
|
|
1049
|
-
recordsProcessed: 0,
|
|
1050
|
-
recordsSuccessful: 0,
|
|
1051
|
-
recordsFailed: 0,
|
|
1052
|
-
mutations: [],
|
|
1053
|
-
errors: [],
|
|
1054
|
-
};
|
|
1055
|
-
|
|
1056
|
-
try {
|
|
1057
|
-
log.info('[processFile] Downloading file', { fileName, filePath });
|
|
1058
|
-
|
|
1059
|
-
// Download file with retry
|
|
1060
|
-
const content = await retryWithBackoff(
|
|
1061
|
-
() => s3.downloadFile(filePath, { encoding: 'utf8' }) as Promise<string>
|
|
1062
|
-
);
|
|
1063
|
-
|
|
1064
|
-
// Parse CSV
|
|
1065
|
-
const records = await parser.parse(content, {
|
|
1066
|
-
columns: true,
|
|
1067
|
-
skip_empty_lines: true,
|
|
1068
|
-
trim: true,
|
|
1069
|
-
});
|
|
1070
|
-
|
|
1071
|
-
log.info('[processFile] CSV parsed', { fileName, recordCount: records.length });
|
|
1072
|
-
|
|
1073
|
-
if (records.length === 0) {
|
|
1074
|
-
result.success = true;
|
|
1075
|
-
result.errors.push('Empty CSV file');
|
|
1076
|
-
return result;
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
result.recordsProcessed = records.length;
|
|
1080
|
-
|
|
1081
|
-
// Map and validate each record using GraphQLMutationMapper
|
|
1082
|
-
const validRecords: Array<{ query: string; variables: any; input: any }> = [];
|
|
1083
|
-
|
|
1084
|
-
// ✅ PRODUCTION ENHANCEMENT: Log transformation start
|
|
1085
|
-
log.info('[processFile] Transforming records to GraphQL mutations', {
|
|
1086
|
-
fileName,
|
|
1087
|
-
totalRecords: records.length,
|
|
1088
|
-
});
|
|
1089
|
-
|
|
1090
|
-
for (let i = 0; i < records.length; i++) {
|
|
1091
|
-
const rec = records[i];
|
|
1092
|
-
const recordNumber = i + 1;
|
|
1093
|
-
|
|
1094
|
-
// ✅ PRODUCTION ENHANCEMENT: Log progress every 50 records
|
|
1095
|
-
if (recordNumber % 50 === 0) {
|
|
1096
|
-
log.info(`📤 Transforming record ${recordNumber}/${records.length}`, {
|
|
1097
|
-
fileName,
|
|
1098
|
-
recordNumber,
|
|
1099
|
-
totalRecords: records.length,
|
|
1100
|
-
validSoFar: validRecords.length,
|
|
1101
|
-
failedSoFar: result.recordsFailed,
|
|
1102
|
-
progress: `${((recordNumber / records.length) * 100).toFixed(1)}%`,
|
|
1103
|
-
});
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
try {
|
|
1107
|
-
// GraphQLMutationMapper returns { query, variables } directly
|
|
1108
|
-
const mapped = await mapper.map(rec);
|
|
1109
|
-
|
|
1110
|
-
const control = {
|
|
1111
|
-
query: mapped.query,
|
|
1112
|
-
variables: mapped.variables,
|
|
1113
|
-
input: mapped.variables.input || mapped.variables,
|
|
1114
|
-
};
|
|
1115
|
-
|
|
1116
|
-
// Validate schema using input
|
|
1117
|
-
const validation = validator.validateForCreate(control.input);
|
|
1118
|
-
if (!validation.valid) {
|
|
1119
|
-
log.warn('[processFile] Validation error', { row: recordNumber, errors: validation.errors });
|
|
1120
|
-
result.recordsFailed++;
|
|
1121
|
-
result.errors.push(`Row ${recordNumber}: Validation failed - ${validation.errors.join(', ')}`);
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
validRecords.push(control);
|
|
1126
|
-
result.recordsSuccessful++;
|
|
1127
|
-
} catch (error: unknown) {
|
|
1128
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1129
|
-
log.warn('[processFile] Mapping error', { row: recordNumber, error: errorMsg });
|
|
1130
|
-
result.recordsFailed++;
|
|
1131
|
-
result.errors.push(`Row ${recordNumber}: Mapping failed - ${errorMsg}`);
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
log.info('[processFile] Processing complete', {
|
|
1136
|
-
fileName,
|
|
1137
|
-
total: result.recordsProcessed,
|
|
1138
|
-
valid: result.recordsSuccessful,
|
|
1139
|
-
invalid: result.recordsFailed,
|
|
1140
|
-
successRate: `${((result.recordsSuccessful / result.recordsProcessed) * 100).toFixed(1)}%`,
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
result.success = true;
|
|
1144
|
-
|
|
1145
|
-
// Store valid records in result for executeMutations
|
|
1146
|
-
(result as any).validRecords = validRecords;
|
|
1147
|
-
|
|
1148
|
-
return result;
|
|
1149
|
-
} catch (error: unknown) {
|
|
1150
|
-
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1151
|
-
const errorDetails = {
|
|
1152
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1153
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1154
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1155
|
-
};
|
|
1156
|
-
log.error('[processFile] Failed to process file', errorDetails, { fileName });
|
|
1157
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1158
|
-
result.errors.push(`File processing error: ${errorMsg}`);
|
|
1159
|
-
return result;
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
/**
|
|
1164
|
-
* ✅ NEW: Fetch all existing controls with pagination
|
|
1165
|
-
* Returns Map for O(1) lookups during upsert logic
|
|
1166
|
-
*/
|
|
1167
|
-
async function fetchExistingControls(
|
|
1168
|
-
client: any,
|
|
1169
|
-
catalogRef: string,
|
|
1170
|
-
log: any
|
|
1171
|
-
): Promise<Map<string, any>> {
|
|
1172
|
-
const controlsMap = new Map();
|
|
1173
|
-
let hasMore = true;
|
|
1174
|
-
let cursor: string | null = null;
|
|
1175
|
-
const MAX_PAGES = 50; // Safety limit
|
|
1176
|
-
|
|
1177
|
-
const query = `
|
|
1178
|
-
query GetControls($catalogRef: String!, $first: Int!, $after: String) {
|
|
1179
|
-
controlGroup(ref: $catalogRef) {
|
|
1180
|
-
controls(first: $first, after: $after) {
|
|
1181
|
-
edges {
|
|
1182
|
-
node {
|
|
1183
|
-
ref
|
|
1184
|
-
type
|
|
1185
|
-
status
|
|
1186
|
-
executionOrder
|
|
1187
|
-
}
|
|
1188
|
-
cursor
|
|
1189
|
-
}
|
|
1190
|
-
pageInfo {
|
|
1191
|
-
hasNextPage
|
|
1192
|
-
endCursor
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
`;
|
|
1198
|
-
|
|
1199
|
-
let pageCount = 0;
|
|
1200
|
-
while (hasMore && pageCount < MAX_PAGES) {
|
|
1201
|
-
try {
|
|
1202
|
-
const result = await client.graphql({
|
|
1203
|
-
query,
|
|
1204
|
-
variables: { catalogRef, first: 100, after: cursor },
|
|
1205
|
-
});
|
|
1206
|
-
|
|
1207
|
-
const edges = result?.data?.controlGroup?.controls?.edges || [];
|
|
1208
|
-
edges.forEach((edge: any) => {
|
|
1209
|
-
if (edge?.node?.ref) {
|
|
1210
|
-
controlsMap.set(edge.node.ref, edge.node);
|
|
1211
|
-
}
|
|
1212
|
-
});
|
|
1213
|
-
|
|
1214
|
-
const pageInfo = result?.data?.controlGroup?.controls?.pageInfo;
|
|
1215
|
-
hasMore = pageInfo?.hasNextPage || false;
|
|
1216
|
-
cursor = pageInfo?.endCursor || null;
|
|
1217
|
-
pageCount++;
|
|
1218
|
-
|
|
1219
|
-
log.info(`Fetched page ${pageCount} of existing controls`, {
|
|
1220
|
-
pageCount,
|
|
1221
|
-
totalFound: controlsMap.size,
|
|
1222
|
-
});
|
|
1223
|
-
} catch (error: unknown) {
|
|
1224
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1225
|
-
log.error('Failed to fetch existing controls', {
|
|
1226
|
-
message: errorMsg,
|
|
1227
|
-
pageCount,
|
|
1228
|
-
});
|
|
1229
|
-
break;
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
log.info(`Bulk query complete: ${controlsMap.size} existing controls found`);
|
|
1234
|
-
return controlsMap;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
/**
|
|
1238
|
-
* SERVICE 2: Execute GraphQL mutations for control records
|
|
1239
|
-
* - Separates creates from updates
|
|
1240
|
-
* - Supports alias batching for creates
|
|
1241
|
-
* - Processes updates individually
|
|
1242
|
-
* - Returns mutation results
|
|
1243
|
-
*/
|
|
1244
|
-
async function executeMutations(
|
|
1245
|
-
controls: Array<{ query: string; variables: any; input: any }>,
|
|
1246
|
-
existingControlsMap: Map<string, any>,
|
|
1247
|
-
client: any,
|
|
1248
|
-
mapper: GraphQLMutationMapper,
|
|
1249
|
-
log: any,
|
|
1250
|
-
batchSize: number = 1, // ✅ Default: 1 (sequential)
|
|
1251
|
-
mutationsPerAliasBatch?: number // ✅ NEW: Alias batching parameter (default: undefined = disabled)
|
|
1252
|
-
): Promise<MutationResult[]> {
|
|
1253
|
-
const results: MutationResult[] = [];
|
|
1254
|
-
|
|
1255
|
-
log.info('[executeMutations] Starting mutations', {
|
|
1256
|
-
controlCount: controls.length,
|
|
1257
|
-
batchSize,
|
|
1258
|
-
mutationsPerAliasBatch,
|
|
1259
|
-
});
|
|
1260
|
-
|
|
1261
|
-
// Separate creates from updates
|
|
1262
|
-
const toCreate: Array<{ query: string; variables: any; input: any }> = [];
|
|
1263
|
-
const toUpdate: Array<{ query: string; variables: any; input: any }> = [];
|
|
1264
|
-
|
|
1265
|
-
controls.forEach(control => {
|
|
1266
|
-
const exists = existingControlsMap.has(control.input.controlRef);
|
|
1267
|
-
if (exists) {
|
|
1268
|
-
toUpdate.push(control);
|
|
1269
|
-
} else {
|
|
1270
|
-
toCreate.push(control);
|
|
1271
|
-
}
|
|
1272
|
-
});
|
|
1273
|
-
|
|
1274
|
-
log.info(`Mutation breakdown: ${toCreate.length} creates, ${toUpdate.length} updates`);
|
|
1275
|
-
|
|
1276
|
-
// ✅ Process creates with alias batching if enabled
|
|
1277
|
-
if (toCreate.length > 0) {
|
|
1278
|
-
const useAliases = mutationsPerAliasBatch && mutationsPerAliasBatch > 1;
|
|
1279
|
-
|
|
1280
|
-
if (useAliases) {
|
|
1281
|
-
const createResults = await executeMutationsWithAliases(
|
|
1282
|
-
toCreate,
|
|
1283
|
-
client,
|
|
1284
|
-
mapper,
|
|
1285
|
-
log,
|
|
1286
|
-
batchSize,
|
|
1287
|
-
mutationsPerAliasBatch,
|
|
1288
|
-
'createControl'
|
|
1289
|
-
);
|
|
1290
|
-
|
|
1291
|
-
// Convert to MutationResult format
|
|
1292
|
-
toCreate.forEach((control, idx) => {
|
|
1293
|
-
if (idx < createResults.executed) {
|
|
1294
|
-
results.push({
|
|
1295
|
-
success: true,
|
|
1296
|
-
controlRef: control.input.controlRef,
|
|
1297
|
-
operation: 'create',
|
|
1298
|
-
});
|
|
1299
|
-
} else {
|
|
1300
|
-
results.push({
|
|
1301
|
-
success: false,
|
|
1302
|
-
controlRef: control.input.controlRef,
|
|
1303
|
-
operation: 'create',
|
|
1304
|
-
error: createResults.errors[idx - createResults.executed] || 'Unknown error',
|
|
1305
|
-
});
|
|
1306
|
-
}
|
|
1307
|
-
});
|
|
1308
|
-
} else {
|
|
1309
|
-
// Process creates individually
|
|
1310
|
-
for (const control of toCreate) {
|
|
1311
|
-
try {
|
|
1312
|
-
await retryWithBackoff(() =>
|
|
1313
|
-
client.graphql({
|
|
1314
|
-
query: control.query,
|
|
1315
|
-
variables: control.variables,
|
|
1316
|
-
})
|
|
1317
|
-
);
|
|
1318
|
-
results.push({
|
|
1319
|
-
success: true,
|
|
1320
|
-
controlRef: control.input.controlRef,
|
|
1321
|
-
operation: 'create',
|
|
1322
|
-
});
|
|
1323
|
-
} catch (error: unknown) {
|
|
1324
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1325
|
-
results.push({
|
|
1326
|
-
success: false,
|
|
1327
|
-
controlRef: control.input.controlRef,
|
|
1328
|
-
operation: 'create',
|
|
1329
|
-
error: errorMsg,
|
|
1330
|
-
});
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
// ✅ Process updates individually (conditional logic requires individual handling)
|
|
1337
|
-
for (const control of toUpdate) {
|
|
1338
|
-
const mutationResult: MutationResult = {
|
|
1339
|
-
success: false,
|
|
1340
|
-
controlRef: control.input.controlRef,
|
|
1341
|
-
operation: 'update',
|
|
1342
|
-
};
|
|
1343
|
-
|
|
1344
|
-
try {
|
|
1345
|
-
await retryWithBackoff(() =>
|
|
1346
|
-
client.graphql({
|
|
1347
|
-
query: control.query,
|
|
1348
|
-
variables: control.variables,
|
|
1349
|
-
})
|
|
1350
|
-
);
|
|
1351
|
-
mutationResult.success = true;
|
|
1352
|
-
} catch (error: unknown) {
|
|
1353
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1354
|
-
mutationResult.error = errorMsg;
|
|
1355
|
-
const errorDetails = {
|
|
1356
|
-
message: errorMsg,
|
|
1357
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1358
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1359
|
-
};
|
|
1360
|
-
log.error('[executeMutations] Mutation failed', errorDetails, { ref: control.input.controlRef });
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
results.push(mutationResult);
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
log.info('[executeMutations] Mutations complete', {
|
|
1367
|
-
total: results.length,
|
|
1368
|
-
successful: results.filter(r => r.success).length,
|
|
1369
|
-
failed: results.filter(r => !r.success).length,
|
|
1370
|
-
});
|
|
1371
|
-
|
|
1372
|
-
return results;
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
/**
|
|
1376
|
-
* ✅ NEW: Execute mutations with GraphQL alias batching
|
|
1377
|
-
*/
|
|
1378
|
-
async function executeMutationsWithAliases(
|
|
1379
|
-
controls: Array<{ query: string; variables: any; input: any }>,
|
|
1380
|
-
client: any,
|
|
1381
|
-
mapper: GraphQLMutationMapper,
|
|
1382
|
-
log: any,
|
|
1383
|
-
maxParallel: number,
|
|
1384
|
-
mutationsPerAliasBatch: number,
|
|
1385
|
-
mutationName: string
|
|
1386
|
-
): Promise<{ executed: number; failed: number; errors: string[] }> {
|
|
1387
|
-
const results = { executed: 0, failed: 0, errors: [] as string[] };
|
|
1388
|
-
|
|
1389
|
-
const aliasBatches: Array<Array<typeof controls[0]>> = [];
|
|
1390
|
-
for (let i = 0; i < controls.length; i += mutationsPerAliasBatch) {
|
|
1391
|
-
aliasBatches.push(controls.slice(i, i + mutationsPerAliasBatch));
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
log.info(`Processing ${aliasBatches.length} alias batches`, {
|
|
1395
|
-
totalControls: controls.length,
|
|
1396
|
-
maxParallel,
|
|
1397
|
-
});
|
|
1398
|
-
|
|
1399
|
-
for (let i = 0; i < aliasBatches.length; i += maxParallel) {
|
|
1400
|
-
const concurrentBatches = aliasBatches.slice(i, i + maxParallel);
|
|
1401
|
-
|
|
1402
|
-
const batchResults = await Promise.allSettled(
|
|
1403
|
-
concurrentBatches.map(async (batch) => {
|
|
1404
|
-
const { query, variables } = buildAliasedBatch(batch, mutationName);
|
|
1405
|
-
const response = await retryWithBackoff(() => client.graphql({ query, variables }), log);
|
|
1406
|
-
return parseAliasResponse(response, batch, mutationName);
|
|
1407
|
-
})
|
|
1408
|
-
);
|
|
1409
|
-
|
|
1410
|
-
batchResults.forEach((result, idx) => {
|
|
1411
|
-
if (result.status === 'fulfilled') {
|
|
1412
|
-
const batchResult = result.value;
|
|
1413
|
-
results.executed += batchResult.executed;
|
|
1414
|
-
results.failed += batchResult.failed;
|
|
1415
|
-
results.errors.push(...batchResult.errors);
|
|
1416
|
-
} else {
|
|
1417
|
-
const batch = concurrentBatches[idx];
|
|
1418
|
-
const errorMsg = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
1419
|
-
batch.forEach(control => {
|
|
1420
|
-
results.failed++;
|
|
1421
|
-
const controlRef = control.input?.controlRef || 'unknown';
|
|
1422
|
-
results.errors.push(`Failed to ${mutationName} ${controlRef}: ${errorMsg}`);
|
|
1423
|
-
});
|
|
1424
|
-
}
|
|
1425
|
-
});
|
|
1426
|
-
|
|
1427
|
-
if (i + maxParallel < aliasBatches.length) {
|
|
1428
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
return results;
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
/**
|
|
1436
|
-
* ✅ NEW: Build aliased batch query and variables
|
|
1437
|
-
*/
|
|
1438
|
-
function buildAliasedBatch(
|
|
1439
|
-
batch: Array<{ query: string; variables: any; input: any }>,
|
|
1440
|
-
mutationName: string
|
|
1441
|
-
): { query: string; variables: Record<string, any> } {
|
|
1442
|
-
const batchSize = batch.length;
|
|
1443
|
-
const inputTypeName = `${mutationName.charAt(0).toUpperCase() + mutationName.slice(1)}Input`;
|
|
1444
|
-
|
|
1445
|
-
const variables = Array.from({ length: batchSize }, (_, i) =>
|
|
1446
|
-
`$input${i + 1}: ${inputTypeName}!`
|
|
1447
|
-
).join(', ');
|
|
1448
|
-
|
|
1449
|
-
const aliasedMutations = Array.from({ length: batchSize }, (_, i) => {
|
|
1450
|
-
const alias = `${mutationName}${i + 1}`;
|
|
1451
|
-
return ` ${alias}: ${mutationName}(input: $input${i + 1}) { ref type }`;
|
|
1452
|
-
}).join('\n');
|
|
1453
|
-
|
|
1454
|
-
const operationName = `Batch${mutationName.charAt(0).toUpperCase() + mutationName.slice(1)}s`;
|
|
1455
|
-
const query = `mutation ${operationName}(${variables}) {\n${aliasedMutations}\n}`;
|
|
1456
|
-
|
|
1457
|
-
const variablesObj: Record<string, any> = {};
|
|
1458
|
-
batch.forEach((control, index) => {
|
|
1459
|
-
const input = control.variables.input || control.variables;
|
|
1460
|
-
variablesObj[`input${index + 1}`] = input;
|
|
1461
|
-
});
|
|
1462
|
-
|
|
1463
|
-
return { query, variables: variablesObj };
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
/**
|
|
1467
|
-
* ✅ NEW: Parse aliased GraphQL response
|
|
1468
|
-
*/
|
|
1469
|
-
function parseAliasResponse(
|
|
1470
|
-
response: any,
|
|
1471
|
-
batch: Array<{ query: string; variables: any; input: any }>,
|
|
1472
|
-
mutationName: string
|
|
1473
|
-
): { executed: number; failed: number; errors: string[] } {
|
|
1474
|
-
const result = { executed: 0, failed: 0, errors: [] as string[] };
|
|
1475
|
-
|
|
1476
|
-
const data = response.data || {};
|
|
1477
|
-
const errors = response.errors || [];
|
|
1478
|
-
|
|
1479
|
-
batch.forEach((control, index) => {
|
|
1480
|
-
const alias = `${mutationName}${index + 1}`;
|
|
1481
|
-
const aliasData = data[alias];
|
|
1482
|
-
const aliasErrors = errors.filter((e: unknown) =>
|
|
1483
|
-
e && typeof e === 'object' && 'path' in e && Array.isArray((e as any).path) && (e as any).path.includes(alias)
|
|
1484
|
-
);
|
|
1485
|
-
|
|
1486
|
-
if (aliasData && !aliasErrors.length) {
|
|
1487
|
-
result.executed++;
|
|
1488
|
-
} else {
|
|
1489
|
-
result.failed++;
|
|
1490
|
-
const errorMsg = aliasErrors[0] && typeof aliasErrors[0] === 'object' && 'message' in aliasErrors[0]
|
|
1491
|
-
? String((aliasErrors[0] as any).message)
|
|
1492
|
-
: 'Mutation failed';
|
|
1493
|
-
const controlRef = control.input?.controlRef || 'unknown';
|
|
1494
|
-
result.errors.push(`Failed to ${mutationName} ${controlRef}: ${errorMsg}`);
|
|
1495
|
-
}
|
|
1496
|
-
});
|
|
1497
|
-
|
|
1498
|
-
return result;
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
/**
|
|
1502
|
-
* SERVICE 3: Write mutation results log to S3
|
|
1503
|
-
* - Creates detailed JSON log with processing results
|
|
1504
|
-
* - Uploads to S3 logs directory with Buffer
|
|
1505
|
-
* - Returns upload success/failure
|
|
1506
|
-
*/
|
|
1507
|
-
async function writeMutationLog(
|
|
1508
|
-
s3: S3DataSource,
|
|
1509
|
-
fileName: string,
|
|
1510
|
-
fileResult: FileProcessingResult,
|
|
1511
|
-
logPrefix: string,
|
|
1512
|
-
log: any
|
|
1513
|
-
): Promise<boolean> {
|
|
1514
|
-
try {
|
|
1515
|
-
const logFileName = `${fileName.replace('.csv', '')}-log-${Date.now()}.json`;
|
|
1516
|
-
const logPath = `${logPrefix}${logFileName}`;
|
|
1517
|
-
|
|
1518
|
-
const logData = {
|
|
1519
|
-
fileName,
|
|
1520
|
-
timestamp: new Date().toISOString(),
|
|
1521
|
-
summary: {
|
|
1522
|
-
totalRecords: fileResult.recordsProcessed,
|
|
1523
|
-
successful: fileResult.recordsSuccessful,
|
|
1524
|
-
failed: fileResult.recordsFailed,
|
|
1525
|
-
mutations: {
|
|
1526
|
-
total: fileResult.mutations.length,
|
|
1527
|
-
created: fileResult.mutations.filter(m => m.operation === 'create' && m.success).length,
|
|
1528
|
-
updated: fileResult.mutations.filter(m => m.operation === 'update' && m.success).length,
|
|
1529
|
-
failed: fileResult.mutations.filter(m => !m.success).length,
|
|
1530
|
-
},
|
|
1531
|
-
},
|
|
1532
|
-
errors: fileResult.errors,
|
|
1533
|
-
mutations: fileResult.mutations,
|
|
1534
|
-
};
|
|
1535
|
-
|
|
1536
|
-
const logContent = JSON.stringify(logData, null, 2);
|
|
1537
|
-
|
|
1538
|
-
log.info('[writeMutationLog] Writing log to S3', { logPath, size: logContent.length });
|
|
1539
|
-
|
|
1540
|
-
// Upload log to S3 (uploadFile accepts string or Buffer)
|
|
1541
|
-
await s3.uploadFile(logPath, logContent);
|
|
1542
|
-
|
|
1543
|
-
log.info('[writeMutationLog] Log written successfully', { logPath });
|
|
1544
|
-
return true;
|
|
1545
|
-
} catch (error: unknown) {
|
|
1546
|
-
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1547
|
-
const errorDetails = {
|
|
1548
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1549
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1550
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1551
|
-
};
|
|
1552
|
-
log.error('[writeMutationLog] Failed to write log', errorDetails, { fileName });
|
|
1553
|
-
return false;
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// ============================================================================
|
|
1558
|
-
// MAIN WORKFLOW FUNCTION
|
|
1559
|
-
// ============================================================================
|
|
1560
|
-
|
|
1561
|
-
/**
|
|
1562
|
-
* Main workflow: Control CSV sync from S3
|
|
1563
|
-
* Per-file processing with three service functions:
|
|
1564
|
-
* 1. processFile() - Download, parse, map, validate
|
|
1565
|
-
* 2. executeMutations() - Create/update controls via GraphQL
|
|
1566
|
-
* 3. writeMutationLog() - Write detailed logs to S3
|
|
1567
|
-
*/
|
|
1568
|
-
async function runControlSync(ctx: any) {
|
|
1569
|
-
const log = ctx.log;
|
|
1570
|
-
const executionStartTime = Date.now();
|
|
1571
|
-
log.info('🚀 [WORKFLOW] Starting control sync from S3');
|
|
1572
|
-
|
|
1573
|
-
// Read activation variables
|
|
1574
|
-
const s3Bucket = ctx.activation?.getVariable('s3BucketName');
|
|
1575
|
-
const s3Region = ctx.activation?.getVariable('awsRegion') || 'us-east-1';
|
|
1576
|
-
const s3AccessKeyId = ctx.activation?.getVariable('awsAccessKeyId');
|
|
1577
|
-
const s3SecretAccessKey = ctx.activation?.getVariable('awsSecretAccessKey');
|
|
1578
|
-
const s3Prefix = ctx.activation?.getVariable('s3Prefix') || 'controls/';
|
|
1579
|
-
const maxFiles = parseInt(ctx.activation?.getVariable('maxFilesToProcess') || '10', 10);
|
|
1580
|
-
const filePattern = (ctx.activation?.getVariable('filePattern') || '.csv').toLowerCase();
|
|
1581
|
-
const enableArchival = ctx.activation?.getVariable('enableArchival') !== 'false';
|
|
1582
|
-
const enableFileTracking = ctx.activation?.getVariable('enableFileTracking') !== 'false';
|
|
1583
|
-
const validateConnection = ctx.activation?.getVariable('validateConnection') !== 'false';
|
|
1584
|
-
const archivePrefix = ctx.activation?.getVariable('archivePrefix') || 'processed/';
|
|
1585
|
-
const errorPrefix = ctx.activation?.getVariable('errorPrefix') || 'errors/';
|
|
1586
|
-
const logPrefix = ctx.activation?.getVariable('logPrefix') || 'logs/';
|
|
1587
|
-
const rateLimit = parseInt(ctx.activation?.getVariable('mutationRateLimit') || '10', 10);
|
|
1588
|
-
|
|
1589
|
-
// ✅ Configuration with defaults
|
|
1590
|
-
const mutationBatchSize = parseInt(
|
|
1591
|
-
ctx.activation?.getVariable('mutationBatchSize') || '1', // ✅ Default: 1 (sequential)
|
|
1592
|
-
10
|
|
1593
|
-
);
|
|
1594
|
-
|
|
1595
|
-
const mutationsPerAliasBatch = ctx.activation?.getVariable('mutationsPerAliasBatch')
|
|
1596
|
-
? parseInt(ctx.activation?.getVariable('mutationsPerAliasBatch') || '1', 10)
|
|
1597
|
-
: undefined; // ✅ Default: undefined (disabled, use separate requests)
|
|
1598
|
-
|
|
1599
|
-
// Validate required variables
|
|
1600
|
-
const missingVars: string[] = [];
|
|
1601
|
-
if (!s3Bucket) missingVars.push('s3BucketName');
|
|
1602
|
-
if (!s3AccessKeyId) missingVars.push('awsAccessKeyId');
|
|
1603
|
-
if (!s3SecretAccessKey) missingVars.push('awsSecretAccessKey');
|
|
1604
|
-
|
|
1605
|
-
if (missingVars.length > 0) {
|
|
1606
|
-
const errorMsg = `Missing required variables: ${missingVars.join(', ')}`;
|
|
1607
|
-
log.error('❌ ' + errorMsg);
|
|
1608
|
-
return {
|
|
1609
|
-
success: false,
|
|
1610
|
-
error: errorMsg,
|
|
1611
|
-
processed: 0,
|
|
1612
|
-
recommendation: 'Please configure the missing activation variables in Versori settings',
|
|
1613
|
-
};
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
log.info('⚙️ [WORKFLOW] Configuration loaded', {
|
|
1617
|
-
bucket: s3Bucket,
|
|
1618
|
-
region: s3Region,
|
|
1619
|
-
prefix: s3Prefix,
|
|
1620
|
-
maxFiles,
|
|
1621
|
-
rateLimit,
|
|
1622
|
-
enableArchival,
|
|
1623
|
-
enableFileTracking,
|
|
1624
|
-
validateConnection,
|
|
1625
|
-
});
|
|
1626
|
-
|
|
1627
|
-
// Initialize services
|
|
1628
|
-
log.info('🔌 [WORKFLOW] Initializing Fluent Commerce client');
|
|
1629
|
-
const client = await createClient(ctx);
|
|
1630
|
-
if (!client) {
|
|
1631
|
-
log.error('❌ Failed to create Fluent Commerce client');
|
|
1632
|
-
return {
|
|
1633
|
-
success: false,
|
|
1634
|
-
error: 'Failed to create Fluent Commerce client',
|
|
1635
|
-
recommendation: 'Check Fluent Commerce connection configuration in Versori',
|
|
1636
|
-
};
|
|
1637
|
-
}
|
|
1638
|
-
log.info('✅ [WORKFLOW] Fluent Commerce client initialized');
|
|
1639
|
-
|
|
1640
|
-
// ✅ CORRECT: GraphQL mutations don't need client.setRetailerId()
|
|
1641
|
-
// Check your GraphQL schema to determine retailerId handling:
|
|
1642
|
-
// - Mandatory retailerId → Must pass it in mutation input
|
|
1643
|
-
// - Optional retailerId → Can pass it if needed
|
|
1644
|
-
// - No retailerId field → Don't pass it
|
|
1645
|
-
// See: fc-connect-sdk/docs/00-START-HERE/retailerid-configuration.md
|
|
1646
|
-
|
|
1647
|
-
log.info('☁️ [WORKFLOW] Initializing S3 data source');
|
|
1648
|
-
const s3 = new S3DataSource(
|
|
1649
|
-
{
|
|
1650
|
-
type: 'S3_CSV',
|
|
1651
|
-
connectionId: 's3-control-sync',
|
|
1652
|
-
name: 'Source S3',
|
|
1653
|
-
s3Config: {
|
|
1654
|
-
bucket: s3Bucket,
|
|
1655
|
-
region: s3Region,
|
|
1656
|
-
accessKeyId: s3AccessKeyId,
|
|
1657
|
-
secretAccessKey: s3SecretAccessKey,
|
|
1658
|
-
},
|
|
1659
|
-
},
|
|
1660
|
-
log
|
|
1661
|
-
);
|
|
1662
|
-
|
|
1663
|
-
if (validateConnection) {
|
|
1664
|
-
try {
|
|
1665
|
-
log.info('🔍 [WORKFLOW] Validating S3 connection');
|
|
1666
|
-
await s3.validateConnection();
|
|
1667
|
-
log.info('✅ [WORKFLOW] S3 connection validated successfully');
|
|
1668
|
-
} catch (error: any) {
|
|
1669
|
-
log.error('❌ [WORKFLOW] S3 connection validation failed', {
|
|
1670
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1671
|
-
});
|
|
1672
|
-
return {
|
|
1673
|
-
success: false,
|
|
1674
|
-
error: 'S3 connection validation failed',
|
|
1675
|
-
details: error instanceof Error ? error.message : String(error),
|
|
1676
|
-
recommendation: 'Check S3 credentials and bucket permissions',
|
|
1677
|
-
};
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
const parser = new CSVParserService();
|
|
1682
|
-
const { openKv } = ctx;
|
|
1683
|
-
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
1684
|
-
const validator = new ControlSchemaValidator();
|
|
1685
|
-
|
|
1686
|
-
// Custom resolvers for control ref building
|
|
1687
|
-
const customResolvers = {
|
|
1688
|
-
'custom.buildOrUseControlRef': (_: any, sourceData: any) => {
|
|
1689
|
-
if (sourceData.controlRef && sourceData.controlRef.trim()) {
|
|
1690
|
-
return sourceData.controlRef.trim();
|
|
1691
|
-
}
|
|
1692
|
-
return buildControlRef({
|
|
1693
|
-
controlGroupRef: sourceData.controlGroupRef,
|
|
1694
|
-
virtualCatalogueRef: sourceData.virtualCatalogueRef,
|
|
1695
|
-
controlType: sourceData.controlType,
|
|
1696
|
-
productRef: sourceData.productRef,
|
|
1697
|
-
categoryRef: sourceData.categoryRef,
|
|
1698
|
-
locationRef: sourceData.locationRef,
|
|
1699
|
-
});
|
|
1700
|
-
},
|
|
1701
|
-
'custom.resolveCatalogRef': (_: any, sourceData: any) => {
|
|
1702
|
-
if (sourceData.catalogRef && sourceData.catalogRef.trim()) {
|
|
1703
|
-
return sourceData.catalogRef.trim();
|
|
1704
|
-
}
|
|
1705
|
-
if (sourceData.controlType === 'EXCLUSION') {
|
|
1706
|
-
return sourceData.controlGroupRef?.trim() || '';
|
|
1707
|
-
} else if (sourceData.controlType === 'QUANTITY_BUFFER') {
|
|
1708
|
-
return sourceData.virtualCatalogueRef?.trim() || '';
|
|
1709
|
-
}
|
|
1710
|
-
return '';
|
|
1711
|
-
},
|
|
1712
|
-
};
|
|
1713
|
-
|
|
1714
|
-
// ✅ CRITICAL: Load mapping config from external JSON file
|
|
1715
|
-
// Mapping config uses GraphQLMutationMapper structure (nested objects, not dot notation)
|
|
1716
|
-
// File: src/config/control-mapping.json
|
|
1717
|
-
const mappingConfigJson = await import('../config/control-mapping.json', { assert: { type: 'json' } });
|
|
1718
|
-
const mappingConfig = mappingConfigJson.default;
|
|
1719
|
-
|
|
1720
|
-
// Initialize GraphQLMutationMapper with client for schema introspection
|
|
1721
|
-
const mapper = new GraphQLMutationMapper(mappingConfig, log, { fluentClient: client });
|
|
1722
|
-
|
|
1723
|
-
try {
|
|
1724
|
-
// List files from S3 (pattern filtering handled by listFiles)
|
|
1725
|
-
log.info('📂 [WORKFLOW] Listing files from S3', { prefix: s3Prefix, filePattern });
|
|
1726
|
-
const files = await s3.listFiles({
|
|
1727
|
-
prefix: s3Prefix,
|
|
1728
|
-
pattern: filePattern,
|
|
1729
|
-
maxKeys: 1000
|
|
1730
|
-
});
|
|
1731
|
-
|
|
1732
|
-
const csvFiles = files
|
|
1733
|
-
.sort((a, b) => {
|
|
1734
|
-
const dateA = a.lastModified ? new Date(a.lastModified).getTime() : 0;
|
|
1735
|
-
const dateB = b.lastModified ? new Date(b.lastModified).getTime() : 0;
|
|
1736
|
-
return dateA - dateB;
|
|
1737
|
-
})
|
|
1738
|
-
.slice(0, maxFiles);
|
|
1739
|
-
|
|
1740
|
-
log.info('📋 [WORKFLOW] Files discovered', { total: files.length, toProcess: csvFiles.length });
|
|
1741
|
-
|
|
1742
|
-
// ✅ BULK QUERY: Fetch all existing controls once for efficient upsert detection
|
|
1743
|
-
const catalogRef = ctx.activation?.getVariable('catalogRef');
|
|
1744
|
-
const existingControlsMap = catalogRef
|
|
1745
|
-
? await fetchExistingControls(client, catalogRef, log)
|
|
1746
|
-
: new Map<string, any>();
|
|
1747
|
-
|
|
1748
|
-
const workflowResults = {
|
|
1749
|
-
processed: 0,
|
|
1750
|
-
skipped: 0,
|
|
1751
|
-
failed: 0,
|
|
1752
|
-
totalRecords: 0,
|
|
1753
|
-
controlsCreated: 0,
|
|
1754
|
-
controlsUpdated: 0,
|
|
1755
|
-
errors: [] as string[],
|
|
1756
|
-
};
|
|
1757
|
-
|
|
1758
|
-
// Process each file using service functions
|
|
1759
|
-
for (const file of csvFiles) {
|
|
1760
|
-
const fileStartTime = Date.now();
|
|
1761
|
-
const filePath = file.path;
|
|
1762
|
-
const fileName = file.name;
|
|
1763
|
-
|
|
1764
|
-
log.info('📄 [WORKFLOW] Processing file', { fileName, filePath });
|
|
1765
|
-
|
|
1766
|
-
// Check duplicate via KV state
|
|
1767
|
-
if (enableFileTracking) {
|
|
1768
|
-
const stateKey = ['processed-files', 's3-control-sync', fileName];
|
|
1769
|
-
const existing = await kv.get(stateKey);
|
|
1770
|
-
if (existing) {
|
|
1771
|
-
log.info('⏭️ [WORKFLOW] Skipping already processed file', { fileName });
|
|
1772
|
-
workflowResults.skipped++;
|
|
1773
|
-
continue;
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
try {
|
|
1778
|
-
// SERVICE 1: Process file (download, parse, map, validate)
|
|
1779
|
-
const fileResult = await processFile(
|
|
1780
|
-
s3,
|
|
1781
|
-
parser,
|
|
1782
|
-
mapper,
|
|
1783
|
-
validator,
|
|
1784
|
-
filePath,
|
|
1785
|
-
fileName,
|
|
1786
|
-
log
|
|
1787
|
-
);
|
|
1788
|
-
|
|
1789
|
-
if (!fileResult.success) {
|
|
1790
|
-
log.error('❌ [WORKFLOW] File processing failed', { fileName, errors: fileResult.errors });
|
|
1791
|
-
workflowResults.failed++;
|
|
1792
|
-
workflowResults.errors.push(...fileResult.errors);
|
|
1793
|
-
|
|
1794
|
-
// Move to error directory
|
|
1795
|
-
if (enableArchival) {
|
|
1796
|
-
try {
|
|
1797
|
-
await s3.moveFile(filePath, `${errorPrefix}${fileName}`);
|
|
1798
|
-
log.info('🗂️ [WORKFLOW] Moved failed file to error directory', { fileName });
|
|
1799
|
-
} catch (moveError: any) {
|
|
1800
|
-
log.error('⚠️ [WORKFLOW] Failed to move error file', {
|
|
1801
|
-
fileName,
|
|
1802
|
-
error: moveError instanceof Error ? moveError.message : String(moveError),
|
|
1803
|
-
});
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
1806
|
-
continue;
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
workflowResults.totalRecords += fileResult.recordsProcessed;
|
|
1810
|
-
|
|
1811
|
-
// Get valid records from result
|
|
1812
|
-
const validRecords = (fileResult as any).validRecords as ControlRecord[];
|
|
1813
|
-
|
|
1814
|
-
if (validRecords.length === 0) {
|
|
1815
|
-
log.warn('⚠️ [WORKFLOW] No valid records to process', { fileName });
|
|
1816
|
-
if (enableArchival) {
|
|
1817
|
-
try {
|
|
1818
|
-
await s3.moveFile(filePath, `${archivePrefix}${fileName}`);
|
|
1819
|
-
log.info('📦 [WORKFLOW] Archived empty file', { fileName });
|
|
1820
|
-
} catch (moveError: any) {
|
|
1821
|
-
log.error('⚠️ [WORKFLOW] Failed to archive empty file', {
|
|
1822
|
-
fileName,
|
|
1823
|
-
error: moveError instanceof Error ? moveError.message : String(moveError),
|
|
1824
|
-
});
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
workflowResults.skipped++;
|
|
1828
|
-
continue;
|
|
1829
|
-
}
|
|
1830
|
-
|
|
1831
|
-
// SERVICE 2: Execute mutations (create/update controls)
|
|
1832
|
-
// ✅ Configuration with defaults
|
|
1833
|
-
const mutationBatchSize = parseInt(
|
|
1834
|
-
ctx.activation?.getVariable('mutationBatchSize') || '1', // ✅ Default: 1 (sequential)
|
|
1835
|
-
10
|
|
1836
|
-
);
|
|
1837
|
-
|
|
1838
|
-
const mutationsPerAliasBatch = ctx.activation?.getVariable('mutationsPerAliasBatch')
|
|
1839
|
-
? parseInt(ctx.activation?.getVariable('mutationsPerAliasBatch') || '1', 10)
|
|
1840
|
-
: undefined; // ✅ Default: undefined (disabled, use separate requests)
|
|
1841
|
-
|
|
1842
|
-
log.info('🔄 [WORKFLOW] Executing mutations', {
|
|
1843
|
-
fileName,
|
|
1844
|
-
controlCount: validRecords.length,
|
|
1845
|
-
batchSize: mutationBatchSize,
|
|
1846
|
-
aliasBatching: mutationsPerAliasBatch ? `enabled (${mutationsPerAliasBatch})` : 'disabled',
|
|
1847
|
-
});
|
|
1848
|
-
|
|
1849
|
-
// ? Enhanced: Extract context for progress logging
|
|
1850
|
-
const sampleControlRefs = validRecords.slice(0, 5).map((r: any) => r.input?.controlRef || 'unknown');
|
|
1851
|
-
const mutationType = mapper?.mutationName || 'createControl';
|
|
1852
|
-
|
|
1853
|
-
// ? Enhanced: Start logging with context
|
|
1854
|
-
log.info(`[GraphQLMutations] Sending mutations for file "${fileName}"`, {
|
|
1855
|
-
totalMutations: validRecords.length,
|
|
1856
|
-
mutationType,
|
|
1857
|
-
batchSize: mutationBatchSize,
|
|
1858
|
-
batchMode: mutationBatchSize === 1 ? 'sequential' : `parallel (${mutationBatchSize})`,
|
|
1859
|
-
sampleControlRefs: sampleControlRefs.join(', '),
|
|
1860
|
-
aliasBatching: mutationsPerAliasBatch ? `enabled (${mutationsPerAliasBatch} per alias)` : 'disabled'
|
|
1861
|
-
});
|
|
1862
|
-
|
|
1863
|
-
const mutationResults = await executeMutations(
|
|
1864
|
-
validRecords,
|
|
1865
|
-
existingControlsMap,
|
|
1866
|
-
client,
|
|
1867
|
-
mapper,
|
|
1868
|
-
log,
|
|
1869
|
-
mutationBatchSize, // Concurrency control (default: 1)
|
|
1870
|
-
mutationsPerAliasBatch // ✅ NEW: Alias batching (default: undefined)
|
|
1871
|
-
);
|
|
1872
|
-
|
|
1873
|
-
// Update workflow results
|
|
1874
|
-
const created = mutationResults.filter(m => m.operation === 'create' && m.success).length;
|
|
1875
|
-
const updated = mutationResults.filter(m => m.operation === 'update' && m.success).length;
|
|
1876
|
-
const failed = mutationResults.filter(m => !m.success).length;
|
|
1877
|
-
|
|
1878
|
-
// ? Enhanced: Completion logging with summary
|
|
1879
|
-
const successRate = validRecords.length > 0 ? Math.round(((created + updated) / validRecords.length) * 100) : 0;
|
|
1880
|
-
log.info(`✅ [GraphQLMutations] Mutation submission completed for file "${fileName}"`, {
|
|
1881
|
-
totalMutations: validRecords.length,
|
|
1882
|
-
created,
|
|
1883
|
-
updated,
|
|
1884
|
-
failed,
|
|
1885
|
-
successRate: `${successRate}%`,
|
|
1886
|
-
mutationType,
|
|
1887
|
-
});
|
|
1888
|
-
|
|
1889
|
-
workflowResults.controlsCreated += created;
|
|
1890
|
-
workflowResults.controlsUpdated += updated;
|
|
1891
|
-
|
|
1892
|
-
// Store mutation results in fileResult for logging
|
|
1893
|
-
fileResult.mutations = mutationResults;
|
|
1894
|
-
|
|
1895
|
-
const fileDuration = Date.now() - fileStartTime;
|
|
1896
|
-
log.info('✅ [WORKFLOW] Mutations complete', {
|
|
1897
|
-
fileName,
|
|
1898
|
-
created,
|
|
1899
|
-
updated,
|
|
1900
|
-
failed,
|
|
1901
|
-
duration: `${fileDuration}ms`,
|
|
1902
|
-
});
|
|
1903
|
-
|
|
1904
|
-
// SERVICE 3: Write mutation log to S3
|
|
1905
|
-
await writeMutationLog(s3, fileName, fileResult, logPrefix, log);
|
|
1906
|
-
|
|
1907
|
-
// Mark file as processed in KV state
|
|
1908
|
-
if (enableFileTracking) {
|
|
1909
|
-
const stateKey = ['processed-files', 's3-control-sync', fileName];
|
|
1910
|
-
await kv.set(stateKey, {
|
|
1911
|
-
processedAt: new Date().toISOString(),
|
|
1912
|
-
recordsProcessed: fileResult.recordsProcessed,
|
|
1913
|
-
recordsSuccessful: fileResult.recordsSuccessful,
|
|
1914
|
-
recordsFailed: fileResult.recordsFailed,
|
|
1915
|
-
controlsCreated: created,
|
|
1916
|
-
controlsUpdated: updated,
|
|
1917
|
-
duration: fileDuration,
|
|
1918
|
-
});
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
// Archive processed file
|
|
1922
|
-
if (enableArchival) {
|
|
1923
|
-
try {
|
|
1924
|
-
await s3.moveFile(filePath, `${archivePrefix}${fileName}`);
|
|
1925
|
-
log.info('📦 [WORKFLOW] File archived', { fileName, destination: archivePrefix });
|
|
1926
|
-
} catch (moveError: any) {
|
|
1927
|
-
log.error('⚠️ [WORKFLOW] Failed to archive file', {
|
|
1928
|
-
fileName,
|
|
1929
|
-
error: moveError instanceof Error ? moveError.message : String(moveError),
|
|
1930
|
-
});
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
workflowResults.processed++;
|
|
1935
|
-
log.info('✅ [WORKFLOW] File complete', { fileName, duration: `${fileDuration}ms` });
|
|
1936
|
-
} catch (error: any) {
|
|
1937
|
-
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1938
|
-
const errorDetails = {
|
|
1939
|
-
message: error?.message || 'Unknown error',
|
|
1940
|
-
stack: error?.stack,
|
|
1941
|
-
fileName: error?.fileName,
|
|
1942
|
-
lineNumber: error?.lineNumber,
|
|
1943
|
-
originalError: error?.context?.originalError?.message,
|
|
1944
|
-
errorType: error?.name || 'Error',
|
|
1945
|
-
};
|
|
1946
|
-
log.error('❌ [WORKFLOW] File processing error', errorDetails, { fileName });
|
|
1947
|
-
workflowResults.failed++;
|
|
1948
|
-
workflowResults.errors.push({
|
|
1949
|
-
file: fileName,
|
|
1950
|
-
error: error.message,
|
|
1951
|
-
recommendation: getErrorRecommendation(error),
|
|
1952
|
-
});
|
|
1953
|
-
|
|
1954
|
-
// Move to error directory
|
|
1955
|
-
if (enableArchival) {
|
|
1956
|
-
try {
|
|
1957
|
-
await s3.moveFile(filePath, `${errorPrefix}${fileName}`);
|
|
1958
|
-
log.info('🗂️ [WORKFLOW] Moved failed file to error directory', { fileName });
|
|
1959
|
-
} catch (moveError: any) {
|
|
1960
|
-
log.error('⚠️ [WORKFLOW] Failed to move error file', {
|
|
1961
|
-
fileName,
|
|
1962
|
-
error: moveError instanceof Error ? moveError.message : String(moveError),
|
|
1963
|
-
});
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
// Track error state with exponential backoff
|
|
1968
|
-
const errorKey = ['error-state', fileName];
|
|
1969
|
-
const prev = (await kv.get(errorKey))?.value as any;
|
|
1970
|
-
const attempts = (prev?.attemptCount || 0) + 1;
|
|
1971
|
-
const backoffMinutes = Math.min(Math.pow(2, attempts) * 5, 24 * 60);
|
|
1972
|
-
const nextRetryAt = new Date(Date.now() + backoffMinutes * 60000).toISOString();
|
|
1973
|
-
|
|
1974
|
-
await kv.set(errorKey, {
|
|
1975
|
-
fileName,
|
|
1976
|
-
attemptCount: attempts,
|
|
1977
|
-
lastError: error?.message || 'unknown',
|
|
1978
|
-
lastAttemptAt: new Date().toISOString(),
|
|
1979
|
-
firstFailedAt: prev?.firstFailedAt || new Date().toISOString(),
|
|
1980
|
-
nextRetryAt,
|
|
1981
|
-
});
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
// Final summary
|
|
1986
|
-
const executionDuration = Date.now() - executionStartTime;
|
|
1987
|
-
const summary = {
|
|
1988
|
-
success: true,
|
|
1989
|
-
processed: workflowResults.processed,
|
|
1990
|
-
skipped: workflowResults.skipped,
|
|
1991
|
-
failed: workflowResults.failed,
|
|
1992
|
-
totalRecords: workflowResults.totalRecords,
|
|
1993
|
-
controlsCreated: workflowResults.controlsCreated,
|
|
1994
|
-
controlsUpdated: workflowResults.controlsUpdated,
|
|
1995
|
-
errors: workflowResults.errors.length > 0 ? workflowResults.errors : undefined,
|
|
1996
|
-
duration: executionDuration,
|
|
1997
|
-
durationFormatted: `${(executionDuration / 1000).toFixed(2)}s`,
|
|
1998
|
-
timestamp: new Date().toISOString(),
|
|
1999
|
-
};
|
|
2000
|
-
|
|
2001
|
-
log.info('🎉 [WORKFLOW] Control sync completed', summary);
|
|
2002
|
-
return summary;
|
|
2003
|
-
} catch (error: unknown) {
|
|
2004
|
-
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
2005
|
-
const errorDetails = {
|
|
2006
|
-
message: error instanceof Error ? error.message : String(error),
|
|
2007
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
2008
|
-
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
2009
|
-
};
|
|
2010
|
-
log.error('❌ [WORKFLOW] Sync failed', errorDetails);
|
|
2011
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2012
|
-
const executionDuration = Date.now() - executionStartTime;
|
|
2013
|
-
return {
|
|
2014
|
-
success: false,
|
|
2015
|
-
error: errorMsg,
|
|
2016
|
-
recommendation: getErrorRecommendation(error),
|
|
2017
|
-
processed: 0,
|
|
2018
|
-
duration: executionDuration,
|
|
2019
|
-
durationFormatted: `${(executionDuration / 1000).toFixed(2)}s`,
|
|
2020
|
-
timestamp: new Date().toISOString(),
|
|
2021
|
-
};
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
/**
|
|
2026
|
-
* Get error recommendation based on error type
|
|
2027
|
-
*/
|
|
2028
|
-
function getErrorRecommendation(error: any): string {
|
|
2029
|
-
const errorMsg = (error?.message || '').toLowerCase();
|
|
2030
|
-
|
|
2031
|
-
if (errorMsg.includes('credentials') || errorMsg.includes('access denied')) {
|
|
2032
|
-
return 'Check AWS credentials and S3 bucket permissions';
|
|
2033
|
-
}
|
|
2034
|
-
if (errorMsg.includes('bucket') || errorMsg.includes('not found')) {
|
|
2035
|
-
return 'Verify S3 bucket name and region configuration';
|
|
2036
|
-
}
|
|
2037
|
-
if (errorMsg.includes('connection') || errorMsg.includes('network')) {
|
|
2038
|
-
return 'Check network connectivity and firewall settings';
|
|
2039
|
-
}
|
|
2040
|
-
if (errorMsg.includes('parse') || errorMsg.includes('invalid csv')) {
|
|
2041
|
-
return 'Verify CSV file format and structure';
|
|
2042
|
-
}
|
|
2043
|
-
if (errorMsg.includes('validation') || errorMsg.includes('required field')) {
|
|
2044
|
-
return 'Review control data validation rules and required fields';
|
|
2045
|
-
}
|
|
2046
|
-
if (errorMsg.includes('graphql') || errorMsg.includes('mutation')) {
|
|
2047
|
-
return 'Check GraphQL schema and mutation configuration';
|
|
2048
|
-
}
|
|
2049
|
-
if (errorMsg.includes('rate limit') || errorMsg.includes('throttl')) {
|
|
2050
|
-
return 'Reduce mutationRateLimit or mutationBatchSize in activation variables';
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
return 'Review error logs and activation variables configuration';
|
|
2054
|
-
}
|
|
2055
|
-
|
|
2056
|
-
// Scheduled daily run
|
|
2057
|
-
export const scheduledControlSync = schedule('s3-control-daily', '0 2 * * *').then(
|
|
2058
|
-
http('sync-controls', { connection: 'fluent_commerce' }, async ctx => {
|
|
2059
|
-
return await runControlSync(ctx);
|
|
2060
|
-
})
|
|
2061
|
-
);
|
|
2062
|
-
|
|
2063
|
-
// Manual trigger
|
|
2064
|
-
export const syncNow = webhook('sync-controls-now').then(
|
|
2065
|
-
http('sync-controls-manual', { connection: 'fluent_commerce' }, async ctx => {
|
|
2066
|
-
return await runControlSync(ctx);
|
|
2067
|
-
})
|
|
2068
|
-
);
|
|
2069
|
-
|
|
2070
|
-
// Status check
|
|
2071
|
-
export const checkStatus = webhook('check-status').then(
|
|
2072
|
-
fn('get-status', async (ctx: any) => {
|
|
2073
|
-
const kv = new VersoriKVAdapter(ctx.openKv(':project:'));
|
|
2074
|
-
const processedKeys = await kv.list({ prefix: ['processed-files', 's3-control-sync'] });
|
|
2075
|
-
return {
|
|
2076
|
-
success: true,
|
|
2077
|
-
recentFiles: processedKeys.slice(0, 10),
|
|
2078
|
-
timestamp: new Date().toISOString(),
|
|
2079
|
-
};
|
|
2080
|
-
})
|
|
2081
|
-
);
|
|
2082
|
-
```
|
|
2083
|
-
|
|
2084
|
-
## Key Patterns Explained
|
|
2085
|
-
|
|
2086
|
-
### Pattern 1: Direct KV State Management
|
|
2087
|
-
|
|
2088
|
-
```typescript
|
|
2089
|
-
const kv = new VersoriKVAdapter(ctx.openKv(':project:'));
|
|
2090
|
-
|
|
2091
|
-
// Check if file already processed
|
|
2092
|
-
const stateKey = ['processed-files', 's3-control-sync', fileName];
|
|
2093
|
-
const existing = await kv.get(stateKey);
|
|
2094
|
-
if (existing) {
|
|
2095
|
-
log.info('Skipping already processed file', { fileName });
|
|
2096
|
-
continue;
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
// Mark as processed after success
|
|
2100
|
-
await kv.set(stateKey, {
|
|
2101
|
-
successful,
|
|
2102
|
-
failed,
|
|
2103
|
-
processedAt: new Date().toISOString(),
|
|
2104
|
-
});
|
|
2105
|
-
```
|
|
2106
|
-
|
|
2107
|
-
### Pattern 2: Rate-Limited GraphQL Mutations
|
|
2108
|
-
|
|
2109
|
-
```typescript
|
|
2110
|
-
async function rateLimitedMutation(operation: () => Promise<any>, delayMs: number) {
|
|
2111
|
-
const result = await operation();
|
|
2112
|
-
if (delayMs > 0) {
|
|
2113
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
2114
|
-
}
|
|
2115
|
-
return result;
|
|
2116
|
-
}
|
|
2117
|
-
|
|
2118
|
-
// Configure rate limit (10 mutations/sec = 100ms delay)
|
|
2119
|
-
const rateLimit = 10;
|
|
2120
|
-
const delayMs = Math.floor(1000 / rateLimit);
|
|
2121
|
-
```
|
|
2122
|
-
|
|
2123
|
-
### Pattern 3: S3 File Operations with Retry
|
|
2124
|
-
|
|
2125
|
-
```typescript
|
|
2126
|
-
// List files with prefix filtering
|
|
2127
|
-
const files = await s3.listFiles({ prefix: 'controls/', maxKeys: 1000 });
|
|
2128
|
-
|
|
2129
|
-
// Download with retry
|
|
2130
|
-
const content = await retryWithBackoff(
|
|
2131
|
-
() => s3.downloadFile(filePath, { encoding: 'utf8' }) as Promise<string>
|
|
2132
|
-
);
|
|
2133
|
-
|
|
2134
|
-
// Move file (archive or error)
|
|
2135
|
-
await s3.moveFile('controls/file.csv', 'processed/file.csv');
|
|
2136
|
-
```
|
|
2137
|
-
|
|
2138
|
-
### Pattern 4: Control Ref Building with Custom Resolvers
|
|
2139
|
-
|
|
2140
|
-
```typescript
|
|
2141
|
-
const customResolvers = {
|
|
2142
|
-
'custom.buildOrUseControlRef': (_: any, sourceData: any) => {
|
|
2143
|
-
// If pre-built ref exists, use it
|
|
2144
|
-
if (sourceData.controlRef?.trim()) return sourceData.controlRef.trim();
|
|
2145
|
-
|
|
2146
|
-
// Otherwise build from components
|
|
2147
|
-
return buildControlRef({ ...sourceData });
|
|
2148
|
-
},
|
|
2149
|
-
};
|
|
2150
|
-
```
|
|
2151
|
-
|
|
2152
|
-
### Pattern 5: UniversalMapper with Custom Resolvers
|
|
2153
|
-
|
|
2154
|
-
Available SDK Resolvers:
|
|
2155
|
-
|
|
2156
|
-
- **String**: `sdk.trim`, `sdk.uppercase`, `sdk.lowercase`, `sdk.toString`
|
|
2157
|
-
- **Number**: `sdk.parseInt`, `sdk.parseFloat`, `sdk.number`
|
|
2158
|
-
- **Date**: `sdk.formatDate`, `sdk.formatDateShort`, `sdk.parseDate`
|
|
2159
|
-
- **Type**: `sdk.boolean`, `sdk.parseJson`, `sdk.toJson`
|
|
2160
|
-
- **Utility**: `sdk.identity`, `sdk.coalesce`
|
|
2161
|
-
|
|
2162
|
-
## Schema Validation (Before Deployment)
|
|
2163
|
-
|
|
2164
|
-
Use SDK CLI tools to validate your GraphQL schema:
|
|
2165
|
-
|
|
2166
|
-
```bash
|
|
2167
|
-
# Install SDK globally (or use npx)
|
|
2168
|
-
npm install -g @fluentcommerce/fc-connect-sdk
|
|
2169
|
-
|
|
2170
|
-
# 1. Introspect Fluent GraphQL schema
|
|
2171
|
-
fc-connect introspect-schema \
|
|
2172
|
-
--url https://api.fluentcommerce.com/graphql \
|
|
2173
|
-
--client-id YOUR_CLIENT_ID \
|
|
2174
|
-
--client-secret YOUR_CLIENT_SECRET \
|
|
2175
|
-
--output fluent-schema.json
|
|
2176
|
-
|
|
2177
|
-
# 2. Create mutation file
|
|
2178
|
-
cat > control-mutation.graphql << 'EOF'
|
|
2179
|
-
mutation CreateControl(
|
|
2180
|
-
$controlRef: String!
|
|
2181
|
-
$catalogRef: String!
|
|
2182
|
-
$controlType: String!
|
|
2183
|
-
$executionOrder: Int!
|
|
2184
|
-
$value: Json!
|
|
2185
|
-
) {
|
|
2186
|
-
createControl(input: {
|
|
2187
|
-
type: $controlType
|
|
2188
|
-
ref: $controlRef
|
|
2189
|
-
name: $controlRef
|
|
2190
|
-
values: { name: "CONTROL_VALUE", type: "INTEGER", value: $value }
|
|
2191
|
-
controlGroup: { ref: $catalogRef }
|
|
2192
|
-
executionOrder: $executionOrder
|
|
2193
|
-
}) {
|
|
2194
|
-
ref
|
|
2195
|
-
}
|
|
2196
|
-
}
|
|
2197
|
-
EOF
|
|
2198
|
-
|
|
2199
|
-
# 3. Generate mapping from mutation
|
|
2200
|
-
fc-connect generate-mutation-mapping \
|
|
2201
|
-
--file control-mutation.graphql \
|
|
2202
|
-
--output control-mapping.json
|
|
2203
|
-
|
|
2204
|
-
# 4. Validate mapping against schema
|
|
2205
|
-
fc-connect validate-schema \
|
|
2206
|
-
--mapping control-mapping.json \
|
|
2207
|
-
--schema fluent-schema.json
|
|
2208
|
-
|
|
2209
|
-
# 5. Analyze field coverage
|
|
2210
|
-
fc-connect analyze-coverage \
|
|
2211
|
-
--mapping control-mapping.json \
|
|
2212
|
-
--schema fluent-schema.json
|
|
2213
|
-
```
|
|
2214
|
-
|
|
2215
|
-
**Output Example**:
|
|
2216
|
-
|
|
2217
|
-
```bash
|
|
2218
|
-
✓ Schema validation passed
|
|
2219
|
-
✓ All required fields present: ref, type, executionOrder, values
|
|
2220
|
-
✓ Mutation structure matches GraphQL schema
|
|
2221
|
-
⚠ Optional fields not mapped: status, description
|
|
2222
|
-
```
|
|
2223
|
-
|
|
2224
|
-
## Testing the Workflow
|
|
2225
|
-
|
|
2226
|
-
### 1. Upload Test CSV to S3
|
|
2227
|
-
|
|
2228
|
-
```bash
|
|
2229
|
-
aws s3 cp controls-test.csv s3://my-controls-bucket/controls/
|
|
2230
|
-
```
|
|
2231
|
-
|
|
2232
|
-
### 2. Deploy to Versori
|
|
2233
|
-
|
|
2234
|
-
```bash
|
|
2235
|
-
npm run deploy
|
|
2236
|
-
```
|
|
2237
|
-
|
|
2238
|
-
### 3. Manual Testing via Webhook
|
|
2239
|
-
|
|
2240
|
-
```bash
|
|
2241
|
-
curl -X POST https://your-workspace.versori.run/sync-controls-now
|
|
2242
|
-
```
|
|
2243
|
-
|
|
2244
|
-
### 4. Check Status
|
|
2245
|
-
|
|
2246
|
-
```bash
|
|
2247
|
-
curl https://your-workspace.versori.run/check-status
|
|
2248
|
-
```
|
|
2249
|
-
|
|
2250
|
-
### 5. Monitor Logs
|
|
2251
|
-
|
|
2252
|
-
```bash
|
|
2253
|
-
npm run logs
|
|
2254
|
-
# Or via Versori dashboard
|
|
2255
|
-
```
|
|
2256
|
-
|
|
2257
|
-
---
|
|
2258
|
-
|
|
2259
|
-
## Monitoring
|
|
2260
|
-
|
|
2261
|
-
### Success Response
|
|
2262
|
-
|
|
2263
|
-
```json
|
|
2264
|
-
{
|
|
2265
|
-
"success": true,
|
|
2266
|
-
"filesProcessed": 1,
|
|
2267
|
-
"filesSkipped": 0,
|
|
2268
|
-
"filesFailed": 0,
|
|
2269
|
-
"totalRecords": 50,
|
|
2270
|
-
"mutationsExecuted": 50,
|
|
2271
|
-
"mutationsFailed": 0,
|
|
2272
|
-
"results": [
|
|
2273
|
-
{
|
|
2274
|
-
"file": "controls_2025-01-22.csv",
|
|
2275
|
-
"success": true,
|
|
2276
|
-
"recordsProcessed": 50,
|
|
2277
|
-
"mutationsExecuted": 50,
|
|
2278
|
-
"mutationsFailed": 0
|
|
2279
|
-
}
|
|
2280
|
-
],
|
|
2281
|
-
"duration": 12345
|
|
2282
|
-
}
|
|
2283
|
-
```
|
|
2284
|
-
|
|
2285
|
-
### Partial Success Response
|
|
2286
|
-
|
|
2287
|
-
```json
|
|
2288
|
-
{
|
|
2289
|
-
"success": true,
|
|
2290
|
-
"filesProcessed": 1,
|
|
2291
|
-
"filesSkipped": 0,
|
|
2292
|
-
"filesFailed": 0,
|
|
2293
|
-
"totalRecords": 50,
|
|
2294
|
-
"mutationsExecuted": 45,
|
|
2295
|
-
"mutationsFailed": 5,
|
|
2296
|
-
"results": [
|
|
2297
|
-
{
|
|
2298
|
-
"file": "controls_2025-01-22.csv",
|
|
2299
|
-
"success": true,
|
|
2300
|
-
"recordsProcessed": 50,
|
|
2301
|
-
"mutationsExecuted": 45,
|
|
2302
|
-
"mutationsFailed": 5,
|
|
2303
|
-
"errors": ["CTRL-001: Invalid control ref", "CTRL-002: Missing required field"]
|
|
2304
|
-
}
|
|
2305
|
-
],
|
|
2306
|
-
"duration": 12345
|
|
2307
|
-
}
|
|
2308
|
-
```
|
|
2309
|
-
|
|
2310
|
-
### Error Response
|
|
2311
|
-
|
|
2312
|
-
```json
|
|
2313
|
-
{
|
|
2314
|
-
"success": false,
|
|
2315
|
-
"filesProcessed": 0,
|
|
2316
|
-
"filesFailed": 1,
|
|
2317
|
-
"totalRecords": 0,
|
|
2318
|
-
"mutationsExecuted": 0,
|
|
2319
|
-
"mutationsFailed": 0,
|
|
2320
|
-
"results": [
|
|
2321
|
-
{
|
|
2322
|
-
"file": "controls_2025-01-22.csv",
|
|
2323
|
-
"success": false,
|
|
2324
|
-
"error": "CSV parse error: Invalid structure"
|
|
2325
|
-
}
|
|
2326
|
-
],
|
|
2327
|
-
"duration": 876
|
|
2328
|
-
}
|
|
2329
|
-
```
|
|
2330
|
-
|
|
2331
|
-
### Monitoring Metrics
|
|
2332
|
-
|
|
2333
|
-
Monitor these metrics via Versori logs filtered by `jobId` or `stage`:
|
|
2334
|
-
|
|
2335
|
-
- **Files Processed** - Total files successfully processed
|
|
2336
|
-
- **Mutations Executed** - Total GraphQL mutations executed successfully
|
|
2337
|
-
- **Mutations Failed** - Mutations that failed (check error logs)
|
|
2338
|
-
- **Processing Duration** - Time taken for complete workflow
|
|
2339
|
-
- **Rate Limiting** - Watch for 429 errors indicating GraphQL throttling
|
|
2340
|
-
|
|
2341
|
-
Use the status webhook for dashboards and automated monitoring.
|
|
2342
|
-
|
|
2343
|
-
---
|
|
2344
|
-
|
|
2345
|
-
### Issue 1: Duplicate File Processing
|
|
2346
|
-
|
|
2347
|
-
**Solution**:
|
|
2348
|
-
|
|
2349
|
-
```typescript
|
|
2350
|
-
const stateKey = ['processed-files', 's3-control-sync', fileName];
|
|
2351
|
-
const existing = await kv.get(stateKey);
|
|
2352
|
-
if (existing) continue;
|
|
2353
|
-
await kv.set(stateKey, { processedAt: new Date().toISOString() });
|
|
2354
|
-
```
|
|
2355
|
-
|
|
2356
|
-
### Issue 2: "EXCLUSION must have value=0"
|
|
2357
|
-
|
|
2358
|
-
**Solution**: Update CSV - EXCLUSION controls must always have value=0
|
|
2359
|
-
|
|
2360
|
-
### Issue 3: "controlRef pattern mismatch"
|
|
2361
|
-
|
|
2362
|
-
**Solution**: Verify required fields are populated:
|
|
2363
|
-
|
|
2364
|
-
- EXCLUSION: `controlGroupRef` + (`productRef` OR `categoryRef`)
|
|
2365
|
-
- QUANTITY_BUFFER: `virtualCatalogueRef` + (`productRef` OR `categoryRef` OR `locationRef`)
|
|
2366
|
-
|
|
2367
|
-
### Issue 4: S3 Access Denied
|
|
2368
|
-
|
|
2369
|
-
**Required IAM Permissions**:
|
|
2370
|
-
|
|
2371
|
-
```json
|
|
2372
|
-
{
|
|
2373
|
-
"Version": "2012-10-17",
|
|
2374
|
-
"Statement": [
|
|
2375
|
-
{
|
|
2376
|
-
"Effect": "Allow",
|
|
2377
|
-
"Action": ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
|
2378
|
-
"Resource": [
|
|
2379
|
-
"arn:aws:s3:::my-controls-bucket",
|
|
2380
|
-
"arn:aws:s3:::my-controls-bucket/*"
|
|
2381
|
-
]
|
|
2382
|
-
}
|
|
2383
|
-
]
|
|
2384
|
-
}
|
|
2385
|
-
```
|
|
2386
|
-
|
|
2387
|
-
### Issue 5: Rate Limiting
|
|
2388
|
-
|
|
2389
|
-
**Solution**:
|
|
2390
|
-
|
|
2391
|
-
```typescript
|
|
2392
|
-
// Adjust rate limit in activation variables
|
|
2393
|
-
mutationRateLimit = 5; // Reduce to 5 mutations/sec
|
|
2394
|
-
```
|
|
2395
|
-
|
|
2396
|
-
## Production Checklist
|
|
2397
|
-
|
|
2398
|
-
- [ ] S3 credentials validated with correct IAM permissions
|
|
2399
|
-
- [ ] Activation secrets stored securely
|
|
2400
|
-
- [ ] GraphQL schema validated using CLI tools
|
|
2401
|
-
- [ ] Mapping configuration tested with sample data
|
|
2402
|
-
- [ ] File duplicate prevention working via KV state
|
|
2403
|
-
- [ ] Rate limiting configured appropriately
|
|
2404
|
-
- [ ] Control validation rules tested
|
|
2405
|
-
- [ ] Error handling tested with malformed CSV
|
|
2406
|
-
- [ ] Retry logic tested with transient failures
|
|
2407
|
-
- [ ] File archival working (processed and error directories)
|
|
2408
|
-
- [ ] Monitoring/alerting configured for failures
|
|
2409
|
-
- [ ] Clear runbook for error recovery
|
|
2410
|
-
|
|
2411
|
-
## Related Guides
|
|
2412
|
-
|
|
2413
|
-
- **SFTP Version**: [sftp-csv-control-to-graphql.md](template-ingestion-sftp-csv-control-graphql.md)
|
|
2414
|
-
- **Universal Mapping**: `docs/02-CORE-GUIDES/mapping/modules/`
|
|
2415
|
-
- **CLI Tools**: `fc-connect-sdk/bin/readme.md`
|
|
2416
|
-
- **State & KV patterns**: `docs/03-PATTERN-GUIDES/file-operations/`
|
|
2417
|
-
- **Error handling**: `docs/03-PATTERN-GUIDES/error-handling/`
|
|
1
|
+
---
|
|
2
|
+
template_id: tpl-ingest-s3-csv-to-control-graphql
|
|
3
|
+
canonical_filename: template-ingestion-s3-csv-control-graphql.md
|
|
4
|
+
version: 2.0.0
|
|
5
|
+
sdk_version: ^0.1.39
|
|
6
|
+
runtime: versori
|
|
7
|
+
direction: ingestion
|
|
8
|
+
source: s3-csv
|
|
9
|
+
destination: fluent-graphql
|
|
10
|
+
entity: control
|
|
11
|
+
format: csv
|
|
12
|
+
logging: versori
|
|
13
|
+
status: stable
|
|
14
|
+
compliance: gold-standard
|
|
15
|
+
features:
|
|
16
|
+
- graphql-mutation-mapper
|
|
17
|
+
- memory-management
|
|
18
|
+
- enhanced-logging
|
|
19
|
+
- attribute-transformation
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Template: Ingestion - S3 CSV to Control GraphQL
|
|
23
|
+
|
|
24
|
+
**Template Version:** 2.0.0
|
|
25
|
+
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
26
|
+
**Last Updated:** 2025-01-24
|
|
27
|
+
|
|
28
|
+
**🆕 Version 2.0.0 Enhancements:**
|
|
29
|
+
- ✅ **GraphQL Mutation Mapper** - Direct field mapping to mutation variables
|
|
30
|
+
- ✅ **Memory Management** - Clear large arrays after processing batches
|
|
31
|
+
- ✅ **Enhanced Logging** - Track mutation execution with emoji indicators
|
|
32
|
+
- ✅ **Attribute Transformation** - Handle complex nested data structures
|
|
33
|
+
|
|
34
|
+
## STEP 1: Read This Template As-Is (Do Not Execute Yet)
|
|
35
|
+
|
|
36
|
+
**IMPORTANT:** This is a template that requires customization before use.
|
|
37
|
+
|
|
38
|
+
**Purpose:** This template shows you how to build a scheduled Versori workflow that:
|
|
39
|
+
- Reads Control CSV files from S3
|
|
40
|
+
- Parses CSV records and validates them
|
|
41
|
+
- Creates/updates Fluent Commerce controls via GraphQL mutations (createControl, updateControl)
|
|
42
|
+
- Archives processed files and prevents duplicates via KV state
|
|
43
|
+
- Handles rate limiting and retries
|
|
44
|
+
- Tracks job status via JobTracker webhook
|
|
45
|
+
|
|
46
|
+
**What You'll Learn:**
|
|
47
|
+
- S3 file discovery with prefix filtering and archiving
|
|
48
|
+
- CSV parsing with CSVParserService
|
|
49
|
+
- Control entity structure (EXCLUSION vs QUANTITY_BUFFER types)
|
|
50
|
+
- Direct GraphQL mutations (NOT Batch API)
|
|
51
|
+
- Rate limiting (25-50 mutations at a time to prevent API throttling)
|
|
52
|
+
- Custom control ref building (catalog + type + entity combinations)
|
|
53
|
+
- Schema validation using CLI commands
|
|
54
|
+
- Error handling and partial failure recovery
|
|
55
|
+
- Versori KV state management for duplicate prevention
|
|
56
|
+
- JobTracker integration with job-status webhook
|
|
57
|
+
|
|
58
|
+
**Before You Begin:**
|
|
59
|
+
1. Read through the entire template to understand the workflow
|
|
60
|
+
2. Identify which parts need customization (marked with activation variables)
|
|
61
|
+
3. Have your Fluent API credentials ready
|
|
62
|
+
4. Have your S3 bucket details ready
|
|
63
|
+
5. Understand Control entity structure in Fluent (see Sample CSV section)
|
|
64
|
+
6. Review schema validation CLI commands (see Schema Validation section)
|
|
65
|
+
|
|
66
|
+
## STEP 2: AI Agent Customization Instructions
|
|
67
|
+
|
|
68
|
+
**If you are an AI agent helping a developer customize this template, follow these steps:**
|
|
69
|
+
|
|
70
|
+
### 1. Gather Requirements
|
|
71
|
+
|
|
72
|
+
Ask the developer:
|
|
73
|
+
- **S3 Configuration:**
|
|
74
|
+
- Bucket name and region
|
|
75
|
+
- Access credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
76
|
+
- Prefix for control files (e.g., `controls/` or `incoming/controls/`)
|
|
77
|
+
- Archive prefix (e.g., `processed/` or `archive/`)
|
|
78
|
+
- Error prefix (e.g., `errors/` or `failed/`)
|
|
79
|
+
|
|
80
|
+
- **CSV Structure:**
|
|
81
|
+
- Does CSV have pre-built control refs OR component fields?
|
|
82
|
+
- Which control types are used? (EXCLUSION, QUANTITY_BUFFER, THRESHOLD, EXPIRY)
|
|
83
|
+
- CSV column names and sample data
|
|
84
|
+
- Any custom validation rules beyond schema requirements
|
|
85
|
+
|
|
86
|
+
- **Processing Rules:**
|
|
87
|
+
- Cron schedule (e.g., `0 2 * * *` = daily at 2 AM)
|
|
88
|
+
- Max files to process per run (default: 10)
|
|
89
|
+
- Rate limiting (mutations per second, default: 10)
|
|
90
|
+
- File naming pattern (default: `.csv`)
|
|
91
|
+
- Enable archival? (default: true)
|
|
92
|
+
- Enable file tracking? (default: true) - prevents duplicate processing
|
|
93
|
+
- Validate S3 connection on startup? (default: true)
|
|
94
|
+
|
|
95
|
+
- **Fluent Configuration:**
|
|
96
|
+
- GraphQL endpoint URL (usually `https://api.fluentcommerce.com/graphql`)
|
|
97
|
+
- OAuth2 client credentials (will be in Versori connection)
|
|
98
|
+
- ⚠️ NOTE: retailerId is NOT needed for GraphQL mutations (only for Job/Event API)
|
|
99
|
+
|
|
100
|
+
### 2. Schema Validation (CRITICAL - Run First!)
|
|
101
|
+
|
|
102
|
+
**Before customizing any code, validate the Control schema:**
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# 1. Introspect current Fluent GraphQL schema
|
|
106
|
+
npx @fluentcommerce/fc-connect-sdk introspect-schema \
|
|
107
|
+
--url https://api.fluentcommerce.com/graphql \
|
|
108
|
+
--client-id YOUR_CLIENT_ID \
|
|
109
|
+
--client-secret YOUR_CLIENT_SECRET \
|
|
110
|
+
--output fluent-schema.json
|
|
111
|
+
|
|
112
|
+
# 2. Create test mutation file for Control
|
|
113
|
+
cat > control-test-mutation.graphql << 'EOF'
|
|
114
|
+
mutation CreateControl(
|
|
115
|
+
$controlRef: String!
|
|
116
|
+
$catalogRef: String!
|
|
117
|
+
$controlType: String!
|
|
118
|
+
$executionOrder: Int!
|
|
119
|
+
$value: Json!
|
|
120
|
+
) {
|
|
121
|
+
createControl(input: {
|
|
122
|
+
type: $controlType
|
|
123
|
+
ref: $controlRef
|
|
124
|
+
name: $controlRef
|
|
125
|
+
values: { name: "CONTROL_VALUE", type: "INTEGER", value: $value }
|
|
126
|
+
controlGroup: { ref: $catalogRef }
|
|
127
|
+
executionOrder: $executionOrder
|
|
128
|
+
}) {
|
|
129
|
+
ref
|
|
130
|
+
type
|
|
131
|
+
status
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
EOF
|
|
135
|
+
|
|
136
|
+
# 3. Validate mutation against schema
|
|
137
|
+
npx @fluentcommerce/fc-connect-sdk validate-schema \
|
|
138
|
+
--mapping control-test-mutation.graphql \
|
|
139
|
+
--schema fluent-schema.json
|
|
140
|
+
|
|
141
|
+
# 4. Analyze field coverage
|
|
142
|
+
npx @fluentcommerce/fc-connect-sdk analyze-coverage \
|
|
143
|
+
--mapping control-test-mutation.graphql \
|
|
144
|
+
--schema fluent-schema.json
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Verify these Control fields exist in schema:**
|
|
148
|
+
- `ref` (String!, required) - Unique control reference
|
|
149
|
+
- `type` (String!, required) - Control type: EXCLUSION, QUANTITY_BUFFER, THRESHOLD, EXPIRY
|
|
150
|
+
- `executionOrder` (Int!, required) - Priority: 1-999 (lower = higher priority)
|
|
151
|
+
- `values` (Json!, required) - Control value object with name, type, value
|
|
152
|
+
- `status` (String) - ACTIVE or INACTIVE
|
|
153
|
+
- `controlGroup` (reference) - For EXCLUSION controls (uses catalogRef)
|
|
154
|
+
- `virtualCatalogue` (reference) - For QUANTITY_BUFFER controls (uses catalogRef)
|
|
155
|
+
|
|
156
|
+
### 3. Customize CSV Mapping
|
|
157
|
+
|
|
158
|
+
**Update the `mappingConfig` in the template:**
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// ✅ CORRECT: GraphQLMutationMapper configuration structure
|
|
162
|
+
// File: src/config/control-mapping.json
|
|
163
|
+
{
|
|
164
|
+
"mutation": "createControl",
|
|
165
|
+
"arguments": {
|
|
166
|
+
"input": {
|
|
167
|
+
"type": { "source": "controlType", "required": true, "resolver": "uppercase" },
|
|
168
|
+
"ref": { "resolver": "custom.buildOrUseControlRef", "required": true },
|
|
169
|
+
"name": { "resolver": "custom.buildOrUseControlRef" },
|
|
170
|
+
"values": {
|
|
171
|
+
"value": [
|
|
172
|
+
{
|
|
173
|
+
"name": { "value": "CONTROL_VALUE" },
|
|
174
|
+
"type": { "value": "INTEGER" },
|
|
175
|
+
"value": { "source": "value", "required": true, "resolver": "parseInt" }
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
},
|
|
179
|
+
"executionOrder": { "source": "executionOrder", "required": true, "resolver": "parseInt" },
|
|
180
|
+
"controlGroup": {
|
|
181
|
+
"ref": { "resolver": "custom.resolveCatalogRef", "required": true }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
**Save mapping to config file:**
|
|
188
|
+
```bash
|
|
189
|
+
mkdir -p config
|
|
190
|
+
cat > config/control.import.csv.json << 'EOF'
|
|
191
|
+
{
|
|
192
|
+
"version": "2.0.0",
|
|
193
|
+
"description": "CSV control to Fluent Commerce GraphQL mapping",
|
|
194
|
+
"fields": {
|
|
195
|
+
"controlRef": { "resolver": "custom.buildOrUseControlRef", "required": true },
|
|
196
|
+
"catalogRef": { "resolver": "custom.resolveCatalogRef", "required": true },
|
|
197
|
+
"controlType": { "source": "controlType", "required": true, "resolver": "sdk.uppercase" },
|
|
198
|
+
"executionOrder": { "source": "executionOrder", "required": true, "resolver": "sdk.parseInt" },
|
|
199
|
+
"value": { "source": "value", "required": true, "resolver": "sdk.parseInt" },
|
|
200
|
+
"status": { "source": "status", "resolver": "sdk.uppercase" }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
EOF
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### 4. Control Ref Building Rules
|
|
207
|
+
|
|
208
|
+
**EXCLUSION controls (catalog + type + product/category):**
|
|
209
|
+
```typescript
|
|
210
|
+
// Pattern: {controlGroupRef}:EXCLUSION:{productRef|categoryRef}
|
|
211
|
+
// Examples:
|
|
212
|
+
CG-RETAIL:EXCLUSION:CAT-SEASONAL // Category exclusion
|
|
213
|
+
CG-RETAIL:EXCLUSION:PROD-12345 // Product exclusion
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**QUANTITY_BUFFER controls (catalog + type + product/category/location):**
|
|
217
|
+
```typescript
|
|
218
|
+
// Pattern: {virtualCatalogueRef}:QUANTITY_BUFFER:{productRef}[:{locationRef}]
|
|
219
|
+
// Examples:
|
|
220
|
+
VC-RETAIL:QUANTITY_BUFFER:PROD-98765:LOC-001 // Product at location
|
|
221
|
+
VC-RETAIL:QUANTITY_BUFFER:PROD-11111 // Product (all locations)
|
|
222
|
+
VC-RETAIL:QUANTITY_BUFFER:CAT-ELECTRONICS // Category
|
|
223
|
+
VC-RETAIL:QUANTITY_BUFFER:LOC-002 // Location (all products)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Validation rules:**
|
|
227
|
+
- EXCLUSION must have `controlGroupRef` + (`productRef` OR `categoryRef`)
|
|
228
|
+
- EXCLUSION must have `value=0` (always zero)
|
|
229
|
+
- QUANTITY_BUFFER must have `virtualCatalogueRef` + (`productRef` OR `categoryRef` OR `locationRef`)
|
|
230
|
+
- QUANTITY_BUFFER must have `value >= 0`
|
|
231
|
+
- `executionOrder` must be 1-999 (lower = higher priority)
|
|
232
|
+
- `status` must be ACTIVE or INACTIVE (defaults to ACTIVE if not provided)
|
|
233
|
+
|
|
234
|
+
### 5. Rate Limiting Configuration
|
|
235
|
+
|
|
236
|
+
**Why rate limiting is critical:**
|
|
237
|
+
- Fluent GraphQL API has rate limits
|
|
238
|
+
- Direct mutations (NOT Batch API) hit GraphQL endpoint directly
|
|
239
|
+
- Recommended: 10-25 mutations per second
|
|
240
|
+
- Higher rates may cause 429 (Too Many Requests) errors
|
|
241
|
+
|
|
242
|
+
**Configure in activation variables:**
|
|
243
|
+
```bash
|
|
244
|
+
# Conservative (10 mutations/sec = 100ms delay between mutations)
|
|
245
|
+
mutationRateLimit=10
|
|
246
|
+
|
|
247
|
+
# Aggressive (25 mutations/sec = 40ms delay)
|
|
248
|
+
mutationRateLimit=25
|
|
249
|
+
|
|
250
|
+
# Very aggressive (50 mutations/sec = 20ms delay) - use with caution
|
|
251
|
+
mutationRateLimit=50
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Implementation in template:**
|
|
255
|
+
```typescript
|
|
256
|
+
const rateLimit = parseInt(ctx.activation?.getVariable('mutationRateLimit') || '10', 10);
|
|
257
|
+
const delayMs = rateLimit > 0 ? Math.floor(1000 / rateLimit) : 0;
|
|
258
|
+
|
|
259
|
+
// Apply delay after each mutation
|
|
260
|
+
await rateLimitedMutation(
|
|
261
|
+
() => client.graphql({ query: createMutation, variables }),
|
|
262
|
+
delayMs
|
|
263
|
+
);
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### 6. NO Batch API Guidance
|
|
267
|
+
|
|
268
|
+
**This template uses DIRECT GraphQL mutations, NOT the Batch API:**
|
|
269
|
+
|
|
270
|
+
**Why Direct Mutations:**
|
|
271
|
+
- Controls are typically small volume (100s, not 1000s)
|
|
272
|
+
- Need immediate feedback on create/update success
|
|
273
|
+
- Simpler error handling (per-control vs per-batch)
|
|
274
|
+
- No BPP (Batch Pre-Processing) needed
|
|
275
|
+
|
|
276
|
+
**What This Means:**
|
|
277
|
+
- Each control = 1 GraphQL mutation (createControl or updateControl)
|
|
278
|
+
- Rate limiting is CRITICAL to prevent API throttling
|
|
279
|
+
- No job/batch polling - mutations succeed or fail immediately
|
|
280
|
+
- Errors are handled per-control, not per-batch
|
|
281
|
+
|
|
282
|
+
**DO NOT use Batch API methods:**
|
|
283
|
+
```typescript
|
|
284
|
+
// ❌ WRONG - Don't use these for controls
|
|
285
|
+
await client.createJob({ ... });
|
|
286
|
+
await client.sendBatch(jobId, { ... });
|
|
287
|
+
await client.getBatchStatus(jobId, batchId);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**✅ CORRECT - Use direct mutations:**
|
|
291
|
+
```typescript
|
|
292
|
+
// Check if exists
|
|
293
|
+
const checkResult = await client.graphql({
|
|
294
|
+
query: checkQuery,
|
|
295
|
+
variables: { ref: control.controlRef }
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Create or update
|
|
299
|
+
if (exists) {
|
|
300
|
+
await client.graphql({ query: updateMutation, variables });
|
|
301
|
+
} else {
|
|
302
|
+
await client.graphql({ query: createMutation, variables });
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### 7. JobTracker Integration
|
|
307
|
+
|
|
308
|
+
**This template includes JobTracker with job-status webhook:**
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
312
|
+
|
|
313
|
+
// Start job
|
|
314
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
315
|
+
await tracker.startJob(jobId, { mode: 'scheduled' });
|
|
316
|
+
|
|
317
|
+
// Update progress (optional)
|
|
318
|
+
await tracker.updateJob(jobId, {
|
|
319
|
+
processed: results.processed,
|
|
320
|
+
controlsCreated: results.controlsCreated
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Complete or fail
|
|
324
|
+
if (success) {
|
|
325
|
+
await tracker.completeJob(jobId, { result });
|
|
326
|
+
} else {
|
|
327
|
+
await tracker.failJob(jobId, { error: e?.message });
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Query job status via webhook:**
|
|
332
|
+
```bash
|
|
333
|
+
curl -X POST https://your-workspace.versori.run/control-csv-job-status \
|
|
334
|
+
-H "x-api-key: YOUR_WEBHOOK_API_KEY" \
|
|
335
|
+
-H "Content-Type: application/json" \
|
|
336
|
+
-d '{"jobId": "control-csv-1234567890"}'
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### 8. Testing Checklist
|
|
340
|
+
|
|
341
|
+
Before deploying to production:
|
|
342
|
+
|
|
343
|
+
- [ ] Schema validation passed (introspect-schema, validate-schema)
|
|
344
|
+
- [ ] CSV mapping tested with sample data
|
|
345
|
+
- [ ] Control ref building logic verified (EXCLUSION vs QUANTITY_BUFFER)
|
|
346
|
+
- [ ] Rate limiting configured appropriately
|
|
347
|
+
- [ ] S3 credentials validated
|
|
348
|
+
- [ ] Activation variables configured in Versori
|
|
349
|
+
- [ ] Test file uploaded to S3
|
|
350
|
+
- [ ] Manual webhook trigger tested
|
|
351
|
+
- [ ] File archival working (processed and error directories)
|
|
352
|
+
- [ ] JobTracker status webhook tested
|
|
353
|
+
- [ ] Error handling tested with malformed CSV
|
|
354
|
+
- [ ] Duplicate prevention working via KV state
|
|
355
|
+
|
|
356
|
+
### 9. Common Customizations
|
|
357
|
+
|
|
358
|
+
**Change cron schedule:**
|
|
359
|
+
```typescript
|
|
360
|
+
// Daily at 2 AM
|
|
361
|
+
export const scheduledControlSync = schedule('control-csv-scheduled', '0 2 * * *')
|
|
362
|
+
|
|
363
|
+
// Every 6 hours
|
|
364
|
+
export const scheduledControlSync = schedule('control-csv-scheduled', '0 */6 * * *')
|
|
365
|
+
|
|
366
|
+
// Hourly
|
|
367
|
+
export const scheduledControlSync = schedule('control-csv-scheduled', '0 * * * *')
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**Add custom validation:**
|
|
371
|
+
```typescript
|
|
372
|
+
// In ControlSchemaValidator class
|
|
373
|
+
validateCustomRules(control: any): { valid: boolean; errors: string[] } {
|
|
374
|
+
const errors: string[] = [];
|
|
375
|
+
|
|
376
|
+
// Example: Require description for all controls
|
|
377
|
+
if (!control.description?.trim()) {
|
|
378
|
+
errors.push('description is required');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Example: Value range validation for QUANTITY_BUFFER
|
|
382
|
+
if (control.controlType === 'QUANTITY_BUFFER' && control.value > 1000) {
|
|
383
|
+
errors.push('QUANTITY_BUFFER value cannot exceed 1000');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return { valid: errors.length === 0, errors };
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
**Change file pattern:**
|
|
391
|
+
```typescript
|
|
392
|
+
// Only process files starting with "control-"
|
|
393
|
+
const filePattern = 'control-.csv';
|
|
394
|
+
|
|
395
|
+
// Process multiple extensions
|
|
396
|
+
const filePattern = '.csv|.txt';
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### 10. Deployment Steps
|
|
400
|
+
|
|
401
|
+
```bash
|
|
402
|
+
# 1. Install dependencies
|
|
403
|
+
npm install @fluentcommerce/fc-connect-sdk@^0.1.39 @versori/run
|
|
404
|
+
|
|
405
|
+
# 2. Configure activation variables in Versori
|
|
406
|
+
# (See Activation Variables section in template)
|
|
407
|
+
|
|
408
|
+
# 3. Deploy
|
|
409
|
+
npm run deploy
|
|
410
|
+
|
|
411
|
+
# 4. Test manual trigger
|
|
412
|
+
curl -X POST https://your-workspace.versori.run/control-csv-adhoc \
|
|
413
|
+
-H "x-api-key: YOUR_WEBHOOK_API_KEY"
|
|
414
|
+
|
|
415
|
+
# 5. Monitor logs
|
|
416
|
+
npm run logs
|
|
417
|
+
|
|
418
|
+
# 6. Check job status
|
|
419
|
+
curl -X POST https://your-workspace.versori.run/control-csv-job-status \
|
|
420
|
+
-H "x-api-key: YOUR_WEBHOOK_API_KEY" \
|
|
421
|
+
-d '{"jobId": "control-csv-1234567890"}'
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
# Template: Ingestion - S3 CSV to Control GraphQL
|
|
427
|
+
|
|
428
|
+
**FC Connect SDK Use Case Guide**
|
|
429
|
+
|
|
430
|
+
> **SDK**: [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk)
|
|
431
|
+
> **Version**: Use latest - `npm install @fluentcommerce/fc-connect-sdk@^0.1.39`
|
|
432
|
+
|
|
433
|
+
**Context**: Scheduled Versori workflow that reads control CSV files from S3 and creates/updates Fluent Commerce controls via GraphQL mutations
|
|
434
|
+
|
|
435
|
+
**Complexity**: Medium-High
|
|
436
|
+
|
|
437
|
+
**Runtime**: Versori Platform (Scheduled)
|
|
438
|
+
|
|
439
|
+
**Estimated Lines**: ~700 lines
|
|
440
|
+
|
|
441
|
+
## What You'll Build
|
|
442
|
+
|
|
443
|
+
- Versori scheduled workflow (cron trigger)
|
|
444
|
+
- S3 file listing and download with retry logic
|
|
445
|
+
- CSV parsing with validation
|
|
446
|
+
- UniversalMapper with custom control ref building
|
|
447
|
+
- GraphQL mutations for control upserts with rate limiting
|
|
448
|
+
- Versori KV state management (duplicate prevention)
|
|
449
|
+
- File archival after processing
|
|
450
|
+
- Schema validation for control-specific rules
|
|
451
|
+
|
|
452
|
+
## Versori Workflows Structure
|
|
453
|
+
|
|
454
|
+
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
455
|
+
|
|
456
|
+
**Trigger Types:**
|
|
457
|
+
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
458
|
+
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
459
|
+
- **`workflow()`** → Durable workflows (advanced, rarely used)
|
|
460
|
+
|
|
461
|
+
**Execution Steps (chained to triggers):**
|
|
462
|
+
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
463
|
+
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
464
|
+
|
|
465
|
+
### Recommended Project Structure
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
s3-csv-control-graphql/
|
|
469
|
+
├── index.ts # Entry point - exports all workflows
|
|
470
|
+
└── src/
|
|
471
|
+
├── workflows/
|
|
472
|
+
│ ├── scheduled/
|
|
473
|
+
│ │ └── daily-control-sync.ts # Scheduled: Daily control sync
|
|
474
|
+
│ │
|
|
475
|
+
│ └── webhook/
|
|
476
|
+
│ ├── adhoc-control-sync.ts # Webhook: Manual trigger
|
|
477
|
+
│ └── job-status-check.ts # Webhook: Status query
|
|
478
|
+
│
|
|
479
|
+
├── services/
|
|
480
|
+
│ └── control-sync.service.ts # Shared orchestration logic (reusable)
|
|
481
|
+
│
|
|
482
|
+
└── config/
|
|
483
|
+
└── control-mapping.json # GraphQL mapping config
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
---
|
|
487
|
+
|
|
488
|
+
## Workflow Files
|
|
489
|
+
|
|
490
|
+
### 1. Scheduled Workflows (`src/workflows/scheduled/`)
|
|
491
|
+
|
|
492
|
+
All time-based triggers that run automatically on cron schedules.
|
|
493
|
+
|
|
494
|
+
#### `src/workflows/scheduled/daily-control-sync.ts`
|
|
495
|
+
|
|
496
|
+
**Purpose**: Automatic daily control sync
|
|
497
|
+
**Trigger**: Cron schedule (`0 2 * * *`)
|
|
498
|
+
**Exposed as Endpoint**: ❌ NO - Runs automatically
|
|
499
|
+
|
|
500
|
+
```typescript
|
|
501
|
+
import { schedule, http } from '@versori/run';
|
|
502
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
503
|
+
import { executeControlSync } from '../../services/control-sync.service';
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Scheduled Workflow: Daily Control Sync
|
|
507
|
+
*
|
|
508
|
+
* Runs automatically daily at 2 AM UTC
|
|
509
|
+
* NOT exposed as HTTP endpoint - Versori executes on schedule
|
|
510
|
+
*
|
|
511
|
+
* Uses shared service: control-sync.service.ts
|
|
512
|
+
*/
|
|
513
|
+
export const dailyControlSync = schedule(
|
|
514
|
+
'control-sync-scheduled',
|
|
515
|
+
'0 2 * * *' // Daily at 2 AM UTC
|
|
516
|
+
).then(
|
|
517
|
+
http('run-control-sync', { connection: 'fluent_commerce' }, async ctx => {
|
|
518
|
+
const { log, openKv } = ctx;
|
|
519
|
+
const executionStartTime = Date.now();
|
|
520
|
+
const jobId = `control-sync-${executionStartTime}`;
|
|
521
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
522
|
+
|
|
523
|
+
await tracker.createJob(jobId, {
|
|
524
|
+
triggeredBy: 'schedule',
|
|
525
|
+
stage: 'initialization',
|
|
526
|
+
startTime: executionStartTime,
|
|
527
|
+
});
|
|
528
|
+
await tracker.updateJob(jobId, { status: 'processing' });
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const result = await executeControlSync(ctx, jobId, tracker);
|
|
532
|
+
await tracker.markCompleted(jobId, result);
|
|
533
|
+
return { success: true, jobId, ...result };
|
|
534
|
+
} catch (e: any) {
|
|
535
|
+
await tracker.markFailed(jobId, e);
|
|
536
|
+
return { success: false, jobId, error: e?.message };
|
|
537
|
+
}
|
|
538
|
+
})
|
|
539
|
+
);
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
544
|
+
### 2. Webhook Workflows (`src/workflows/webhook/`)
|
|
545
|
+
|
|
546
|
+
All HTTP-based triggers that create webhook endpoints.
|
|
547
|
+
|
|
548
|
+
#### `src/workflows/webhook/adhoc-control-sync.ts`
|
|
549
|
+
|
|
550
|
+
**Purpose**: Manual control sync trigger (on-demand)
|
|
551
|
+
**Trigger**: HTTP POST
|
|
552
|
+
**Endpoint**: `POST https://{workspace}.versori.run/control-sync-adhoc`
|
|
553
|
+
**Use Cases**: Testing, priority processing, ad-hoc runs
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
import { webhook, http } from '@versori/run';
|
|
557
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
558
|
+
import { executeControlSync } from '../../services/control-sync.service';
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Webhook: Manual Control Sync Trigger
|
|
562
|
+
*
|
|
563
|
+
* Endpoint: POST https://{workspace}.versori.run/control-sync-adhoc
|
|
564
|
+
* Request body (optional): { filePattern: "urgent_*.csv", maxFiles: 5 }
|
|
565
|
+
*
|
|
566
|
+
* Pattern: webhook().then(http()) - needs Fluent API access
|
|
567
|
+
* Uses shared service: control-sync.service.ts
|
|
568
|
+
*
|
|
569
|
+
* SECURITY: Authentication handled via connection parameter
|
|
570
|
+
* No manual API key validation needed - Versori manages this via connection auth
|
|
571
|
+
*/
|
|
572
|
+
export const adhocControlSync = webhook('control-sync-adhoc', {
|
|
573
|
+
response: { mode: 'sync' },
|
|
574
|
+
connection: 'control-sync-adhoc', // Versori validates API key
|
|
575
|
+
}).then(
|
|
576
|
+
http('run-control-sync-adhoc', { connection: 'fluent_commerce' }, async ctx => {
|
|
577
|
+
const { log, openKv, data } = ctx;
|
|
578
|
+
const executionStartTime = Date.now();
|
|
579
|
+
const jobId = `control-sync-adhoc-${executionStartTime}`;
|
|
580
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
581
|
+
|
|
582
|
+
await tracker.createJob(jobId, {
|
|
583
|
+
triggeredBy: 'manual',
|
|
584
|
+
stage: 'initialization',
|
|
585
|
+
startTime: executionStartTime,
|
|
586
|
+
options: data, // Optional: filePattern, maxFiles, etc.
|
|
587
|
+
});
|
|
588
|
+
await tracker.updateJob(jobId, { status: 'processing' });
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
const result = await executeControlSync(ctx, jobId, tracker);
|
|
592
|
+
await tracker.markCompleted(jobId, result);
|
|
593
|
+
return { success: true, jobId, ...result };
|
|
594
|
+
} catch (e: any) {
|
|
595
|
+
await tracker.markFailed(jobId, e);
|
|
596
|
+
return { success: false, jobId, error: e?.message };
|
|
597
|
+
}
|
|
598
|
+
})
|
|
599
|
+
);
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
---
|
|
603
|
+
|
|
604
|
+
#### `src/workflows/webhook/job-status-check.ts`
|
|
605
|
+
|
|
606
|
+
**Purpose**: Query job status
|
|
607
|
+
**Trigger**: HTTP POST
|
|
608
|
+
**Endpoint**: `POST https://{workspace}.versori.run/control-sync-job-status`
|
|
609
|
+
**Request body**: `{ "jobId": "control-sync-1234567890" }`
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
import { webhook, fn } from '@versori/run';
|
|
613
|
+
import { JobTracker } from '@fluentcommerce/fc-connect-sdk';
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Webhook: Job Status Check
|
|
617
|
+
*
|
|
618
|
+
* Endpoint: POST https://{workspace}.versori.run/control-sync-job-status
|
|
619
|
+
* Request body: { "jobId": "control-sync-1234567890" }
|
|
620
|
+
*
|
|
621
|
+
* Pattern: webhook().then(fn()) - no external API needed, only KV storage
|
|
622
|
+
* Lightweight: Only queries KV store, no Fluent API calls
|
|
623
|
+
*
|
|
624
|
+
* SECURITY: Authentication handled via connection parameter
|
|
625
|
+
* No manual API key validation needed - Versori manages this via connection auth
|
|
626
|
+
*/
|
|
627
|
+
export const controlSyncJobStatus = webhook('control-sync-job-status', {
|
|
628
|
+
response: { mode: 'sync' },
|
|
629
|
+
connection: 'control-sync-job-status',
|
|
630
|
+
}).then(
|
|
631
|
+
fn('status', async ctx => {
|
|
632
|
+
const { data, log, openKv } = ctx;
|
|
633
|
+
const jobId = data?.jobId as string;
|
|
634
|
+
|
|
635
|
+
if (!jobId) {
|
|
636
|
+
return { success: false, error: 'jobId required' };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
640
|
+
const status = await tracker.getJob(jobId);
|
|
641
|
+
|
|
642
|
+
return status
|
|
643
|
+
? { success: true, jobId, ...status }
|
|
644
|
+
: { success: false, error: 'Job not found', jobId };
|
|
645
|
+
})
|
|
646
|
+
);
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
### 3. Entry Point (`index.ts`)
|
|
652
|
+
|
|
653
|
+
**Purpose**: Register all workflows with Versori platform
|
|
654
|
+
|
|
655
|
+
```typescript
|
|
656
|
+
/**
|
|
657
|
+
* Entry Point - Registers all workflows with Versori platform
|
|
658
|
+
*
|
|
659
|
+
* Versori automatically discovers and registers exported workflows
|
|
660
|
+
*
|
|
661
|
+
* File Structure:
|
|
662
|
+
* - src/workflows/scheduled/ → Time-based triggers (cron)
|
|
663
|
+
* - src/workflows/webhook/ → HTTP-based triggers (webhooks)
|
|
664
|
+
*/
|
|
665
|
+
|
|
666
|
+
// Import scheduled workflows
|
|
667
|
+
export { dailyControlSync } from './src/workflows/scheduled/daily-control-sync';
|
|
668
|
+
|
|
669
|
+
// Import webhook workflows
|
|
670
|
+
export { adhocControlSync } from './src/workflows/webhook/adhoc-control-sync';
|
|
671
|
+
export { controlSyncJobStatus } from './src/workflows/webhook/job-status-check';
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
**What Gets Exposed:**
|
|
675
|
+
- ✅ `adhocControlSync` → `https://{workspace}.versori.run/control-sync-adhoc`
|
|
676
|
+
- ✅ `controlSyncJobStatus` → `https://{workspace}.versori.run/control-sync-job-status`
|
|
677
|
+
- ❌ `dailyControlSync` → NOT exposed (runs automatically on cron)
|
|
678
|
+
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
## SDK Methods Used
|
|
682
|
+
|
|
683
|
+
- `createClient(ctx)` - Create Fluent client (auto-detects Versori context)
|
|
684
|
+
- `S3DataSource(config, log)` - S3 operations
|
|
685
|
+
- `CSVParserService()` - CSV parsing
|
|
686
|
+
- `GraphQLMutationMapper(mappingConfig, log, { fluentClient: client })` - Field mapping with schema validation
|
|
687
|
+
- `mapper.map(record)` - Transform single record
|
|
688
|
+
- `client.graphql({ query, variables })` - Execute GraphQL mutation
|
|
689
|
+
- `VersoriKVAdapter` - Direct KV access for state management
|
|
690
|
+
|
|
691
|
+
## Service Functions Architecture
|
|
692
|
+
|
|
693
|
+
This template uses **three dedicated service functions** for clean separation of concerns:
|
|
694
|
+
|
|
695
|
+
### 1. processFile()
|
|
696
|
+
**Purpose:** Download, parse, map, and validate CSV file
|
|
697
|
+
**Inputs:** S3DataSource, CSVParserService, GraphQLMutationMapper, ControlSchemaValidator, filePath, fileName, log
|
|
698
|
+
**Returns:** FileProcessingResult with valid records ready for mutation
|
|
699
|
+
**Responsibilities:**
|
|
700
|
+
- Download file from S3 with retry
|
|
701
|
+
- Parse CSV records
|
|
702
|
+
- Map each record using GraphQLMutationMapper
|
|
703
|
+
- Validate control schema
|
|
704
|
+
- Return validated records for mutation
|
|
705
|
+
|
|
706
|
+
### 2. executeMutations()
|
|
707
|
+
**Purpose:** Execute GraphQL mutations for control records
|
|
708
|
+
**Inputs:** FluentClient, ControlRecord[], delayMs (rate limit), log
|
|
709
|
+
**Returns:** MutationResult[] with success/failure per control
|
|
710
|
+
**Responsibilities:**
|
|
711
|
+
- Check if control exists (query)
|
|
712
|
+
- Create or update control (mutation)
|
|
713
|
+
- Apply rate limiting between mutations
|
|
714
|
+
- Track success/failure per control
|
|
715
|
+
|
|
716
|
+
### 3. writeMutationLog()
|
|
717
|
+
**Purpose:** Write detailed mutation results log to S3
|
|
718
|
+
**Inputs:** S3DataSource, fileName, FileProcessingResult, logPrefix, log
|
|
719
|
+
**Returns:** boolean (upload success)
|
|
720
|
+
**Responsibilities:**
|
|
721
|
+
- Create JSON log with processing summary
|
|
722
|
+
- Include mutation results (created, updated, failed)
|
|
723
|
+
- Upload to S3 logs directory using Buffer
|
|
724
|
+
- Handle upload errors gracefully
|
|
725
|
+
|
|
726
|
+
### Workflow Flow
|
|
727
|
+
```
|
|
728
|
+
Main Workflow (runControlSync)
|
|
729
|
+
↓
|
|
730
|
+
List S3 files → Filter CSV files
|
|
731
|
+
↓
|
|
732
|
+
For each file:
|
|
733
|
+
↓
|
|
734
|
+
1. processFile() → Download, parse, map, validate
|
|
735
|
+
↓
|
|
736
|
+
2. executeMutations() → Create/update controls with rate limiting
|
|
737
|
+
↓
|
|
738
|
+
3. writeMutationLog() → Write detailed logs to S3
|
|
739
|
+
↓
|
|
740
|
+
Archive file → Update KV state
|
|
741
|
+
↓
|
|
742
|
+
Return summary
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
## Sample CSV Input Data
|
|
746
|
+
|
|
747
|
+
### Option 1: Component-Based (Recommended)
|
|
748
|
+
|
|
749
|
+
**File**: `controls-20250122-001.csv`
|
|
750
|
+
|
|
751
|
+
```csv
|
|
752
|
+
controlGroupRef,virtualCatalogueRef,controlType,productRef,categoryRef,locationRef,executionOrder,value,status,description
|
|
753
|
+
CG-RETAIL,,EXCLUSION,,CAT-SEASONAL,,1,0,ACTIVE,Seasonal items - in-store only
|
|
754
|
+
CG-RETAIL,,EXCLUSION,PROD-12345,,,2,0,ACTIVE,Product recall active
|
|
755
|
+
,VC-RETAIL,QUANTITY_BUFFER,PROD-98765,,LOC-001,3,10,ACTIVE,NYC flagship - high foot traffic
|
|
756
|
+
,VC-RETAIL,QUANTITY_BUFFER,PROD-11111,,,4,5,ACTIVE,Fast-moving product safety stock
|
|
757
|
+
,VC-RETAIL,QUANTITY_BUFFER,,CAT-ELECTRONICS,,5,20,ACTIVE,All electronics buffer
|
|
758
|
+
,VC-RETAIL,QUANTITY_BUFFER,,,LOC-002,6,100,ACTIVE,Flagship store large buffer
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
**Note**: Use `controlGroupRef` for EXCLUSION controls, `virtualCatalogueRef` for QUANTITY_BUFFER controls.
|
|
762
|
+
|
|
763
|
+
### Option 2: Pre-Built Refs (Simple)
|
|
764
|
+
|
|
765
|
+
```csv
|
|
766
|
+
controlRef,catalogRef,controlType,executionOrder,value,status
|
|
767
|
+
CG-RETAIL:EXCLUSION:CAT-SEASONAL,CG-RETAIL,EXCLUSION,1,0,ACTIVE
|
|
768
|
+
CG-RETAIL:EXCLUSION:PROD-12345,CG-RETAIL,EXCLUSION,2,0,ACTIVE
|
|
769
|
+
VC-RETAIL:QUANTITY_BUFFER:PROD-98765:LOC-001,VC-RETAIL,QUANTITY_BUFFER,3,10,ACTIVE
|
|
770
|
+
VC-RETAIL:QUANTITY_BUFFER:PROD-11111,VC-RETAIL,QUANTITY_BUFFER,4,5,ACTIVE
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
**Field Mapping**:
|
|
774
|
+
|
|
775
|
+
- `controlRef` → Unique control reference (auto-built from components or pre-built)
|
|
776
|
+
- `controlGroupRef` → Control Group reference (for EXCLUSION controls)
|
|
777
|
+
- `virtualCatalogueRef` → Virtual Catalogue reference (for QUANTITY_BUFFER controls)
|
|
778
|
+
- `catalogRef` → Generic catalog reference (for pre-built format)
|
|
779
|
+
- `controlType` → Control type: EXCLUSION or QUANTITY_BUFFER (required)
|
|
780
|
+
- `productRef` → Product reference (optional, used in ref building)
|
|
781
|
+
- `categoryRef` → Category reference (optional, used in ref building)
|
|
782
|
+
- `locationRef` → Location reference (optional, used in ref building)
|
|
783
|
+
- `executionOrder` → Execution priority: 1-999 (lower = higher priority) (required)
|
|
784
|
+
- `value` → Control value: 0 for EXCLUSION, ≥0 for QUANTITY_BUFFER (required)
|
|
785
|
+
- `status` → Control status: ACTIVE or INACTIVE (defaults to ACTIVE)
|
|
786
|
+
|
|
787
|
+
## Project Setup
|
|
788
|
+
|
|
789
|
+
```bash
|
|
790
|
+
mkdir versori-s3-csv-control-sync && cd $_
|
|
791
|
+
npm init -y
|
|
792
|
+
npm install @fluentcommerce/fc-connect-sdk@^0.1.39 @versori/run
|
|
793
|
+
mkdir -p src
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
### Package Configuration (package.json)
|
|
797
|
+
|
|
798
|
+
```json
|
|
799
|
+
{
|
|
800
|
+
"name": "versori-s3-csv-control-sync",
|
|
801
|
+
"version": "1.0.0",
|
|
802
|
+
"description": "Versori workflow: S3 CSV control sync to Fluent GraphQL",
|
|
803
|
+
"versori": {
|
|
804
|
+
"workflows": "./src/index.ts"
|
|
805
|
+
},
|
|
806
|
+
"type": "module",
|
|
807
|
+
"scripts": {
|
|
808
|
+
"deploy": "versori deploy",
|
|
809
|
+
"logs": "versori logs"
|
|
810
|
+
},
|
|
811
|
+
"dependencies": {
|
|
812
|
+
"@fluentcommerce/fc-connect-sdk": "^0.1.39",
|
|
813
|
+
"@versori/run": "latest"
|
|
814
|
+
},
|
|
815
|
+
"devDependencies": {
|
|
816
|
+
"typescript": "^5.0.0",
|
|
817
|
+
"@types/node": "^20.0.0"
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
### Activation Variables (Versori)
|
|
823
|
+
|
|
824
|
+
```bash
|
|
825
|
+
# Required Variables
|
|
826
|
+
s3BucketName=my-controls-bucket
|
|
827
|
+
awsAccessKeyId=AKIAXXXXXXXXXXXX
|
|
828
|
+
awsSecretAccessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
829
|
+
|
|
830
|
+
# Optional Variables (with defaults shown)
|
|
831
|
+
awsRegion=us-east-1
|
|
832
|
+
s3Prefix=controls/
|
|
833
|
+
archivePrefix=processed/
|
|
834
|
+
errorPrefix=errors/
|
|
835
|
+
logPrefix=logs/
|
|
836
|
+
maxFilesToProcess=10
|
|
837
|
+
filePattern=.csv
|
|
838
|
+
enableArchival=true
|
|
839
|
+
enableFileTracking=true
|
|
840
|
+
validateConnection=true
|
|
841
|
+
mutationRateLimit=10
|
|
842
|
+
mutationBatchSize=1
|
|
843
|
+
# mutationsPerAliasBatch=5 # Optional: Enable alias batching for high-volume scenarios
|
|
844
|
+
|
|
845
|
+
# ⚠️ NOTE: Check your GraphQL schema to determine retailerId handling:
|
|
846
|
+
# - Mandatory retailerId → Must pass it in mutation input
|
|
847
|
+
# - Optional retailerId → Can pass it if needed
|
|
848
|
+
# - No retailerId field → Don't pass it
|
|
849
|
+
# Standard createControl/updateControl do not have retailerId field - only use if your custom schema requires it
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
## Complete Workflow (src/index.ts)
|
|
853
|
+
|
|
854
|
+
```typescript
|
|
855
|
+
import { Buffer } from 'node:buffer'; // Required for Deno/Versori runtime
|
|
856
|
+
import { schedule, webhook, http, fn } from '@versori/run';
|
|
857
|
+
import {
|
|
858
|
+
createClient,
|
|
859
|
+
S3DataSource,
|
|
860
|
+
CSVParserService,
|
|
861
|
+
GraphQLMutationMapper,
|
|
862
|
+
VersoriKVAdapter,
|
|
863
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
864
|
+
|
|
865
|
+
// ============================================================================
|
|
866
|
+
// TYPES
|
|
867
|
+
// ============================================================================
|
|
868
|
+
|
|
869
|
+
interface FileProcessingResult {
|
|
870
|
+
success: boolean;
|
|
871
|
+
fileName: string;
|
|
872
|
+
recordsProcessed: number;
|
|
873
|
+
recordsSuccessful: number;
|
|
874
|
+
recordsFailed: number;
|
|
875
|
+
mutations: MutationResult[];
|
|
876
|
+
errors: string[];
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
interface MutationResult {
|
|
880
|
+
success: boolean;
|
|
881
|
+
controlRef: string;
|
|
882
|
+
operation: 'create' | 'update';
|
|
883
|
+
error?: string;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
interface ControlRecord {
|
|
887
|
+
controlRef: string;
|
|
888
|
+
catalogRef: string;
|
|
889
|
+
controlType: string;
|
|
890
|
+
executionOrder: number;
|
|
891
|
+
value: number;
|
|
892
|
+
status?: string;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ============================================================================
|
|
896
|
+
// UTILITY FUNCTIONS
|
|
897
|
+
// ============================================================================
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Retry utility with exponential backoff
|
|
901
|
+
*/
|
|
902
|
+
async function retryWithBackoff<T>(
|
|
903
|
+
operation: () => Promise<T>,
|
|
904
|
+
maxRetries = 3,
|
|
905
|
+
baseDelayMs = 1000
|
|
906
|
+
): Promise<T> {
|
|
907
|
+
let lastError: any;
|
|
908
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
909
|
+
try {
|
|
910
|
+
return await operation();
|
|
911
|
+
} catch (error) {
|
|
912
|
+
lastError = error;
|
|
913
|
+
if (attempt < maxRetries - 1) {
|
|
914
|
+
const delay = baseDelayMs * Math.pow(2, attempt) + Math.floor(Math.random() * 200);
|
|
915
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
throw lastError;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Rate limiter with delay after operation
|
|
924
|
+
*/
|
|
925
|
+
async function rateLimitedMutation(operation: () => Promise<any>, delayMs: number): Promise<any> {
|
|
926
|
+
const result = await operation();
|
|
927
|
+
if (delayMs > 0) {
|
|
928
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
929
|
+
}
|
|
930
|
+
return result;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Build control ref from components
|
|
935
|
+
*/
|
|
936
|
+
function buildControlRef(data: {
|
|
937
|
+
controlGroupRef?: string;
|
|
938
|
+
virtualCatalogueRef?: string;
|
|
939
|
+
controlType: string;
|
|
940
|
+
productRef?: string;
|
|
941
|
+
categoryRef?: string;
|
|
942
|
+
locationRef?: string;
|
|
943
|
+
}): string {
|
|
944
|
+
const { controlGroupRef, virtualCatalogueRef, controlType, productRef, categoryRef, locationRef } =
|
|
945
|
+
data;
|
|
946
|
+
|
|
947
|
+
if (controlType === 'EXCLUSION') {
|
|
948
|
+
if (!controlGroupRef) throw new Error('EXCLUSION requires controlGroupRef');
|
|
949
|
+
if (categoryRef) return `${controlGroupRef}:EXCLUSION:${categoryRef}`;
|
|
950
|
+
else if (productRef) return `${controlGroupRef}:EXCLUSION:${productRef}`;
|
|
951
|
+
throw new Error('EXCLUSION requires either categoryRef or productRef');
|
|
952
|
+
} else if (controlType === 'QUANTITY_BUFFER') {
|
|
953
|
+
if (!virtualCatalogueRef) throw new Error('QUANTITY_BUFFER requires virtualCatalogueRef');
|
|
954
|
+
if (productRef && locationRef)
|
|
955
|
+
return `${virtualCatalogueRef}:QUANTITY_BUFFER:${productRef}:${locationRef}`;
|
|
956
|
+
else if (productRef) return `${virtualCatalogueRef}:QUANTITY_BUFFER:${productRef}`;
|
|
957
|
+
else if (categoryRef) return `${virtualCatalogueRef}:QUANTITY_BUFFER:${categoryRef}`;
|
|
958
|
+
else if (locationRef) return `${virtualCatalogueRef}:QUANTITY_BUFFER:${locationRef}`;
|
|
959
|
+
throw new Error('QUANTITY_BUFFER requires productRef, categoryRef, or locationRef');
|
|
960
|
+
}
|
|
961
|
+
throw new Error(`Unknown control type: ${controlType}`);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Schema validator for Control entities
|
|
966
|
+
*/
|
|
967
|
+
class ControlSchemaValidator {
|
|
968
|
+
private readonly VALID_CONTROL_TYPES = ['QUANTITY_BUFFER', 'EXCLUSION'];
|
|
969
|
+
private readonly VALID_STATUSES = ['ACTIVE', 'INACTIVE'];
|
|
970
|
+
private readonly MIN_EXECUTION_ORDER = 1;
|
|
971
|
+
private readonly MAX_EXECUTION_ORDER = 999;
|
|
972
|
+
|
|
973
|
+
validateForCreate(control: any): { valid: boolean; errors: string[] } {
|
|
974
|
+
const errors: string[] = [];
|
|
975
|
+
|
|
976
|
+
if (!control.controlRef?.trim()) errors.push('controlRef is required');
|
|
977
|
+
if (!control.catalogRef?.trim()) errors.push('catalogRef is required');
|
|
978
|
+
|
|
979
|
+
if (!control.controlType?.trim()) {
|
|
980
|
+
errors.push('controlType is required');
|
|
981
|
+
} else if (!this.VALID_CONTROL_TYPES.includes(control.controlType)) {
|
|
982
|
+
errors.push(`Invalid controlType: ${control.controlType}`);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (control.executionOrder === undefined || control.executionOrder === null) {
|
|
986
|
+
errors.push('executionOrder is required');
|
|
987
|
+
} else {
|
|
988
|
+
const order = parseInt(control.executionOrder, 10);
|
|
989
|
+
if (isNaN(order) || order < this.MIN_EXECUTION_ORDER || order > this.MAX_EXECUTION_ORDER) {
|
|
990
|
+
errors.push(`executionOrder must be ${this.MIN_EXECUTION_ORDER}-${this.MAX_EXECUTION_ORDER}`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (control.value === undefined || control.value === null) {
|
|
995
|
+
errors.push('value is required');
|
|
996
|
+
} else {
|
|
997
|
+
const value = parseInt(control.value, 10);
|
|
998
|
+
if (isNaN(value)) errors.push('value must be integer');
|
|
999
|
+
if (control.controlType === 'EXCLUSION' && value !== 0) {
|
|
1000
|
+
errors.push('EXCLUSION must have value=0');
|
|
1001
|
+
}
|
|
1002
|
+
if (control.controlType === 'QUANTITY_BUFFER' && value < 0) {
|
|
1003
|
+
errors.push('QUANTITY_BUFFER value cannot be negative');
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (control.status && !this.VALID_STATUSES.includes(control.status)) {
|
|
1008
|
+
errors.push(`Invalid status: ${control.status}`);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const ref = control.controlRef || '';
|
|
1012
|
+
const type = control.controlType || '';
|
|
1013
|
+
|
|
1014
|
+
if (type === 'EXCLUSION' && !ref.includes(':EXCLUSION:')) {
|
|
1015
|
+
errors.push('EXCLUSION ref must contain ":EXCLUSION:"');
|
|
1016
|
+
}
|
|
1017
|
+
if (type === 'QUANTITY_BUFFER' && !ref.includes(':QUANTITY_BUFFER:')) {
|
|
1018
|
+
errors.push('QUANTITY_BUFFER ref must contain ":QUANTITY_BUFFER:"');
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return { valid: errors.length === 0, errors };
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ============================================================================
|
|
1026
|
+
// SERVICE FUNCTIONS
|
|
1027
|
+
// ============================================================================
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* SERVICE 1: Process a single CSV file
|
|
1031
|
+
* - Downloads file from S3
|
|
1032
|
+
* - Parses CSV records
|
|
1033
|
+
* - Maps records using UniversalMapper
|
|
1034
|
+
* - Validates control schema
|
|
1035
|
+
* - Returns processed records ready for mutation
|
|
1036
|
+
*/
|
|
1037
|
+
async function processFile(
|
|
1038
|
+
s3: S3DataSource,
|
|
1039
|
+
parser: CSVParserService,
|
|
1040
|
+
mapper: GraphQLMutationMapper,
|
|
1041
|
+
validator: ControlSchemaValidator,
|
|
1042
|
+
filePath: string,
|
|
1043
|
+
fileName: string,
|
|
1044
|
+
log: any
|
|
1045
|
+
): Promise<FileProcessingResult> {
|
|
1046
|
+
const result: FileProcessingResult = {
|
|
1047
|
+
success: false,
|
|
1048
|
+
fileName,
|
|
1049
|
+
recordsProcessed: 0,
|
|
1050
|
+
recordsSuccessful: 0,
|
|
1051
|
+
recordsFailed: 0,
|
|
1052
|
+
mutations: [],
|
|
1053
|
+
errors: [],
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
try {
|
|
1057
|
+
log.info('[processFile] Downloading file', { fileName, filePath });
|
|
1058
|
+
|
|
1059
|
+
// Download file with retry
|
|
1060
|
+
const content = await retryWithBackoff(
|
|
1061
|
+
() => s3.downloadFile(filePath, { encoding: 'utf8' }) as Promise<string>
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
// Parse CSV
|
|
1065
|
+
const records = await parser.parse(content, {
|
|
1066
|
+
columns: true,
|
|
1067
|
+
skip_empty_lines: true,
|
|
1068
|
+
trim: true,
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
log.info('[processFile] CSV parsed', { fileName, recordCount: records.length });
|
|
1072
|
+
|
|
1073
|
+
if (records.length === 0) {
|
|
1074
|
+
result.success = true;
|
|
1075
|
+
result.errors.push('Empty CSV file');
|
|
1076
|
+
return result;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
result.recordsProcessed = records.length;
|
|
1080
|
+
|
|
1081
|
+
// Map and validate each record using GraphQLMutationMapper
|
|
1082
|
+
const validRecords: Array<{ query: string; variables: any; input: any }> = [];
|
|
1083
|
+
|
|
1084
|
+
// ✅ PRODUCTION ENHANCEMENT: Log transformation start
|
|
1085
|
+
log.info('[processFile] Transforming records to GraphQL mutations', {
|
|
1086
|
+
fileName,
|
|
1087
|
+
totalRecords: records.length,
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
for (let i = 0; i < records.length; i++) {
|
|
1091
|
+
const rec = records[i];
|
|
1092
|
+
const recordNumber = i + 1;
|
|
1093
|
+
|
|
1094
|
+
// ✅ PRODUCTION ENHANCEMENT: Log progress every 50 records
|
|
1095
|
+
if (recordNumber % 50 === 0) {
|
|
1096
|
+
log.info(`📤 Transforming record ${recordNumber}/${records.length}`, {
|
|
1097
|
+
fileName,
|
|
1098
|
+
recordNumber,
|
|
1099
|
+
totalRecords: records.length,
|
|
1100
|
+
validSoFar: validRecords.length,
|
|
1101
|
+
failedSoFar: result.recordsFailed,
|
|
1102
|
+
progress: `${((recordNumber / records.length) * 100).toFixed(1)}%`,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
try {
|
|
1107
|
+
// GraphQLMutationMapper returns { query, variables } directly
|
|
1108
|
+
const mapped = await mapper.map(rec);
|
|
1109
|
+
|
|
1110
|
+
const control = {
|
|
1111
|
+
query: mapped.query,
|
|
1112
|
+
variables: mapped.variables,
|
|
1113
|
+
input: mapped.variables.input || mapped.variables,
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
// Validate schema using input
|
|
1117
|
+
const validation = validator.validateForCreate(control.input);
|
|
1118
|
+
if (!validation.valid) {
|
|
1119
|
+
log.warn('[processFile] Validation error', { row: recordNumber, errors: validation.errors });
|
|
1120
|
+
result.recordsFailed++;
|
|
1121
|
+
result.errors.push(`Row ${recordNumber}: Validation failed - ${validation.errors.join(', ')}`);
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
validRecords.push(control);
|
|
1126
|
+
result.recordsSuccessful++;
|
|
1127
|
+
} catch (error: unknown) {
|
|
1128
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1129
|
+
log.warn('[processFile] Mapping error', { row: recordNumber, error: errorMsg });
|
|
1130
|
+
result.recordsFailed++;
|
|
1131
|
+
result.errors.push(`Row ${recordNumber}: Mapping failed - ${errorMsg}`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
log.info('[processFile] Processing complete', {
|
|
1136
|
+
fileName,
|
|
1137
|
+
total: result.recordsProcessed,
|
|
1138
|
+
valid: result.recordsSuccessful,
|
|
1139
|
+
invalid: result.recordsFailed,
|
|
1140
|
+
successRate: `${((result.recordsSuccessful / result.recordsProcessed) * 100).toFixed(1)}%`,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
result.success = true;
|
|
1144
|
+
|
|
1145
|
+
// Store valid records in result for executeMutations
|
|
1146
|
+
(result as any).validRecords = validRecords;
|
|
1147
|
+
|
|
1148
|
+
return result;
|
|
1149
|
+
} catch (error: unknown) {
|
|
1150
|
+
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1151
|
+
const errorDetails = {
|
|
1152
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1153
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1154
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1155
|
+
};
|
|
1156
|
+
log.error('[processFile] Failed to process file', errorDetails, { fileName });
|
|
1157
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1158
|
+
result.errors.push(`File processing error: ${errorMsg}`);
|
|
1159
|
+
return result;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* ✅ NEW: Fetch all existing controls with pagination
|
|
1165
|
+
* Returns Map for O(1) lookups during upsert logic
|
|
1166
|
+
*/
|
|
1167
|
+
async function fetchExistingControls(
|
|
1168
|
+
client: any,
|
|
1169
|
+
catalogRef: string,
|
|
1170
|
+
log: any
|
|
1171
|
+
): Promise<Map<string, any>> {
|
|
1172
|
+
const controlsMap = new Map();
|
|
1173
|
+
let hasMore = true;
|
|
1174
|
+
let cursor: string | null = null;
|
|
1175
|
+
const MAX_PAGES = 50; // Safety limit
|
|
1176
|
+
|
|
1177
|
+
const query = `
|
|
1178
|
+
query GetControls($catalogRef: String!, $first: Int!, $after: String) {
|
|
1179
|
+
controlGroup(ref: $catalogRef) {
|
|
1180
|
+
controls(first: $first, after: $after) {
|
|
1181
|
+
edges {
|
|
1182
|
+
node {
|
|
1183
|
+
ref
|
|
1184
|
+
type
|
|
1185
|
+
status
|
|
1186
|
+
executionOrder
|
|
1187
|
+
}
|
|
1188
|
+
cursor
|
|
1189
|
+
}
|
|
1190
|
+
pageInfo {
|
|
1191
|
+
hasNextPage
|
|
1192
|
+
endCursor
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
`;
|
|
1198
|
+
|
|
1199
|
+
let pageCount = 0;
|
|
1200
|
+
while (hasMore && pageCount < MAX_PAGES) {
|
|
1201
|
+
try {
|
|
1202
|
+
const result = await client.graphql({
|
|
1203
|
+
query,
|
|
1204
|
+
variables: { catalogRef, first: 100, after: cursor },
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
const edges = result?.data?.controlGroup?.controls?.edges || [];
|
|
1208
|
+
edges.forEach((edge: any) => {
|
|
1209
|
+
if (edge?.node?.ref) {
|
|
1210
|
+
controlsMap.set(edge.node.ref, edge.node);
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
const pageInfo = result?.data?.controlGroup?.controls?.pageInfo;
|
|
1215
|
+
hasMore = pageInfo?.hasNextPage || false;
|
|
1216
|
+
cursor = pageInfo?.endCursor || null;
|
|
1217
|
+
pageCount++;
|
|
1218
|
+
|
|
1219
|
+
log.info(`Fetched page ${pageCount} of existing controls`, {
|
|
1220
|
+
pageCount,
|
|
1221
|
+
totalFound: controlsMap.size,
|
|
1222
|
+
});
|
|
1223
|
+
} catch (error: unknown) {
|
|
1224
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1225
|
+
log.error('Failed to fetch existing controls', {
|
|
1226
|
+
message: errorMsg,
|
|
1227
|
+
pageCount,
|
|
1228
|
+
});
|
|
1229
|
+
break;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
log.info(`Bulk query complete: ${controlsMap.size} existing controls found`);
|
|
1234
|
+
return controlsMap;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* SERVICE 2: Execute GraphQL mutations for control records
|
|
1239
|
+
* - Separates creates from updates
|
|
1240
|
+
* - Supports alias batching for creates
|
|
1241
|
+
* - Processes updates individually
|
|
1242
|
+
* - Returns mutation results
|
|
1243
|
+
*/
|
|
1244
|
+
async function executeMutations(
|
|
1245
|
+
controls: Array<{ query: string; variables: any; input: any }>,
|
|
1246
|
+
existingControlsMap: Map<string, any>,
|
|
1247
|
+
client: any,
|
|
1248
|
+
mapper: GraphQLMutationMapper,
|
|
1249
|
+
log: any,
|
|
1250
|
+
batchSize: number = 1, // ✅ Default: 1 (sequential)
|
|
1251
|
+
mutationsPerAliasBatch?: number // ✅ NEW: Alias batching parameter (default: undefined = disabled)
|
|
1252
|
+
): Promise<MutationResult[]> {
|
|
1253
|
+
const results: MutationResult[] = [];
|
|
1254
|
+
|
|
1255
|
+
log.info('[executeMutations] Starting mutations', {
|
|
1256
|
+
controlCount: controls.length,
|
|
1257
|
+
batchSize,
|
|
1258
|
+
mutationsPerAliasBatch,
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
// Separate creates from updates
|
|
1262
|
+
const toCreate: Array<{ query: string; variables: any; input: any }> = [];
|
|
1263
|
+
const toUpdate: Array<{ query: string; variables: any; input: any }> = [];
|
|
1264
|
+
|
|
1265
|
+
controls.forEach(control => {
|
|
1266
|
+
const exists = existingControlsMap.has(control.input.controlRef);
|
|
1267
|
+
if (exists) {
|
|
1268
|
+
toUpdate.push(control);
|
|
1269
|
+
} else {
|
|
1270
|
+
toCreate.push(control);
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
log.info(`Mutation breakdown: ${toCreate.length} creates, ${toUpdate.length} updates`);
|
|
1275
|
+
|
|
1276
|
+
// ✅ Process creates with alias batching if enabled
|
|
1277
|
+
if (toCreate.length > 0) {
|
|
1278
|
+
const useAliases = mutationsPerAliasBatch && mutationsPerAliasBatch > 1;
|
|
1279
|
+
|
|
1280
|
+
if (useAliases) {
|
|
1281
|
+
const createResults = await executeMutationsWithAliases(
|
|
1282
|
+
toCreate,
|
|
1283
|
+
client,
|
|
1284
|
+
mapper,
|
|
1285
|
+
log,
|
|
1286
|
+
batchSize,
|
|
1287
|
+
mutationsPerAliasBatch,
|
|
1288
|
+
'createControl'
|
|
1289
|
+
);
|
|
1290
|
+
|
|
1291
|
+
// Convert to MutationResult format
|
|
1292
|
+
toCreate.forEach((control, idx) => {
|
|
1293
|
+
if (idx < createResults.executed) {
|
|
1294
|
+
results.push({
|
|
1295
|
+
success: true,
|
|
1296
|
+
controlRef: control.input.controlRef,
|
|
1297
|
+
operation: 'create',
|
|
1298
|
+
});
|
|
1299
|
+
} else {
|
|
1300
|
+
results.push({
|
|
1301
|
+
success: false,
|
|
1302
|
+
controlRef: control.input.controlRef,
|
|
1303
|
+
operation: 'create',
|
|
1304
|
+
error: createResults.errors[idx - createResults.executed] || 'Unknown error',
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
} else {
|
|
1309
|
+
// Process creates individually
|
|
1310
|
+
for (const control of toCreate) {
|
|
1311
|
+
try {
|
|
1312
|
+
await retryWithBackoff(() =>
|
|
1313
|
+
client.graphql({
|
|
1314
|
+
query: control.query,
|
|
1315
|
+
variables: control.variables,
|
|
1316
|
+
})
|
|
1317
|
+
);
|
|
1318
|
+
results.push({
|
|
1319
|
+
success: true,
|
|
1320
|
+
controlRef: control.input.controlRef,
|
|
1321
|
+
operation: 'create',
|
|
1322
|
+
});
|
|
1323
|
+
} catch (error: unknown) {
|
|
1324
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1325
|
+
results.push({
|
|
1326
|
+
success: false,
|
|
1327
|
+
controlRef: control.input.controlRef,
|
|
1328
|
+
operation: 'create',
|
|
1329
|
+
error: errorMsg,
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// ✅ Process updates individually (conditional logic requires individual handling)
|
|
1337
|
+
for (const control of toUpdate) {
|
|
1338
|
+
const mutationResult: MutationResult = {
|
|
1339
|
+
success: false,
|
|
1340
|
+
controlRef: control.input.controlRef,
|
|
1341
|
+
operation: 'update',
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
try {
|
|
1345
|
+
await retryWithBackoff(() =>
|
|
1346
|
+
client.graphql({
|
|
1347
|
+
query: control.query,
|
|
1348
|
+
variables: control.variables,
|
|
1349
|
+
})
|
|
1350
|
+
);
|
|
1351
|
+
mutationResult.success = true;
|
|
1352
|
+
} catch (error: unknown) {
|
|
1353
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1354
|
+
mutationResult.error = errorMsg;
|
|
1355
|
+
const errorDetails = {
|
|
1356
|
+
message: errorMsg,
|
|
1357
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1358
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1359
|
+
};
|
|
1360
|
+
log.error('[executeMutations] Mutation failed', errorDetails, { ref: control.input.controlRef });
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
results.push(mutationResult);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
log.info('[executeMutations] Mutations complete', {
|
|
1367
|
+
total: results.length,
|
|
1368
|
+
successful: results.filter(r => r.success).length,
|
|
1369
|
+
failed: results.filter(r => !r.success).length,
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
return results;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* ✅ NEW: Execute mutations with GraphQL alias batching
|
|
1377
|
+
*/
|
|
1378
|
+
async function executeMutationsWithAliases(
|
|
1379
|
+
controls: Array<{ query: string; variables: any; input: any }>,
|
|
1380
|
+
client: any,
|
|
1381
|
+
mapper: GraphQLMutationMapper,
|
|
1382
|
+
log: any,
|
|
1383
|
+
maxParallel: number,
|
|
1384
|
+
mutationsPerAliasBatch: number,
|
|
1385
|
+
mutationName: string
|
|
1386
|
+
): Promise<{ executed: number; failed: number; errors: string[] }> {
|
|
1387
|
+
const results = { executed: 0, failed: 0, errors: [] as string[] };
|
|
1388
|
+
|
|
1389
|
+
const aliasBatches: Array<Array<typeof controls[0]>> = [];
|
|
1390
|
+
for (let i = 0; i < controls.length; i += mutationsPerAliasBatch) {
|
|
1391
|
+
aliasBatches.push(controls.slice(i, i + mutationsPerAliasBatch));
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
log.info(`Processing ${aliasBatches.length} alias batches`, {
|
|
1395
|
+
totalControls: controls.length,
|
|
1396
|
+
maxParallel,
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
for (let i = 0; i < aliasBatches.length; i += maxParallel) {
|
|
1400
|
+
const concurrentBatches = aliasBatches.slice(i, i + maxParallel);
|
|
1401
|
+
|
|
1402
|
+
const batchResults = await Promise.allSettled(
|
|
1403
|
+
concurrentBatches.map(async (batch) => {
|
|
1404
|
+
const { query, variables } = buildAliasedBatch(batch, mutationName);
|
|
1405
|
+
const response = await retryWithBackoff(() => client.graphql({ query, variables }), log);
|
|
1406
|
+
return parseAliasResponse(response, batch, mutationName);
|
|
1407
|
+
})
|
|
1408
|
+
);
|
|
1409
|
+
|
|
1410
|
+
batchResults.forEach((result, idx) => {
|
|
1411
|
+
if (result.status === 'fulfilled') {
|
|
1412
|
+
const batchResult = result.value;
|
|
1413
|
+
results.executed += batchResult.executed;
|
|
1414
|
+
results.failed += batchResult.failed;
|
|
1415
|
+
results.errors.push(...batchResult.errors);
|
|
1416
|
+
} else {
|
|
1417
|
+
const batch = concurrentBatches[idx];
|
|
1418
|
+
const errorMsg = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
1419
|
+
batch.forEach(control => {
|
|
1420
|
+
results.failed++;
|
|
1421
|
+
const controlRef = control.input?.controlRef || 'unknown';
|
|
1422
|
+
results.errors.push(`Failed to ${mutationName} ${controlRef}: ${errorMsg}`);
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
if (i + maxParallel < aliasBatches.length) {
|
|
1428
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
return results;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* ✅ NEW: Build aliased batch query and variables
|
|
1437
|
+
*/
|
|
1438
|
+
function buildAliasedBatch(
|
|
1439
|
+
batch: Array<{ query: string; variables: any; input: any }>,
|
|
1440
|
+
mutationName: string
|
|
1441
|
+
): { query: string; variables: Record<string, any> } {
|
|
1442
|
+
const batchSize = batch.length;
|
|
1443
|
+
const inputTypeName = `${mutationName.charAt(0).toUpperCase() + mutationName.slice(1)}Input`;
|
|
1444
|
+
|
|
1445
|
+
const variables = Array.from({ length: batchSize }, (_, i) =>
|
|
1446
|
+
`$input${i + 1}: ${inputTypeName}!`
|
|
1447
|
+
).join(', ');
|
|
1448
|
+
|
|
1449
|
+
const aliasedMutations = Array.from({ length: batchSize }, (_, i) => {
|
|
1450
|
+
const alias = `${mutationName}${i + 1}`;
|
|
1451
|
+
return ` ${alias}: ${mutationName}(input: $input${i + 1}) { ref type }`;
|
|
1452
|
+
}).join('\n');
|
|
1453
|
+
|
|
1454
|
+
const operationName = `Batch${mutationName.charAt(0).toUpperCase() + mutationName.slice(1)}s`;
|
|
1455
|
+
const query = `mutation ${operationName}(${variables}) {\n${aliasedMutations}\n}`;
|
|
1456
|
+
|
|
1457
|
+
const variablesObj: Record<string, any> = {};
|
|
1458
|
+
batch.forEach((control, index) => {
|
|
1459
|
+
const input = control.variables.input || control.variables;
|
|
1460
|
+
variablesObj[`input${index + 1}`] = input;
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
return { query, variables: variablesObj };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* ✅ NEW: Parse aliased GraphQL response
|
|
1468
|
+
*/
|
|
1469
|
+
function parseAliasResponse(
|
|
1470
|
+
response: any,
|
|
1471
|
+
batch: Array<{ query: string; variables: any; input: any }>,
|
|
1472
|
+
mutationName: string
|
|
1473
|
+
): { executed: number; failed: number; errors: string[] } {
|
|
1474
|
+
const result = { executed: 0, failed: 0, errors: [] as string[] };
|
|
1475
|
+
|
|
1476
|
+
const data = response.data || {};
|
|
1477
|
+
const errors = response.errors || [];
|
|
1478
|
+
|
|
1479
|
+
batch.forEach((control, index) => {
|
|
1480
|
+
const alias = `${mutationName}${index + 1}`;
|
|
1481
|
+
const aliasData = data[alias];
|
|
1482
|
+
const aliasErrors = errors.filter((e: unknown) =>
|
|
1483
|
+
e && typeof e === 'object' && 'path' in e && Array.isArray((e as any).path) && (e as any).path.includes(alias)
|
|
1484
|
+
);
|
|
1485
|
+
|
|
1486
|
+
if (aliasData && !aliasErrors.length) {
|
|
1487
|
+
result.executed++;
|
|
1488
|
+
} else {
|
|
1489
|
+
result.failed++;
|
|
1490
|
+
const errorMsg = aliasErrors[0] && typeof aliasErrors[0] === 'object' && 'message' in aliasErrors[0]
|
|
1491
|
+
? String((aliasErrors[0] as any).message)
|
|
1492
|
+
: 'Mutation failed';
|
|
1493
|
+
const controlRef = control.input?.controlRef || 'unknown';
|
|
1494
|
+
result.errors.push(`Failed to ${mutationName} ${controlRef}: ${errorMsg}`);
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
return result;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* SERVICE 3: Write mutation results log to S3
|
|
1503
|
+
* - Creates detailed JSON log with processing results
|
|
1504
|
+
* - Uploads to S3 logs directory with Buffer
|
|
1505
|
+
* - Returns upload success/failure
|
|
1506
|
+
*/
|
|
1507
|
+
async function writeMutationLog(
|
|
1508
|
+
s3: S3DataSource,
|
|
1509
|
+
fileName: string,
|
|
1510
|
+
fileResult: FileProcessingResult,
|
|
1511
|
+
logPrefix: string,
|
|
1512
|
+
log: any
|
|
1513
|
+
): Promise<boolean> {
|
|
1514
|
+
try {
|
|
1515
|
+
const logFileName = `${fileName.replace('.csv', '')}-log-${Date.now()}.json`;
|
|
1516
|
+
const logPath = `${logPrefix}${logFileName}`;
|
|
1517
|
+
|
|
1518
|
+
const logData = {
|
|
1519
|
+
fileName,
|
|
1520
|
+
timestamp: new Date().toISOString(),
|
|
1521
|
+
summary: {
|
|
1522
|
+
totalRecords: fileResult.recordsProcessed,
|
|
1523
|
+
successful: fileResult.recordsSuccessful,
|
|
1524
|
+
failed: fileResult.recordsFailed,
|
|
1525
|
+
mutations: {
|
|
1526
|
+
total: fileResult.mutations.length,
|
|
1527
|
+
created: fileResult.mutations.filter(m => m.operation === 'create' && m.success).length,
|
|
1528
|
+
updated: fileResult.mutations.filter(m => m.operation === 'update' && m.success).length,
|
|
1529
|
+
failed: fileResult.mutations.filter(m => !m.success).length,
|
|
1530
|
+
},
|
|
1531
|
+
},
|
|
1532
|
+
errors: fileResult.errors,
|
|
1533
|
+
mutations: fileResult.mutations,
|
|
1534
|
+
};
|
|
1535
|
+
|
|
1536
|
+
const logContent = JSON.stringify(logData, null, 2);
|
|
1537
|
+
|
|
1538
|
+
log.info('[writeMutationLog] Writing log to S3', { logPath, size: logContent.length });
|
|
1539
|
+
|
|
1540
|
+
// Upload log to S3 (uploadFile accepts string or Buffer)
|
|
1541
|
+
await s3.uploadFile(logPath, logContent);
|
|
1542
|
+
|
|
1543
|
+
log.info('[writeMutationLog] Log written successfully', { logPath });
|
|
1544
|
+
return true;
|
|
1545
|
+
} catch (error: unknown) {
|
|
1546
|
+
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1547
|
+
const errorDetails = {
|
|
1548
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1549
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1550
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
1551
|
+
};
|
|
1552
|
+
log.error('[writeMutationLog] Failed to write log', errorDetails, { fileName });
|
|
1553
|
+
return false;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// ============================================================================
|
|
1558
|
+
// MAIN WORKFLOW FUNCTION
|
|
1559
|
+
// ============================================================================
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Main workflow: Control CSV sync from S3
|
|
1563
|
+
* Per-file processing with three service functions:
|
|
1564
|
+
* 1. processFile() - Download, parse, map, validate
|
|
1565
|
+
* 2. executeMutations() - Create/update controls via GraphQL
|
|
1566
|
+
* 3. writeMutationLog() - Write detailed logs to S3
|
|
1567
|
+
*/
|
|
1568
|
+
async function runControlSync(ctx: any) {
|
|
1569
|
+
const log = ctx.log;
|
|
1570
|
+
const executionStartTime = Date.now();
|
|
1571
|
+
log.info('🚀 [WORKFLOW] Starting control sync from S3');
|
|
1572
|
+
|
|
1573
|
+
// Read activation variables
|
|
1574
|
+
const s3Bucket = ctx.activation?.getVariable('s3BucketName');
|
|
1575
|
+
const s3Region = ctx.activation?.getVariable('awsRegion') || 'us-east-1';
|
|
1576
|
+
const s3AccessKeyId = ctx.activation?.getVariable('awsAccessKeyId');
|
|
1577
|
+
const s3SecretAccessKey = ctx.activation?.getVariable('awsSecretAccessKey');
|
|
1578
|
+
const s3Prefix = ctx.activation?.getVariable('s3Prefix') || 'controls/';
|
|
1579
|
+
const maxFiles = parseInt(ctx.activation?.getVariable('maxFilesToProcess') || '10', 10);
|
|
1580
|
+
const filePattern = (ctx.activation?.getVariable('filePattern') || '.csv').toLowerCase();
|
|
1581
|
+
const enableArchival = ctx.activation?.getVariable('enableArchival') !== 'false';
|
|
1582
|
+
const enableFileTracking = ctx.activation?.getVariable('enableFileTracking') !== 'false';
|
|
1583
|
+
const validateConnection = ctx.activation?.getVariable('validateConnection') !== 'false';
|
|
1584
|
+
const archivePrefix = ctx.activation?.getVariable('archivePrefix') || 'processed/';
|
|
1585
|
+
const errorPrefix = ctx.activation?.getVariable('errorPrefix') || 'errors/';
|
|
1586
|
+
const logPrefix = ctx.activation?.getVariable('logPrefix') || 'logs/';
|
|
1587
|
+
const rateLimit = parseInt(ctx.activation?.getVariable('mutationRateLimit') || '10', 10);
|
|
1588
|
+
|
|
1589
|
+
// ✅ Configuration with defaults
|
|
1590
|
+
const mutationBatchSize = parseInt(
|
|
1591
|
+
ctx.activation?.getVariable('mutationBatchSize') || '1', // ✅ Default: 1 (sequential)
|
|
1592
|
+
10
|
|
1593
|
+
);
|
|
1594
|
+
|
|
1595
|
+
const mutationsPerAliasBatch = ctx.activation?.getVariable('mutationsPerAliasBatch')
|
|
1596
|
+
? parseInt(ctx.activation?.getVariable('mutationsPerAliasBatch') || '1', 10)
|
|
1597
|
+
: undefined; // ✅ Default: undefined (disabled, use separate requests)
|
|
1598
|
+
|
|
1599
|
+
// Validate required variables
|
|
1600
|
+
const missingVars: string[] = [];
|
|
1601
|
+
if (!s3Bucket) missingVars.push('s3BucketName');
|
|
1602
|
+
if (!s3AccessKeyId) missingVars.push('awsAccessKeyId');
|
|
1603
|
+
if (!s3SecretAccessKey) missingVars.push('awsSecretAccessKey');
|
|
1604
|
+
|
|
1605
|
+
if (missingVars.length > 0) {
|
|
1606
|
+
const errorMsg = `Missing required variables: ${missingVars.join(', ')}`;
|
|
1607
|
+
log.error('❌ ' + errorMsg);
|
|
1608
|
+
return {
|
|
1609
|
+
success: false,
|
|
1610
|
+
error: errorMsg,
|
|
1611
|
+
processed: 0,
|
|
1612
|
+
recommendation: 'Please configure the missing activation variables in Versori settings',
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
log.info('⚙️ [WORKFLOW] Configuration loaded', {
|
|
1617
|
+
bucket: s3Bucket,
|
|
1618
|
+
region: s3Region,
|
|
1619
|
+
prefix: s3Prefix,
|
|
1620
|
+
maxFiles,
|
|
1621
|
+
rateLimit,
|
|
1622
|
+
enableArchival,
|
|
1623
|
+
enableFileTracking,
|
|
1624
|
+
validateConnection,
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
// Initialize services
|
|
1628
|
+
log.info('🔌 [WORKFLOW] Initializing Fluent Commerce client');
|
|
1629
|
+
const client = await createClient(ctx);
|
|
1630
|
+
if (!client) {
|
|
1631
|
+
log.error('❌ Failed to create Fluent Commerce client');
|
|
1632
|
+
return {
|
|
1633
|
+
success: false,
|
|
1634
|
+
error: 'Failed to create Fluent Commerce client',
|
|
1635
|
+
recommendation: 'Check Fluent Commerce connection configuration in Versori',
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
log.info('✅ [WORKFLOW] Fluent Commerce client initialized');
|
|
1639
|
+
|
|
1640
|
+
// ✅ CORRECT: GraphQL mutations don't need client.setRetailerId()
|
|
1641
|
+
// Check your GraphQL schema to determine retailerId handling:
|
|
1642
|
+
// - Mandatory retailerId → Must pass it in mutation input
|
|
1643
|
+
// - Optional retailerId → Can pass it if needed
|
|
1644
|
+
// - No retailerId field → Don't pass it
|
|
1645
|
+
// See: fc-connect-sdk/docs/00-START-HERE/retailerid-configuration.md
|
|
1646
|
+
|
|
1647
|
+
log.info('☁️ [WORKFLOW] Initializing S3 data source');
|
|
1648
|
+
const s3 = new S3DataSource(
|
|
1649
|
+
{
|
|
1650
|
+
type: 'S3_CSV',
|
|
1651
|
+
connectionId: 's3-control-sync',
|
|
1652
|
+
name: 'Source S3',
|
|
1653
|
+
s3Config: {
|
|
1654
|
+
bucket: s3Bucket,
|
|
1655
|
+
region: s3Region,
|
|
1656
|
+
accessKeyId: s3AccessKeyId,
|
|
1657
|
+
secretAccessKey: s3SecretAccessKey,
|
|
1658
|
+
},
|
|
1659
|
+
},
|
|
1660
|
+
log
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
if (validateConnection) {
|
|
1664
|
+
try {
|
|
1665
|
+
log.info('🔍 [WORKFLOW] Validating S3 connection');
|
|
1666
|
+
await s3.validateConnection();
|
|
1667
|
+
log.info('✅ [WORKFLOW] S3 connection validated successfully');
|
|
1668
|
+
} catch (error: any) {
|
|
1669
|
+
log.error('❌ [WORKFLOW] S3 connection validation failed', {
|
|
1670
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1671
|
+
});
|
|
1672
|
+
return {
|
|
1673
|
+
success: false,
|
|
1674
|
+
error: 'S3 connection validation failed',
|
|
1675
|
+
details: error instanceof Error ? error.message : String(error),
|
|
1676
|
+
recommendation: 'Check S3 credentials and bucket permissions',
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
const parser = new CSVParserService();
|
|
1682
|
+
const { openKv } = ctx;
|
|
1683
|
+
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
1684
|
+
const validator = new ControlSchemaValidator();
|
|
1685
|
+
|
|
1686
|
+
// Custom resolvers for control ref building
|
|
1687
|
+
const customResolvers = {
|
|
1688
|
+
'custom.buildOrUseControlRef': (_: any, sourceData: any) => {
|
|
1689
|
+
if (sourceData.controlRef && sourceData.controlRef.trim()) {
|
|
1690
|
+
return sourceData.controlRef.trim();
|
|
1691
|
+
}
|
|
1692
|
+
return buildControlRef({
|
|
1693
|
+
controlGroupRef: sourceData.controlGroupRef,
|
|
1694
|
+
virtualCatalogueRef: sourceData.virtualCatalogueRef,
|
|
1695
|
+
controlType: sourceData.controlType,
|
|
1696
|
+
productRef: sourceData.productRef,
|
|
1697
|
+
categoryRef: sourceData.categoryRef,
|
|
1698
|
+
locationRef: sourceData.locationRef,
|
|
1699
|
+
});
|
|
1700
|
+
},
|
|
1701
|
+
'custom.resolveCatalogRef': (_: any, sourceData: any) => {
|
|
1702
|
+
if (sourceData.catalogRef && sourceData.catalogRef.trim()) {
|
|
1703
|
+
return sourceData.catalogRef.trim();
|
|
1704
|
+
}
|
|
1705
|
+
if (sourceData.controlType === 'EXCLUSION') {
|
|
1706
|
+
return sourceData.controlGroupRef?.trim() || '';
|
|
1707
|
+
} else if (sourceData.controlType === 'QUANTITY_BUFFER') {
|
|
1708
|
+
return sourceData.virtualCatalogueRef?.trim() || '';
|
|
1709
|
+
}
|
|
1710
|
+
return '';
|
|
1711
|
+
},
|
|
1712
|
+
};
|
|
1713
|
+
|
|
1714
|
+
// ✅ CRITICAL: Load mapping config from external JSON file
|
|
1715
|
+
// Mapping config uses GraphQLMutationMapper structure (nested objects, not dot notation)
|
|
1716
|
+
// File: src/config/control-mapping.json
|
|
1717
|
+
const mappingConfigJson = await import('../config/control-mapping.json', { assert: { type: 'json' } });
|
|
1718
|
+
const mappingConfig = mappingConfigJson.default;
|
|
1719
|
+
|
|
1720
|
+
// Initialize GraphQLMutationMapper with client for schema introspection
|
|
1721
|
+
const mapper = new GraphQLMutationMapper(mappingConfig, log, { fluentClient: client });
|
|
1722
|
+
|
|
1723
|
+
try {
|
|
1724
|
+
// List files from S3 (pattern filtering handled by listFiles)
|
|
1725
|
+
log.info('📂 [WORKFLOW] Listing files from S3', { prefix: s3Prefix, filePattern });
|
|
1726
|
+
const files = await s3.listFiles({
|
|
1727
|
+
prefix: s3Prefix,
|
|
1728
|
+
pattern: filePattern,
|
|
1729
|
+
maxKeys: 1000
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
const csvFiles = files
|
|
1733
|
+
.sort((a, b) => {
|
|
1734
|
+
const dateA = a.lastModified ? new Date(a.lastModified).getTime() : 0;
|
|
1735
|
+
const dateB = b.lastModified ? new Date(b.lastModified).getTime() : 0;
|
|
1736
|
+
return dateA - dateB;
|
|
1737
|
+
})
|
|
1738
|
+
.slice(0, maxFiles);
|
|
1739
|
+
|
|
1740
|
+
log.info('📋 [WORKFLOW] Files discovered', { total: files.length, toProcess: csvFiles.length });
|
|
1741
|
+
|
|
1742
|
+
// ✅ BULK QUERY: Fetch all existing controls once for efficient upsert detection
|
|
1743
|
+
const catalogRef = ctx.activation?.getVariable('catalogRef');
|
|
1744
|
+
const existingControlsMap = catalogRef
|
|
1745
|
+
? await fetchExistingControls(client, catalogRef, log)
|
|
1746
|
+
: new Map<string, any>();
|
|
1747
|
+
|
|
1748
|
+
const workflowResults = {
|
|
1749
|
+
processed: 0,
|
|
1750
|
+
skipped: 0,
|
|
1751
|
+
failed: 0,
|
|
1752
|
+
totalRecords: 0,
|
|
1753
|
+
controlsCreated: 0,
|
|
1754
|
+
controlsUpdated: 0,
|
|
1755
|
+
errors: [] as string[],
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
// Process each file using service functions
|
|
1759
|
+
for (const file of csvFiles) {
|
|
1760
|
+
const fileStartTime = Date.now();
|
|
1761
|
+
const filePath = file.path;
|
|
1762
|
+
const fileName = file.name;
|
|
1763
|
+
|
|
1764
|
+
log.info('📄 [WORKFLOW] Processing file', { fileName, filePath });
|
|
1765
|
+
|
|
1766
|
+
// Check duplicate via KV state
|
|
1767
|
+
if (enableFileTracking) {
|
|
1768
|
+
const stateKey = ['processed-files', 's3-control-sync', fileName];
|
|
1769
|
+
const existing = await kv.get(stateKey);
|
|
1770
|
+
if (existing) {
|
|
1771
|
+
log.info('⏭️ [WORKFLOW] Skipping already processed file', { fileName });
|
|
1772
|
+
workflowResults.skipped++;
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
try {
|
|
1778
|
+
// SERVICE 1: Process file (download, parse, map, validate)
|
|
1779
|
+
const fileResult = await processFile(
|
|
1780
|
+
s3,
|
|
1781
|
+
parser,
|
|
1782
|
+
mapper,
|
|
1783
|
+
validator,
|
|
1784
|
+
filePath,
|
|
1785
|
+
fileName,
|
|
1786
|
+
log
|
|
1787
|
+
);
|
|
1788
|
+
|
|
1789
|
+
if (!fileResult.success) {
|
|
1790
|
+
log.error('❌ [WORKFLOW] File processing failed', { fileName, errors: fileResult.errors });
|
|
1791
|
+
workflowResults.failed++;
|
|
1792
|
+
workflowResults.errors.push(...fileResult.errors);
|
|
1793
|
+
|
|
1794
|
+
// Move to error directory
|
|
1795
|
+
if (enableArchival) {
|
|
1796
|
+
try {
|
|
1797
|
+
await s3.moveFile(filePath, `${errorPrefix}${fileName}`);
|
|
1798
|
+
log.info('🗂️ [WORKFLOW] Moved failed file to error directory', { fileName });
|
|
1799
|
+
} catch (moveError: any) {
|
|
1800
|
+
log.error('⚠️ [WORKFLOW] Failed to move error file', {
|
|
1801
|
+
fileName,
|
|
1802
|
+
error: moveError instanceof Error ? moveError.message : String(moveError),
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
workflowResults.totalRecords += fileResult.recordsProcessed;
|
|
1810
|
+
|
|
1811
|
+
// Get valid records from result
|
|
1812
|
+
const validRecords = (fileResult as any).validRecords as ControlRecord[];
|
|
1813
|
+
|
|
1814
|
+
if (validRecords.length === 0) {
|
|
1815
|
+
log.warn('⚠️ [WORKFLOW] No valid records to process', { fileName });
|
|
1816
|
+
if (enableArchival) {
|
|
1817
|
+
try {
|
|
1818
|
+
await s3.moveFile(filePath, `${archivePrefix}${fileName}`);
|
|
1819
|
+
log.info('📦 [WORKFLOW] Archived empty file', { fileName });
|
|
1820
|
+
} catch (moveError: any) {
|
|
1821
|
+
log.error('⚠️ [WORKFLOW] Failed to archive empty file', {
|
|
1822
|
+
fileName,
|
|
1823
|
+
error: moveError instanceof Error ? moveError.message : String(moveError),
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
workflowResults.skipped++;
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// SERVICE 2: Execute mutations (create/update controls)
|
|
1832
|
+
// ✅ Configuration with defaults
|
|
1833
|
+
const mutationBatchSize = parseInt(
|
|
1834
|
+
ctx.activation?.getVariable('mutationBatchSize') || '1', // ✅ Default: 1 (sequential)
|
|
1835
|
+
10
|
|
1836
|
+
);
|
|
1837
|
+
|
|
1838
|
+
const mutationsPerAliasBatch = ctx.activation?.getVariable('mutationsPerAliasBatch')
|
|
1839
|
+
? parseInt(ctx.activation?.getVariable('mutationsPerAliasBatch') || '1', 10)
|
|
1840
|
+
: undefined; // ✅ Default: undefined (disabled, use separate requests)
|
|
1841
|
+
|
|
1842
|
+
log.info('🔄 [WORKFLOW] Executing mutations', {
|
|
1843
|
+
fileName,
|
|
1844
|
+
controlCount: validRecords.length,
|
|
1845
|
+
batchSize: mutationBatchSize,
|
|
1846
|
+
aliasBatching: mutationsPerAliasBatch ? `enabled (${mutationsPerAliasBatch})` : 'disabled',
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
// ? Enhanced: Extract context for progress logging
|
|
1850
|
+
const sampleControlRefs = validRecords.slice(0, 5).map((r: any) => r.input?.controlRef || 'unknown');
|
|
1851
|
+
const mutationType = mapper?.mutationName || 'createControl';
|
|
1852
|
+
|
|
1853
|
+
// ? Enhanced: Start logging with context
|
|
1854
|
+
log.info(`[GraphQLMutations] Sending mutations for file "${fileName}"`, {
|
|
1855
|
+
totalMutations: validRecords.length,
|
|
1856
|
+
mutationType,
|
|
1857
|
+
batchSize: mutationBatchSize,
|
|
1858
|
+
batchMode: mutationBatchSize === 1 ? 'sequential' : `parallel (${mutationBatchSize})`,
|
|
1859
|
+
sampleControlRefs: sampleControlRefs.join(', '),
|
|
1860
|
+
aliasBatching: mutationsPerAliasBatch ? `enabled (${mutationsPerAliasBatch} per alias)` : 'disabled'
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
const mutationResults = await executeMutations(
|
|
1864
|
+
validRecords,
|
|
1865
|
+
existingControlsMap,
|
|
1866
|
+
client,
|
|
1867
|
+
mapper,
|
|
1868
|
+
log,
|
|
1869
|
+
mutationBatchSize, // Concurrency control (default: 1)
|
|
1870
|
+
mutationsPerAliasBatch // ✅ NEW: Alias batching (default: undefined)
|
|
1871
|
+
);
|
|
1872
|
+
|
|
1873
|
+
// Update workflow results
|
|
1874
|
+
const created = mutationResults.filter(m => m.operation === 'create' && m.success).length;
|
|
1875
|
+
const updated = mutationResults.filter(m => m.operation === 'update' && m.success).length;
|
|
1876
|
+
const failed = mutationResults.filter(m => !m.success).length;
|
|
1877
|
+
|
|
1878
|
+
// ? Enhanced: Completion logging with summary
|
|
1879
|
+
const successRate = validRecords.length > 0 ? Math.round(((created + updated) / validRecords.length) * 100) : 0;
|
|
1880
|
+
log.info(`✅ [GraphQLMutations] Mutation submission completed for file "${fileName}"`, {
|
|
1881
|
+
totalMutations: validRecords.length,
|
|
1882
|
+
created,
|
|
1883
|
+
updated,
|
|
1884
|
+
failed,
|
|
1885
|
+
successRate: `${successRate}%`,
|
|
1886
|
+
mutationType,
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
workflowResults.controlsCreated += created;
|
|
1890
|
+
workflowResults.controlsUpdated += updated;
|
|
1891
|
+
|
|
1892
|
+
// Store mutation results in fileResult for logging
|
|
1893
|
+
fileResult.mutations = mutationResults;
|
|
1894
|
+
|
|
1895
|
+
const fileDuration = Date.now() - fileStartTime;
|
|
1896
|
+
log.info('✅ [WORKFLOW] Mutations complete', {
|
|
1897
|
+
fileName,
|
|
1898
|
+
created,
|
|
1899
|
+
updated,
|
|
1900
|
+
failed,
|
|
1901
|
+
duration: `${fileDuration}ms`,
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
// SERVICE 3: Write mutation log to S3
|
|
1905
|
+
await writeMutationLog(s3, fileName, fileResult, logPrefix, log);
|
|
1906
|
+
|
|
1907
|
+
// Mark file as processed in KV state
|
|
1908
|
+
if (enableFileTracking) {
|
|
1909
|
+
const stateKey = ['processed-files', 's3-control-sync', fileName];
|
|
1910
|
+
await kv.set(stateKey, {
|
|
1911
|
+
processedAt: new Date().toISOString(),
|
|
1912
|
+
recordsProcessed: fileResult.recordsProcessed,
|
|
1913
|
+
recordsSuccessful: fileResult.recordsSuccessful,
|
|
1914
|
+
recordsFailed: fileResult.recordsFailed,
|
|
1915
|
+
controlsCreated: created,
|
|
1916
|
+
controlsUpdated: updated,
|
|
1917
|
+
duration: fileDuration,
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// Archive processed file
|
|
1922
|
+
if (enableArchival) {
|
|
1923
|
+
try {
|
|
1924
|
+
await s3.moveFile(filePath, `${archivePrefix}${fileName}`);
|
|
1925
|
+
log.info('📦 [WORKFLOW] File archived', { fileName, destination: archivePrefix });
|
|
1926
|
+
} catch (moveError: any) {
|
|
1927
|
+
log.error('⚠️ [WORKFLOW] Failed to archive file', {
|
|
1928
|
+
fileName,
|
|
1929
|
+
error: moveError instanceof Error ? moveError.message : String(moveError),
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
workflowResults.processed++;
|
|
1935
|
+
log.info('✅ [WORKFLOW] File complete', { fileName, duration: `${fileDuration}ms` });
|
|
1936
|
+
} catch (error: any) {
|
|
1937
|
+
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
1938
|
+
const errorDetails = {
|
|
1939
|
+
message: error?.message || 'Unknown error',
|
|
1940
|
+
stack: error?.stack,
|
|
1941
|
+
fileName: error?.fileName,
|
|
1942
|
+
lineNumber: error?.lineNumber,
|
|
1943
|
+
originalError: error?.context?.originalError?.message,
|
|
1944
|
+
errorType: error?.name || 'Error',
|
|
1945
|
+
};
|
|
1946
|
+
log.error('❌ [WORKFLOW] File processing error', errorDetails, { fileName });
|
|
1947
|
+
workflowResults.failed++;
|
|
1948
|
+
workflowResults.errors.push({
|
|
1949
|
+
file: fileName,
|
|
1950
|
+
error: error.message,
|
|
1951
|
+
recommendation: getErrorRecommendation(error),
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
// Move to error directory
|
|
1955
|
+
if (enableArchival) {
|
|
1956
|
+
try {
|
|
1957
|
+
await s3.moveFile(filePath, `${errorPrefix}${fileName}`);
|
|
1958
|
+
log.info('🗂️ [WORKFLOW] Moved failed file to error directory', { fileName });
|
|
1959
|
+
} catch (moveError: any) {
|
|
1960
|
+
log.error('⚠️ [WORKFLOW] Failed to move error file', {
|
|
1961
|
+
fileName,
|
|
1962
|
+
error: moveError instanceof Error ? moveError.message : String(moveError),
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// Track error state with exponential backoff
|
|
1968
|
+
const errorKey = ['error-state', fileName];
|
|
1969
|
+
const prev = (await kv.get(errorKey))?.value as any;
|
|
1970
|
+
const attempts = (prev?.attemptCount || 0) + 1;
|
|
1971
|
+
const backoffMinutes = Math.min(Math.pow(2, attempts) * 5, 24 * 60);
|
|
1972
|
+
const nextRetryAt = new Date(Date.now() + backoffMinutes * 60000).toISOString();
|
|
1973
|
+
|
|
1974
|
+
await kv.set(errorKey, {
|
|
1975
|
+
fileName,
|
|
1976
|
+
attemptCount: attempts,
|
|
1977
|
+
lastError: error?.message || 'unknown',
|
|
1978
|
+
lastAttemptAt: new Date().toISOString(),
|
|
1979
|
+
firstFailedAt: prev?.firstFailedAt || new Date().toISOString(),
|
|
1980
|
+
nextRetryAt,
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// Final summary
|
|
1986
|
+
const executionDuration = Date.now() - executionStartTime;
|
|
1987
|
+
const summary = {
|
|
1988
|
+
success: true,
|
|
1989
|
+
processed: workflowResults.processed,
|
|
1990
|
+
skipped: workflowResults.skipped,
|
|
1991
|
+
failed: workflowResults.failed,
|
|
1992
|
+
totalRecords: workflowResults.totalRecords,
|
|
1993
|
+
controlsCreated: workflowResults.controlsCreated,
|
|
1994
|
+
controlsUpdated: workflowResults.controlsUpdated,
|
|
1995
|
+
errors: workflowResults.errors.length > 0 ? workflowResults.errors : undefined,
|
|
1996
|
+
duration: executionDuration,
|
|
1997
|
+
durationFormatted: `${(executionDuration / 1000).toFixed(2)}s`,
|
|
1998
|
+
timestamp: new Date().toISOString(),
|
|
1999
|
+
};
|
|
2000
|
+
|
|
2001
|
+
log.info('🎉 [WORKFLOW] Control sync completed', summary);
|
|
2002
|
+
return summary;
|
|
2003
|
+
} catch (error: unknown) {
|
|
2004
|
+
// ✅ Enhanced error logging: Extract all error details for visibility
|
|
2005
|
+
const errorDetails = {
|
|
2006
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2007
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
2008
|
+
errorType: error instanceof Error ? error.constructor.name : 'Error',
|
|
2009
|
+
};
|
|
2010
|
+
log.error('❌ [WORKFLOW] Sync failed', errorDetails);
|
|
2011
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2012
|
+
const executionDuration = Date.now() - executionStartTime;
|
|
2013
|
+
return {
|
|
2014
|
+
success: false,
|
|
2015
|
+
error: errorMsg,
|
|
2016
|
+
recommendation: getErrorRecommendation(error),
|
|
2017
|
+
processed: 0,
|
|
2018
|
+
duration: executionDuration,
|
|
2019
|
+
durationFormatted: `${(executionDuration / 1000).toFixed(2)}s`,
|
|
2020
|
+
timestamp: new Date().toISOString(),
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
/**
|
|
2026
|
+
* Get error recommendation based on error type
|
|
2027
|
+
*/
|
|
2028
|
+
function getErrorRecommendation(error: any): string {
|
|
2029
|
+
const errorMsg = (error?.message || '').toLowerCase();
|
|
2030
|
+
|
|
2031
|
+
if (errorMsg.includes('credentials') || errorMsg.includes('access denied')) {
|
|
2032
|
+
return 'Check AWS credentials and S3 bucket permissions';
|
|
2033
|
+
}
|
|
2034
|
+
if (errorMsg.includes('bucket') || errorMsg.includes('not found')) {
|
|
2035
|
+
return 'Verify S3 bucket name and region configuration';
|
|
2036
|
+
}
|
|
2037
|
+
if (errorMsg.includes('connection') || errorMsg.includes('network')) {
|
|
2038
|
+
return 'Check network connectivity and firewall settings';
|
|
2039
|
+
}
|
|
2040
|
+
if (errorMsg.includes('parse') || errorMsg.includes('invalid csv')) {
|
|
2041
|
+
return 'Verify CSV file format and structure';
|
|
2042
|
+
}
|
|
2043
|
+
if (errorMsg.includes('validation') || errorMsg.includes('required field')) {
|
|
2044
|
+
return 'Review control data validation rules and required fields';
|
|
2045
|
+
}
|
|
2046
|
+
if (errorMsg.includes('graphql') || errorMsg.includes('mutation')) {
|
|
2047
|
+
return 'Check GraphQL schema and mutation configuration';
|
|
2048
|
+
}
|
|
2049
|
+
if (errorMsg.includes('rate limit') || errorMsg.includes('throttl')) {
|
|
2050
|
+
return 'Reduce mutationRateLimit or mutationBatchSize in activation variables';
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
return 'Review error logs and activation variables configuration';
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// Scheduled daily run
|
|
2057
|
+
export const scheduledControlSync = schedule('s3-control-daily', '0 2 * * *').then(
|
|
2058
|
+
http('sync-controls', { connection: 'fluent_commerce' }, async ctx => {
|
|
2059
|
+
return await runControlSync(ctx);
|
|
2060
|
+
})
|
|
2061
|
+
);
|
|
2062
|
+
|
|
2063
|
+
// Manual trigger
|
|
2064
|
+
export const syncNow = webhook('sync-controls-now').then(
|
|
2065
|
+
http('sync-controls-manual', { connection: 'fluent_commerce' }, async ctx => {
|
|
2066
|
+
return await runControlSync(ctx);
|
|
2067
|
+
})
|
|
2068
|
+
);
|
|
2069
|
+
|
|
2070
|
+
// Status check
|
|
2071
|
+
export const checkStatus = webhook('check-status').then(
|
|
2072
|
+
fn('get-status', async (ctx: any) => {
|
|
2073
|
+
const kv = new VersoriKVAdapter(ctx.openKv(':project:'));
|
|
2074
|
+
const processedKeys = await kv.list({ prefix: ['processed-files', 's3-control-sync'] });
|
|
2075
|
+
return {
|
|
2076
|
+
success: true,
|
|
2077
|
+
recentFiles: processedKeys.slice(0, 10),
|
|
2078
|
+
timestamp: new Date().toISOString(),
|
|
2079
|
+
};
|
|
2080
|
+
})
|
|
2081
|
+
);
|
|
2082
|
+
```
|
|
2083
|
+
|
|
2084
|
+
## Key Patterns Explained
|
|
2085
|
+
|
|
2086
|
+
### Pattern 1: Direct KV State Management
|
|
2087
|
+
|
|
2088
|
+
```typescript
|
|
2089
|
+
const kv = new VersoriKVAdapter(ctx.openKv(':project:'));
|
|
2090
|
+
|
|
2091
|
+
// Check if file already processed
|
|
2092
|
+
const stateKey = ['processed-files', 's3-control-sync', fileName];
|
|
2093
|
+
const existing = await kv.get(stateKey);
|
|
2094
|
+
if (existing) {
|
|
2095
|
+
log.info('Skipping already processed file', { fileName });
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// Mark as processed after success
|
|
2100
|
+
await kv.set(stateKey, {
|
|
2101
|
+
successful,
|
|
2102
|
+
failed,
|
|
2103
|
+
processedAt: new Date().toISOString(),
|
|
2104
|
+
});
|
|
2105
|
+
```
|
|
2106
|
+
|
|
2107
|
+
### Pattern 2: Rate-Limited GraphQL Mutations
|
|
2108
|
+
|
|
2109
|
+
```typescript
|
|
2110
|
+
async function rateLimitedMutation(operation: () => Promise<any>, delayMs: number) {
|
|
2111
|
+
const result = await operation();
|
|
2112
|
+
if (delayMs > 0) {
|
|
2113
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
2114
|
+
}
|
|
2115
|
+
return result;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// Configure rate limit (10 mutations/sec = 100ms delay)
|
|
2119
|
+
const rateLimit = 10;
|
|
2120
|
+
const delayMs = Math.floor(1000 / rateLimit);
|
|
2121
|
+
```
|
|
2122
|
+
|
|
2123
|
+
### Pattern 3: S3 File Operations with Retry
|
|
2124
|
+
|
|
2125
|
+
```typescript
|
|
2126
|
+
// List files with prefix filtering
|
|
2127
|
+
const files = await s3.listFiles({ prefix: 'controls/', maxKeys: 1000 });
|
|
2128
|
+
|
|
2129
|
+
// Download with retry
|
|
2130
|
+
const content = await retryWithBackoff(
|
|
2131
|
+
() => s3.downloadFile(filePath, { encoding: 'utf8' }) as Promise<string>
|
|
2132
|
+
);
|
|
2133
|
+
|
|
2134
|
+
// Move file (archive or error)
|
|
2135
|
+
await s3.moveFile('controls/file.csv', 'processed/file.csv');
|
|
2136
|
+
```
|
|
2137
|
+
|
|
2138
|
+
### Pattern 4: Control Ref Building with Custom Resolvers
|
|
2139
|
+
|
|
2140
|
+
```typescript
|
|
2141
|
+
const customResolvers = {
|
|
2142
|
+
'custom.buildOrUseControlRef': (_: any, sourceData: any) => {
|
|
2143
|
+
// If pre-built ref exists, use it
|
|
2144
|
+
if (sourceData.controlRef?.trim()) return sourceData.controlRef.trim();
|
|
2145
|
+
|
|
2146
|
+
// Otherwise build from components
|
|
2147
|
+
return buildControlRef({ ...sourceData });
|
|
2148
|
+
},
|
|
2149
|
+
};
|
|
2150
|
+
```
|
|
2151
|
+
|
|
2152
|
+
### Pattern 5: UniversalMapper with Custom Resolvers
|
|
2153
|
+
|
|
2154
|
+
Available SDK Resolvers:
|
|
2155
|
+
|
|
2156
|
+
- **String**: `sdk.trim`, `sdk.uppercase`, `sdk.lowercase`, `sdk.toString`
|
|
2157
|
+
- **Number**: `sdk.parseInt`, `sdk.parseFloat`, `sdk.number`
|
|
2158
|
+
- **Date**: `sdk.formatDate`, `sdk.formatDateShort`, `sdk.parseDate`
|
|
2159
|
+
- **Type**: `sdk.boolean`, `sdk.parseJson`, `sdk.toJson`
|
|
2160
|
+
- **Utility**: `sdk.identity`, `sdk.coalesce`
|
|
2161
|
+
|
|
2162
|
+
## Schema Validation (Before Deployment)
|
|
2163
|
+
|
|
2164
|
+
Use SDK CLI tools to validate your GraphQL schema:
|
|
2165
|
+
|
|
2166
|
+
```bash
|
|
2167
|
+
# Install SDK globally (or use npx)
|
|
2168
|
+
npm install -g @fluentcommerce/fc-connect-sdk
|
|
2169
|
+
|
|
2170
|
+
# 1. Introspect Fluent GraphQL schema
|
|
2171
|
+
fc-connect introspect-schema \
|
|
2172
|
+
--url https://api.fluentcommerce.com/graphql \
|
|
2173
|
+
--client-id YOUR_CLIENT_ID \
|
|
2174
|
+
--client-secret YOUR_CLIENT_SECRET \
|
|
2175
|
+
--output fluent-schema.json
|
|
2176
|
+
|
|
2177
|
+
# 2. Create mutation file
|
|
2178
|
+
cat > control-mutation.graphql << 'EOF'
|
|
2179
|
+
mutation CreateControl(
|
|
2180
|
+
$controlRef: String!
|
|
2181
|
+
$catalogRef: String!
|
|
2182
|
+
$controlType: String!
|
|
2183
|
+
$executionOrder: Int!
|
|
2184
|
+
$value: Json!
|
|
2185
|
+
) {
|
|
2186
|
+
createControl(input: {
|
|
2187
|
+
type: $controlType
|
|
2188
|
+
ref: $controlRef
|
|
2189
|
+
name: $controlRef
|
|
2190
|
+
values: { name: "CONTROL_VALUE", type: "INTEGER", value: $value }
|
|
2191
|
+
controlGroup: { ref: $catalogRef }
|
|
2192
|
+
executionOrder: $executionOrder
|
|
2193
|
+
}) {
|
|
2194
|
+
ref
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
EOF
|
|
2198
|
+
|
|
2199
|
+
# 3. Generate mapping from mutation
|
|
2200
|
+
fc-connect generate-mutation-mapping \
|
|
2201
|
+
--file control-mutation.graphql \
|
|
2202
|
+
--output control-mapping.json
|
|
2203
|
+
|
|
2204
|
+
# 4. Validate mapping against schema
|
|
2205
|
+
fc-connect validate-schema \
|
|
2206
|
+
--mapping control-mapping.json \
|
|
2207
|
+
--schema fluent-schema.json
|
|
2208
|
+
|
|
2209
|
+
# 5. Analyze field coverage
|
|
2210
|
+
fc-connect analyze-coverage \
|
|
2211
|
+
--mapping control-mapping.json \
|
|
2212
|
+
--schema fluent-schema.json
|
|
2213
|
+
```
|
|
2214
|
+
|
|
2215
|
+
**Output Example**:
|
|
2216
|
+
|
|
2217
|
+
```bash
|
|
2218
|
+
✓ Schema validation passed
|
|
2219
|
+
✓ All required fields present: ref, type, executionOrder, values
|
|
2220
|
+
✓ Mutation structure matches GraphQL schema
|
|
2221
|
+
⚠ Optional fields not mapped: status, description
|
|
2222
|
+
```
|
|
2223
|
+
|
|
2224
|
+
## Testing the Workflow
|
|
2225
|
+
|
|
2226
|
+
### 1. Upload Test CSV to S3
|
|
2227
|
+
|
|
2228
|
+
```bash
|
|
2229
|
+
aws s3 cp controls-test.csv s3://my-controls-bucket/controls/
|
|
2230
|
+
```
|
|
2231
|
+
|
|
2232
|
+
### 2. Deploy to Versori
|
|
2233
|
+
|
|
2234
|
+
```bash
|
|
2235
|
+
npm run deploy
|
|
2236
|
+
```
|
|
2237
|
+
|
|
2238
|
+
### 3. Manual Testing via Webhook
|
|
2239
|
+
|
|
2240
|
+
```bash
|
|
2241
|
+
curl -X POST https://your-workspace.versori.run/sync-controls-now
|
|
2242
|
+
```
|
|
2243
|
+
|
|
2244
|
+
### 4. Check Status
|
|
2245
|
+
|
|
2246
|
+
```bash
|
|
2247
|
+
curl https://your-workspace.versori.run/check-status
|
|
2248
|
+
```
|
|
2249
|
+
|
|
2250
|
+
### 5. Monitor Logs
|
|
2251
|
+
|
|
2252
|
+
```bash
|
|
2253
|
+
npm run logs
|
|
2254
|
+
# Or via Versori dashboard
|
|
2255
|
+
```
|
|
2256
|
+
|
|
2257
|
+
---
|
|
2258
|
+
|
|
2259
|
+
## Monitoring
|
|
2260
|
+
|
|
2261
|
+
### Success Response
|
|
2262
|
+
|
|
2263
|
+
```json
|
|
2264
|
+
{
|
|
2265
|
+
"success": true,
|
|
2266
|
+
"filesProcessed": 1,
|
|
2267
|
+
"filesSkipped": 0,
|
|
2268
|
+
"filesFailed": 0,
|
|
2269
|
+
"totalRecords": 50,
|
|
2270
|
+
"mutationsExecuted": 50,
|
|
2271
|
+
"mutationsFailed": 0,
|
|
2272
|
+
"results": [
|
|
2273
|
+
{
|
|
2274
|
+
"file": "controls_2025-01-22.csv",
|
|
2275
|
+
"success": true,
|
|
2276
|
+
"recordsProcessed": 50,
|
|
2277
|
+
"mutationsExecuted": 50,
|
|
2278
|
+
"mutationsFailed": 0
|
|
2279
|
+
}
|
|
2280
|
+
],
|
|
2281
|
+
"duration": 12345
|
|
2282
|
+
}
|
|
2283
|
+
```
|
|
2284
|
+
|
|
2285
|
+
### Partial Success Response
|
|
2286
|
+
|
|
2287
|
+
```json
|
|
2288
|
+
{
|
|
2289
|
+
"success": true,
|
|
2290
|
+
"filesProcessed": 1,
|
|
2291
|
+
"filesSkipped": 0,
|
|
2292
|
+
"filesFailed": 0,
|
|
2293
|
+
"totalRecords": 50,
|
|
2294
|
+
"mutationsExecuted": 45,
|
|
2295
|
+
"mutationsFailed": 5,
|
|
2296
|
+
"results": [
|
|
2297
|
+
{
|
|
2298
|
+
"file": "controls_2025-01-22.csv",
|
|
2299
|
+
"success": true,
|
|
2300
|
+
"recordsProcessed": 50,
|
|
2301
|
+
"mutationsExecuted": 45,
|
|
2302
|
+
"mutationsFailed": 5,
|
|
2303
|
+
"errors": ["CTRL-001: Invalid control ref", "CTRL-002: Missing required field"]
|
|
2304
|
+
}
|
|
2305
|
+
],
|
|
2306
|
+
"duration": 12345
|
|
2307
|
+
}
|
|
2308
|
+
```
|
|
2309
|
+
|
|
2310
|
+
### Error Response
|
|
2311
|
+
|
|
2312
|
+
```json
|
|
2313
|
+
{
|
|
2314
|
+
"success": false,
|
|
2315
|
+
"filesProcessed": 0,
|
|
2316
|
+
"filesFailed": 1,
|
|
2317
|
+
"totalRecords": 0,
|
|
2318
|
+
"mutationsExecuted": 0,
|
|
2319
|
+
"mutationsFailed": 0,
|
|
2320
|
+
"results": [
|
|
2321
|
+
{
|
|
2322
|
+
"file": "controls_2025-01-22.csv",
|
|
2323
|
+
"success": false,
|
|
2324
|
+
"error": "CSV parse error: Invalid structure"
|
|
2325
|
+
}
|
|
2326
|
+
],
|
|
2327
|
+
"duration": 876
|
|
2328
|
+
}
|
|
2329
|
+
```
|
|
2330
|
+
|
|
2331
|
+
### Monitoring Metrics
|
|
2332
|
+
|
|
2333
|
+
Monitor these metrics via Versori logs filtered by `jobId` or `stage`:
|
|
2334
|
+
|
|
2335
|
+
- **Files Processed** - Total files successfully processed
|
|
2336
|
+
- **Mutations Executed** - Total GraphQL mutations executed successfully
|
|
2337
|
+
- **Mutations Failed** - Mutations that failed (check error logs)
|
|
2338
|
+
- **Processing Duration** - Time taken for complete workflow
|
|
2339
|
+
- **Rate Limiting** - Watch for 429 errors indicating GraphQL throttling
|
|
2340
|
+
|
|
2341
|
+
Use the status webhook for dashboards and automated monitoring.
|
|
2342
|
+
|
|
2343
|
+
---
|
|
2344
|
+
|
|
2345
|
+
### Issue 1: Duplicate File Processing
|
|
2346
|
+
|
|
2347
|
+
**Solution**:
|
|
2348
|
+
|
|
2349
|
+
```typescript
|
|
2350
|
+
const stateKey = ['processed-files', 's3-control-sync', fileName];
|
|
2351
|
+
const existing = await kv.get(stateKey);
|
|
2352
|
+
if (existing) continue;
|
|
2353
|
+
await kv.set(stateKey, { processedAt: new Date().toISOString() });
|
|
2354
|
+
```
|
|
2355
|
+
|
|
2356
|
+
### Issue 2: "EXCLUSION must have value=0"
|
|
2357
|
+
|
|
2358
|
+
**Solution**: Update CSV - EXCLUSION controls must always have value=0
|
|
2359
|
+
|
|
2360
|
+
### Issue 3: "controlRef pattern mismatch"
|
|
2361
|
+
|
|
2362
|
+
**Solution**: Verify required fields are populated:
|
|
2363
|
+
|
|
2364
|
+
- EXCLUSION: `controlGroupRef` + (`productRef` OR `categoryRef`)
|
|
2365
|
+
- QUANTITY_BUFFER: `virtualCatalogueRef` + (`productRef` OR `categoryRef` OR `locationRef`)
|
|
2366
|
+
|
|
2367
|
+
### Issue 4: S3 Access Denied
|
|
2368
|
+
|
|
2369
|
+
**Required IAM Permissions**:
|
|
2370
|
+
|
|
2371
|
+
```json
|
|
2372
|
+
{
|
|
2373
|
+
"Version": "2012-10-17",
|
|
2374
|
+
"Statement": [
|
|
2375
|
+
{
|
|
2376
|
+
"Effect": "Allow",
|
|
2377
|
+
"Action": ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
|
2378
|
+
"Resource": [
|
|
2379
|
+
"arn:aws:s3:::my-controls-bucket",
|
|
2380
|
+
"arn:aws:s3:::my-controls-bucket/*"
|
|
2381
|
+
]
|
|
2382
|
+
}
|
|
2383
|
+
]
|
|
2384
|
+
}
|
|
2385
|
+
```
|
|
2386
|
+
|
|
2387
|
+
### Issue 5: Rate Limiting
|
|
2388
|
+
|
|
2389
|
+
**Solution**:
|
|
2390
|
+
|
|
2391
|
+
```typescript
|
|
2392
|
+
// Adjust rate limit in activation variables
|
|
2393
|
+
mutationRateLimit = 5; // Reduce to 5 mutations/sec
|
|
2394
|
+
```
|
|
2395
|
+
|
|
2396
|
+
## Production Checklist
|
|
2397
|
+
|
|
2398
|
+
- [ ] S3 credentials validated with correct IAM permissions
|
|
2399
|
+
- [ ] Activation secrets stored securely
|
|
2400
|
+
- [ ] GraphQL schema validated using CLI tools
|
|
2401
|
+
- [ ] Mapping configuration tested with sample data
|
|
2402
|
+
- [ ] File duplicate prevention working via KV state
|
|
2403
|
+
- [ ] Rate limiting configured appropriately
|
|
2404
|
+
- [ ] Control validation rules tested
|
|
2405
|
+
- [ ] Error handling tested with malformed CSV
|
|
2406
|
+
- [ ] Retry logic tested with transient failures
|
|
2407
|
+
- [ ] File archival working (processed and error directories)
|
|
2408
|
+
- [ ] Monitoring/alerting configured for failures
|
|
2409
|
+
- [ ] Clear runbook for error recovery
|
|
2410
|
+
|
|
2411
|
+
## Related Guides
|
|
2412
|
+
|
|
2413
|
+
- **SFTP Version**: [sftp-csv-control-to-graphql.md](template-ingestion-sftp-csv-control-graphql.md)
|
|
2414
|
+
- **Universal Mapping**: `docs/02-CORE-GUIDES/mapping/modules/`
|
|
2415
|
+
- **CLI Tools**: `fc-connect-sdk/bin/readme.md`
|
|
2416
|
+
- **State & KV patterns**: `docs/03-PATTERN-GUIDES/file-operations/`
|
|
2417
|
+
- **Error handling**: `docs/03-PATTERN-GUIDES/error-handling/`
|