@fluentcommerce/fc-connect-sdk 0.1.53 → 0.1.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -2
- package/README.md +39 -0
- package/dist/cjs/auth/index.d.ts +3 -0
- package/dist/cjs/auth/index.js +13 -0
- package/dist/cjs/auth/profile-loader.d.ts +18 -0
- package/dist/cjs/auth/profile-loader.js +208 -0
- package/dist/cjs/client-factory.d.ts +4 -0
- package/dist/cjs/client-factory.js +10 -0
- package/dist/cjs/clients/fluent-client.js +13 -6
- package/dist/cjs/index.d.ts +3 -1
- package/dist/cjs/index.js +8 -2
- package/dist/cjs/utils/pagination-helpers.js +38 -2
- package/dist/cjs/versori/fluent-versori-client.js +11 -5
- package/dist/esm/auth/index.d.ts +3 -0
- package/dist/esm/auth/index.js +2 -0
- package/dist/esm/auth/profile-loader.d.ts +18 -0
- package/dist/esm/auth/profile-loader.js +169 -0
- package/dist/esm/client-factory.d.ts +4 -0
- package/dist/esm/client-factory.js +9 -0
- package/dist/esm/clients/fluent-client.js +13 -6
- package/dist/esm/index.d.ts +3 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/utils/pagination-helpers.js +38 -2
- package/dist/esm/versori/fluent-versori-client.js +11 -5
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/tsconfig.types.tsbuildinfo +1 -1
- package/dist/types/auth/index.d.ts +3 -0
- package/dist/types/auth/profile-loader.d.ts +18 -0
- package/dist/types/client-factory.d.ts +4 -0
- package/dist/types/index.d.ts +3 -1
- package/docs/00-START-HERE/EXPORT-VALIDATION.md +158 -158
- package/docs/00-START-HERE/cli-analyze-source-structure-guide.md +655 -655
- package/docs/00-START-HERE/cli-documentation-index.md +202 -202
- package/docs/00-START-HERE/cli-quick-reference.md +252 -252
- package/docs/00-START-HERE/decision-tree.md +552 -552
- package/docs/00-START-HERE/getting-started.md +1070 -1070
- package/docs/00-START-HERE/mapper-quick-decision-guide.md +235 -235
- package/docs/00-START-HERE/readme.md +237 -237
- package/docs/00-START-HERE/retailerid-configuration.md +404 -404
- package/docs/00-START-HERE/sdk-philosophy.md +794 -794
- package/docs/00-START-HERE/troubleshooting-quick-reference.md +1086 -1086
- package/docs/01-TEMPLATES/faq.md +686 -686
- package/docs/01-TEMPLATES/patterns/pattern-templates-guide.md +68 -68
- package/docs/01-TEMPLATES/patterns/patterns-csv-schema-validation-and-rejection-report.md +233 -233
- package/docs/01-TEMPLATES/patterns/patterns-custom-resolvers.md +407 -407
- package/docs/01-TEMPLATES/patterns/patterns-error-handling-retry.md +511 -511
- package/docs/01-TEMPLATES/patterns/patterns-field-mapping-universal.md +701 -701
- package/docs/01-TEMPLATES/patterns/patterns-large-file-splitting.md +1430 -1430
- package/docs/01-TEMPLATES/patterns/patterns-master-data-etl.md +2399 -2399
- package/docs/01-TEMPLATES/patterns/patterns-pagination-streaming.md +447 -447
- package/docs/01-TEMPLATES/patterns/patterns-state-duplicate-prevention.md +385 -385
- package/docs/01-TEMPLATES/readme.md +957 -957
- package/docs/01-TEMPLATES/standalone/standalone-asn-inbound-processing.md +1209 -1209
- package/docs/01-TEMPLATES/standalone/standalone-graphql-query-export.md +1140 -1140
- package/docs/01-TEMPLATES/standalone/standalone-graphql-to-parquet-partitioned-s3.md +432 -432
- package/docs/01-TEMPLATES/standalone/standalone-multi-channel-inventory-sync.md +1185 -1185
- package/docs/01-TEMPLATES/standalone/standalone-multi-source-aggregation.md +1462 -1462
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-batch-api.md +1390 -1390
- package/docs/01-TEMPLATES/standalone/standalone-s3-csv-inventory-to-batch.md +330 -330
- package/docs/01-TEMPLATES/standalone/standalone-scripts-guide.md +87 -87
- package/docs/01-TEMPLATES/standalone/standalone-sftp-xml-graphql.md +1444 -1444
- package/docs/01-TEMPLATES/standalone/standalone-webhook-payload-processing.md +688 -688
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-dropship-order-routing.md +193 -193
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-graphql-parquet-extraction.md +518 -518
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-inter-location-transfers.md +2162 -2162
- package/docs/01-TEMPLATES/versori/business-examples/business-examples-pre-order-allocation.md +2226 -2226
- package/docs/01-TEMPLATES/versori/business-examples/business-scenarios-guide.md +87 -87
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-connection-validation-pattern.md +656 -656
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-dual-workflow-connector.md +835 -835
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-guide.md +108 -108
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-kv-state-management.md +1533 -1533
- package/docs/01-TEMPLATES/versori/patterns/versori-patterns-xml-response-patterns.md +1160 -1160
- package/docs/01-TEMPLATES/versori/versori-platform-guide.md +201 -201
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-asn-purchase-order.md +1906 -1906
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-dropship-routing.md +1074 -1074
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-flash-sale-reserve.md +1395 -1395
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-generic-xml-order.md +888 -888
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-payment-gateway-integration.md +2478 -2478
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-rma-returns-comprehensive.md +2240 -2240
- package/docs/01-TEMPLATES/versori/webhooks/template-webhook-xml-order-ingestion.md +2029 -2029
- package/docs/01-TEMPLATES/versori/webhooks/webhook-templates-guide.md +140 -140
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/inventory-mapping.json +20 -20
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/products_2025-01-22.csv +11 -11
- package/docs/01-TEMPLATES/versori/workflows/_examples/sample-data/sample-data-guide.md +34 -34
- package/docs/01-TEMPLATES/versori/workflows/_examples/workflow-examples-guide.md +36 -36
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-modes-guide.md +1038 -1038
- package/docs/01-TEMPLATES/versori/workflows/extraction/extraction-workflows-guide.md +138 -138
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/graphql-extraction-guide.md +63 -63
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-csv.md +2062 -2062
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-fulfillments-to-sftp-xml.md +2294 -2294
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-s3-csv.md +2461 -2461
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-positions-to-sftp-xml.md +2529 -2529
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-csv.md +2464 -2464
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-inventory-quantities-to-s3-json.md +1959 -1959
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-s3-csv.md +1953 -1953
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-orders-to-sftp-xml.md +2541 -2541
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-s3-json.md +2384 -2384
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-products-to-sftp-xml.md +2445 -2445
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-csv.md +2355 -2355
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-s3-json.md +2042 -2042
- package/docs/01-TEMPLATES/versori/workflows/extraction/graphql-queries/template-extraction-virtual-positions-to-sftp-xml.md +2726 -2726
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/batch-api-guide.md +206 -206
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-cycle-count-reconciliation.md +2030 -2030
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-multi-channel-inventory-sync.md +1882 -1882
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-csv-inventory-batch.md +2827 -2827
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-json-inventory-batch.md +1952 -1952
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-s3-xml-inventory-batch.md +3289 -3289
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-csv-inventory-batch.md +3064 -3064
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-json-inventory-batch.md +3238 -3238
- package/docs/01-TEMPLATES/versori/workflows/ingestion/batch-api/template-ingestion-sftp-xml-inventory-batch.md +2977 -2977
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/event-api-guide.md +321 -321
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-json-order-cancel-event.md +959 -959
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-payload-xml-order-cancel-event.md +1170 -1170
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-csv-product-event.md +2312 -2312
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-json-product-event.md +2999 -2999
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-parquet-product-event.md +2836 -2836
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-s3-xml-product-event.md +2395 -2395
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-csv-product-event.md +2295 -2295
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-json-product-event.md +2602 -2602
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-parquet-product-event.md +2589 -2589
- package/docs/01-TEMPLATES/versori/workflows/ingestion/event-api/template-ingestion-sftp-xml-product-event.md +3578 -3578
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/graphql-mutations-guide.md +93 -93
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-json-order-update-graphql.md +1260 -1260
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-payload-xml-order-update-graphql.md +1472 -1472
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-control-graphql.md +2417 -2417
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-location-graphql.md +2811 -2811
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-price-graphql.md +2619 -2619
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-json-location-graphql.md +2807 -2807
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-xml-location-graphql.md +2373 -2373
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-control-graphql.md +2740 -2740
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-csv-location-graphql.md +2760 -2760
- package/docs/01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-sftp-json-location-graphql.md +1710 -1710
- package/docs/01-TEMPLATES/versori/workflows/ingestion/ingestion-workflows-guide.md +136 -136
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/rubix-webhooks-guide.md +520 -520
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-inline.md +1418 -1418
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-fulfilment-to-sftp-xml-universal-mapper.md +1785 -1785
- package/docs/01-TEMPLATES/versori/workflows/rubix-webhooks/template-webhook-rubix-order-attribute-update.md +824 -824
- package/docs/01-TEMPLATES/versori/workflows/workflows-overview-guide.md +646 -646
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-batch-archival.md +724 -724
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-job-tracker.md +627 -627
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-partial-batch-recovery.md +561 -561
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-quick-reference.md +367 -367
- package/docs/02-CORE-GUIDES/advanced-services/advanced-services-readme.md +407 -407
- package/docs/02-CORE-GUIDES/advanced-services/readme.md +49 -49
- package/docs/02-CORE-GUIDES/api-reference/api-reference-quick-reference.md +548 -548
- package/docs/02-CORE-GUIDES/api-reference/event-api-input-output-reference.md +702 -1171
- package/docs/02-CORE-GUIDES/api-reference/examples/client-initialization.ts +286 -286
- package/docs/02-CORE-GUIDES/api-reference/graphql-error-classification.md +337 -337
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-01-client-api.md +399 -482
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-03-authentication.md +199 -199
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-04-graphql-mapping.md +925 -925
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-05-services.md +1198 -1198
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-06-data-sources.md +1083 -1083
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-07-parsers.md +1097 -1097
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-pagination.md +513 -513
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-08-types.md +545 -597
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-error-handling.md +527 -527
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-09-webhook-validation.md +514 -514
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-extraction.md +557 -557
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-10-utilities.md +412 -412
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-cli-tools.md +423 -423
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-11-error-handling.md +716 -716
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-analyze-source-structure.md +518 -518
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-partial-responses.md +212 -212
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-12-testing.md +300 -300
- package/docs/02-CORE-GUIDES/api-reference/modules/api-reference-13-resolver-builder.md +322 -322
- package/docs/02-CORE-GUIDES/api-reference/readme.md +279 -279
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-quick-reference.md +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/auto-pagination-readme.md +277 -277
- package/docs/02-CORE-GUIDES/auto-pagination/examples/auto-pagination-readme.md +178 -178
- package/docs/02-CORE-GUIDES/auto-pagination/examples/common-patterns.ts +351 -351
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-products.ts +384 -384
- package/docs/02-CORE-GUIDES/auto-pagination/examples/paginate-virtual-positions.ts +308 -308
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-01-foundations.md +470 -470
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-02-quick-start.md +713 -713
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-03-configuration.md +754 -754
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-04-advanced-patterns.md +732 -732
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-05-sdk-integration.md +847 -847
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-06-troubleshooting.md +359 -359
- package/docs/02-CORE-GUIDES/auto-pagination/modules/auto-pagination-07-api-reference.md +462 -462
- package/docs/02-CORE-GUIDES/auto-pagination/readme.md +54 -54
- package/docs/02-CORE-GUIDES/data-sources/data-sources-file-operations-error-handling.md +1487 -1487
- package/docs/02-CORE-GUIDES/data-sources/data-sources-quick-reference.md +836 -836
- package/docs/02-CORE-GUIDES/data-sources/data-sources-readme.md +276 -276
- package/docs/02-CORE-GUIDES/data-sources/data-sources-sftp-credential-access-security.md +553 -553
- package/docs/02-CORE-GUIDES/data-sources/examples/common-patterns.ts +409 -409
- package/docs/02-CORE-GUIDES/data-sources/examples/data-sources-readme.md +178 -178
- package/docs/02-CORE-GUIDES/data-sources/examples/s3-operations.ts +308 -308
- package/docs/02-CORE-GUIDES/data-sources/examples/sftp-operations.ts +371 -371
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-01-foundations.md +735 -735
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-02-s3-operations.md +1302 -1302
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-03-sftp-operations.md +1379 -1379
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-04-file-patterns.md +941 -941
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-05-advanced-topics.md +813 -813
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-06-integration-patterns.md +486 -486
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-07-troubleshooting.md +387 -387
- package/docs/02-CORE-GUIDES/data-sources/modules/data-sources-08-api-reference.md +417 -417
- package/docs/02-CORE-GUIDES/data-sources/readme.md +77 -77
- package/docs/02-CORE-GUIDES/error-handling-guide.md +936 -936
- package/docs/02-CORE-GUIDES/extraction/examples/02-core-guides-extraction-readme.md +116 -116
- package/docs/02-CORE-GUIDES/extraction/examples/common-patterns.ts +428 -428
- package/docs/02-CORE-GUIDES/extraction/examples/extract-inventory-basic.ts +187 -187
- package/docs/02-CORE-GUIDES/extraction/extraction-quick-reference.md +596 -596
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-01-foundations.md +514 -514
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-02-basic-extraction.md +823 -823
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-03-parquet-processing.md +507 -507
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-04-data-enrichment.md +546 -546
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-05-transformation.md +494 -494
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-export-formats.md +458 -458
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-06-performance.md +138 -138
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-api-reference.md +148 -148
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-07-optimization.md +692 -692
- package/docs/02-CORE-GUIDES/extraction/modules/02-core-guides-extraction-08-extraction-orchestrator.md +1008 -1008
- package/docs/02-CORE-GUIDES/extraction/readme.md +151 -151
- package/docs/02-CORE-GUIDES/ingestion/examples/_simple-kv-store.ts +40 -40
- package/docs/02-CORE-GUIDES/ingestion/examples/error-recovery.ts +728 -728
- package/docs/02-CORE-GUIDES/ingestion/examples/event-driven.ts +501 -501
- package/docs/02-CORE-GUIDES/ingestion/examples/local-file-ingestion.ts +88 -88
- package/docs/02-CORE-GUIDES/ingestion/examples/parquet-ingestion.ts +117 -117
- package/docs/02-CORE-GUIDES/ingestion/examples/performance-optimized.ts +647 -647
- package/docs/02-CORE-GUIDES/ingestion/examples/s3-csv-ingestion.ts +169 -169
- package/docs/02-CORE-GUIDES/ingestion/examples/sftp-csv-ingestion.ts +134 -134
- package/docs/02-CORE-GUIDES/ingestion/ingestion-quick-reference.md +546 -546
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-01-introduction.md +626 -626
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-02-quick-start.md +658 -658
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-03-data-sources.md +1052 -1052
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-04-field-mapping.md +763 -763
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-05-advanced-parsers.md +676 -676
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-06-batch-api.md +1295 -1295
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-api-reference.md +138 -138
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-state-management.md +1037 -1037
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-08-performance-optimization.md +1349 -1349
- package/docs/02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-09-best-practices.md +1893 -1893
- package/docs/02-CORE-GUIDES/ingestion/readme.md +160 -160
- package/docs/02-CORE-GUIDES/logging-guide.md +585 -585
- package/docs/02-CORE-GUIDES/mapping/error-handling-patterns.md +401 -401
- package/docs/02-CORE-GUIDES/mapping/examples/02-core-guides-mapping-readme.md +128 -128
- package/docs/02-CORE-GUIDES/mapping/examples/common-patterns.ts +273 -273
- package/docs/02-CORE-GUIDES/mapping/examples/csv-location-ingestion.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/csv-mapping.ts +242 -242
- package/docs/02-CORE-GUIDES/mapping/examples/graphql-to-parquet-extraction.json +36 -36
- package/docs/02-CORE-GUIDES/mapping/examples/json-mapping.ts +213 -213
- package/docs/02-CORE-GUIDES/mapping/examples/json-product-to-mutation.json +48 -48
- package/docs/02-CORE-GUIDES/mapping/examples/xml-mapping.ts +291 -291
- package/docs/02-CORE-GUIDES/mapping/examples/xml-order-to-mutation.json +45 -45
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-quick-reference.md +463 -463
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/graphql-mutation-mapping-readme.md +227 -227
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-01-introduction.md +222 -222
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-02-quick-start.md +351 -351
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-03-schema-validation.md +569 -569
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-04-mapping-patterns.md +471 -471
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-05-configuration-reference.md +611 -611
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-advanced-xpath.md +148 -148
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-06-path-syntax.md +464 -464
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-api-reference.md +94 -94
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-07-array-handling.md +307 -307
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-08-custom-resolvers.md +544 -544
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-09-advanced-patterns.md +427 -427
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-10-hooks-and-variables.md +336 -336
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-11-error-handling.md +488 -488
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-12-arguments-vs-nodes.md +383 -383
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/modules/graphql-mutation-mapping-13-best-practices.md +477 -477
- package/docs/02-CORE-GUIDES/mapping/graphql-mutation-mapping/readme.md +62 -62
- package/docs/02-CORE-GUIDES/mapping/mapping-format-decision-tree.md +480 -480
- package/docs/02-CORE-GUIDES/mapping/mapping-graphql-alias-batching-guide.md +820 -820
- package/docs/02-CORE-GUIDES/mapping/mapping-javascript-objects.md +2369 -2369
- package/docs/02-CORE-GUIDES/mapping/mapping-mapper-comparison-guide.md +682 -682
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-07-api-reference.md +1327 -1327
- package/docs/02-CORE-GUIDES/mapping/modules/02-core-guides-mapping-08-error-handling.md +1142 -1142
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-04-use-cases.md +891 -891
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-helpers-resolvers.md +1126 -1126
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-06-sdk-resolvers.md +199 -199
- package/docs/02-CORE-GUIDES/mapping/modules/mapping-07-api-reference.md +1319 -1319
- package/docs/02-CORE-GUIDES/mapping/readme.md +178 -178
- package/docs/02-CORE-GUIDES/mapping/resolver-registration.md +410 -410
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/common-patterns.ts +226 -226
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/custom-resolvers.ts +227 -227
- package/docs/02-CORE-GUIDES/mapping/resolvers/examples/sdk-resolvers-usage.ts +203 -203
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-readme.md +274 -274
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-api-reference.md +679 -679
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-cookbook.md +826 -826
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-guide.md +1330 -1330
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-helpers-reference.md +1437 -1437
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-parameters-reference.md +553 -553
- package/docs/02-CORE-GUIDES/mapping/resolvers/mapping-resolvers-resolver-troubleshooting.md +854 -854
- package/docs/02-CORE-GUIDES/mapping/resolvers/readme.md +75 -75
- package/docs/02-CORE-GUIDES/parsers/examples/02-core-guides-parsers-readme.md +161 -161
- package/docs/02-CORE-GUIDES/parsers/examples/csv-parser-examples.ts +110 -110
- package/docs/02-CORE-GUIDES/parsers/examples/json-parser-examples.ts +33 -33
- package/docs/02-CORE-GUIDES/parsers/examples/parquet-parser-examples.ts +47 -47
- package/docs/02-CORE-GUIDES/parsers/examples/xml-parser-examples.ts +38 -38
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-01-foundations.md +355 -355
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-02-csv-parser.md +772 -772
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-03-json-parser.md +789 -789
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-04-xml-parser.md +857 -857
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-05-parquet-parser.md +603 -603
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-integration-patterns.md +702 -702
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-06-streaming.md +121 -121
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-api-reference.md +89 -89
- package/docs/02-CORE-GUIDES/parsers/modules/02-core-guides-parsers-07-troubleshooting.md +727 -727
- package/docs/02-CORE-GUIDES/parsers/parsers-quick-reference.md +482 -482
- package/docs/02-CORE-GUIDES/parsers/parsers-readme.md +258 -258
- package/docs/02-CORE-GUIDES/parsers/readme.md +65 -65
- package/docs/02-CORE-GUIDES/readme.md +194 -194
- package/docs/02-CORE-GUIDES/webhook-validation/examples/basic-validation.ts +108 -108
- package/docs/02-CORE-GUIDES/webhook-validation/examples/common-patterns.ts +316 -316
- package/docs/02-CORE-GUIDES/webhook-validation/examples/webhook-validation-readme.md +61 -61
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-01-foundations.md +440 -440
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-02-quick-start.md +525 -525
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-03-versori-integration.md +741 -741
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-04-platform-integration.md +629 -629
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-05-configuration.md +535 -535
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-error-handling.md +611 -611
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-06-troubleshooting.md +124 -124
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-07-api-reference.md +511 -511
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-08-rubix-webhooks.md +590 -590
- package/docs/02-CORE-GUIDES/webhook-validation/modules/webhook-validation-09-rubix-event-vs-http-call.md +432 -432
- package/docs/02-CORE-GUIDES/webhook-validation/readme.md +239 -239
- package/docs/02-CORE-GUIDES/webhook-validation/webhook-validation-quick-reference.md +392 -392
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-quick-reference.md +498 -498
- package/docs/03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-readme.md +313 -313
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/common-patterns.ts +612 -612
- package/docs/03-PATTERN-GUIDES/connector-scenarios/examples/connector-scenarios-readme.md +253 -253
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-01-foundations.md +452 -452
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-02-simple-scenarios.md +681 -681
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-03-intermediate-scenarios.md +637 -637
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-04-advanced-scenarios.md +650 -650
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-05-bidirectional-sync.md +233 -233
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-06-production-patterns.md +442 -442
- package/docs/03-PATTERN-GUIDES/connector-scenarios/modules/connector-scenarios-07-reference.md +445 -445
- package/docs/03-PATTERN-GUIDES/connector-scenarios/readme.md +31 -31
- package/docs/03-PATTERN-GUIDES/enterprise-integration-patterns.md +1528 -1528
- package/docs/03-PATTERN-GUIDES/error-handling/comprehensive-error-handling-guide.md +1437 -1437
- package/docs/03-PATTERN-GUIDES/error-handling/error-handling-quick-reference.md +390 -390
- package/docs/03-PATTERN-GUIDES/error-handling/examples/common-patterns.ts +438 -438
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-01-foundations.md +362 -362
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-02-error-types.md +850 -850
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-03-utf8-handling.md +456 -456
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-04-error-scenarios.md +658 -658
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-05-calling-patterns.md +671 -671
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-06-retry-strategies.md +1034 -1034
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-07-monitoring.md +653 -653
- package/docs/03-PATTERN-GUIDES/error-handling/modules/error-handling-08-api-reference.md +847 -847
- package/docs/03-PATTERN-GUIDES/error-handling/readme.md +36 -36
- package/docs/03-PATTERN-GUIDES/examples/__tests__/readme.md +40 -40
- package/docs/03-PATTERN-GUIDES/examples/__tests__/resolver-examples.test.js +282 -282
- package/docs/03-PATTERN-GUIDES/examples/test-data/03-pattern-guides-readme.md +110 -110
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-inventory.json +123 -123
- package/docs/03-PATTERN-GUIDES/examples/test-data/canonical-order.json +171 -171
- package/docs/03-PATTERN-GUIDES/examples/test-data/readme.md +28 -28
- package/docs/03-PATTERN-GUIDES/extraction/extraction-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/extraction/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/file-operations/examples/common-patterns.ts +407 -407
- package/docs/03-PATTERN-GUIDES/file-operations/examples/file-operations-readme.md +142 -142
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-quick-reference.md +462 -462
- package/docs/03-PATTERN-GUIDES/file-operations/file-operations-readme.md +379 -379
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-01-foundations.md +430 -430
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-02-quick-start.md +484 -484
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-03-s3-operations.md +507 -507
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-04-sftp-operations.md +963 -963
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-05-streaming-performance.md +503 -503
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-archive-patterns.md +386 -386
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-06-error-handling.md +117 -117
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-api-reference.md +78 -78
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-07-testing-troubleshooting.md +567 -567
- package/docs/03-PATTERN-GUIDES/file-operations/modules/file-operations-08-api-reference.md +1055 -1055
- package/docs/03-PATTERN-GUIDES/file-operations/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/ingestion/ingestion-readme.md +15 -15
- package/docs/03-PATTERN-GUIDES/ingestion/readme.md +25 -25
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/batch-processing.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/common-patterns.ts +360 -360
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/delta-sync.ts +130 -130
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/integration-patterns-readme.md +100 -100
- package/docs/03-PATTERN-GUIDES/integration-patterns/examples/real-time-webhook.ts +398 -398
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-quick-reference.md +962 -962
- package/docs/03-PATTERN-GUIDES/integration-patterns/integration-patterns-readme.md +134 -134
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-01-real-time-processing.md +991 -991
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-02-batch-processing.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-03-delta-sync.md +1108 -1108
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-04-webhook-patterns.md +1181 -1181
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-05-error-handling.md +1061 -1061
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-advanced-integration-services.md +1547 -1547
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-06-performance.md +109 -109
- package/docs/03-PATTERN-GUIDES/integration-patterns/modules/integration-patterns-07-api-reference.md +34 -34
- package/docs/03-PATTERN-GUIDES/integration-patterns/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/logging-minimal-mode.md +128 -128
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/common-patterns.ts +380 -380
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/multiple-connections-readme.md +139 -139
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/parallel-root-connections.ts +149 -149
- package/docs/03-PATTERN-GUIDES/multiple-connections/examples/real-world-scenarios.ts +405 -405
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-01-foundations.md +378 -378
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-02-quick-start.md +566 -566
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-03-targeting-connections.md +659 -659
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-04-parallel-queries.md +656 -656
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-05-best-practices.md +624 -624
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-api-reference.md +824 -824
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-06-versori.md +119 -119
- package/docs/03-PATTERN-GUIDES/multiple-connections/modules/multiple-connections-07-api-reference.md +87 -87
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-quick-reference.md +353 -353
- package/docs/03-PATTERN-GUIDES/multiple-connections/multiple-connections-readme.md +270 -270
- package/docs/03-PATTERN-GUIDES/multiple-connections/readme.md +30 -30
- package/docs/03-PATTERN-GUIDES/pagination/pagination-readme.md +14 -14
- package/docs/03-PATTERN-GUIDES/pagination/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/parquet/examples/common-patterns.ts +180 -180
- package/docs/03-PATTERN-GUIDES/parquet/examples/read-parquet.ts +48 -48
- package/docs/03-PATTERN-GUIDES/parquet/examples/write-parquet.ts +65 -65
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-01-introduction.md +393 -393
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-02-quick-start.md +572 -572
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-03-reading-parquet.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-04-writing-parquet.md +554 -554
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-05-graphql-extraction.md +405 -405
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-performance.md +104 -104
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-06-s3-integration.md +511 -511
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-api-reference.md +90 -90
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-07-performance-optimization.md +525 -525
- package/docs/03-PATTERN-GUIDES/parquet/modules/03-pattern-guides-parquet-08-best-practices.md +712 -712
- package/docs/03-PATTERN-GUIDES/parquet/parquet-quick-reference.md +683 -683
- package/docs/03-PATTERN-GUIDES/parquet/parquet-readme.md +248 -248
- package/docs/03-PATTERN-GUIDES/parquet/readme.md +32 -32
- package/docs/03-PATTERN-GUIDES/parsers/parsers-readme.md +12 -12
- package/docs/03-PATTERN-GUIDES/parsers/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/readme.md +159 -159
- package/docs/03-PATTERN-GUIDES/webhooks/readme.md +24 -24
- package/docs/03-PATTERN-GUIDES/webhooks/webhooks-readme.md +8 -8
- package/docs/04-REFERENCE/architecture/architecture-01-overview.md +427 -427
- package/docs/04-REFERENCE/architecture/architecture-02-client-architecture.md +424 -424
- package/docs/04-REFERENCE/architecture/architecture-03-data-flow.md +690 -690
- package/docs/04-REFERENCE/architecture/architecture-04-service-layer.md +834 -834
- package/docs/04-REFERENCE/architecture/architecture-05-integration-architecture.md +655 -655
- package/docs/04-REFERENCE/architecture/architecture-06-state-management.md +653 -653
- package/docs/04-REFERENCE/architecture/architecture-adding-new-data-sources.md +686 -686
- package/docs/04-REFERENCE/architecture/readme.md +279 -279
- package/docs/04-REFERENCE/platforms/deno/readme.md +117 -117
- package/docs/04-REFERENCE/platforms/nodejs/readme.md +146 -146
- package/docs/04-REFERENCE/platforms/readme.md +135 -135
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-01-introduction.md +398 -398
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-02-quick-start.md +560 -560
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-03-authentication.md +757 -757
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-04-workflows.md +2476 -2476
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-05-connections.md +1167 -1167
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-kv-storage.md +990 -990
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-06-state-management.md +121 -121
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-api-reference.md +68 -68
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-07-deployment.md +731 -731
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-08-best-practices.md +1111 -1111
- package/docs/04-REFERENCE/platforms/versori/modules/platforms-versori-09-signature-reference.md +766 -766
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-readme.md +299 -299
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-s3-sftp-configuration-guide.md +1425 -1425
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-api-key-security.md +816 -816
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-webhook-connection-security.md +681 -681
- package/docs/04-REFERENCE/platforms/versori/platforms-versori-workflow-task-types.md +708 -708
- package/docs/04-REFERENCE/platforms/versori/readme.md +108 -108
- package/docs/04-REFERENCE/readme.md +148 -148
- package/docs/04-REFERENCE/resolver-signature/examples/advanced-resolvers.ts +482 -482
- package/docs/04-REFERENCE/resolver-signature/examples/async-resolvers.ts +496 -496
- package/docs/04-REFERENCE/resolver-signature/examples/basic-resolvers.ts +343 -343
- package/docs/04-REFERENCE/resolver-signature/examples/resolver-signature-readme.md +188 -188
- package/docs/04-REFERENCE/resolver-signature/examples/testing-resolvers.ts +463 -463
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-01-foundations.md +286 -286
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-02-parameter-reference.md +643 -643
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-03-basic-examples.md +521 -521
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-04-advanced-patterns.md +739 -739
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-05-sdk-resolvers.md +531 -531
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-migration-guide.md +650 -650
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-06-testing.md +125 -125
- package/docs/04-REFERENCE/resolver-signature/modules/resolver-signature-07-api-reference.md +794 -794
- package/docs/04-REFERENCE/resolver-signature/readme.md +64 -64
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-quick-reference.md +270 -270
- package/docs/04-REFERENCE/resolver-signature/resolver-signature-readme.md +351 -351
- package/docs/04-REFERENCE/schema/fluent-commerce-schema.json +764 -764
- package/docs/04-REFERENCE/schema/readme.md +141 -141
- package/docs/04-REFERENCE/testing/examples/04-reference-testing-readme.md +158 -158
- package/docs/04-REFERENCE/testing/examples/fluent-testing.ts +62 -62
- package/docs/04-REFERENCE/testing/examples/health-check.ts +155 -155
- package/docs/04-REFERENCE/testing/examples/integration-test.ts +119 -119
- package/docs/04-REFERENCE/testing/examples/performance-test.ts +183 -183
- package/docs/04-REFERENCE/testing/examples/s3-testing.ts +127 -127
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-01-foundations.md +267 -267
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-02-s3-testing.md +599 -599
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-03-fluent-testing.md +589 -589
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-04-integration-testing.md +699 -699
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-05-debugging.md +478 -478
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-cicd-integration.md +463 -463
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-06-preflight-validation.md +131 -131
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-best-practices.md +499 -499
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-07-coverage-ci.md +165 -165
- package/docs/04-REFERENCE/testing/modules/04-reference-testing-08-api-reference.md +634 -634
- package/docs/04-REFERENCE/testing/readme.md +86 -86
- package/docs/04-REFERENCE/testing/testing-quick-reference.md +667 -667
- package/docs/04-REFERENCE/testing/testing-readme.md +286 -286
- package/docs/04-REFERENCE/troubleshooting/readme.md +144 -144
- package/docs/04-REFERENCE/troubleshooting/troubleshooting-deno-sftp-compatibility.md +392 -392
- package/docs/template-loading-matrix.md +242 -242
- package/package.json +5 -3
|
@@ -1,2384 +1,2384 @@
|
|
|
1
|
-
---
|
|
2
|
-
template_id: tpl-extract-products-graphql-to-s3-json
|
|
3
|
-
canonical_filename: template-extraction-products-to-s3-json.md
|
|
4
|
-
sdk_version: ^0.1.39
|
|
5
|
-
runtime: versori
|
|
6
|
-
direction: extraction
|
|
7
|
-
source: fluent-graphql
|
|
8
|
-
destination: s3-json
|
|
9
|
-
entity: products
|
|
10
|
-
format: json
|
|
11
|
-
logging: versori
|
|
12
|
-
status: stable
|
|
13
|
-
features:
|
|
14
|
-
- memory-management
|
|
15
|
-
- enhanced-logging
|
|
16
|
-
- pagination-progress
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
# Template: Extraction - Products GraphQL to S3 JSON
|
|
20
|
-
|
|
21
|
-
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
22
|
-
**Last Updated:** 2025-01-24
|
|
23
|
-
**Deployment Target:** Versori Platform
|
|
24
|
-
|
|
25
|
-
---
|
|
26
|
-
|
|
27
|
-
## 📋 Implementation Prompt
|
|
28
|
-
|
|
29
|
-
Copy/paste the standardized prompt from `docs/template-loading-matrix.md#prompts`.
|
|
30
|
-
|
|
31
|
-
---
|
|
32
|
-
|
|
33
|
-
## 💻 STEP 3: Implementation (Verified Imports)
|
|
34
|
-
|
|
35
|
-
```ts
|
|
36
|
-
import { Buffer } from 'node:buffer';
|
|
37
|
-
import {
|
|
38
|
-
createClient,
|
|
39
|
-
UniversalMapper,
|
|
40
|
-
S3DataSource,
|
|
41
|
-
JSONBuilder,
|
|
42
|
-
VersoriKVAdapter,
|
|
43
|
-
ExtractionOrchestrator,
|
|
44
|
-
JobTracker,
|
|
45
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
These are the only SDK imports required for this template. Keep type-only imports out of code samples. Prefer the orchestrator-based flow when you need built-in pagination and stats.
|
|
49
|
-
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
# Versori Workflows: Product Catalog Extraction to S3 JSON
|
|
53
|
-
|
|
54
|
-
**FC Connect SDK Use Case Guide**
|
|
55
|
-
|
|
56
|
-
> SDK: [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk)
|
|
57
|
-
> Version: ^0.1.39
|
|
58
|
-
|
|
59
|
-
Context: Three Versori workflows for extracting product catalog from Fluent Commerce via GraphQL query with **incremental timestamp tracking**, **ad hoc extraction**, and **job status monitoring**. Transforms with `UniversalMapper`, writes JSON files to S3 for marketplace integration (Amazon, eBay, etc).
|
|
60
|
-
|
|
61
|
-
**Pattern**: EXTRACTION (Fluent → S3 JSON)
|
|
62
|
-
**Complexity**: Medium | Runtime: Versori Platform
|
|
63
|
-
**Format**: JSON with metadata wrapper
|
|
64
|
-
|
|
65
|
-
---
|
|
66
|
-
|
|
67
|
-
## ⚠️ IMPORTANT: Production-Ready Base Template
|
|
68
|
-
|
|
69
|
-
> **📋 BASE TEMPLATE - Ready for Production (Customize for Your Needs)**
|
|
70
|
-
>
|
|
71
|
-
> This is a **production-ready base template** demonstrating FC Connect SDK best practices for product extraction workflows with JSON output to S3.
|
|
72
|
-
>
|
|
73
|
-
> **✅ INCLUDED FEATURES:**
|
|
74
|
-
>
|
|
75
|
-
> - ✅ Comprehensive error handling with retry logic
|
|
76
|
-
> - ✅ S3 upload with proper error handling
|
|
77
|
-
> - ✅ State management with overlap buffer (prevents missed records)
|
|
78
|
-
> - ✅ Job tracking with lifecycle management
|
|
79
|
-
> - ✅ Security (credential masking in logs)
|
|
80
|
-
> - ✅ UTC time enforcement (prevents timezone bugs)
|
|
81
|
-
> - ✅ Incremental extraction (safe, efficient, production-ready)
|
|
82
|
-
> - ✅ Natural rate limiting via timestamps
|
|
83
|
-
>
|
|
84
|
-
> **📝 BEFORE DEPLOYING:**
|
|
85
|
-
>
|
|
86
|
-
> 1. Review and customize activation variables for your environment
|
|
87
|
-
> 2. Test with sample data in your Versori workspace
|
|
88
|
-
> 3. Adjust safety limits (pageSize, maxRecords) if needed
|
|
89
|
-
> 4. Configure monitoring alerts for extraction failures
|
|
90
|
-
> 5. Verify S3 bucket credentials and paths
|
|
91
|
-
>
|
|
92
|
-
> **This base template follows SDK best practices - tweak specific to your needs.**
|
|
93
|
-
|
|
94
|
-
---
|
|
95
|
-
|
|
96
|
-
## What You'll Build
|
|
97
|
-
|
|
98
|
-
**Three Versori Workflows:**
|
|
99
|
-
|
|
100
|
-
1. **Scheduled Extraction** - Daily/hourly incremental product updates
|
|
101
|
-
2. **Ad Hoc Extraction** - On-demand HTTP webhook for manual triggers
|
|
102
|
-
3. **Job Status Checker** - Monitor extraction job status via JobTracker
|
|
103
|
-
|
|
104
|
-
**Features:**
|
|
105
|
-
|
|
106
|
-
- **Incremental extraction** using `updatedOn > lastRunTime` filter
|
|
107
|
-
- **State management** with VersoriKVAdapter to track last successful run
|
|
108
|
-
- **Job tracking** with JobTracker for status monitoring
|
|
109
|
-
- GraphQL query with auto-pagination
|
|
110
|
-
- UniversalMapper transformation for marketplace schema
|
|
111
|
-
- JSON file generation with product catalog
|
|
112
|
-
- S3 upload to marketplace integration bucket
|
|
113
|
-
- **Failure recovery** with timestamp tracking
|
|
114
|
-
- **Pretty print option** for human-readable JSON
|
|
115
|
-
|
|
116
|
-
## Business Use Case
|
|
117
|
-
|
|
118
|
-
**Daily product catalog sync to marketplaces:**
|
|
119
|
-
|
|
120
|
-
- Extract only changed products since last run
|
|
121
|
-
- Export as JSON for marketplace API consumption
|
|
122
|
-
- Run daily at midnight for overnight processing
|
|
123
|
-
- Include SKU, title, description, pricing, attributes
|
|
124
|
-
- Enable Amazon/eBay listing updates
|
|
125
|
-
- Support ad hoc manual refresh on demand
|
|
126
|
-
- Monitor job status for workflow integration
|
|
127
|
-
|
|
128
|
-
## SDK Methods Used
|
|
129
|
-
|
|
130
|
-
```typescript
|
|
131
|
-
import { Buffer } from 'node:buffer';
|
|
132
|
-
import {
|
|
133
|
-
createClient,
|
|
134
|
-
UniversalMapper,
|
|
135
|
-
S3DataSource,
|
|
136
|
-
JSONBuilder,
|
|
137
|
-
VersoriKVAdapter,
|
|
138
|
-
JobTracker,
|
|
139
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
140
|
-
|
|
141
|
-
await createClient(ctx); // Versori-aware client
|
|
142
|
-
await client.graphql({ query, variables, pagination }); // GraphQL with auto-pagination
|
|
143
|
-
new VersoriKVAdapter(ctx.openKv(':project:')); // State management
|
|
144
|
-
new JobTracker(ctx.openKv(':project:'), ctx.log); // Job tracking
|
|
145
|
-
new UniversalMapper(exportMapping); // Field transformation
|
|
146
|
-
const jsonBuilder = new JSONBuilder({ prettyPrint: true, indent: 2 });
|
|
147
|
-
const jsonContent = jsonBuilder.build(dataObject); // JSON generation
|
|
148
|
-
await s3.upload(key, Buffer.from(jsonContent, 'utf8'), options); // S3 upload
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
## Activation Variables
|
|
152
|
-
|
|
153
|
-
```json
|
|
154
|
-
{
|
|
155
|
-
"retailerId": "your-retailer-id",
|
|
156
|
-
"s3BucketName": "marketplace-catalog-exports",
|
|
157
|
-
"awsAccessKeyId": "AKIAXXXXXXXXXXXX",
|
|
158
|
-
"awsSecretAccessKey": "********",
|
|
159
|
-
"awsRegion": "us-east-1",
|
|
160
|
-
"s3Prefix": "products/daily/",
|
|
161
|
-
"pageSize": 200,
|
|
162
|
-
"maxRecords": 50000,
|
|
163
|
-
"fallbackStartDate": "2024-01-01T00:00:00Z",
|
|
164
|
-
"overlapBufferSeconds": "60",
|
|
165
|
-
"prettyPrint": "false",
|
|
166
|
-
"validateConnectionOnStart": "false"
|
|
167
|
-
}
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
**New Variables (v2.1.0):**
|
|
171
|
-
- `validateConnectionOnStart`: Optional fail-fast connection validation. When `"true"`, validates connection before extraction. Default: `"false"` (validation on first API call)
|
|
172
|
-
|
|
173
|
-
**Configuration Notes:**
|
|
174
|
-
- All variables are required except `validateConnectionOnStart`, `overlapBufferSeconds`, and `prettyPrint`
|
|
175
|
-
- Credentials should be stored securely in Versori activation variables
|
|
176
|
-
- `pageSize` and `maxRecords` can be adjusted based on data volume and performance requirements
|
|
177
|
-
|
|
178
|
-
## Export Mapping Configuration
|
|
179
|
-
|
|
180
|
-
Create file: `./config/products.export.json`
|
|
181
|
-
|
|
182
|
-
```json
|
|
183
|
-
{
|
|
184
|
-
"name": "products.export",
|
|
185
|
-
"version": "1.0.0",
|
|
186
|
-
"description": "Fluent Products → Marketplace JSON Export",
|
|
187
|
-
"fields": {
|
|
188
|
-
"sku": { "source": "ref", "required": true, "resolver": "sdk.trim" },
|
|
189
|
-
"title": { "source": "name", "required": true, "resolver": "sdk.trim" },
|
|
190
|
-
"description": { "source": "summary", "required": false, "resolver": "sdk.trim" },
|
|
191
|
-
"gtin": { "source": "gtin", "required": false, "resolver": "sdk.trim" },
|
|
192
|
-
"type": { "source": "type", "required": false, "resolver": "sdk.uppercase" },
|
|
193
|
-
"status": { "source": "status", "required": true, "resolver": "sdk.uppercase" },
|
|
194
|
-
"createdOn": { "source": "createdOn", "required": false, "resolver": "sdk.toString" },
|
|
195
|
-
"lastUpdated": { "source": "updatedOn", "required": true, "resolver": "sdk.toString" }
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
**Note**: Actual GraphQL query structure should be verified against your Fluent schema using introspection. The fields above are examples - adjust based on your actual schema.
|
|
201
|
-
|
|
202
|
-
## GraphQL Query
|
|
203
|
-
|
|
204
|
-
**Note**: This query is an **example**. Verify against your Fluent Commerce schema using introspection.
|
|
205
|
-
|
|
206
|
-
```graphql
|
|
207
|
-
query GetProducts($retailerId: ID!, $updatedAfter: DateTime, $first: Int!, $after: String) {
|
|
208
|
-
products(
|
|
209
|
-
retailerId: $retailerId
|
|
210
|
-
updatedOn: { after: $updatedAfter }
|
|
211
|
-
first: $first
|
|
212
|
-
after: $after
|
|
213
|
-
) {
|
|
214
|
-
edges {
|
|
215
|
-
node {
|
|
216
|
-
id
|
|
217
|
-
ref
|
|
218
|
-
name
|
|
219
|
-
summary
|
|
220
|
-
gtin
|
|
221
|
-
type
|
|
222
|
-
status
|
|
223
|
-
createdOn
|
|
224
|
-
updatedOn
|
|
225
|
-
}
|
|
226
|
-
cursor
|
|
227
|
-
}
|
|
228
|
-
pageInfo {
|
|
229
|
-
hasNextPage
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
---
|
|
236
|
-
|
|
237
|
-
## Versori Workflows Structure
|
|
238
|
-
|
|
239
|
-
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
240
|
-
|
|
241
|
-
**Trigger Types:**
|
|
242
|
-
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
243
|
-
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
244
|
-
- **`workflow()`** → Durable workflows (advanced) - Multi-step processes with state persistence
|
|
245
|
-
|
|
246
|
-
**Execution Steps (chained to triggers):**
|
|
247
|
-
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
248
|
-
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
249
|
-
|
|
250
|
-
### Durable Workflows (Advanced Pattern)
|
|
251
|
-
|
|
252
|
-
**⚠️ Note:** Durable workflows are an advanced pattern not yet used in this template. They're documented here for completeness.
|
|
253
|
-
|
|
254
|
-
**Use Case:** Multi-step processes requiring state persistence, long-running operations (hours/days), human approval workflows, or complex retry/error handling across steps.
|
|
255
|
-
|
|
256
|
-
**Example Pattern:**
|
|
257
|
-
```typescript
|
|
258
|
-
import { workflow } from '@versori/run';
|
|
259
|
-
|
|
260
|
-
workflow('order-fulfillment', { connection: 'fluent_commerce' }, async (ctx, step) => {
|
|
261
|
-
// Step 1: Validate order
|
|
262
|
-
const order = await step.run('validate-order', async () => {
|
|
263
|
-
return await validateOrder(ctx);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
// Step 2: Allocate inventory (with retry)
|
|
267
|
-
const allocation = await step.run('allocate-inventory', {
|
|
268
|
-
retry: { max: 3, delay: '1s' }
|
|
269
|
-
}, async () => {
|
|
270
|
-
return await allocateInventory(order);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
// Step 3: Wait for approval (human-in-loop)
|
|
274
|
-
await step.sleep('wait-for-approval', '1h');
|
|
275
|
-
|
|
276
|
-
// Step 4: Create fulfillment
|
|
277
|
-
return await step.run('create-fulfillment', async () => {
|
|
278
|
-
return await createFulfillment(allocation);
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
**When to use workflow():**
|
|
284
|
-
- ✅ Multi-step processes requiring state persistence
|
|
285
|
-
- ✅ Long-running operations (hours/days)
|
|
286
|
-
- ✅ Human approval workflows
|
|
287
|
-
- ✅ Complex retry/error handling across steps
|
|
288
|
-
- ❌ Simple CRUD operations (use schedule/webhook + http)
|
|
289
|
-
- ❌ Fire-and-forget patterns (use schedule + http)
|
|
290
|
-
|
|
291
|
-
### Recommended Project Structure
|
|
292
|
-
|
|
293
|
-
```
|
|
294
|
-
products-extraction/
|
|
295
|
-
├── index.ts # Entry point - exports all workflows
|
|
296
|
-
└── src/
|
|
297
|
-
├── workflows/
|
|
298
|
-
│ ├── scheduled/
|
|
299
|
-
│ │ └── daily-products-extraction.ts # Scheduled: Daily products extraction
|
|
300
|
-
│ │
|
|
301
|
-
│ └── webhook/
|
|
302
|
-
│ ├── adhoc-products-extraction.ts # Webhook: Manual trigger
|
|
303
|
-
│ └── job-status-check.ts # Webhook: Status query
|
|
304
|
-
│
|
|
305
|
-
├── services/
|
|
306
|
-
│ └── products-extraction.service.ts # Shared orchestration logic (reusable)
|
|
307
|
-
│
|
|
308
|
-
└── config/
|
|
309
|
-
└── products.export.json.json # Mapping configuration
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
---
|
|
313
|
-
|
|
314
|
-
```json
|
|
315
|
-
{
|
|
316
|
-
"metadata": {
|
|
317
|
-
"extractedAt": "2025-01-22T00:00:00.000Z",
|
|
318
|
-
"productCount": 2,
|
|
319
|
-
"incrementalFrom": "2025-01-21T00:00:00.000Z",
|
|
320
|
-
"incrementalTo": "2025-01-22T00:00:00.000Z"
|
|
321
|
-
},
|
|
322
|
-
"products": [
|
|
323
|
-
{
|
|
324
|
-
"sku": "SKU-001",
|
|
325
|
-
"title": "Premium Widget",
|
|
326
|
-
"description": "High-quality widget for all purposes",
|
|
327
|
-
"gtin": "012345678901",
|
|
328
|
-
"type": "STANDARD",
|
|
329
|
-
"status": "ACTIVE",
|
|
330
|
-
"createdOn": "2025-01-15T10:00:00Z",
|
|
331
|
-
"lastUpdated": "2025-01-21T10:30:00Z"
|
|
332
|
-
},
|
|
333
|
-
{
|
|
334
|
-
"sku": "SKU-002",
|
|
335
|
-
"title": "Deluxe Gadget",
|
|
336
|
-
"description": "Advanced gadget with premium features",
|
|
337
|
-
"gtin": "012345678902",
|
|
338
|
-
"type": "STANDARD",
|
|
339
|
-
"status": "ACTIVE",
|
|
340
|
-
"createdOn": "2025-01-16T11:00:00Z",
|
|
341
|
-
"lastUpdated": "2025-01-21T14:15:00Z"
|
|
342
|
-
}
|
|
343
|
-
]
|
|
344
|
-
}
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
## Mapping & Resolvers Explained
|
|
348
|
-
|
|
349
|
-
### SDK Resolvers Used
|
|
350
|
-
|
|
351
|
-
| Field | Resolver | Why? | Example |
|
|
352
|
-
| ------------- | --------------- | ----------------------- | ----------------------------- |
|
|
353
|
-
| `sku` | `sdk.trim` | Clean SKU | " SKU-001 " → "SKU-001" |
|
|
354
|
-
| `title` | `sdk.trim` | Clean names | " Widget " → "Widget" |
|
|
355
|
-
| `description` | `sdk.trim` | Remove extra whitespace | " desc " → "desc" |
|
|
356
|
-
| `gtin` | `sdk.trim` | Normalize GTIN/UPC | " 012345 " → "012345" |
|
|
357
|
-
| `type` | `sdk.uppercase` | Normalize type | "standard" → "STANDARD" |
|
|
358
|
-
| `status` | `sdk.uppercase` | Normalize status | "active" → "ACTIVE" |
|
|
359
|
-
| `createdOn` | `sdk.toString` | Ensure ISO string | Date → "2025-01-15T10:00:00Z" |
|
|
360
|
-
| `lastUpdated` | `sdk.toString` | Ensure ISO string | Date → "2025-01-22T14:30:00Z" |
|
|
361
|
-
|
|
362
|
-
### Transformation Flow
|
|
363
|
-
|
|
364
|
-
```typescript
|
|
365
|
-
// 1) GraphQL node (example)
|
|
366
|
-
{
|
|
367
|
-
ref: " SKU-001 ",
|
|
368
|
-
name: " Widget ",
|
|
369
|
-
summary: " High quality ",
|
|
370
|
-
gtin: " 012345678901 ",
|
|
371
|
-
type: "standard",
|
|
372
|
-
status: "active",
|
|
373
|
-
createdOn: "2025-01-15T10:00:00.000Z",
|
|
374
|
-
updatedOn: "2025-01-21T10:30:00.000Z"
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// 2) Map with UniversalMapper
|
|
378
|
-
const mapper = new UniversalMapper(productsExportMapping);
|
|
379
|
-
const result = await mapper.map(node);
|
|
380
|
-
|
|
381
|
-
// 3) Output
|
|
382
|
-
result.data = {
|
|
383
|
-
sku: "SKU-001",
|
|
384
|
-
title: "Widget",
|
|
385
|
-
description: "High quality",
|
|
386
|
-
gtin: "012345678901",
|
|
387
|
-
type: "STANDARD",
|
|
388
|
-
status: "ACTIVE",
|
|
389
|
-
createdOn: "2025-01-15T10:00:00.000Z",
|
|
390
|
-
lastUpdated: "2025-01-21T10:30:00.000Z"
|
|
391
|
-
};
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
### Custom Resolvers for Product-Specific Logic
|
|
395
|
-
|
|
396
|
-
While the mapping above uses built-in SDK resolvers, you can extend with custom business logic for marketplace integration:
|
|
397
|
-
|
|
398
|
-
```typescript
|
|
399
|
-
const customResolvers = {
|
|
400
|
-
/**
|
|
401
|
-
* Extract category hierarchy for marketplace navigation
|
|
402
|
-
*/
|
|
403
|
-
'custom.formatCategoryPath': (product: any) => {
|
|
404
|
-
const categories = product.categories || [];
|
|
405
|
-
return categories
|
|
406
|
-
.map((c: any) => c.name?.trim())
|
|
407
|
-
.filter(Boolean)
|
|
408
|
-
.join(' > ');
|
|
409
|
-
// Example: "Electronics > Computers > Laptops"
|
|
410
|
-
},
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Generate marketplace-compatible SKU with prefix
|
|
414
|
-
*/
|
|
415
|
-
'custom.generateMarketplaceSKU': (ref: string, retailerId: string) => {
|
|
416
|
-
return `${retailerId}-${ref.trim()}`.toUpperCase();
|
|
417
|
-
// Example: "RETAILER123-SKU-001"
|
|
418
|
-
},
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Format price for marketplace API (convert cents to dollars)
|
|
422
|
-
*/
|
|
423
|
-
'custom.formatPrice': (priceInCents: number) => {
|
|
424
|
-
const dollars = (priceInCents || 0) / 100;
|
|
425
|
-
return dollars.toFixed(2);
|
|
426
|
-
// Example: 2499 → "24.99"
|
|
427
|
-
},
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Extract primary image URL from attributes
|
|
431
|
-
*/
|
|
432
|
-
'custom.getPrimaryImage': (product: any) => {
|
|
433
|
-
const images = product.attributes?.images || [];
|
|
434
|
-
return images.length > 0 ? images[0]?.url : null;
|
|
435
|
-
},
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Generate marketplace title with brand and product name
|
|
439
|
-
*/
|
|
440
|
-
'custom.generateMarketplaceTitle': (product: any) => {
|
|
441
|
-
const brand = product.attributes?.brand?.trim() || '';
|
|
442
|
-
const name = product.name?.trim() || '';
|
|
443
|
-
const maxLength = 200; // Amazon/eBay limit
|
|
444
|
-
|
|
445
|
-
const title = brand ? `${brand} - ${name}` : name;
|
|
446
|
-
return title.length > maxLength ? title.substring(0, maxLength - 3) + '...' : title;
|
|
447
|
-
},
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Extract product attributes for marketplace listing
|
|
451
|
-
*/
|
|
452
|
-
'custom.extractAttributes': (product: any) => {
|
|
453
|
-
const attrs = product.attributes || {};
|
|
454
|
-
return {
|
|
455
|
-
brand: attrs.brand?.trim() || 'Unbranded',
|
|
456
|
-
color: attrs.color?.trim() || null,
|
|
457
|
-
size: attrs.size?.trim() || null,
|
|
458
|
-
weight: attrs.weight ? `${attrs.weight} ${attrs.weightUnit || 'lb'}` : null,
|
|
459
|
-
dimensions: attrs.dimensions || null,
|
|
460
|
-
material: attrs.material?.trim() || null,
|
|
461
|
-
};
|
|
462
|
-
},
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Determine product eligibility for marketplaces
|
|
466
|
-
*/
|
|
467
|
-
'custom.checkMarketplaceEligibility': (product: any) => {
|
|
468
|
-
const status = (product.status || '').toUpperCase();
|
|
469
|
-
const hasGTIN = !!product.gtin;
|
|
470
|
-
const hasImages = (product.attributes?.images || []).length > 0;
|
|
471
|
-
const hasPrice = (product.prices || []).length > 0;
|
|
472
|
-
|
|
473
|
-
return {
|
|
474
|
-
isActive: status === 'ACTIVE',
|
|
475
|
-
hasRequiredFields: hasGTIN && hasImages && hasPrice,
|
|
476
|
-
isEligibleForAmazon: status === 'ACTIVE' && hasGTIN && hasImages && hasPrice,
|
|
477
|
-
isEligibleForEbay: status === 'ACTIVE' && hasImages && hasPrice,
|
|
478
|
-
missingFields: [!hasGTIN && 'GTIN', !hasImages && 'Images', !hasPrice && 'Price'].filter(
|
|
479
|
-
Boolean
|
|
480
|
-
),
|
|
481
|
-
};
|
|
482
|
-
},
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Format for marketplace API submission
|
|
486
|
-
*/
|
|
487
|
-
'custom.formatForMarketplace': (product: any) => {
|
|
488
|
-
return {
|
|
489
|
-
sku: product.ref?.trim(),
|
|
490
|
-
title: product.name?.trim(),
|
|
491
|
-
description: product.summary?.trim() || product.name?.trim(),
|
|
492
|
-
category: product.categories?.[0]?.name || 'Uncategorized',
|
|
493
|
-
brand: product.attributes?.brand?.trim() || 'Generic',
|
|
494
|
-
gtin: product.gtin?.trim() || null,
|
|
495
|
-
price: (product.prices?.[0]?.value || 0) / 100, // cents to dollars
|
|
496
|
-
currency: product.prices?.[0]?.currency || 'USD',
|
|
497
|
-
images: (product.attributes?.images || []).map((img: any) => img.url),
|
|
498
|
-
status: product.status?.toUpperCase(),
|
|
499
|
-
lastUpdated: product.updatedOn,
|
|
500
|
-
};
|
|
501
|
-
},
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
// Use custom resolvers with UniversalMapper
|
|
505
|
-
const mapper = new UniversalMapper(productsExportMapping, {
|
|
506
|
-
customResolvers,
|
|
507
|
-
});
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
### Available SDK Resolvers
|
|
511
|
-
|
|
512
|
-
The SDK provides these built-in resolvers (no custom code needed):
|
|
513
|
-
|
|
514
|
-
**String Transformations:**
|
|
515
|
-
|
|
516
|
-
- `sdk.trim` - Remove leading/trailing whitespace
|
|
517
|
-
- `sdk.uppercase` - Convert to uppercase
|
|
518
|
-
- `sdk.lowercase` - Convert to lowercase
|
|
519
|
-
- `sdk.toString` - Convert to string
|
|
520
|
-
|
|
521
|
-
**Number Parsing:**
|
|
522
|
-
|
|
523
|
-
- `sdk.parseInt` - Parse as integer
|
|
524
|
-
- `sdk.parseFloat` - Parse as decimal (ideal for prices)
|
|
525
|
-
- `sdk.number` - Parse as number (auto-detect int/float)
|
|
526
|
-
|
|
527
|
-
**Date Formatting:**
|
|
528
|
-
|
|
529
|
-
- `sdk.formatDate` - ISO 8601 date only (YYYY-MM-DD)
|
|
530
|
-
- `sdk.formatDateShort` - Short date format
|
|
531
|
-
- `sdk.parseDate` - Parse various date formats
|
|
532
|
-
|
|
533
|
-
**Type Conversions:**
|
|
534
|
-
|
|
535
|
-
- `sdk.boolean` - Convert to boolean
|
|
536
|
-
- `sdk.parseJson` - Parse JSON strings (useful for attributes)
|
|
537
|
-
- `sdk.toJson` - Convert to JSON string
|
|
538
|
-
|
|
539
|
-
**Utilities:**
|
|
540
|
-
|
|
541
|
-
- `sdk.identity` - Return value unchanged
|
|
542
|
-
- `sdk.coalesce` - Return first non-null value
|
|
543
|
-
|
|
544
|
-
See [Universal Mapping Guide](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) for complete resolver documentation.
|
|
545
|
-
|
|
546
|
-
## Production Safety & Guardrails
|
|
547
|
-
|
|
548
|
-
### Overview
|
|
549
|
-
|
|
550
|
-
Even with **incremental-only** extraction, product catalogs need safeguards to prevent runtime failures:
|
|
551
|
-
|
|
552
|
-
- **Memory limits**: Product records can be large (descriptions, attributes, images)
|
|
553
|
-
- **S3 upload limits**: Single files > 5GB require multipart upload
|
|
554
|
-
- **Processing time**: Large catalogs can timeout
|
|
555
|
-
- **JSON parsing**: Massive JSON files stress memory and downstream parsers
|
|
556
|
-
|
|
557
|
-
### Hard Limits
|
|
558
|
-
|
|
559
|
-
```typescript
|
|
560
|
-
const SAFETY_LIMITS = {
|
|
561
|
-
MAX_RECORDS_PER_RUN: 100000, // 100k products per run
|
|
562
|
-
MAX_RECORDS_PER_FILE: 25000, // 25k per JSON file
|
|
563
|
-
MAX_FILE_SIZE_MB: 250, // 250MB per file
|
|
564
|
-
MAX_JSON_SIZE_MB: 500, // Total extraction size
|
|
565
|
-
CHUNK_SIZE: 10000, // Process in chunks
|
|
566
|
-
ESTIMATED_BYTES_PER_PRODUCT: 2048, // Conservative estimate (2KB per product)
|
|
567
|
-
};
|
|
568
|
-
```
|
|
569
|
-
|
|
570
|
-
**Why these limits?**
|
|
571
|
-
|
|
572
|
-
- Products have more fields than orders/fulfillments (descriptions, attributes, variants)
|
|
573
|
-
- JSON is less compact than CSV
|
|
574
|
-
- Marketplace APIs often have file size limits (Amazon: 100MB compressed)
|
|
575
|
-
|
|
576
|
-
### Runtime Validation Function
|
|
577
|
-
|
|
578
|
-
```typescript
|
|
579
|
-
/**
|
|
580
|
-
* Validate extraction safety limits before processing
|
|
581
|
-
*/
|
|
582
|
-
function validateExtractionLimits(recordCount: number, estimatedSizeMB: number) {
|
|
583
|
-
const MAX_RECORDS_PER_RUN = 100000;
|
|
584
|
-
const MAX_JSON_SIZE_MB = 500;
|
|
585
|
-
|
|
586
|
-
if (recordCount > MAX_RECORDS_PER_RUN) {
|
|
587
|
-
return {
|
|
588
|
-
valid: false,
|
|
589
|
-
error: `Extraction limit exceeded: ${recordCount} records (max: ${MAX_RECORDS_PER_RUN})`,
|
|
590
|
-
recommendation: `Catalog too large for single extraction. Consider:
|
|
591
|
-
1. Increase extraction frequency to reduce batch size
|
|
592
|
-
2. Filter by product status (ACTIVE only)
|
|
593
|
-
3. Split by product type or category
|
|
594
|
-
4. Use multiple extractions with different filters`,
|
|
595
|
-
recordCount,
|
|
596
|
-
maxAllowed: MAX_RECORDS_PER_RUN,
|
|
597
|
-
};
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
if (estimatedSizeMB > MAX_JSON_SIZE_MB) {
|
|
601
|
-
return {
|
|
602
|
-
valid: false,
|
|
603
|
-
error: `JSON size limit exceeded: ${estimatedSizeMB}MB (max: ${MAX_JSON_SIZE_MB}MB)`,
|
|
604
|
-
recommendation: 'File splitting required. Consider filtering or splitting by category.',
|
|
605
|
-
estimatedSizeMB,
|
|
606
|
-
maxAllowed: MAX_JSON_SIZE_MB,
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
return { valid: true };
|
|
611
|
-
}
|
|
612
|
-
```
|
|
613
|
-
|
|
614
|
-
## Complete Workflows Implementation
|
|
615
|
-
|
|
616
|
-
The code examples below demonstrate what goes in each file according to the modular structure shown in the "Recommended Project Structure" section above.
|
|
617
|
-
|
|
618
|
-
### Workflow 1: Scheduled Incremental Extraction
|
|
619
|
-
**File:** `src/workflows/scheduled/daily-products-extraction.ts`
|
|
620
|
-
|
|
621
|
-
```typescript
|
|
622
|
-
import { schedule, http } from '@versori/run';
|
|
623
|
-
import { Buffer } from 'node:buffer';
|
|
624
|
-
import {
|
|
625
|
-
createClient,
|
|
626
|
-
UniversalMapper,
|
|
627
|
-
S3DataSource,
|
|
628
|
-
JSONBuilder,
|
|
629
|
-
VersoriKVAdapter,
|
|
630
|
-
JobTracker,
|
|
631
|
-
} from '@fluentcommerce/fc-connect-sdk';
|
|
632
|
-
import productsExportMapping from './config/products.export.json' with { type: 'json' };
|
|
633
|
-
|
|
634
|
-
const PRODUCTS_QUERY = `
|
|
635
|
-
query GetProducts(
|
|
636
|
-
$retailerId: ID!
|
|
637
|
-
$updatedAfter: DateTime
|
|
638
|
-
$first: Int!
|
|
639
|
-
$after: String
|
|
640
|
-
) {
|
|
641
|
-
products(
|
|
642
|
-
retailerId: $retailerId
|
|
643
|
-
updatedOn: { after: $updatedAfter }
|
|
644
|
-
first: $first
|
|
645
|
-
after: $after
|
|
646
|
-
) {
|
|
647
|
-
edges {
|
|
648
|
-
node {
|
|
649
|
-
id
|
|
650
|
-
ref
|
|
651
|
-
name
|
|
652
|
-
summary
|
|
653
|
-
gtin
|
|
654
|
-
type
|
|
655
|
-
status
|
|
656
|
-
createdOn
|
|
657
|
-
updatedOn
|
|
658
|
-
}
|
|
659
|
-
cursor
|
|
660
|
-
}
|
|
661
|
-
pageInfo {
|
|
662
|
-
hasNextPage
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
`;
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* WORKFLOW 1/3: Scheduled Daily Product Extraction (Incremental)
|
|
670
|
-
*
|
|
671
|
-
* Runs daily at midnight to extract changed products
|
|
672
|
-
*/
|
|
673
|
-
export const scheduledProductsExtraction = schedule('scheduled-products-extraction', '0 0 * * *').then(
|
|
674
|
-
http('extract-products', { connection: 'fluent_commerce' }, async ctx => {
|
|
675
|
-
const { log, openKv, activation } = ctx;
|
|
676
|
-
const executionStartTime = Date.now();
|
|
677
|
-
|
|
678
|
-
log.info('=== EXECUTION START ===', { timestamp: new Date().toISOString() });
|
|
679
|
-
|
|
680
|
-
// STEP 1/8: Parse activation variables
|
|
681
|
-
const retailerId = activation?.getVariable('retailerId');
|
|
682
|
-
const pageSize = parseInt(activation?.getVariable('pageSize') || '200', 10);
|
|
683
|
-
const maxRecords = parseInt(activation?.getVariable('maxRecords') || '50000', 10);
|
|
684
|
-
const fallbackStartDate =
|
|
685
|
-
activation?.getVariable('fallbackStartDate') || '2024-01-01T00:00:00Z';
|
|
686
|
-
const prettyPrint = activation?.getVariable('prettyPrint') === 'true';
|
|
687
|
-
|
|
688
|
-
const s3Config = {
|
|
689
|
-
bucket: activation?.getVariable('s3BucketName'),
|
|
690
|
-
region: activation?.getVariable('awsRegion') || 'us-east-1',
|
|
691
|
-
accessKeyId: activation?.getVariable('awsAccessKeyId'),
|
|
692
|
-
secretAccessKey: activation?.getVariable('awsSecretAccessKey'),
|
|
693
|
-
};
|
|
694
|
-
const s3Prefix = activation?.getVariable('s3Prefix') || 'products/daily/';
|
|
695
|
-
|
|
696
|
-
// Validate required variables
|
|
697
|
-
const missing: string[] = [];
|
|
698
|
-
if (!retailerId) missing.push('retailerId');
|
|
699
|
-
if (!s3Config.bucket) missing.push('s3BucketName');
|
|
700
|
-
if (!s3Config.accessKeyId) missing.push('awsAccessKeyId');
|
|
701
|
-
if (!s3Config.secretAccessKey) missing.push('awsSecretAccessKey');
|
|
702
|
-
if (missing.length) {
|
|
703
|
-
return { success: false, error: `Missing required variables: ${missing.join(', ')}` };
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
try {
|
|
707
|
-
// STEP 2/8: Initialize JobTracker and start tracking
|
|
708
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
709
|
-
const jobId = `products-extraction-${Date.now()}`;
|
|
710
|
-
|
|
711
|
-
await tracker.createJob(jobId, {
|
|
712
|
-
type: 'extraction',
|
|
713
|
-
entity: 'products',
|
|
714
|
-
mode: 'scheduled',
|
|
715
|
-
retailerId,
|
|
716
|
-
startTime: executionStartTime,
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
// STEP 3/8: Load last successful extraction timestamp with overlap buffer
|
|
720
|
-
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
721
|
-
const stateKey = ['extraction', 'products', 'lastRunTime'];
|
|
722
|
-
const lastRunState = await kv.get(stateKey);
|
|
723
|
-
const rawLastRunTime = lastRunState?.value?.timestamp || fallbackStartDate;
|
|
724
|
-
|
|
725
|
-
// Overlap buffer configuration (default: 60 seconds)
|
|
726
|
-
const overlapBufferSeconds = parseInt(
|
|
727
|
-
activation?.getVariable('overlapBufferSeconds') || '60',
|
|
728
|
-
10
|
|
729
|
-
);
|
|
730
|
-
const OVERLAP_BUFFER_MS = overlapBufferSeconds * 1000;
|
|
731
|
-
|
|
732
|
-
// Apply overlap buffer for query (safety window)
|
|
733
|
-
const bufferedLastRunTime = new Date(
|
|
734
|
-
new Date(rawLastRunTime).getTime() - OVERLAP_BUFFER_MS
|
|
735
|
-
).toISOString();
|
|
736
|
-
|
|
737
|
-
const toDate = undefined; // No manual override for scheduled extraction
|
|
738
|
-
|
|
739
|
-
log.info('🔍 Starting incremental products extraction with overlap buffer', {
|
|
740
|
-
jobId,
|
|
741
|
-
rawLastRunTime,
|
|
742
|
-
bufferedLastRunTime,
|
|
743
|
-
effectiveEndTime: toDate || new Date().toISOString(),
|
|
744
|
-
overlapBufferSeconds,
|
|
745
|
-
retailerId,
|
|
746
|
-
maxRecords,
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
// STEP 4/8: Initialize Fluent client + ExtractionOrchestrator
|
|
750
|
-
// ✅ Optional: Validate connection immediately (fail-fast mode)
|
|
751
|
-
// Set activation variable 'validateConnectionOnStart' = 'true' to enable
|
|
752
|
-
// When enabled: Executes query { me { ref } } to verify authentication
|
|
753
|
-
// When disabled: Fast creation, validation happens on first API call (default)
|
|
754
|
-
const validateConnection = activation.getVariable('validateConnectionOnStart') === 'true';
|
|
755
|
-
const client = await createClient(ctx, validateConnection ? { validateConnection: true } : undefined);
|
|
756
|
-
|
|
757
|
-
if (validateConnection) {
|
|
758
|
-
log.info('✅ Connection validated successfully - authentication confirmed', { jobId });
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
const orchestrator = new ExtractionOrchestrator(client, log);
|
|
762
|
-
|
|
763
|
-
// STEP 5/8: Execute extraction with auto-pagination (WITH overlap buffer)
|
|
764
|
-
const effectiveEndTime = toDate || new Date().toISOString();
|
|
765
|
-
|
|
766
|
-
// ? Enhanced: Extract context for progress logging
|
|
767
|
-
const dateRangeInfo = {
|
|
768
|
-
start: bufferedLastRunTime,
|
|
769
|
-
end: effectiveEndTime,
|
|
770
|
-
retailerId
|
|
771
|
-
};
|
|
772
|
-
|
|
773
|
-
// ? Enhanced: Start logging with context
|
|
774
|
-
log.info(`📊 [ExtractionOrchestrator] Starting extraction`, {
|
|
775
|
-
query: 'products',
|
|
776
|
-
pageSize,
|
|
777
|
-
maxRecords,
|
|
778
|
-
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
779
|
-
retailerId: dateRangeInfo.retailerId,
|
|
780
|
-
jobId
|
|
781
|
-
});
|
|
782
|
-
|
|
783
|
-
const extractionResult = await orchestrator.extract({
|
|
784
|
-
query: PRODUCTS_QUERY,
|
|
785
|
-
resultPath: 'products.edges.node',
|
|
786
|
-
variables: {
|
|
787
|
-
retailerId,
|
|
788
|
-
dateRangeFilter: {
|
|
789
|
-
after: bufferedLastRunTime,
|
|
790
|
-
before: effectiveEndTime, // End of extraction window
|
|
791
|
-
},
|
|
792
|
-
},
|
|
793
|
-
pageSize,
|
|
794
|
-
maxRecords,
|
|
795
|
-
validateItem: item => !!item.ref,
|
|
796
|
-
});
|
|
797
|
-
|
|
798
|
-
const rawRecords = extractionResult.data;
|
|
799
|
-
|
|
800
|
-
log.info('Products extraction completed', {
|
|
801
|
-
jobId,
|
|
802
|
-
totalRecords: extractionResult.stats.totalRecords,
|
|
803
|
-
totalPages: extractionResult.stats.totalPages,
|
|
804
|
-
validRecords: extractionResult.stats.validRecords ?? rawRecords.length,
|
|
805
|
-
errors: extractionResult.errors ? extractionResult.errors.length : 0,
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
// ? Enhanced: Completion logging with summary
|
|
809
|
-
log.info(`✅ [ExtractionOrchestrator] Extraction completed`, {
|
|
810
|
-
totalRecords: extractionResult.stats.totalRecords,
|
|
811
|
-
totalPages: extractionResult.stats.totalPages,
|
|
812
|
-
validRecords: extractionResult.stats.validRecords ?? rawRecords.length,
|
|
813
|
-
failedValidations: extractionResult.stats.failedValidations,
|
|
814
|
-
truncated: extractionResult.stats.truncated,
|
|
815
|
-
truncationReason: extractionResult.stats.truncationReason,
|
|
816
|
-
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
817
|
-
jobId
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
if (extractionResult.errors && extractionResult.errors.length > 0) {
|
|
821
|
-
log.warn('Non-fatal extraction errors encountered', {
|
|
822
|
-
jobId,
|
|
823
|
-
errorCount: extractionResult.errors.length,
|
|
824
|
-
sampleErrors: extractionResult.errors.slice(0, 3),
|
|
825
|
-
});
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
if (rawRecords.length === 0) {
|
|
829
|
-
log.info('No new products to extract');
|
|
830
|
-
await kv.set(stateKey, {
|
|
831
|
-
timestamp: new Date().toISOString(),
|
|
832
|
-
productCount: 0,
|
|
833
|
-
extractedAt: new Date().toISOString(),
|
|
834
|
-
});
|
|
835
|
-
await tracker.markCompleted(jobId, {
|
|
836
|
-
recordsProcessed: 0,
|
|
837
|
-
message: 'No new products to extract',
|
|
838
|
-
});
|
|
839
|
-
return {
|
|
840
|
-
success: true,
|
|
841
|
-
message: 'No new products to extract',
|
|
842
|
-
jobId,
|
|
843
|
-
lastRunTime: rawLastRunTime,
|
|
844
|
-
};
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
log.info('Products retrieved', { jobId, count: rawRecords.length });
|
|
848
|
-
|
|
849
|
-
// STEP 6/8: Validate extraction limits
|
|
850
|
-
const MAX_RECORDS_PER_RUN = 100000;
|
|
851
|
-
const ESTIMATED_BYTES_PER_PRODUCT = 2048;
|
|
852
|
-
const estimatedSizeMB = (rawRecords.length * ESTIMATED_BYTES_PER_PRODUCT) / (1024 * 1024);
|
|
853
|
-
const MAX_JSON_SIZE_MB = 500;
|
|
854
|
-
|
|
855
|
-
if (rawRecords.length > MAX_RECORDS_PER_RUN) {
|
|
856
|
-
await tracker.markFailed(jobId, {
|
|
857
|
-
error: 'Extraction limit exceeded',
|
|
858
|
-
recordCount: rawRecords.length,
|
|
859
|
-
maxAllowed: MAX_RECORDS_PER_RUN,
|
|
860
|
-
});
|
|
861
|
-
log.error('Extraction limit exceeded', {
|
|
862
|
-
recordCount: rawRecords.length,
|
|
863
|
-
maxAllowed: MAX_RECORDS_PER_RUN,
|
|
864
|
-
});
|
|
865
|
-
return {
|
|
866
|
-
success: false,
|
|
867
|
-
error: `Extraction limit exceeded: ${rawRecords.length} records (max: ${MAX_RECORDS_PER_RUN})`,
|
|
868
|
-
recommendation: `Catalog too large for single extraction. Consider:
|
|
869
|
-
1. Increase extraction frequency to reduce batch size
|
|
870
|
-
2. Filter by product status (ACTIVE only)
|
|
871
|
-
3. Split by product type or category
|
|
872
|
-
4. Use multiple extractions with different filters`,
|
|
873
|
-
recordCount: rawRecords.length,
|
|
874
|
-
maxAllowed: MAX_RECORDS_PER_RUN,
|
|
875
|
-
};
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
if (estimatedSizeMB > MAX_JSON_SIZE_MB) {
|
|
879
|
-
log.warn('JSON size approaching limit', {
|
|
880
|
-
estimatedSizeMB: estimatedSizeMB.toFixed(2),
|
|
881
|
-
maxAllowed: MAX_JSON_SIZE_MB,
|
|
882
|
-
recommendation: 'Consider file splitting or category-based filtering',
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
await tracker.updateJobProgress(jobId, {
|
|
887
|
-
stage: 'validation_complete',
|
|
888
|
-
recordCount: rawRecords.length,
|
|
889
|
-
estimatedSizeMB: estimatedSizeMB.toFixed(2),
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
// STEP 7/8: Transform with UniversalMapper
|
|
893
|
-
const mapper = new UniversalMapper(productsExportMapping);
|
|
894
|
-
const mappingResult = await mapper.map(rawRecords);
|
|
895
|
-
|
|
896
|
-
if (!mappingResult.success) {
|
|
897
|
-
const mappingErrors = mappingResult.errors || ['Unknown mapping failure'];
|
|
898
|
-
await tracker.markFailed(
|
|
899
|
-
jobId,
|
|
900
|
-
mappingErrors[0] || 'UniversalMapper returned unsuccessful result',
|
|
901
|
-
{
|
|
902
|
-
failedCount: mappingErrors.length,
|
|
903
|
-
errors: mappingErrors,
|
|
904
|
-
}
|
|
905
|
-
);
|
|
906
|
-
return {
|
|
907
|
-
success: false,
|
|
908
|
-
error: `Transformation failed: ${mappingErrors[0] || 'Unknown error'}`,
|
|
909
|
-
errors: mappingErrors,
|
|
910
|
-
};
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
const transformedProducts = Array.isArray(mappingResult.data) ? mappingResult.data : [];
|
|
914
|
-
const mappingErrors = mappingResult.errors || [];
|
|
915
|
-
|
|
916
|
-
if (mappingErrors.length > 0) {
|
|
917
|
-
log.warn('Some products failed transformation', {
|
|
918
|
-
jobId,
|
|
919
|
-
errorCount: mappingErrors.length,
|
|
920
|
-
sampleErrors: mappingErrors.slice(0, 3),
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
925
|
-
log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
926
|
-
jobId,
|
|
927
|
-
skippedFields: mappingResult.skippedFields,
|
|
928
|
-
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
929
|
-
});
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
if (transformedProducts.length === 0) {
|
|
933
|
-
await tracker.markFailed(jobId, 'All records failed mapping', {
|
|
934
|
-
failedCount: mappingErrors.length,
|
|
935
|
-
errors: mappingErrors,
|
|
936
|
-
});
|
|
937
|
-
return {
|
|
938
|
-
success: false,
|
|
939
|
-
error: 'All records failed mapping',
|
|
940
|
-
errors: mappingErrors,
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
log.info('Records transformed', {
|
|
945
|
-
jobId,
|
|
946
|
-
successful: transformedProducts.length,
|
|
947
|
-
skippedRecords: rawRecords.length - transformedProducts.length,
|
|
948
|
-
});
|
|
949
|
-
|
|
950
|
-
await tracker.updateJobProgress(jobId, {
|
|
951
|
-
stage: 'transformation_complete',
|
|
952
|
-
transformedCount: transformedProducts.length,
|
|
953
|
-
failedCount: mappingErrors.length,
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
// Calculate max updatedOn for next run (WITHOUT buffer)
|
|
957
|
-
const maxUpdatedOn = transformedProducts.reduce((max, product) => {
|
|
958
|
-
const productTime = new Date(product.lastUpdated).getTime();
|
|
959
|
-
return productTime > max ? productTime : max;
|
|
960
|
-
}, new Date(rawLastRunTime).getTime());
|
|
961
|
-
|
|
962
|
-
const newTimestamp = new Date(maxUpdatedOn).toISOString();
|
|
963
|
-
|
|
964
|
-
// Build JSON with metadata
|
|
965
|
-
const jsonOutput = {
|
|
966
|
-
metadata: {
|
|
967
|
-
extractedAt: new Date().toISOString(),
|
|
968
|
-
productCount: transformedProducts.length,
|
|
969
|
-
incrementalFrom: rawLastRunTime,
|
|
970
|
-
incrementalTo: newTimestamp,
|
|
971
|
-
},
|
|
972
|
-
products: transformedProducts,
|
|
973
|
-
};
|
|
974
|
-
|
|
975
|
-
// Use JSONBuilder for consistent JSON generation
|
|
976
|
-
const jsonBuilder = new JSONBuilder({
|
|
977
|
-
prettyPrint,
|
|
978
|
-
indent: 2,
|
|
979
|
-
});
|
|
980
|
-
const jsonContent = jsonBuilder.build(jsonOutput);
|
|
981
|
-
|
|
982
|
-
// Generate timestamped filename
|
|
983
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
984
|
-
const fileName = `products-${timestamp}.json`;
|
|
985
|
-
const s3Key = `${s3Prefix}${fileName}`;
|
|
986
|
-
|
|
987
|
-
log.info('Generated JSON file', {
|
|
988
|
-
jobId,
|
|
989
|
-
fileName,
|
|
990
|
-
size: jsonContent.length,
|
|
991
|
-
productCount: transformedProducts.length,
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
// STEP 8/8: Upload to S3
|
|
995
|
-
const s3 = new S3DataSource(
|
|
996
|
-
{
|
|
997
|
-
type: 'S3_JSON',
|
|
998
|
-
connectionId: 's3-products-export',
|
|
999
|
-
name: 'S3 Products Export',
|
|
1000
|
-
s3Config,
|
|
1001
|
-
},
|
|
1002
|
-
log
|
|
1003
|
-
);
|
|
1004
|
-
|
|
1005
|
-
await s3.upload(s3Key, Buffer.from(jsonContent, 'utf8'), {
|
|
1006
|
-
contentType: 'application/json',
|
|
1007
|
-
metadata: {
|
|
1008
|
-
productCount: String(transformedProducts.length),
|
|
1009
|
-
extractedAt: new Date().toISOString(),
|
|
1010
|
-
incrementalFrom: rawLastRunTime,
|
|
1011
|
-
incrementalTo: newTimestamp,
|
|
1012
|
-
},
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
log.info('JSON file uploaded to S3', { jobId, s3Key });
|
|
1016
|
-
|
|
1017
|
-
// Update state with new timestamp (WITHOUT buffer)
|
|
1018
|
-
await kv.set(stateKey, {
|
|
1019
|
-
timestamp: newTimestamp, // ← NO buffer applied
|
|
1020
|
-
productCount: transformedProducts.length,
|
|
1021
|
-
extractedAt: new Date().toISOString(),
|
|
1022
|
-
overlapBufferSeconds,
|
|
1023
|
-
fileName,
|
|
1024
|
-
s3Key,
|
|
1025
|
-
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
log.info('State updated with new timestamp (without buffer)', {
|
|
1029
|
-
jobId,
|
|
1030
|
-
newTimestamp,
|
|
1031
|
-
overlapBufferSeconds,
|
|
1032
|
-
});
|
|
1033
|
-
|
|
1034
|
-
// Complete job tracking
|
|
1035
|
-
const executionDurationMs = Date.now() - executionStartTime;
|
|
1036
|
-
await tracker.markCompleted(jobId, {
|
|
1037
|
-
recordsProcessed: transformedProducts.length,
|
|
1038
|
-
recordsFailed: mappingErrors.length,
|
|
1039
|
-
fileName,
|
|
1040
|
-
s3Key,
|
|
1041
|
-
newTimestamp,
|
|
1042
|
-
executionDurationMs,
|
|
1043
|
-
errors: mappingErrors,
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
log.info('=== EXECUTION END ===', {
|
|
1047
|
-
timestamp: new Date().toISOString(),
|
|
1048
|
-
durationMs: executionDurationMs,
|
|
1049
|
-
success: true,
|
|
1050
|
-
});
|
|
1051
|
-
|
|
1052
|
-
return {
|
|
1053
|
-
success: true,
|
|
1054
|
-
jobId,
|
|
1055
|
-
productsExtracted: transformedProducts.length,
|
|
1056
|
-
recordsFailed: mappingErrors.length,
|
|
1057
|
-
fileName,
|
|
1058
|
-
s3Key,
|
|
1059
|
-
lastRunTime: rawLastRunTime,
|
|
1060
|
-
newTimestamp,
|
|
1061
|
-
executionDurationMs,
|
|
1062
|
-
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1063
|
-
};
|
|
1064
|
-
} catch (error: any) {
|
|
1065
|
-
const executionDurationMs = Date.now() - executionStartTime;
|
|
1066
|
-
log.error('Extraction failed', error, {
|
|
1067
|
-
message: error?.message,
|
|
1068
|
-
executionDurationMs,
|
|
1069
|
-
});
|
|
1070
|
-
|
|
1071
|
-
log.info('=== EXECUTION END ===', {
|
|
1072
|
-
timestamp: new Date().toISOString(),
|
|
1073
|
-
durationMs: executionDurationMs,
|
|
1074
|
-
success: false,
|
|
1075
|
-
});
|
|
1076
|
-
|
|
1077
|
-
return {
|
|
1078
|
-
success: false,
|
|
1079
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1080
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1081
|
-
errorType: error instanceof Error ? error.constructor.name : 'UnknownError',
|
|
1082
|
-
executionDurationMs,
|
|
1083
|
-
};
|
|
1084
|
-
}
|
|
1085
|
-
})
|
|
1086
|
-
);
|
|
1087
|
-
```
|
|
1088
|
-
|
|
1089
|
-
### Workflow 2: Ad Hoc HTTP Extraction
|
|
1090
|
-
**File:** `src/workflows/webhook/adhoc-products-extraction.ts`
|
|
1091
|
-
|
|
1092
|
-
```typescript
|
|
1093
|
-
/**
|
|
1094
|
-
* WORKFLOW 2/3: Ad Hoc Product Extraction (HTTP Webhook)
|
|
1095
|
-
*
|
|
1096
|
-
* Manual trigger for on-demand product catalog extraction
|
|
1097
|
-
*
|
|
1098
|
-
* Usage (payload examples):
|
|
1099
|
-
* 1) Incremental (use stored state):
|
|
1100
|
-
* {}
|
|
1101
|
-
*
|
|
1102
|
-
* 2) Date range override (preferred keys):
|
|
1103
|
-
* {
|
|
1104
|
-
* "fromDate": "2025-01-01T00:00:00Z",
|
|
1105
|
-
* "toDate": "2025-01-22T00:00:00Z",
|
|
1106
|
-
* "updateState": false
|
|
1107
|
-
* }
|
|
1108
|
-
*
|
|
1109
|
-
* 3) Backward-compatible keys (startDate/endDate) are also accepted and mapped:
|
|
1110
|
-
* {
|
|
1111
|
-
* "startDate": "2025-01-01T00:00:00Z",
|
|
1112
|
-
* "endDate": "2025-01-22T00:00:00Z"
|
|
1113
|
-
* }
|
|
1114
|
-
*/
|
|
1115
|
-
export const adHocProductsExtraction = webhook('extract-products-adhoc', {
|
|
1116
|
-
connection: 'products-adhoc',
|
|
1117
|
-
}).then(
|
|
1118
|
-
http('execute-adhoc-extraction', { connection: 'fluent_commerce' }, async ctx => {
|
|
1119
|
-
const { log, openKv, activation, data } = ctx;
|
|
1120
|
-
const executionStartTime = Date.now();
|
|
1121
|
-
|
|
1122
|
-
log.info('=== EXECUTION START ===', { timestamp: new Date().toISOString() });
|
|
1123
|
-
|
|
1124
|
-
// Parse request body and support both new (fromDate/toDate) and alternative (startDate/endDate) keys
|
|
1125
|
-
const requestData = typeof data === 'string' ? JSON.parse(data) : data;
|
|
1126
|
-
const fromDate = requestData?.fromDate || requestData?.startDate;
|
|
1127
|
-
const toDate = requestData?.toDate || requestData?.endDate;
|
|
1128
|
-
const updateState = requestData?.updateState === true; // default: false
|
|
1129
|
-
const maxRecordsOverride = requestData?.maxRecords;
|
|
1130
|
-
|
|
1131
|
-
const retailerId = activation?.getVariable('retailerId');
|
|
1132
|
-
const pageSize = parseInt(activation?.getVariable('pageSize') || '200', 10);
|
|
1133
|
-
const maxRecords =
|
|
1134
|
-
maxRecordsOverride || parseInt(activation?.getVariable('maxRecords') || '50000', 10);
|
|
1135
|
-
const prettyPrint = activation?.getVariable('prettyPrint') === 'true';
|
|
1136
|
-
|
|
1137
|
-
const s3Config = {
|
|
1138
|
-
bucket: activation?.getVariable('s3BucketName'),
|
|
1139
|
-
region: activation?.getVariable('awsRegion') || 'us-east-1',
|
|
1140
|
-
accessKeyId: activation?.getVariable('awsAccessKeyId'),
|
|
1141
|
-
secretAccessKey: activation?.getVariable('awsSecretAccessKey'),
|
|
1142
|
-
};
|
|
1143
|
-
const s3Prefix = activation?.getVariable('s3Prefix') || 'products/adhoc/';
|
|
1144
|
-
|
|
1145
|
-
// Validate required variables
|
|
1146
|
-
const missing: string[] = [];
|
|
1147
|
-
if (!retailerId) missing.push('retailerId');
|
|
1148
|
-
if (!s3Config.bucket) missing.push('s3BucketName');
|
|
1149
|
-
if (!s3Config.accessKeyId) missing.push('awsAccessKeyId');
|
|
1150
|
-
if (!s3Config.secretAccessKey) missing.push('awsSecretAccessKey');
|
|
1151
|
-
if (missing.length) {
|
|
1152
|
-
return { success: false, error: `Missing required variables: ${missing.join(', ')}` };
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
try {
|
|
1156
|
-
// Initialize JobTracker
|
|
1157
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
1158
|
-
const jobId = `products-adhoc-${Date.now()}`;
|
|
1159
|
-
|
|
1160
|
-
await tracker.createJob(jobId, {
|
|
1161
|
-
type: 'extraction',
|
|
1162
|
-
entity: 'products',
|
|
1163
|
-
mode: 'adhoc',
|
|
1164
|
-
retailerId,
|
|
1165
|
-
fromDate,
|
|
1166
|
-
toDate,
|
|
1167
|
-
updateState,
|
|
1168
|
-
startTime: executionStartTime,
|
|
1169
|
-
});
|
|
1170
|
-
|
|
1171
|
-
log.info('Starting ad hoc products extraction', {
|
|
1172
|
-
jobId,
|
|
1173
|
-
retailerId,
|
|
1174
|
-
fromDate: fromDate || 'not specified',
|
|
1175
|
-
toDate: toDate || 'not specified',
|
|
1176
|
-
maxRecords,
|
|
1177
|
-
updateState,
|
|
1178
|
-
});
|
|
1179
|
-
|
|
1180
|
-
// Initialize Fluent client + Orchestrator
|
|
1181
|
-
// ✅ Optional: Validate connection immediately (fail-fast mode)
|
|
1182
|
-
// Set activation variable 'validateConnectionOnStart' = 'true' to enable
|
|
1183
|
-
// When enabled: Executes query { me { ref } } to verify authentication
|
|
1184
|
-
// When disabled: Fast creation, validation happens on first API call (default)
|
|
1185
|
-
const validateConnection = activation.getVariable('validateConnectionOnStart') === 'true';
|
|
1186
|
-
const client = await createClient(ctx, validateConnection ? { validateConnection: true } : undefined);
|
|
1187
|
-
|
|
1188
|
-
if (validateConnection) {
|
|
1189
|
-
log.info('✅ Connection validated successfully - authentication confirmed', { jobId });
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
const orchestrator = new ExtractionOrchestrator(client, log);
|
|
1193
|
-
|
|
1194
|
-
// Execute extraction with auto-pagination
|
|
1195
|
-
// ? Enhanced: Extract context for progress logging
|
|
1196
|
-
const dateRangeInfo = {
|
|
1197
|
-
start: fromDate || 'all',
|
|
1198
|
-
end: toDate || 'now',
|
|
1199
|
-
retailerId
|
|
1200
|
-
};
|
|
1201
|
-
|
|
1202
|
-
// ? Enhanced: Start logging with context
|
|
1203
|
-
log.info(`📊 [ExtractionOrchestrator] Starting extraction`, {
|
|
1204
|
-
query: 'products',
|
|
1205
|
-
pageSize,
|
|
1206
|
-
maxRecords,
|
|
1207
|
-
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
1208
|
-
retailerId: dateRangeInfo.retailerId,
|
|
1209
|
-
jobId
|
|
1210
|
-
});
|
|
1211
|
-
|
|
1212
|
-
const extraction = await orchestrator.extract({
|
|
1213
|
-
query: PRODUCTS_QUERY,
|
|
1214
|
-
resultPath: 'products.edges.node',
|
|
1215
|
-
variables: {
|
|
1216
|
-
retailerId,
|
|
1217
|
-
...(fromDate ? { updatedAfter: fromDate } : {}),
|
|
1218
|
-
},
|
|
1219
|
-
pageSize,
|
|
1220
|
-
maxRecords,
|
|
1221
|
-
validateItem: (item: any) => !!item.ref,
|
|
1222
|
-
});
|
|
1223
|
-
|
|
1224
|
-
const nodes = extraction.data || [];
|
|
1225
|
-
|
|
1226
|
-
// ? Enhanced: Completion logging with summary
|
|
1227
|
-
log.info(`✅ [ExtractionOrchestrator] Extraction completed`, {
|
|
1228
|
-
totalRecords: extraction.stats.totalRecords,
|
|
1229
|
-
totalPages: extraction.stats.totalPages,
|
|
1230
|
-
validRecords: extraction.stats.validRecords ?? nodes.length,
|
|
1231
|
-
failedValidations: extraction.stats.failedValidations,
|
|
1232
|
-
truncated: extraction.stats.truncated,
|
|
1233
|
-
truncationReason: extraction.stats.truncationReason,
|
|
1234
|
-
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
1235
|
-
jobId
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
if (nodes.length === 0) {
|
|
1239
|
-
await tracker.markCompleted(jobId, {
|
|
1240
|
-
recordsProcessed: 0,
|
|
1241
|
-
message: 'No products found',
|
|
1242
|
-
});
|
|
1243
|
-
return {
|
|
1244
|
-
success: true,
|
|
1245
|
-
message: 'No products found',
|
|
1246
|
-
jobId,
|
|
1247
|
-
fromDate,
|
|
1248
|
-
toDate,
|
|
1249
|
-
stateUpdated: false,
|
|
1250
|
-
};
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// Transform with UniversalMapper (bulk mapping)
|
|
1254
|
-
const mapper = new UniversalMapper(productsExportMapping);
|
|
1255
|
-
const mappingResult = await mapper.map(nodes);
|
|
1256
|
-
|
|
1257
|
-
if (!mappingResult.success) {
|
|
1258
|
-
const mappingErrors = mappingResult.errors || ['Unknown mapping failure'];
|
|
1259
|
-
log.error('Mapping failed - terminating job', {
|
|
1260
|
-
jobId,
|
|
1261
|
-
errorCount: mappingErrors.length,
|
|
1262
|
-
sampleErrors: mappingErrors.slice(0, 3),
|
|
1263
|
-
});
|
|
1264
|
-
|
|
1265
|
-
await tracker.markFailed(
|
|
1266
|
-
jobId,
|
|
1267
|
-
mappingErrors[0] || 'UniversalMapper returned unsuccessful result',
|
|
1268
|
-
{
|
|
1269
|
-
errors: mappingErrors,
|
|
1270
|
-
failedCount: mappingErrors.length,
|
|
1271
|
-
}
|
|
1272
|
-
);
|
|
1273
|
-
return {
|
|
1274
|
-
success: false,
|
|
1275
|
-
error: `Transformation failed: ${mappingErrors[0] || 'Unknown error'}`,
|
|
1276
|
-
jobId,
|
|
1277
|
-
errors: mappingErrors,
|
|
1278
|
-
};
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
const transformedProducts = mappingResult.data || [];
|
|
1282
|
-
const mappingErrors = mappingResult.errors || [];
|
|
1283
|
-
|
|
1284
|
-
if (mappingErrors.length > 0) {
|
|
1285
|
-
log.warn('Some records failed transformation', {
|
|
1286
|
-
jobId,
|
|
1287
|
-
errorCount: mappingErrors.length,
|
|
1288
|
-
sampleErrors: mappingErrors.slice(0, 3),
|
|
1289
|
-
});
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
1293
|
-
log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
1294
|
-
jobId,
|
|
1295
|
-
skippedFields: mappingResult.skippedFields,
|
|
1296
|
-
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
1297
|
-
});
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
if (transformedProducts.length === 0) {
|
|
1301
|
-
await tracker.markFailed(jobId, 'All records failed mapping', {
|
|
1302
|
-
failedCount: mappingErrors.length,
|
|
1303
|
-
errors: mappingErrors,
|
|
1304
|
-
});
|
|
1305
|
-
return {
|
|
1306
|
-
success: false,
|
|
1307
|
-
error: 'All records failed mapping',
|
|
1308
|
-
jobId,
|
|
1309
|
-
errors: mappingErrors,
|
|
1310
|
-
};
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
// Compute newTimestamp from transformed records (WITHOUT buffer)
|
|
1314
|
-
const newTimestamp = new Date(
|
|
1315
|
-
transformedProducts.reduce(
|
|
1316
|
-
(max, product) => {
|
|
1317
|
-
const t = new Date(product.lastUpdated).getTime();
|
|
1318
|
-
return t > max ? t : max;
|
|
1319
|
-
},
|
|
1320
|
-
fromDate ? new Date(fromDate).getTime() : 0
|
|
1321
|
-
)
|
|
1322
|
-
).toISOString();
|
|
1323
|
-
|
|
1324
|
-
// Build JSON output
|
|
1325
|
-
const jsonOutput = {
|
|
1326
|
-
metadata: {
|
|
1327
|
-
extractedAt: new Date().toISOString(),
|
|
1328
|
-
productCount: transformedProducts.length,
|
|
1329
|
-
mode: 'adhoc',
|
|
1330
|
-
...(fromDate && { fromDate }),
|
|
1331
|
-
...(toDate && { toDate }),
|
|
1332
|
-
stateUpdated: updateState,
|
|
1333
|
-
},
|
|
1334
|
-
products: transformedProducts,
|
|
1335
|
-
};
|
|
1336
|
-
|
|
1337
|
-
// Use JSONBuilder for consistent JSON generation
|
|
1338
|
-
const jsonBuilder = new JSONBuilder({
|
|
1339
|
-
prettyPrint,
|
|
1340
|
-
indent: 2,
|
|
1341
|
-
});
|
|
1342
|
-
const jsonContent = jsonBuilder.build(jsonOutput);
|
|
1343
|
-
|
|
1344
|
-
// Upload to S3
|
|
1345
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1346
|
-
const fileName = `products-adhoc-${timestamp}.json`;
|
|
1347
|
-
const s3Key = `${s3Prefix}${fileName}`;
|
|
1348
|
-
|
|
1349
|
-
const s3 = new S3DataSource(
|
|
1350
|
-
{
|
|
1351
|
-
type: 'S3_JSON',
|
|
1352
|
-
connectionId: 's3-products-export',
|
|
1353
|
-
name: 'S3 Products Export',
|
|
1354
|
-
s3Config,
|
|
1355
|
-
},
|
|
1356
|
-
log
|
|
1357
|
-
);
|
|
1358
|
-
|
|
1359
|
-
await s3.upload(s3Key, Buffer.from(jsonContent, 'utf8'), {
|
|
1360
|
-
contentType: 'application/json',
|
|
1361
|
-
metadata: {
|
|
1362
|
-
productCount: String(transformedProducts.length),
|
|
1363
|
-
extractedAt: new Date().toISOString(),
|
|
1364
|
-
mode: 'adhoc',
|
|
1365
|
-
},
|
|
1366
|
-
});
|
|
1367
|
-
|
|
1368
|
-
// Optionally update state (adhoc): respects updateState flag
|
|
1369
|
-
let stateUpdated = false;
|
|
1370
|
-
if (updateState) {
|
|
1371
|
-
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
1372
|
-
const stateKey = ['extraction', 'products', 'lastRunTime'];
|
|
1373
|
-
await kv.set(stateKey, {
|
|
1374
|
-
timestamp: newTimestamp, // WITHOUT buffer
|
|
1375
|
-
productCount: transformedProducts.length,
|
|
1376
|
-
extractedAt: new Date().toISOString(),
|
|
1377
|
-
fileName,
|
|
1378
|
-
s3Key,
|
|
1379
|
-
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1380
|
-
});
|
|
1381
|
-
stateUpdated = true;
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
// Complete job tracking
|
|
1385
|
-
const executionDurationMs = Date.now() - executionStartTime;
|
|
1386
|
-
await tracker.markCompleted(jobId, {
|
|
1387
|
-
recordsProcessed: transformedProducts.length,
|
|
1388
|
-
recordsFailed: mappingErrors.length,
|
|
1389
|
-
fileName,
|
|
1390
|
-
s3Key,
|
|
1391
|
-
newTimestamp,
|
|
1392
|
-
stateUpdated,
|
|
1393
|
-
executionDurationMs,
|
|
1394
|
-
errors: mappingErrors,
|
|
1395
|
-
});
|
|
1396
|
-
|
|
1397
|
-
log.info('=== EXECUTION END ===', {
|
|
1398
|
-
timestamp: new Date().toISOString(),
|
|
1399
|
-
durationMs: executionDurationMs,
|
|
1400
|
-
success: true,
|
|
1401
|
-
});
|
|
1402
|
-
|
|
1403
|
-
return {
|
|
1404
|
-
success: true,
|
|
1405
|
-
jobId,
|
|
1406
|
-
productsExtracted: transformedProducts.length,
|
|
1407
|
-
recordsFailed: mappingErrors.length,
|
|
1408
|
-
fileName,
|
|
1409
|
-
s3Key,
|
|
1410
|
-
newTimestamp,
|
|
1411
|
-
stateUpdated,
|
|
1412
|
-
executionDurationMs,
|
|
1413
|
-
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1414
|
-
};
|
|
1415
|
-
} catch (error: any) {
|
|
1416
|
-
const executionDurationMs = Date.now() - executionStartTime;
|
|
1417
|
-
log.error('Ad hoc extraction failed', error, { executionDurationMs });
|
|
1418
|
-
|
|
1419
|
-
log.info('=== EXECUTION END ===', {
|
|
1420
|
-
timestamp: new Date().toISOString(),
|
|
1421
|
-
durationMs: executionDurationMs,
|
|
1422
|
-
success: false,
|
|
1423
|
-
});
|
|
1424
|
-
|
|
1425
|
-
return {
|
|
1426
|
-
success: false,
|
|
1427
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1428
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1429
|
-
errorType: error instanceof Error ? error.constructor.name : 'UnknownError',
|
|
1430
|
-
executionDurationMs,
|
|
1431
|
-
};
|
|
1432
|
-
}
|
|
1433
|
-
})
|
|
1434
|
-
);
|
|
1435
|
-
```
|
|
1436
|
-
|
|
1437
|
-
### Workflow 3: Job Status Checker
|
|
1438
|
-
**File:** `src/workflows/webhook/job-status-check.ts`
|
|
1439
|
-
|
|
1440
|
-
```typescript
|
|
1441
|
-
/**
|
|
1442
|
-
* WORKFLOW 3/3: Job Status Checker
|
|
1443
|
-
*
|
|
1444
|
-
* Check status of extraction job
|
|
1445
|
-
*
|
|
1446
|
-
* Usage:
|
|
1447
|
-
* POST /job-status
|
|
1448
|
-
* {
|
|
1449
|
-
* "jobId": "products-extraction-1234567890"
|
|
1450
|
-
* }
|
|
1451
|
-
*/
|
|
1452
|
-
export const checkJobStatus = webhook('products-job-status', {
|
|
1453
|
-
connection: 'products-job-status',
|
|
1454
|
-
}).then(
|
|
1455
|
-
http('query-job-status', {}, async ctx => {
|
|
1456
|
-
const { log, openKv, data } = ctx;
|
|
1457
|
-
|
|
1458
|
-
const requestData = typeof data === 'string' ? JSON.parse(data) : data;
|
|
1459
|
-
const jobId = requestData?.jobId;
|
|
1460
|
-
|
|
1461
|
-
if (!jobId) {
|
|
1462
|
-
return {
|
|
1463
|
-
success: false,
|
|
1464
|
-
error: 'Missing required field: jobId',
|
|
1465
|
-
};
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
try {
|
|
1469
|
-
const tracker = new JobTracker(openKv(':project:'), log);
|
|
1470
|
-
const job = await tracker.getJob(jobId);
|
|
1471
|
-
|
|
1472
|
-
if (!job) {
|
|
1473
|
-
return {
|
|
1474
|
-
success: false,
|
|
1475
|
-
error: `Job not found: ${jobId}`,
|
|
1476
|
-
};
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
return {
|
|
1480
|
-
success: true,
|
|
1481
|
-
job,
|
|
1482
|
-
};
|
|
1483
|
-
} catch (error: any) {
|
|
1484
|
-
log.error('Failed to get job status', error);
|
|
1485
|
-
return {
|
|
1486
|
-
success: false,
|
|
1487
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1488
|
-
|
|
1489
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1490
|
-
|
|
1491
|
-
errorType: error instanceof Error ? error.constructor.name : 'UnknownError',
|
|
1492
|
-
};
|
|
1493
|
-
}
|
|
1494
|
-
})
|
|
1495
|
-
);
|
|
1496
|
-
```
|
|
1497
|
-
|
|
1498
|
-
### Entry Point (Workflow Registration)
|
|
1499
|
-
**File:** `index.ts`
|
|
1500
|
-
|
|
1501
|
-
```typescript
|
|
1502
|
-
/**
|
|
1503
|
-
* Entry point - Export all workflows for Versori platform
|
|
1504
|
-
*
|
|
1505
|
-
* This file exports all workflows to be registered with Versori.
|
|
1506
|
-
* Each workflow is defined in its own file for better organization.
|
|
1507
|
-
*/
|
|
1508
|
-
|
|
1509
|
-
// Scheduled workflows
|
|
1510
|
-
export { scheduledProductsExtraction } from './src/workflows/scheduled/daily-products-extraction';
|
|
1511
|
-
|
|
1512
|
-
// Webhook workflows
|
|
1513
|
-
export { adHocProductsExtraction } from './src/workflows/webhook/adhoc-products-extraction';
|
|
1514
|
-
export { checkJobStatus } from './src/workflows/webhook/job-status-check';
|
|
1515
|
-
```
|
|
1516
|
-
|
|
1517
|
-
---
|
|
1518
|
-
|
|
1519
|
-
## Important: Schema Verification
|
|
1520
|
-
|
|
1521
|
-
**Before using this template**, verify the GraphQL query structure against your actual Fluent Commerce schema:
|
|
1522
|
-
|
|
1523
|
-
```bash
|
|
1524
|
-
# Introspect your schema
|
|
1525
|
-
cd fc-connect-sdk
|
|
1526
|
-
npx fc-connect introspect-schema --url https://your-fluent-api.com/graphql
|
|
1527
|
-
|
|
1528
|
-
# Check available fields on Product type
|
|
1529
|
-
npx fc-connect introspect-schema --url <url> --output schema.json
|
|
1530
|
-
# Then search for "Product" type in schema.json
|
|
1531
|
-
```
|
|
1532
|
-
|
|
1533
|
-
The query structure shown above is an **example**. Adjust field names to match your actual schema.
|
|
1534
|
-
|
|
1535
|
-
## Use Cases
|
|
1536
|
-
|
|
1537
|
-
**1. Amazon Seller Central Integration:**
|
|
1538
|
-
|
|
1539
|
-
- Daily product feed for listing updates
|
|
1540
|
-
- Include GTIN/UPC for catalog matching
|
|
1541
|
-
- Export pricing/inventory separately
|
|
1542
|
-
|
|
1543
|
-
**2. eBay Marketplace Sync:**
|
|
1544
|
-
|
|
1545
|
-
- Catalog updates for active listings
|
|
1546
|
-
- Include product descriptions and attributes
|
|
1547
|
-
- Handle variation products
|
|
1548
|
-
|
|
1549
|
-
**3. Internal Product Master:**
|
|
1550
|
-
|
|
1551
|
-
- Central product catalog for all systems
|
|
1552
|
-
- Include extended attributes
|
|
1553
|
-
- Version control for catalog changes
|
|
1554
|
-
|
|
1555
|
-
**4. Ad Hoc Refresh:**
|
|
1556
|
-
|
|
1557
|
-
- Manual catalog refresh for urgent updates
|
|
1558
|
-
- Integration testing with sample data
|
|
1559
|
-
- Troubleshooting specific product changes
|
|
1560
|
-
|
|
1561
|
-
## Production Checklist
|
|
1562
|
-
|
|
1563
|
-
- [ ] **Verify GraphQL query against actual schema** (use introspection)
|
|
1564
|
-
- [ ] Set appropriate extraction frequency (daily for catalog)
|
|
1565
|
-
- [ ] Configure correct product status filter (ACTIVE only?)
|
|
1566
|
-
- [ ] Test with real product data changes
|
|
1567
|
-
- [ ] Verify S3 bucket permissions
|
|
1568
|
-
- [ ] Set up monitoring/alerts for failed extractions
|
|
1569
|
-
- [ ] Document JSON schema for downstream consumers
|
|
1570
|
-
- [ ] Configure S3 lifecycle policy for old files
|
|
1571
|
-
- [ ] Test incremental extraction (only changed products)
|
|
1572
|
-
- [ ] Test ad hoc extraction workflow
|
|
1573
|
-
- [ ] Test job status monitoring
|
|
1574
|
-
- [ ] Verify overlap buffer prevents missed records
|
|
1575
|
-
|
|
1576
|
-
---
|
|
1577
|
-
|
|
1578
|
-
### Pattern 7: State Management & Date Overrides
|
|
1579
|
-
|
|
1580
|
-
**Use Case**: Understand how state management works with scheduled and ad-hoc extractions.
|
|
1581
|
-
|
|
1582
|
-
**How it works**:
|
|
1583
|
-
|
|
1584
|
-
VersoriKV stores the last successful extraction timestamp to enable incremental sync:
|
|
1585
|
-
|
|
1586
|
-
```typescript
|
|
1587
|
-
interface ExtractionState {
|
|
1588
|
-
timestamp: string; // Last run timestamp (WITHOUT overlap buffer)
|
|
1589
|
-
recordCount: number; // Number of records extracted
|
|
1590
|
-
extractedAt: string; // When extraction completed
|
|
1591
|
-
fileName?: string; // Generated filename
|
|
1592
|
-
s3Key?: string; // S3 upload path
|
|
1593
|
-
overlapBufferSeconds?: number; // Buffer configuration
|
|
1594
|
-
}
|
|
1595
|
-
```
|
|
1596
|
-
|
|
1597
|
-
**State Priority Chain** (highest to lowest):
|
|
1598
|
-
|
|
1599
|
-
1. **`fromDate` override** (manual date in webhook payload) - Highest priority
|
|
1600
|
-
2. **Stored state** (`await kv.get(stateKey)`) - Normal incremental mode
|
|
1601
|
-
3. **`fallbackStartDate`** (activation variable) - First run fallback
|
|
1602
|
-
|
|
1603
|
-
**Three Scenarios**:
|
|
1604
|
-
|
|
1605
|
-
#### Scenario 1: Normal Scheduled Runs (Incremental)
|
|
1606
|
-
|
|
1607
|
-
```typescript
|
|
1608
|
-
// Payload: {} (empty - no overrides)
|
|
1609
|
-
|
|
1610
|
-
// Behavior:
|
|
1611
|
-
// 1. Load last timestamp from KV: "2025-01-22T10:00:00Z"
|
|
1612
|
-
// 2. Apply overlap buffer: "2025-01-22T09:59:00Z" (query WITH buffer)
|
|
1613
|
-
// 3. Extract records updated since buffered time
|
|
1614
|
-
// 4. Calculate MAX(updatedOn) from results: "2025-01-22T14:30:00Z"
|
|
1615
|
-
// 5. Save new timestamp WITHOUT buffer: "2025-01-22T14:30:00Z"
|
|
1616
|
-
// 6. Next run starts from "2025-01-22T14:29:00Z" (with buffer)
|
|
1617
|
-
```
|
|
1618
|
-
|
|
1619
|
-
**Test**:
|
|
1620
|
-
|
|
1621
|
-
```bash
|
|
1622
|
-
# Trigger scheduled run (no payload needed)
|
|
1623
|
-
# State advances automatically
|
|
1624
|
-
curl -X POST https://workspace.versori.run/products-extract-daily
|
|
1625
|
-
```
|
|
1626
|
-
|
|
1627
|
-
#### Scenario 2: Ad-hoc Extraction WITH State Update
|
|
1628
|
-
|
|
1629
|
-
```typescript
|
|
1630
|
-
// Payload: { "fromDate": "2025-01-01T00:00:00Z", "updateState": true }
|
|
1631
|
-
|
|
1632
|
-
// Behavior:
|
|
1633
|
-
// 1. Ignore stored state
|
|
1634
|
-
// 2. Use fromDate: "2025-01-01T00:00:00Z" (no buffer applied to manual dates)
|
|
1635
|
-
// 3. Extract all records since 2025-01-01
|
|
1636
|
-
// 4. Calculate MAX(updatedOn): "2025-01-22T14:30:00Z"
|
|
1637
|
-
// 5. Save new timestamp: "2025-01-22T14:30:00Z" (updates state!)
|
|
1638
|
-
// 6. Next scheduled run starts from this new timestamp
|
|
1639
|
-
```
|
|
1640
|
-
|
|
1641
|
-
**Use Case**: One-time catch-up extraction that advances the state pointer.
|
|
1642
|
-
|
|
1643
|
-
**Test**:
|
|
1644
|
-
|
|
1645
|
-
```bash
|
|
1646
|
-
curl -X POST https://workspace.versori.run/products-extract-webhook \
|
|
1647
|
-
-H "x-api-key: your-api-key" \
|
|
1648
|
-
-H "Content-Type: application/json" \
|
|
1649
|
-
-d '{
|
|
1650
|
-
"fromDate": "2025-01-01T00:00:00Z",
|
|
1651
|
-
"updateState": true
|
|
1652
|
-
}'
|
|
1653
|
-
```
|
|
1654
|
-
|
|
1655
|
-
#### Scenario 3: Ad-hoc Extraction WITHOUT State Update
|
|
1656
|
-
|
|
1657
|
-
```typescript
|
|
1658
|
-
// Payload: { "fromDate": "2025-01-01T00:00:00Z", "updateState": false }
|
|
1659
|
-
|
|
1660
|
-
// Behavior:
|
|
1661
|
-
// 1. Ignore stored state
|
|
1662
|
-
// 2. Use fromDate: "2025-01-01T00:00:00Z"
|
|
1663
|
-
// 3. Extract all records since 2025-01-01
|
|
1664
|
-
// 4. DO NOT update state
|
|
1665
|
-
// 5. Next scheduled run uses previous timestamp (unaffected)
|
|
1666
|
-
```
|
|
1667
|
-
|
|
1668
|
-
**Use Case**: Historical backfill or testing without affecting incremental sync.
|
|
1669
|
-
|
|
1670
|
-
**Test**:
|
|
1671
|
-
|
|
1672
|
-
```bash
|
|
1673
|
-
curl -X POST https://workspace.versori.run/products-extract-webhook \
|
|
1674
|
-
-H "x-api-key: your-api-key" \
|
|
1675
|
-
-H "Content-Type: application/json" \
|
|
1676
|
-
-d '{
|
|
1677
|
-
"fromDate": "2025-01-01T00:00:00Z",
|
|
1678
|
-
"toDate": "2025-01-31T23:59:59Z",
|
|
1679
|
-
"updateState": false
|
|
1680
|
-
}'
|
|
1681
|
-
```
|
|
1682
|
-
|
|
1683
|
-
**Why this matters**:
|
|
1684
|
-
|
|
1685
|
-
- **Incremental sync** relies on state continuity
|
|
1686
|
-
- **Manual overrides** allow catch-up without breaking incremental flow
|
|
1687
|
-
- **Overlap buffer** prevents missed records at time boundaries
|
|
1688
|
-
- **State isolation** lets you test/backfill without affecting production sync
|
|
1689
|
-
|
|
1690
|
-
---
|
|
1691
|
-
|
|
1692
|
-
### Pattern 8: Optional GraphQL Query Logging
|
|
1693
|
-
|
|
1694
|
-
**Use Case**: Debug extraction issues by logging the exact GraphQL query sent to Fluent Commerce API.
|
|
1695
|
-
|
|
1696
|
-
**When to use**:
|
|
1697
|
-
|
|
1698
|
-
- ✅ Debugging pagination issues
|
|
1699
|
-
- ✅ Verifying query variables (dates, filters, limits)
|
|
1700
|
-
- ✅ Development and testing
|
|
1701
|
-
- ❌ Production (verbose logs, potential secrets in variables)
|
|
1702
|
-
|
|
1703
|
-
**How to enable**:
|
|
1704
|
-
|
|
1705
|
-
Set `DEBUG_GRAPHQL=true` environment variable in Versori activation settings.
|
|
1706
|
-
|
|
1707
|
-
**Implementation**:
|
|
1708
|
-
|
|
1709
|
-
```typescript
|
|
1710
|
-
// In your extraction workflow
|
|
1711
|
-
const DEBUG_GRAPHQL = activation?.getVariable('DEBUG_GRAPHQL') === 'true';
|
|
1712
|
-
|
|
1713
|
-
if (DEBUG_GRAPHQL) {
|
|
1714
|
-
log.info('GraphQL Query Debug', {
|
|
1715
|
-
query: PRODUCTS_QUERY,
|
|
1716
|
-
variables: {
|
|
1717
|
-
catalogues,
|
|
1718
|
-
dateRangeFilter,
|
|
1719
|
-
first: pageSize,
|
|
1720
|
-
after: null, // First page
|
|
1721
|
-
},
|
|
1722
|
-
pagination: {
|
|
1723
|
-
pageSize,
|
|
1724
|
-
maxRecords,
|
|
1725
|
-
currentPage: 1,
|
|
1726
|
-
},
|
|
1727
|
-
});
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
const extractionResult = await orchestrator.extract({
|
|
1731
|
-
query: PRODUCTS_QUERY,
|
|
1732
|
-
resultPath: 'products.edges.node',
|
|
1733
|
-
variables: {
|
|
1734
|
-
catalogues,
|
|
1735
|
-
dateRangeFilter,
|
|
1736
|
-
},
|
|
1737
|
-
pageSize,
|
|
1738
|
-
maxRecords,
|
|
1739
|
-
});
|
|
1740
|
-
|
|
1741
|
-
if (DEBUG_GRAPHQL) {
|
|
1742
|
-
log.info('GraphQL Response Debug', {
|
|
1743
|
-
totalRecords: extractionResult.stats.totalRecords,
|
|
1744
|
-
totalPages: extractionResult.stats.totalPages,
|
|
1745
|
-
truncated: extractionResult.stats.truncated,
|
|
1746
|
-
truncationReason: extractionResult.stats.truncationReason,
|
|
1747
|
-
validRecords: extractionResult.stats.validRecords ?? extractionResult.data.length,
|
|
1748
|
-
firstRecordId: extractionResult.data[0]?.id,
|
|
1749
|
-
lastRecordId: extractionResult.data[extractionResult.data.length - 1]?.id,
|
|
1750
|
-
});
|
|
1751
|
-
}
|
|
1752
|
-
```
|
|
1753
|
-
|
|
1754
|
-
**What gets logged**:
|
|
1755
|
-
|
|
1756
|
-
```json
|
|
1757
|
-
{
|
|
1758
|
-
"level": "info",
|
|
1759
|
-
"message": "GraphQL Query Debug",
|
|
1760
|
-
"query": "query GetProducts($catalogues: [ProductCatalogueKey], $dateRangeFilter: DateRange, ...)",
|
|
1761
|
-
"variables": {
|
|
1762
|
-
"catalogues": [{ "ref": "DEFAULT_CATALOGUE" }],
|
|
1763
|
-
"dateRangeFilter": "2025-01-22T09:59:00Z",
|
|
1764
|
-
"first": 200,
|
|
1765
|
-
"after": null
|
|
1766
|
-
},
|
|
1767
|
-
"pagination": {
|
|
1768
|
-
"pageSize": 200,
|
|
1769
|
-
"maxRecords": 50000,
|
|
1770
|
-
"currentPage": 1
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
```
|
|
1774
|
-
|
|
1775
|
-
**Versori Environment Variables**:
|
|
1776
|
-
|
|
1777
|
-
Add to activation settings:
|
|
1778
|
-
|
|
1779
|
-
```json
|
|
1780
|
-
{
|
|
1781
|
-
"DEBUG_GRAPHQL": "true"
|
|
1782
|
-
}
|
|
1783
|
-
```
|
|
1784
|
-
|
|
1785
|
-
**Testing**:
|
|
1786
|
-
|
|
1787
|
-
```bash
|
|
1788
|
-
# Enable debug logging
|
|
1789
|
-
curl -X POST https://workspace.versori.run/products-extract-daily
|
|
1790
|
-
|
|
1791
|
-
# Check Versori logs for "GraphQL Query Debug" entries
|
|
1792
|
-
# Verify query structure and variables are correct
|
|
1793
|
-
```
|
|
1794
|
-
|
|
1795
|
-
**Sample Debug Output**:
|
|
1796
|
-
|
|
1797
|
-
```
|
|
1798
|
-
[INFO] GraphQL Query Debug
|
|
1799
|
-
query: "query GetProducts($catalogues: [ProductCatalogueKey], $dateRangeFilter: DateRange, ...)"
|
|
1800
|
-
variables: { catalogues: [{ ref: "DEFAULT_CATALOGUE" }], dateRangeFilter: "2025-01-22T09:59:00Z", first: 200, after: null }
|
|
1801
|
-
pagination: { pageSize: 200, maxRecords: 50000, currentPage: 1 }
|
|
1802
|
-
|
|
1803
|
-
[INFO] Extraction complete
|
|
1804
|
-
totalRecords: 1250
|
|
1805
|
-
totalPages: 7
|
|
1806
|
-
truncated: false
|
|
1807
|
-
validRecords: 1250
|
|
1808
|
-
|
|
1809
|
-
[INFO] GraphQL Response Debug
|
|
1810
|
-
totalRecords: 1250
|
|
1811
|
-
totalPages: 7
|
|
1812
|
-
truncated: false
|
|
1813
|
-
truncationReason: null
|
|
1814
|
-
validRecords: 1250
|
|
1815
|
-
firstRecordId: "product_abc"
|
|
1816
|
-
lastRecordId: "product_xyz"
|
|
1817
|
-
```
|
|
1818
|
-
|
|
1819
|
-
**Key Benefits**:
|
|
1820
|
-
|
|
1821
|
-
- Quickly identify pagination configuration issues
|
|
1822
|
-
- Verify date filters are applied correctly
|
|
1823
|
-
- Debug "no records found" scenarios
|
|
1824
|
-
- Validate ExtractionOrchestrator variable injection
|
|
1825
|
-
|
|
1826
|
-
**Production Best Practice**: Disable `DEBUG_GRAPHQL` in production to reduce log volume and avoid logging sensitive data.
|
|
1827
|
-
|
|
1828
|
-
---
|
|
1829
|
-
|
|
1830
|
-
## Troubleshooting Guide
|
|
1831
|
-
|
|
1832
|
-
**Issue**: "Extraction timeout after 10 minutes"
|
|
1833
|
-
|
|
1834
|
-
- **Cause**: Too many records
|
|
1835
|
-
- **Fix**: Reduce maxRecords, increase frequency
|
|
1836
|
-
|
|
1837
|
-
**Issue**: "Mapping errors for 50% of records"
|
|
1838
|
-
|
|
1839
|
-
- **Cause**: Schema mismatch
|
|
1840
|
-
- **Fix**: Run schema validation, check field names
|
|
1841
|
-
|
|
1842
|
-
**Issue**: "State not updating"
|
|
1843
|
-
|
|
1844
|
-
- **Cause**: KV write failure or intentional retry
|
|
1845
|
-
- **Fix**: Check KV logs, verify state update code
|
|
1846
|
-
|
|
1847
|
-
**Issue**: "First run exceeds limits"
|
|
1848
|
-
|
|
1849
|
-
- **Cause**: No previous timestamp, fetches all
|
|
1850
|
-
- **Fix**: Set fallbackStartDate close to current, apply filters
|
|
1851
|
-
|
|
1852
|
-
**Issue**: "Excessive duplicates"
|
|
1853
|
-
|
|
1854
|
-
- **Cause**: Overlap buffer (expected) or timestamp not saved
|
|
1855
|
-
- **Fix**: Verify newTimestamp saved WITHOUT buffer
|
|
1856
|
-
|
|
1857
|
-
**Issue**: "Job status not found"
|
|
1858
|
-
|
|
1859
|
-
- **Cause**: JobTracker not initialized or wrong jobId
|
|
1860
|
-
- **Fix**: Verify JobTracker initialization and jobId format
|
|
1861
|
-
|
|
1862
|
-
---
|
|
1863
|
-
|
|
1864
|
-
**Pattern**: Enterprise incremental extraction with overlap buffer for product catalog - Three-workflow approach
|
|
1865
|
-
**⚠️ Versori Sample**: Reference implementation - adapt for your production use case
|
|
1866
|
-
**Key Learning**: ALWAYS verify GraphQL schema before using queries
|
|
1867
|
-
**Critical**: Apply 60-second overlap buffer to prevent missed records due to clock skew
|
|
1868
|
-
**Buffer Pattern**: Query WITH buffer (`updatedOn >= lastRunTime - 60s`), save WITHOUT buffer (`MAX(updatedOn)`)
|
|
1869
|
-
**Schema**: Use schema introspection - don't guess field names
|
|
1870
|
-
**Three Workflows**: Scheduled incremental, Ad hoc HTTP trigger, Job status monitoring
|
|
1871
|
-
|
|
1872
|
-
---
|
|
1873
|
-
|
|
1874
|
-
## See Also
|
|
1875
|
-
|
|
1876
|
-
**Validation & Best Practices:**
|
|
1877
|
-
|
|
1878
|
-
- [CLI Validation Workflow](../../../../../02-CORE-GUIDES/api-reference/modules/api-reference-11-cli-tools.md) - Validate GraphQL queries and mappings before deployment
|
|
1879
|
-
- [Extraction Modes Guide](../extraction-modes-guide.md) - Comparison of incremental vs dateRange vs historical modes
|
|
1880
|
-
|
|
1881
|
-
**SDK Documentation:**
|
|
1882
|
-
|
|
1883
|
-
- [Universal Mapping Guide](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Complete field mapping documentation
|
|
1884
|
-
- [SDK CLI Tools](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Command-line tools reference
|
|
1885
|
-
|
|
1886
|
-
**Related Extraction Patterns:**
|
|
1887
|
-
|
|
1888
|
-
- [Products to SFTP XML](./template-extraction-products-to-sftp-xml.md) - Same entity, different format/destination
|
|
1889
|
-
- [Virtual Positions to S3 JSON](./template-extraction-virtual-positions-to-s3-json.md) - Different entity, JSON format
|
|
1890
|
-
|
|
1891
|
-
---
|
|
1892
|
-
|
|
1893
|
-
### Pattern 9: Backward Pagination (Optional - Advanced)
|
|
1894
|
-
|
|
1895
|
-
**Use Case**: Extract data in reverse chronological order (newest to oldest) instead of oldest to newest.
|
|
1896
|
-
|
|
1897
|
-
**When to Use**:
|
|
1898
|
-
|
|
1899
|
-
- ✅ Need most recent records first (e.g., latest orders, recent inventory updates)
|
|
1900
|
-
- ✅ Time-bounded reverse traversal for auditing
|
|
1901
|
-
- ✅ Display newest-first in UI/reports
|
|
1902
|
-
- ❌ **Don't use for standard incremental sync** - use forward pagination (default)
|
|
1903
|
-
|
|
1904
|
-
**GraphQL Query Requirements**:
|
|
1905
|
-
|
|
1906
|
-
Your query must support backward pagination by including `$last` and `$before`:
|
|
1907
|
-
|
|
1908
|
-
```graphql
|
|
1909
|
-
query GetData(
|
|
1910
|
-
$retailerId: ID!
|
|
1911
|
-
$first: Int # For forward pagination
|
|
1912
|
-
$after: String # For forward pagination
|
|
1913
|
-
$last: Int # For backward pagination
|
|
1914
|
-
$before: String # For backward pagination
|
|
1915
|
-
) {
|
|
1916
|
-
data(retailerId: $retailerId, first: $first, after: $after, last: $last, before: $before) {
|
|
1917
|
-
edges {
|
|
1918
|
-
cursor # ✅ REQUIRED
|
|
1919
|
-
node {
|
|
1920
|
-
id
|
|
1921
|
-
createdAt
|
|
1922
|
-
# ... other fields
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
1925
|
-
pageInfo {
|
|
1926
|
-
hasNextPage # For forward
|
|
1927
|
-
hasPreviousPage # ✅ REQUIRED for backward
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
```
|
|
1932
|
-
|
|
1933
|
-
**Implementation**:
|
|
1934
|
-
|
|
1935
|
-
```typescript
|
|
1936
|
-
// Backward pagination - newest records first
|
|
1937
|
-
const result = await orchestrator.extract({
|
|
1938
|
-
query: YOUR_QUERY,
|
|
1939
|
-
resultPath: 'data.edges.node',
|
|
1940
|
-
variables: {
|
|
1941
|
-
retailerId,
|
|
1942
|
-
dateRangeFilter: { from: bufferedLastRunTime, to: effectiveEndTime },
|
|
1943
|
-
// ❌ Don't include last/before - orchestrator injects them
|
|
1944
|
-
},
|
|
1945
|
-
pageSize: 200,
|
|
1946
|
-
direction: 'backward', // ✅ Enable reverse pagination
|
|
1947
|
-
maxRecords: 10000,
|
|
1948
|
-
});
|
|
1949
|
-
|
|
1950
|
-
// Records are returned in reverse chronological order
|
|
1951
|
-
log.info('Extraction order verification', {
|
|
1952
|
-
newest: result.data[0].createdAt,
|
|
1953
|
-
oldest: result.data[result.data.length - 1].createdAt
|
|
1954
|
-
});
|
|
1955
|
-
```
|
|
1956
|
-
|
|
1957
|
-
**Key Differences from Forward Pagination**:
|
|
1958
|
-
|
|
1959
|
-
| Aspect | Forward (Default) | Backward |
|
|
1960
|
-
| ---------------------- | -------------------------------- | ----------------------- |
|
|
1961
|
-
| **Direction** | `direction: 'forward'` (default) | `direction: 'backward'` |
|
|
1962
|
-
| **Variables Injected** | `first`, `after` | `last`, `before` |
|
|
1963
|
-
| **PageInfo Field** | `hasNextPage` | `hasPreviousPage` |
|
|
1964
|
-
| **Cursor Source** | Last edge of page | First edge of page |
|
|
1965
|
-
| **Record Order** | Oldest → Newest | Newest → Oldest |
|
|
1966
|
-
|
|
1967
|
-
**Important Notes**:
|
|
1968
|
-
|
|
1969
|
-
1. **Orchestrator injects variables**: Don't pass `last` or `before` in your variables object - the orchestrator injects them based on `pageSize` and cursor tracking.
|
|
1970
|
-
|
|
1971
|
-
2. **Query signature**: Your GraphQL query must declare `$last` and `$before` parameters even if you don't pass them explicitly.
|
|
1972
|
-
|
|
1973
|
-
3. **PageInfo requirement**: Response must include `pageInfo.hasPreviousPage` or the orchestrator will throw an error.
|
|
1974
|
-
|
|
1975
|
-
4. **Cursor requirement**: Each edge must include `cursor` field for pagination to work.
|
|
1976
|
-
|
|
1977
|
-
**Example: Extract Latest 1000 Orders**
|
|
1978
|
-
|
|
1979
|
-
```typescript
|
|
1980
|
-
const latestOrders = await orchestrator.extract({
|
|
1981
|
-
query: ORDERS_QUERY,
|
|
1982
|
-
resultPath: 'orders.edges.node',
|
|
1983
|
-
variables: {
|
|
1984
|
-
retailerId,
|
|
1985
|
-
statuses: ['BOOKED', 'ALLOCATED'],
|
|
1986
|
-
},
|
|
1987
|
-
direction: 'backward', // Start from newest
|
|
1988
|
-
maxRecords: 1000, // Stop after 1000 records
|
|
1989
|
-
pageSize: 100, // 100 per page = 10 pages
|
|
1990
|
-
});
|
|
1991
|
-
|
|
1992
|
-
// latestOrders.data[0] is the newest order
|
|
1993
|
-
// latestOrders.data[999] is the 1000th newest order
|
|
1994
|
-
```
|
|
1995
|
-
|
|
1996
|
-
**When to Use Forward vs Backward**:
|
|
1997
|
-
|
|
1998
|
-
```typescript
|
|
1999
|
-
// ✅ Forward (default) - For incremental sync
|
|
2000
|
-
const incrementalData = await orchestrator.extract({
|
|
2001
|
-
query: YOUR_QUERY,
|
|
2002
|
-
resultPath: 'data.edges.node',
|
|
2003
|
-
variables: {
|
|
2004
|
-
dateRangeFilter: { from: lastSyncTime, to: now },
|
|
2005
|
-
},
|
|
2006
|
-
// direction defaults to 'forward'
|
|
2007
|
-
// Processes oldest → newest for proper sequencing
|
|
2008
|
-
});
|
|
2009
|
-
|
|
2010
|
-
// ✅ Backward - For "latest N records" use cases
|
|
2011
|
-
const latestData = await orchestrator.extract({
|
|
2012
|
-
query: YOUR_QUERY,
|
|
2013
|
-
resultPath: 'data.edges.node',
|
|
2014
|
-
direction: 'backward',
|
|
2015
|
-
maxRecords: 100, // Just get latest 100
|
|
2016
|
-
// Gets newest → oldest
|
|
2017
|
-
});
|
|
2018
|
-
```
|
|
2019
|
-
|
|
2020
|
-
**Pagination Variables Reference**:
|
|
2021
|
-
|
|
2022
|
-
| Variable | Forward | Backward | Injected By | Notes |
|
|
2023
|
-
| -------- | ----------- | ----------- | ------------ | ------------------------ |
|
|
2024
|
-
| `first` | ✅ Used | ❌ Not used | Orchestrator | From `pageSize` |
|
|
2025
|
-
| `after` | ✅ Used | ❌ Not used | Orchestrator | From cursor (last edge) |
|
|
2026
|
-
| `last` | ❌ Not used | ✅ Used | Orchestrator | From `pageSize` |
|
|
2027
|
-
| `before` | ❌ Not used | ✅ Used | Orchestrator | From cursor (first edge) |
|
|
2028
|
-
|
|
2029
|
-
**Common Mistakes to Avoid**:
|
|
2030
|
-
|
|
2031
|
-
```typescript
|
|
2032
|
-
// ❌ WRONG - Don't pass pagination variables
|
|
2033
|
-
const result = await orchestrator.extract({
|
|
2034
|
-
variables: {
|
|
2035
|
-
last: 200, // ❌ Orchestrator will override this
|
|
2036
|
-
before: cursor, // ❌ Orchestrator manages cursor
|
|
2037
|
-
},
|
|
2038
|
-
direction: 'backward',
|
|
2039
|
-
});
|
|
2040
|
-
|
|
2041
|
-
// ✅ CORRECT - Let orchestrator inject pagination
|
|
2042
|
-
const result = await orchestrator.extract({
|
|
2043
|
-
variables: {
|
|
2044
|
-
retailerId, // ✅ Your business variables only
|
|
2045
|
-
},
|
|
2046
|
-
pageSize: 200, // ✅ Orchestrator uses this for last/before
|
|
2047
|
-
direction: 'backward',
|
|
2048
|
-
});
|
|
2049
|
-
```
|
|
2050
|
-
|
|
2051
|
-
#### Optional: Reverse Pagination
|
|
2052
|
-
|
|
2053
|
-
- Default: forward ($first/$after) + pageInfo.hasNextPage.
|
|
2054
|
-
- Reverse: query must use $last/$before; response must include pageInfo.hasPreviousPage; set direction='backward'.
|
|
2055
|
-
|
|
2056
|
-
GraphQL:
|
|
2057
|
-
|
|
2058
|
-
```graphql
|
|
2059
|
-
query GetProductsBackward(
|
|
2060
|
-
$catalogues: [CatalogueKey]
|
|
2061
|
-
$dateRangeFilter: DateRange
|
|
2062
|
-
$last: Int!
|
|
2063
|
-
$before: String
|
|
2064
|
-
) {
|
|
2065
|
-
products(catalogues: $catalogues, updatedOn: $dateRangeFilter, last: $last, before: $before) {
|
|
2066
|
-
edges {
|
|
2067
|
-
cursor
|
|
2068
|
-
node {
|
|
2069
|
-
id
|
|
2070
|
-
ref
|
|
2071
|
-
updatedOn
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
|
-
pageInfo {
|
|
2075
|
-
hasPreviousPage
|
|
2076
|
-
}
|
|
2077
|
-
}
|
|
2078
|
-
}
|
|
2079
|
-
```
|
|
2080
|
-
|
|
2081
|
-
SDK:
|
|
2082
|
-
|
|
2083
|
-
```typescript
|
|
2084
|
-
await orchestrator.extract({
|
|
2085
|
-
query: PRODUCTS_BACKWARD_QUERY,
|
|
2086
|
-
resultPath: 'products.edges.node',
|
|
2087
|
-
variables: { catalogues, dateRangeFilter },
|
|
2088
|
-
pageSize,
|
|
2089
|
-
direction: 'backward',
|
|
2090
|
-
});
|
|
2091
|
-
```
|
|
2092
|
-
|
|
2093
|
-
---
|
|
2094
|
-
|
|
2095
|
-
## Testing Checklist
|
|
2096
|
-
|
|
2097
|
-
**Before production deployment:**
|
|
2098
|
-
|
|
2099
|
-
### 1. Schema Validation
|
|
2100
|
-
|
|
2101
|
-
- [ ] Run `npx fc-connect introspect-schema --url <your-graphql-url>`
|
|
2102
|
-
- [ ] Run `npx fc-connect validate-schema --mapping ./config/products.export.json --schema ./fluent-schema.json`
|
|
2103
|
-
- [ ] Run `npx fc-connect analyze-coverage --mapping ./config/products.export.json --schema ./fluent-schema.json`
|
|
2104
|
-
- [ ] Verify all `source` paths in mapping exist in GraphQL schema
|
|
2105
|
-
- [ ] Verify query structure matches schema (fields, types, filters)
|
|
2106
|
-
|
|
2107
|
-
### 2. Extraction Testing
|
|
2108
|
-
|
|
2109
|
-
- [ ] Test with small dataset first (maxRecords=10)
|
|
2110
|
-
- [ ] Verify ExtractionOrchestrator pagination works correctly
|
|
2111
|
-
- [ ] Test with multiple pages of data (verify cursor handling)
|
|
2112
|
-
- [ ] Verify date range filtering (updatedOn filter)
|
|
2113
|
-
- [ ] Test empty result handling (no records in date range)
|
|
2114
|
-
- [ ] Verify extraction stops at maxRecords limit
|
|
2115
|
-
|
|
2116
|
-
### 3. Mapping Testing
|
|
2117
|
-
|
|
2118
|
-
- [ ] Verify required fields are populated
|
|
2119
|
-
- [ ] Verify SDK resolvers work correctly (sdk.trim, sdk.parseInt, sdk.formatDate, etc.)
|
|
2120
|
-
- [ ] Test custom resolvers with edge cases (if any)
|
|
2121
|
-
- [ ] Verify nested field extraction
|
|
2122
|
-
- [ ] Test with null/missing fields
|
|
2123
|
-
- [ ] Verify mapping error collection works
|
|
2124
|
-
|
|
2125
|
-
### 4. JSON Generation Testing
|
|
2126
|
-
|
|
2127
|
-
- [ ] Verify JSON structure matches expected format
|
|
2128
|
-
- [ ] Test JSON validation against schema (if applicable)
|
|
2129
|
-
- [ ] Verify proper nesting and structure
|
|
2130
|
-
- [ ] Test with large datasets (>1000 records)
|
|
2131
|
-
- [ ] Verify UTF-8 encoding
|
|
2132
|
-
- [ ] Test special character escaping
|
|
2133
|
-
|
|
2134
|
-
### 5. S3 Upload Testing
|
|
2135
|
-
|
|
2136
|
-
- [ ] Test S3 connection and authentication
|
|
2137
|
-
- [ ] Verify file upload to correct bucket and path
|
|
2138
|
-
- [ ] Test file naming convention (timestamp format)
|
|
2139
|
-
- [ ] Verify S3 object metadata
|
|
2140
|
-
- [ ] Test upload retry logic (simulate network failure)
|
|
2141
|
-
- [ ] Verify file permissions and ACLs
|
|
2142
|
-
|
|
2143
|
-
### 6. State Management Testing
|
|
2144
|
-
|
|
2145
|
-
- [ ] Verify overlap buffer prevents missed records (60-second default)
|
|
2146
|
-
- [ ] Test state recovery after extraction failure
|
|
2147
|
-
- [ ] Verify timestamp saved WITHOUT buffer (MAX(updatedOn))
|
|
2148
|
-
- [ ] Test first run with no previous state (uses fallbackStartDate)
|
|
2149
|
-
- [ ] Verify state update only happens on successful upload
|
|
2150
|
-
- [ ] Test manual date override (doesn't update state)
|
|
2151
|
-
|
|
2152
|
-
### 7. Job Tracking Testing
|
|
2153
|
-
|
|
2154
|
-
- [ ] Test job creation with JobTracker
|
|
2155
|
-
- [ ] Verify job status updates at each stage
|
|
2156
|
-
- [ ] Test job completion with metadata
|
|
2157
|
-
- [ ] Test job failure handling
|
|
2158
|
-
- [ ] Query job status via webhook endpoint
|
|
2159
|
-
- [ ] Verify job status persists in KV store
|
|
2160
|
-
|
|
2161
|
-
### 8. Error Handling Testing
|
|
2162
|
-
|
|
2163
|
-
- [ ] Test with invalid GraphQL query
|
|
2164
|
-
- [ ] Test with mapping errors (invalid field paths)
|
|
2165
|
-
- [ ] Test with S3 connection failures
|
|
2166
|
-
- [ ] Test with authentication failures
|
|
2167
|
-
- [ ] Test with network timeouts
|
|
2168
|
-
- [ ] Verify error logging includes context (jobId, stage, error details)
|
|
2169
|
-
- [ ] Test error threshold logic (if applicable)
|
|
2170
|
-
|
|
2171
|
-
### 9. Staging Environment Testing
|
|
2172
|
-
|
|
2173
|
-
- [ ] Run full extraction in staging environment
|
|
2174
|
-
- [ ] Verify JSON file format with downstream system
|
|
2175
|
-
- [ ] Monitor extraction duration and resource usage
|
|
2176
|
-
- [ ] Test with production-like data volumes
|
|
2177
|
-
- [ ] Verify no performance degradation over time
|
|
2178
|
-
|
|
2179
|
-
### 10. Integration Testing
|
|
2180
|
-
|
|
2181
|
-
- [ ] Test scheduled workflow (cron trigger)
|
|
2182
|
-
- [ ] Test ad hoc webhook trigger
|
|
2183
|
-
- [ ] Test job status query webhook
|
|
2184
|
-
- [ ] Verify activation variables are read correctly
|
|
2185
|
-
- [ ] Test with different extraction modes (incremental, date range)
|
|
2186
|
-
- [ ] End-to-end test: trigger → extract → transform → upload → verify file
|
|
2187
|
-
|
|
2188
|
-
---
|
|
2189
|
-
## Monitoring & Alerting
|
|
2190
|
-
|
|
2191
|
-
### Success Response Example
|
|
2192
|
-
|
|
2193
|
-
```json
|
|
2194
|
-
{
|
|
2195
|
-
"success": true,
|
|
2196
|
-
"jobId": "SCHEDULED_PRD_20251102_140000_abc123",
|
|
2197
|
-
"recordsExtracted": 1523,
|
|
2198
|
-
"fileName": "products-2025-11-02T14-00-00-000Z.json",
|
|
2199
|
-
"s3Path": "s3://bucket/products/products-2025-11-02T14-00-00-000Z.json",
|
|
2200
|
-
"metrics": {
|
|
2201
|
-
"extractionDurationMs": 12543,
|
|
2202
|
-
"totalPages": 8,
|
|
2203
|
-
"pageSize": 200,
|
|
2204
|
-
"mappingErrors": 0,
|
|
2205
|
-
"fileSizeBytes": 524288,
|
|
2206
|
-
"uploadDurationMs": 1234
|
|
2207
|
-
},
|
|
2208
|
-
"timestamps": {
|
|
2209
|
-
"extractionStart": "2025-11-02T14:00:00.000Z",
|
|
2210
|
-
"extractionEnd": "2025-11-02T14:00:12.543Z",
|
|
2211
|
-
"uploadComplete": "2025-11-02T14:00:13.777Z"
|
|
2212
|
-
},
|
|
2213
|
-
"state": {
|
|
2214
|
-
"previousTimestamp": "2025-11-02T13:00:00.000Z",
|
|
2215
|
-
"newTimestamp": "2025-11-02T13:59:58.123Z",
|
|
2216
|
-
"stateUpdated": true,
|
|
2217
|
-
"overlapBufferSeconds": 60
|
|
2218
|
-
}
|
|
2219
|
-
}
|
|
2220
|
-
```
|
|
2221
|
-
|
|
2222
|
-
### Error Response Example
|
|
2223
|
-
|
|
2224
|
-
```json
|
|
2225
|
-
{
|
|
2226
|
-
"success": false,
|
|
2227
|
-
"jobId": "ADHOC_PRD_20251102_140500_xyz789",
|
|
2228
|
-
"error": "S3 upload failed: Connection timeout",
|
|
2229
|
-
"errorCategory": "NETWORK",
|
|
2230
|
-
"recordsExtracted": 0,
|
|
2231
|
-
"stage": "s3_upload",
|
|
2232
|
-
"details": {
|
|
2233
|
-
"message": "Failed to upload file after 3 retry attempts",
|
|
2234
|
-
"retryAttempts": 3,
|
|
2235
|
-
"lastError": "ETIMEDOUT: Connection timed out after 30000ms"
|
|
2236
|
-
},
|
|
2237
|
-
"state": {
|
|
2238
|
-
"stateUpdated": false,
|
|
2239
|
-
"willRetryNextRun": true,
|
|
2240
|
-
"note": "State not advanced - next extraction will retry same time window"
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2243
|
-
```
|
|
2244
|
-
|
|
2245
|
-
### Key Metrics to Track
|
|
2246
|
-
|
|
2247
|
-
```typescript
|
|
2248
|
-
const METRICS = {
|
|
2249
|
-
// Extraction Performance
|
|
2250
|
-
extractionDurationMs: Date.now() - extractionStart,
|
|
2251
|
-
recordCount: records.length,
|
|
2252
|
-
pageCount: extractionResult.stats.totalPages,
|
|
2253
|
-
avgRecordsPerPage: records.length / extractionResult.stats.totalPages,
|
|
2254
|
-
|
|
2255
|
-
// Transformation Performance
|
|
2256
|
-
transformedCount: transformedRecords.length,
|
|
2257
|
-
failedCount: mappingErrors.length,
|
|
2258
|
-
errorRate: ((mappingErrors.length / records.length) * 100).toFixed(2) + '%',
|
|
2259
|
-
|
|
2260
|
-
// File Generation
|
|
2261
|
-
fileSizeMB: (jsonContent.length / (1024 * 1024)).toFixed(2),
|
|
2262
|
-
|
|
2263
|
-
// Upload Performance
|
|
2264
|
-
uploadDurationMs: uploadEnd - uploadStart,
|
|
2265
|
-
uploadSpeedMBps: (fileSizeMB / (uploadDurationMs / 1000)).toFixed(2),
|
|
2266
|
-
|
|
2267
|
-
// State Management
|
|
2268
|
-
timeSinceLastRun: Date.now() - new Date(lastTimestamp).getTime(),
|
|
2269
|
-
recordsPerMinute: (records.length / (extractionDurationMs / 60000)).toFixed(0),
|
|
2270
|
-
};
|
|
2271
|
-
|
|
2272
|
-
log.info('Extraction metrics', metrics);
|
|
2273
|
-
```
|
|
2274
|
-
|
|
2275
|
-
### Alert Thresholds
|
|
2276
|
-
|
|
2277
|
-
```typescript
|
|
2278
|
-
const ALERT_THRESHOLDS = {
|
|
2279
|
-
// Duration Alerts
|
|
2280
|
-
EXTRACTION_DURATION_MS: 5 * 60 * 1000, // 5 minutes
|
|
2281
|
-
UPLOAD_DURATION_MS: 2 * 60 * 1000, // 2 minutes
|
|
2282
|
-
TOTAL_DURATION_MS: 10 * 60 * 1000, // 10 minutes
|
|
2283
|
-
|
|
2284
|
-
// Error Rate Alerts
|
|
2285
|
-
MAX_ERROR_RATE: 0.05, // 5% mapping errors
|
|
2286
|
-
MAX_VALIDATION_FAILURES: 0.02, // 2% validation failures
|
|
2287
|
-
|
|
2288
|
-
// Volume Alerts
|
|
2289
|
-
MAX_RECORDS_PER_RUN: 100000,
|
|
2290
|
-
MIN_RECORDS_WARNING: 0, // Alert if no records found
|
|
2291
|
-
MAX_FILE_SIZE_MB: 150, // 150MB
|
|
2292
|
-
|
|
2293
|
-
// State Alerts
|
|
2294
|
-
MAX_TIME_SINCE_LAST_RUN_HOURS: 25, // Alert if >25 hours (should run hourly)
|
|
2295
|
-
MAX_OVERLAP_BUFFER_SECONDS: 300, // Alert if buffer >5 minutes
|
|
2296
|
-
};
|
|
2297
|
-
|
|
2298
|
-
// Check thresholds
|
|
2299
|
-
if (metrics.extractionDurationMs > ALERT_THRESHOLDS.EXTRACTION_DURATION_MS) {
|
|
2300
|
-
log.warn('Extraction duration exceeded threshold', {
|
|
2301
|
-
duration: metrics.extractionDurationMs,
|
|
2302
|
-
threshold: ALERT_THRESHOLDS.EXTRACTION_DURATION_MS,
|
|
2303
|
-
recommendation: 'Consider reducing maxRecords or increasing extraction frequency'
|
|
2304
|
-
});
|
|
2305
|
-
}
|
|
2306
|
-
```
|
|
2307
|
-
|
|
2308
|
-
### Monitoring Dashboard Queries
|
|
2309
|
-
|
|
2310
|
-
**Versori Platform Logs Query:**
|
|
2311
|
-
|
|
2312
|
-
```
|
|
2313
|
-
# Successful extractions
|
|
2314
|
-
log_level:info AND message:"Extraction complete" AND jobId:*
|
|
2315
|
-
|
|
2316
|
-
# Failed extractions
|
|
2317
|
-
log_level:error AND message:"Extraction workflow failed" AND jobId:*
|
|
2318
|
-
|
|
2319
|
-
# Performance issues
|
|
2320
|
-
extractionDurationMs:>300000 OR uploadDurationMs:>120000
|
|
2321
|
-
|
|
2322
|
-
# High error rates
|
|
2323
|
-
errorRate:>5
|
|
2324
|
-
|
|
2325
|
-
# State management issues
|
|
2326
|
-
stateUpdated:false AND success:true
|
|
2327
|
-
```
|
|
2328
|
-
|
|
2329
|
-
### Common Issues and Solutions
|
|
2330
|
-
|
|
2331
|
-
**Issue**: "Extraction timeout after 10 minutes"
|
|
2332
|
-
|
|
2333
|
-
- **Cause**: Too many records in single extraction
|
|
2334
|
-
- **Fix**: Reduce maxRecords, increase extraction frequency, or optimize query filters
|
|
2335
|
-
- **Prevention**: Monitor recordCount trends, set appropriate maxRecords
|
|
2336
|
-
|
|
2337
|
-
**Issue**: "Mapping errors for 50% of records"
|
|
2338
|
-
|
|
2339
|
-
- **Cause**: Schema mismatch between GraphQL response and mapping config
|
|
2340
|
-
- **Fix**: Run schema validation, update mapping config paths
|
|
2341
|
-
- **Prevention**: Use `npx fc-connect validate-schema` before deployment
|
|
2342
|
-
|
|
2343
|
-
**Issue**: "S3 connection timeout"
|
|
2344
|
-
|
|
2345
|
-
- **Cause**: Network issues, firewall, or connection pool exhaustion
|
|
2346
|
-
- **Fix**: Check S3 credentials, verify network connectivity
|
|
2347
|
-
- **Prevention**: Implement connection health checks, monitor connection status
|
|
2348
|
-
|
|
2349
|
-
**Issue**: "State not updating after successful extraction"
|
|
2350
|
-
|
|
2351
|
-
- **Cause**: KV write failure or intentional retry logic
|
|
2352
|
-
- **Fix**: Check KV logs, verify state update code executed
|
|
2353
|
-
- **Prevention**: Add KV write verification, log state updates explicitly
|
|
2354
|
-
|
|
2355
|
-
**Issue**: "First run exceeds record limits"
|
|
2356
|
-
|
|
2357
|
-
- **Cause**: No previous timestamp, fetches all historical records
|
|
2358
|
-
- **Fix**: Set fallbackStartDate close to current date, apply additional filters
|
|
2359
|
-
- **Prevention**: Use appropriate fallbackStartDate for initial runs
|
|
2360
|
-
|
|
2361
|
-
**Issue**: "Excessive duplicate records in output"
|
|
2362
|
-
|
|
2363
|
-
- **Cause**: Overlap buffer (expected) or timestamp not saved correctly
|
|
2364
|
-
- **Fix**: Verify newTimestamp saved WITHOUT buffer, check state persistence
|
|
2365
|
-
- **Prevention**: Monitor duplicate rates, verify state update logic
|
|
2366
|
-
|
|
2367
|
-
---
|
|
2368
|
-
|
|
2369
|
-
## Troubleshooting Quick Reference
|
|
2370
|
-
|
|
2371
|
-
| Error Message | Likely Cause | Solution |
|
|
2372
|
-
|--------------|--------------|----------|
|
|
2373
|
-
| "Failed to create Fluent Commerce client" | Authentication failure | Check OAuth2 credentials, verify connection config |
|
|
2374
|
-
| "GraphQL query validation error" | Invalid query syntax | Validate query against schema with introspection tool |
|
|
2375
|
-
| "Pagination cursor invalid" | Stale cursor or query change | Reset extraction, verify cursor handling in query |
|
|
2376
|
-
| "Mapping failed: field not found" | Schema mismatch | Run schema validation, update mapping paths |
|
|
2377
|
-
| "S3 authentication failed" | Invalid credentials | Verify S3 credentials in activation variables |
|
|
2378
|
-
| "Connection pool exhausted" | Too many concurrent requests | Reduce concurrency, increase pool size |
|
|
2379
|
-
| "KV operation failed" | Versori KV issue | Check Versori platform status, retry operation |
|
|
2380
|
-
| "Job status not found" | Invalid jobId or expired | Verify jobId format, check KV retention policy |
|
|
2381
|
-
| "Memory limit exceeded" | Dataset too large | Reduce maxRecords, enable streaming mode |
|
|
2382
|
-
| "JSON generation failed" | Format-specific error | Check JSON generation logic, validate output |
|
|
2383
|
-
|
|
2384
|
-
---
|
|
1
|
+
---
|
|
2
|
+
template_id: tpl-extract-products-graphql-to-s3-json
|
|
3
|
+
canonical_filename: template-extraction-products-to-s3-json.md
|
|
4
|
+
sdk_version: ^0.1.39
|
|
5
|
+
runtime: versori
|
|
6
|
+
direction: extraction
|
|
7
|
+
source: fluent-graphql
|
|
8
|
+
destination: s3-json
|
|
9
|
+
entity: products
|
|
10
|
+
format: json
|
|
11
|
+
logging: versori
|
|
12
|
+
status: stable
|
|
13
|
+
features:
|
|
14
|
+
- memory-management
|
|
15
|
+
- enhanced-logging
|
|
16
|
+
- pagination-progress
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Template: Extraction - Products GraphQL to S3 JSON
|
|
20
|
+
|
|
21
|
+
**SDK Version:** @fluentcommerce/fc-connect-sdk@^0.1.39
|
|
22
|
+
**Last Updated:** 2025-01-24
|
|
23
|
+
**Deployment Target:** Versori Platform
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 📋 Implementation Prompt
|
|
28
|
+
|
|
29
|
+
Copy/paste the standardized prompt from `docs/template-loading-matrix.md#prompts`.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 💻 STEP 3: Implementation (Verified Imports)
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { Buffer } from 'node:buffer';
|
|
37
|
+
import {
|
|
38
|
+
createClient,
|
|
39
|
+
UniversalMapper,
|
|
40
|
+
S3DataSource,
|
|
41
|
+
JSONBuilder,
|
|
42
|
+
VersoriKVAdapter,
|
|
43
|
+
ExtractionOrchestrator,
|
|
44
|
+
JobTracker,
|
|
45
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
These are the only SDK imports required for this template. Keep type-only imports out of code samples. Prefer the orchestrator-based flow when you need built-in pagination and stats.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
# Versori Workflows: Product Catalog Extraction to S3 JSON
|
|
53
|
+
|
|
54
|
+
**FC Connect SDK Use Case Guide**
|
|
55
|
+
|
|
56
|
+
> SDK: [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk)
|
|
57
|
+
> Version: ^0.1.39
|
|
58
|
+
|
|
59
|
+
Context: Three Versori workflows for extracting product catalog from Fluent Commerce via GraphQL query with **incremental timestamp tracking**, **ad hoc extraction**, and **job status monitoring**. Transforms with `UniversalMapper`, writes JSON files to S3 for marketplace integration (Amazon, eBay, etc).
|
|
60
|
+
|
|
61
|
+
**Pattern**: EXTRACTION (Fluent → S3 JSON)
|
|
62
|
+
**Complexity**: Medium | Runtime: Versori Platform
|
|
63
|
+
**Format**: JSON with metadata wrapper
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## ⚠️ IMPORTANT: Production-Ready Base Template
|
|
68
|
+
|
|
69
|
+
> **📋 BASE TEMPLATE - Ready for Production (Customize for Your Needs)**
|
|
70
|
+
>
|
|
71
|
+
> This is a **production-ready base template** demonstrating FC Connect SDK best practices for product extraction workflows with JSON output to S3.
|
|
72
|
+
>
|
|
73
|
+
> **✅ INCLUDED FEATURES:**
|
|
74
|
+
>
|
|
75
|
+
> - ✅ Comprehensive error handling with retry logic
|
|
76
|
+
> - ✅ S3 upload with proper error handling
|
|
77
|
+
> - ✅ State management with overlap buffer (prevents missed records)
|
|
78
|
+
> - ✅ Job tracking with lifecycle management
|
|
79
|
+
> - ✅ Security (credential masking in logs)
|
|
80
|
+
> - ✅ UTC time enforcement (prevents timezone bugs)
|
|
81
|
+
> - ✅ Incremental extraction (safe, efficient, production-ready)
|
|
82
|
+
> - ✅ Natural rate limiting via timestamps
|
|
83
|
+
>
|
|
84
|
+
> **📝 BEFORE DEPLOYING:**
|
|
85
|
+
>
|
|
86
|
+
> 1. Review and customize activation variables for your environment
|
|
87
|
+
> 2. Test with sample data in your Versori workspace
|
|
88
|
+
> 3. Adjust safety limits (pageSize, maxRecords) if needed
|
|
89
|
+
> 4. Configure monitoring alerts for extraction failures
|
|
90
|
+
> 5. Verify S3 bucket credentials and paths
|
|
91
|
+
>
|
|
92
|
+
> **This base template follows SDK best practices - tweak specific to your needs.**
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## What You'll Build
|
|
97
|
+
|
|
98
|
+
**Three Versori Workflows:**
|
|
99
|
+
|
|
100
|
+
1. **Scheduled Extraction** - Daily/hourly incremental product updates
|
|
101
|
+
2. **Ad Hoc Extraction** - On-demand HTTP webhook for manual triggers
|
|
102
|
+
3. **Job Status Checker** - Monitor extraction job status via JobTracker
|
|
103
|
+
|
|
104
|
+
**Features:**
|
|
105
|
+
|
|
106
|
+
- **Incremental extraction** using `updatedOn > lastRunTime` filter
|
|
107
|
+
- **State management** with VersoriKVAdapter to track last successful run
|
|
108
|
+
- **Job tracking** with JobTracker for status monitoring
|
|
109
|
+
- GraphQL query with auto-pagination
|
|
110
|
+
- UniversalMapper transformation for marketplace schema
|
|
111
|
+
- JSON file generation with product catalog
|
|
112
|
+
- S3 upload to marketplace integration bucket
|
|
113
|
+
- **Failure recovery** with timestamp tracking
|
|
114
|
+
- **Pretty print option** for human-readable JSON
|
|
115
|
+
|
|
116
|
+
## Business Use Case
|
|
117
|
+
|
|
118
|
+
**Daily product catalog sync to marketplaces:**
|
|
119
|
+
|
|
120
|
+
- Extract only changed products since last run
|
|
121
|
+
- Export as JSON for marketplace API consumption
|
|
122
|
+
- Run daily at midnight for overnight processing
|
|
123
|
+
- Include SKU, title, description, pricing, attributes
|
|
124
|
+
- Enable Amazon/eBay listing updates
|
|
125
|
+
- Support ad hoc manual refresh on demand
|
|
126
|
+
- Monitor job status for workflow integration
|
|
127
|
+
|
|
128
|
+
## SDK Methods Used
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { Buffer } from 'node:buffer';
|
|
132
|
+
import {
|
|
133
|
+
createClient,
|
|
134
|
+
UniversalMapper,
|
|
135
|
+
S3DataSource,
|
|
136
|
+
JSONBuilder,
|
|
137
|
+
VersoriKVAdapter,
|
|
138
|
+
JobTracker,
|
|
139
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
140
|
+
|
|
141
|
+
await createClient(ctx); // Versori-aware client
|
|
142
|
+
await client.graphql({ query, variables, pagination }); // GraphQL with auto-pagination
|
|
143
|
+
new VersoriKVAdapter(ctx.openKv(':project:')); // State management
|
|
144
|
+
new JobTracker(ctx.openKv(':project:'), ctx.log); // Job tracking
|
|
145
|
+
new UniversalMapper(exportMapping); // Field transformation
|
|
146
|
+
const jsonBuilder = new JSONBuilder({ prettyPrint: true, indent: 2 });
|
|
147
|
+
const jsonContent = jsonBuilder.build(dataObject); // JSON generation
|
|
148
|
+
await s3.upload(key, Buffer.from(jsonContent, 'utf8'), options); // S3 upload
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Activation Variables
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"retailerId": "your-retailer-id",
|
|
156
|
+
"s3BucketName": "marketplace-catalog-exports",
|
|
157
|
+
"awsAccessKeyId": "AKIAXXXXXXXXXXXX",
|
|
158
|
+
"awsSecretAccessKey": "********",
|
|
159
|
+
"awsRegion": "us-east-1",
|
|
160
|
+
"s3Prefix": "products/daily/",
|
|
161
|
+
"pageSize": 200,
|
|
162
|
+
"maxRecords": 50000,
|
|
163
|
+
"fallbackStartDate": "2024-01-01T00:00:00Z",
|
|
164
|
+
"overlapBufferSeconds": "60",
|
|
165
|
+
"prettyPrint": "false",
|
|
166
|
+
"validateConnectionOnStart": "false"
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**New Variables (v2.1.0):**
|
|
171
|
+
- `validateConnectionOnStart`: Optional fail-fast connection validation. When `"true"`, validates connection before extraction. Default: `"false"` (validation on first API call)
|
|
172
|
+
|
|
173
|
+
**Configuration Notes:**
|
|
174
|
+
- All variables are required except `validateConnectionOnStart`, `overlapBufferSeconds`, and `prettyPrint`
|
|
175
|
+
- Credentials should be stored securely in Versori activation variables
|
|
176
|
+
- `pageSize` and `maxRecords` can be adjusted based on data volume and performance requirements
|
|
177
|
+
|
|
178
|
+
## Export Mapping Configuration
|
|
179
|
+
|
|
180
|
+
Create file: `./config/products.export.json`
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"name": "products.export",
|
|
185
|
+
"version": "1.0.0",
|
|
186
|
+
"description": "Fluent Products → Marketplace JSON Export",
|
|
187
|
+
"fields": {
|
|
188
|
+
"sku": { "source": "ref", "required": true, "resolver": "sdk.trim" },
|
|
189
|
+
"title": { "source": "name", "required": true, "resolver": "sdk.trim" },
|
|
190
|
+
"description": { "source": "summary", "required": false, "resolver": "sdk.trim" },
|
|
191
|
+
"gtin": { "source": "gtin", "required": false, "resolver": "sdk.trim" },
|
|
192
|
+
"type": { "source": "type", "required": false, "resolver": "sdk.uppercase" },
|
|
193
|
+
"status": { "source": "status", "required": true, "resolver": "sdk.uppercase" },
|
|
194
|
+
"createdOn": { "source": "createdOn", "required": false, "resolver": "sdk.toString" },
|
|
195
|
+
"lastUpdated": { "source": "updatedOn", "required": true, "resolver": "sdk.toString" }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Note**: Actual GraphQL query structure should be verified against your Fluent schema using introspection. The fields above are examples - adjust based on your actual schema.
|
|
201
|
+
|
|
202
|
+
## GraphQL Query
|
|
203
|
+
|
|
204
|
+
**Note**: This query is an **example**. Verify against your Fluent Commerce schema using introspection.
|
|
205
|
+
|
|
206
|
+
```graphql
|
|
207
|
+
query GetProducts($retailerId: ID!, $updatedAfter: DateTime, $first: Int!, $after: String) {
|
|
208
|
+
products(
|
|
209
|
+
retailerId: $retailerId
|
|
210
|
+
updatedOn: { after: $updatedAfter }
|
|
211
|
+
first: $first
|
|
212
|
+
after: $after
|
|
213
|
+
) {
|
|
214
|
+
edges {
|
|
215
|
+
node {
|
|
216
|
+
id
|
|
217
|
+
ref
|
|
218
|
+
name
|
|
219
|
+
summary
|
|
220
|
+
gtin
|
|
221
|
+
type
|
|
222
|
+
status
|
|
223
|
+
createdOn
|
|
224
|
+
updatedOn
|
|
225
|
+
}
|
|
226
|
+
cursor
|
|
227
|
+
}
|
|
228
|
+
pageInfo {
|
|
229
|
+
hasNextPage
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Versori Workflows Structure
|
|
238
|
+
|
|
239
|
+
**Key Concept**: Versori workflows are organized by **trigger type** at the first level, then by **specific workflow** with descriptive file names.
|
|
240
|
+
|
|
241
|
+
**Trigger Types:**
|
|
242
|
+
- **`schedule()`** → Time-based triggers (cron expressions) - NOT exposed as HTTP endpoints
|
|
243
|
+
- **`webhook()`** → HTTP-based triggers (event-driven) - Creates HTTP endpoints
|
|
244
|
+
- **`workflow()`** → Durable workflows (advanced) - Multi-step processes with state persistence
|
|
245
|
+
|
|
246
|
+
**Execution Steps (chained to triggers):**
|
|
247
|
+
- **`http()`** → External API calls (chained from schedule/webhook)
|
|
248
|
+
- **`fn()`** → Internal processing (chained from schedule/webhook)
|
|
249
|
+
|
|
250
|
+
### Durable Workflows (Advanced Pattern)
|
|
251
|
+
|
|
252
|
+
**⚠️ Note:** Durable workflows are an advanced pattern not yet used in this template. They're documented here for completeness.
|
|
253
|
+
|
|
254
|
+
**Use Case:** Multi-step processes requiring state persistence, long-running operations (hours/days), human approval workflows, or complex retry/error handling across steps.
|
|
255
|
+
|
|
256
|
+
**Example Pattern:**
|
|
257
|
+
```typescript
|
|
258
|
+
import { workflow } from '@versori/run';
|
|
259
|
+
|
|
260
|
+
workflow('order-fulfillment', { connection: 'fluent_commerce' }, async (ctx, step) => {
|
|
261
|
+
// Step 1: Validate order
|
|
262
|
+
const order = await step.run('validate-order', async () => {
|
|
263
|
+
return await validateOrder(ctx);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Step 2: Allocate inventory (with retry)
|
|
267
|
+
const allocation = await step.run('allocate-inventory', {
|
|
268
|
+
retry: { max: 3, delay: '1s' }
|
|
269
|
+
}, async () => {
|
|
270
|
+
return await allocateInventory(order);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Step 3: Wait for approval (human-in-loop)
|
|
274
|
+
await step.sleep('wait-for-approval', '1h');
|
|
275
|
+
|
|
276
|
+
// Step 4: Create fulfillment
|
|
277
|
+
return await step.run('create-fulfillment', async () => {
|
|
278
|
+
return await createFulfillment(allocation);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**When to use workflow():**
|
|
284
|
+
- ✅ Multi-step processes requiring state persistence
|
|
285
|
+
- ✅ Long-running operations (hours/days)
|
|
286
|
+
- ✅ Human approval workflows
|
|
287
|
+
- ✅ Complex retry/error handling across steps
|
|
288
|
+
- ❌ Simple CRUD operations (use schedule/webhook + http)
|
|
289
|
+
- ❌ Fire-and-forget patterns (use schedule + http)
|
|
290
|
+
|
|
291
|
+
### Recommended Project Structure
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
products-extraction/
|
|
295
|
+
├── index.ts # Entry point - exports all workflows
|
|
296
|
+
└── src/
|
|
297
|
+
├── workflows/
|
|
298
|
+
│ ├── scheduled/
|
|
299
|
+
│ │ └── daily-products-extraction.ts # Scheduled: Daily products extraction
|
|
300
|
+
│ │
|
|
301
|
+
│ └── webhook/
|
|
302
|
+
│ ├── adhoc-products-extraction.ts # Webhook: Manual trigger
|
|
303
|
+
│ └── job-status-check.ts # Webhook: Status query
|
|
304
|
+
│
|
|
305
|
+
├── services/
|
|
306
|
+
│ └── products-extraction.service.ts # Shared orchestration logic (reusable)
|
|
307
|
+
│
|
|
308
|
+
└── config/
|
|
309
|
+
└── products.export.json.json # Mapping configuration
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
```json
|
|
315
|
+
{
|
|
316
|
+
"metadata": {
|
|
317
|
+
"extractedAt": "2025-01-22T00:00:00.000Z",
|
|
318
|
+
"productCount": 2,
|
|
319
|
+
"incrementalFrom": "2025-01-21T00:00:00.000Z",
|
|
320
|
+
"incrementalTo": "2025-01-22T00:00:00.000Z"
|
|
321
|
+
},
|
|
322
|
+
"products": [
|
|
323
|
+
{
|
|
324
|
+
"sku": "SKU-001",
|
|
325
|
+
"title": "Premium Widget",
|
|
326
|
+
"description": "High-quality widget for all purposes",
|
|
327
|
+
"gtin": "012345678901",
|
|
328
|
+
"type": "STANDARD",
|
|
329
|
+
"status": "ACTIVE",
|
|
330
|
+
"createdOn": "2025-01-15T10:00:00Z",
|
|
331
|
+
"lastUpdated": "2025-01-21T10:30:00Z"
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
"sku": "SKU-002",
|
|
335
|
+
"title": "Deluxe Gadget",
|
|
336
|
+
"description": "Advanced gadget with premium features",
|
|
337
|
+
"gtin": "012345678902",
|
|
338
|
+
"type": "STANDARD",
|
|
339
|
+
"status": "ACTIVE",
|
|
340
|
+
"createdOn": "2025-01-16T11:00:00Z",
|
|
341
|
+
"lastUpdated": "2025-01-21T14:15:00Z"
|
|
342
|
+
}
|
|
343
|
+
]
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
## Mapping & Resolvers Explained
|
|
348
|
+
|
|
349
|
+
### SDK Resolvers Used
|
|
350
|
+
|
|
351
|
+
| Field | Resolver | Why? | Example |
|
|
352
|
+
| ------------- | --------------- | ----------------------- | ----------------------------- |
|
|
353
|
+
| `sku` | `sdk.trim` | Clean SKU | " SKU-001 " → "SKU-001" |
|
|
354
|
+
| `title` | `sdk.trim` | Clean names | " Widget " → "Widget" |
|
|
355
|
+
| `description` | `sdk.trim` | Remove extra whitespace | " desc " → "desc" |
|
|
356
|
+
| `gtin` | `sdk.trim` | Normalize GTIN/UPC | " 012345 " → "012345" |
|
|
357
|
+
| `type` | `sdk.uppercase` | Normalize type | "standard" → "STANDARD" |
|
|
358
|
+
| `status` | `sdk.uppercase` | Normalize status | "active" → "ACTIVE" |
|
|
359
|
+
| `createdOn` | `sdk.toString` | Ensure ISO string | Date → "2025-01-15T10:00:00Z" |
|
|
360
|
+
| `lastUpdated` | `sdk.toString` | Ensure ISO string | Date → "2025-01-22T14:30:00Z" |
|
|
361
|
+
|
|
362
|
+
### Transformation Flow
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
// 1) GraphQL node (example)
|
|
366
|
+
{
|
|
367
|
+
ref: " SKU-001 ",
|
|
368
|
+
name: " Widget ",
|
|
369
|
+
summary: " High quality ",
|
|
370
|
+
gtin: " 012345678901 ",
|
|
371
|
+
type: "standard",
|
|
372
|
+
status: "active",
|
|
373
|
+
createdOn: "2025-01-15T10:00:00.000Z",
|
|
374
|
+
updatedOn: "2025-01-21T10:30:00.000Z"
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 2) Map with UniversalMapper
|
|
378
|
+
const mapper = new UniversalMapper(productsExportMapping);
|
|
379
|
+
const result = await mapper.map(node);
|
|
380
|
+
|
|
381
|
+
// 3) Output
|
|
382
|
+
result.data = {
|
|
383
|
+
sku: "SKU-001",
|
|
384
|
+
title: "Widget",
|
|
385
|
+
description: "High quality",
|
|
386
|
+
gtin: "012345678901",
|
|
387
|
+
type: "STANDARD",
|
|
388
|
+
status: "ACTIVE",
|
|
389
|
+
createdOn: "2025-01-15T10:00:00.000Z",
|
|
390
|
+
lastUpdated: "2025-01-21T10:30:00.000Z"
|
|
391
|
+
};
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### Custom Resolvers for Product-Specific Logic
|
|
395
|
+
|
|
396
|
+
While the mapping above uses built-in SDK resolvers, you can extend with custom business logic for marketplace integration:
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
const customResolvers = {
|
|
400
|
+
/**
|
|
401
|
+
* Extract category hierarchy for marketplace navigation
|
|
402
|
+
*/
|
|
403
|
+
'custom.formatCategoryPath': (product: any) => {
|
|
404
|
+
const categories = product.categories || [];
|
|
405
|
+
return categories
|
|
406
|
+
.map((c: any) => c.name?.trim())
|
|
407
|
+
.filter(Boolean)
|
|
408
|
+
.join(' > ');
|
|
409
|
+
// Example: "Electronics > Computers > Laptops"
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Generate marketplace-compatible SKU with prefix
|
|
414
|
+
*/
|
|
415
|
+
'custom.generateMarketplaceSKU': (ref: string, retailerId: string) => {
|
|
416
|
+
return `${retailerId}-${ref.trim()}`.toUpperCase();
|
|
417
|
+
// Example: "RETAILER123-SKU-001"
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Format price for marketplace API (convert cents to dollars)
|
|
422
|
+
*/
|
|
423
|
+
'custom.formatPrice': (priceInCents: number) => {
|
|
424
|
+
const dollars = (priceInCents || 0) / 100;
|
|
425
|
+
return dollars.toFixed(2);
|
|
426
|
+
// Example: 2499 → "24.99"
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Extract primary image URL from attributes
|
|
431
|
+
*/
|
|
432
|
+
'custom.getPrimaryImage': (product: any) => {
|
|
433
|
+
const images = product.attributes?.images || [];
|
|
434
|
+
return images.length > 0 ? images[0]?.url : null;
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Generate marketplace title with brand and product name
|
|
439
|
+
*/
|
|
440
|
+
'custom.generateMarketplaceTitle': (product: any) => {
|
|
441
|
+
const brand = product.attributes?.brand?.trim() || '';
|
|
442
|
+
const name = product.name?.trim() || '';
|
|
443
|
+
const maxLength = 200; // Amazon/eBay limit
|
|
444
|
+
|
|
445
|
+
const title = brand ? `${brand} - ${name}` : name;
|
|
446
|
+
return title.length > maxLength ? title.substring(0, maxLength - 3) + '...' : title;
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Extract product attributes for marketplace listing
|
|
451
|
+
*/
|
|
452
|
+
'custom.extractAttributes': (product: any) => {
|
|
453
|
+
const attrs = product.attributes || {};
|
|
454
|
+
return {
|
|
455
|
+
brand: attrs.brand?.trim() || 'Unbranded',
|
|
456
|
+
color: attrs.color?.trim() || null,
|
|
457
|
+
size: attrs.size?.trim() || null,
|
|
458
|
+
weight: attrs.weight ? `${attrs.weight} ${attrs.weightUnit || 'lb'}` : null,
|
|
459
|
+
dimensions: attrs.dimensions || null,
|
|
460
|
+
material: attrs.material?.trim() || null,
|
|
461
|
+
};
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Determine product eligibility for marketplaces
|
|
466
|
+
*/
|
|
467
|
+
'custom.checkMarketplaceEligibility': (product: any) => {
|
|
468
|
+
const status = (product.status || '').toUpperCase();
|
|
469
|
+
const hasGTIN = !!product.gtin;
|
|
470
|
+
const hasImages = (product.attributes?.images || []).length > 0;
|
|
471
|
+
const hasPrice = (product.prices || []).length > 0;
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
isActive: status === 'ACTIVE',
|
|
475
|
+
hasRequiredFields: hasGTIN && hasImages && hasPrice,
|
|
476
|
+
isEligibleForAmazon: status === 'ACTIVE' && hasGTIN && hasImages && hasPrice,
|
|
477
|
+
isEligibleForEbay: status === 'ACTIVE' && hasImages && hasPrice,
|
|
478
|
+
missingFields: [!hasGTIN && 'GTIN', !hasImages && 'Images', !hasPrice && 'Price'].filter(
|
|
479
|
+
Boolean
|
|
480
|
+
),
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Format for marketplace API submission
|
|
486
|
+
*/
|
|
487
|
+
'custom.formatForMarketplace': (product: any) => {
|
|
488
|
+
return {
|
|
489
|
+
sku: product.ref?.trim(),
|
|
490
|
+
title: product.name?.trim(),
|
|
491
|
+
description: product.summary?.trim() || product.name?.trim(),
|
|
492
|
+
category: product.categories?.[0]?.name || 'Uncategorized',
|
|
493
|
+
brand: product.attributes?.brand?.trim() || 'Generic',
|
|
494
|
+
gtin: product.gtin?.trim() || null,
|
|
495
|
+
price: (product.prices?.[0]?.value || 0) / 100, // cents to dollars
|
|
496
|
+
currency: product.prices?.[0]?.currency || 'USD',
|
|
497
|
+
images: (product.attributes?.images || []).map((img: any) => img.url),
|
|
498
|
+
status: product.status?.toUpperCase(),
|
|
499
|
+
lastUpdated: product.updatedOn,
|
|
500
|
+
};
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// Use custom resolvers with UniversalMapper
|
|
505
|
+
const mapper = new UniversalMapper(productsExportMapping, {
|
|
506
|
+
customResolvers,
|
|
507
|
+
});
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Available SDK Resolvers
|
|
511
|
+
|
|
512
|
+
The SDK provides these built-in resolvers (no custom code needed):
|
|
513
|
+
|
|
514
|
+
**String Transformations:**
|
|
515
|
+
|
|
516
|
+
- `sdk.trim` - Remove leading/trailing whitespace
|
|
517
|
+
- `sdk.uppercase` - Convert to uppercase
|
|
518
|
+
- `sdk.lowercase` - Convert to lowercase
|
|
519
|
+
- `sdk.toString` - Convert to string
|
|
520
|
+
|
|
521
|
+
**Number Parsing:**
|
|
522
|
+
|
|
523
|
+
- `sdk.parseInt` - Parse as integer
|
|
524
|
+
- `sdk.parseFloat` - Parse as decimal (ideal for prices)
|
|
525
|
+
- `sdk.number` - Parse as number (auto-detect int/float)
|
|
526
|
+
|
|
527
|
+
**Date Formatting:**
|
|
528
|
+
|
|
529
|
+
- `sdk.formatDate` - ISO 8601 date only (YYYY-MM-DD)
|
|
530
|
+
- `sdk.formatDateShort` - Short date format
|
|
531
|
+
- `sdk.parseDate` - Parse various date formats
|
|
532
|
+
|
|
533
|
+
**Type Conversions:**
|
|
534
|
+
|
|
535
|
+
- `sdk.boolean` - Convert to boolean
|
|
536
|
+
- `sdk.parseJson` - Parse JSON strings (useful for attributes)
|
|
537
|
+
- `sdk.toJson` - Convert to JSON string
|
|
538
|
+
|
|
539
|
+
**Utilities:**
|
|
540
|
+
|
|
541
|
+
- `sdk.identity` - Return value unchanged
|
|
542
|
+
- `sdk.coalesce` - Return first non-null value
|
|
543
|
+
|
|
544
|
+
See [Universal Mapping Guide](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) for complete resolver documentation.
|
|
545
|
+
|
|
546
|
+
## Production Safety & Guardrails
|
|
547
|
+
|
|
548
|
+
### Overview
|
|
549
|
+
|
|
550
|
+
Even with **incremental-only** extraction, product catalogs need safeguards to prevent runtime failures:
|
|
551
|
+
|
|
552
|
+
- **Memory limits**: Product records can be large (descriptions, attributes, images)
|
|
553
|
+
- **S3 upload limits**: Single files > 5GB require multipart upload
|
|
554
|
+
- **Processing time**: Large catalogs can timeout
|
|
555
|
+
- **JSON parsing**: Massive JSON files stress memory and downstream parsers
|
|
556
|
+
|
|
557
|
+
### Hard Limits
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
const SAFETY_LIMITS = {
|
|
561
|
+
MAX_RECORDS_PER_RUN: 100000, // 100k products per run
|
|
562
|
+
MAX_RECORDS_PER_FILE: 25000, // 25k per JSON file
|
|
563
|
+
MAX_FILE_SIZE_MB: 250, // 250MB per file
|
|
564
|
+
MAX_JSON_SIZE_MB: 500, // Total extraction size
|
|
565
|
+
CHUNK_SIZE: 10000, // Process in chunks
|
|
566
|
+
ESTIMATED_BYTES_PER_PRODUCT: 2048, // Conservative estimate (2KB per product)
|
|
567
|
+
};
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
**Why these limits?**
|
|
571
|
+
|
|
572
|
+
- Products have more fields than orders/fulfillments (descriptions, attributes, variants)
|
|
573
|
+
- JSON is less compact than CSV
|
|
574
|
+
- Marketplace APIs often have file size limits (Amazon: 100MB compressed)
|
|
575
|
+
|
|
576
|
+
### Runtime Validation Function
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
/**
|
|
580
|
+
* Validate extraction safety limits before processing
|
|
581
|
+
*/
|
|
582
|
+
function validateExtractionLimits(recordCount: number, estimatedSizeMB: number) {
|
|
583
|
+
const MAX_RECORDS_PER_RUN = 100000;
|
|
584
|
+
const MAX_JSON_SIZE_MB = 500;
|
|
585
|
+
|
|
586
|
+
if (recordCount > MAX_RECORDS_PER_RUN) {
|
|
587
|
+
return {
|
|
588
|
+
valid: false,
|
|
589
|
+
error: `Extraction limit exceeded: ${recordCount} records (max: ${MAX_RECORDS_PER_RUN})`,
|
|
590
|
+
recommendation: `Catalog too large for single extraction. Consider:
|
|
591
|
+
1. Increase extraction frequency to reduce batch size
|
|
592
|
+
2. Filter by product status (ACTIVE only)
|
|
593
|
+
3. Split by product type or category
|
|
594
|
+
4. Use multiple extractions with different filters`,
|
|
595
|
+
recordCount,
|
|
596
|
+
maxAllowed: MAX_RECORDS_PER_RUN,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (estimatedSizeMB > MAX_JSON_SIZE_MB) {
|
|
601
|
+
return {
|
|
602
|
+
valid: false,
|
|
603
|
+
error: `JSON size limit exceeded: ${estimatedSizeMB}MB (max: ${MAX_JSON_SIZE_MB}MB)`,
|
|
604
|
+
recommendation: 'File splitting required. Consider filtering or splitting by category.',
|
|
605
|
+
estimatedSizeMB,
|
|
606
|
+
maxAllowed: MAX_JSON_SIZE_MB,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return { valid: true };
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
## Complete Workflows Implementation
|
|
615
|
+
|
|
616
|
+
The code examples below demonstrate what goes in each file according to the modular structure shown in the "Recommended Project Structure" section above.
|
|
617
|
+
|
|
618
|
+
### Workflow 1: Scheduled Incremental Extraction
|
|
619
|
+
**File:** `src/workflows/scheduled/daily-products-extraction.ts`
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
import { schedule, http } from '@versori/run';
|
|
623
|
+
import { Buffer } from 'node:buffer';
|
|
624
|
+
import {
|
|
625
|
+
createClient,
|
|
626
|
+
UniversalMapper,
|
|
627
|
+
S3DataSource,
|
|
628
|
+
JSONBuilder,
|
|
629
|
+
VersoriKVAdapter,
|
|
630
|
+
JobTracker,
|
|
631
|
+
} from '@fluentcommerce/fc-connect-sdk';
|
|
632
|
+
import productsExportMapping from './config/products.export.json' with { type: 'json' };
|
|
633
|
+
|
|
634
|
+
const PRODUCTS_QUERY = `
|
|
635
|
+
query GetProducts(
|
|
636
|
+
$retailerId: ID!
|
|
637
|
+
$updatedAfter: DateTime
|
|
638
|
+
$first: Int!
|
|
639
|
+
$after: String
|
|
640
|
+
) {
|
|
641
|
+
products(
|
|
642
|
+
retailerId: $retailerId
|
|
643
|
+
updatedOn: { after: $updatedAfter }
|
|
644
|
+
first: $first
|
|
645
|
+
after: $after
|
|
646
|
+
) {
|
|
647
|
+
edges {
|
|
648
|
+
node {
|
|
649
|
+
id
|
|
650
|
+
ref
|
|
651
|
+
name
|
|
652
|
+
summary
|
|
653
|
+
gtin
|
|
654
|
+
type
|
|
655
|
+
status
|
|
656
|
+
createdOn
|
|
657
|
+
updatedOn
|
|
658
|
+
}
|
|
659
|
+
cursor
|
|
660
|
+
}
|
|
661
|
+
pageInfo {
|
|
662
|
+
hasNextPage
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
`;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* WORKFLOW 1/3: Scheduled Daily Product Extraction (Incremental)
|
|
670
|
+
*
|
|
671
|
+
* Runs daily at midnight to extract changed products
|
|
672
|
+
*/
|
|
673
|
+
export const scheduledProductsExtraction = schedule('scheduled-products-extraction', '0 0 * * *').then(
|
|
674
|
+
http('extract-products', { connection: 'fluent_commerce' }, async ctx => {
|
|
675
|
+
const { log, openKv, activation } = ctx;
|
|
676
|
+
const executionStartTime = Date.now();
|
|
677
|
+
|
|
678
|
+
log.info('=== EXECUTION START ===', { timestamp: new Date().toISOString() });
|
|
679
|
+
|
|
680
|
+
// STEP 1/8: Parse activation variables
|
|
681
|
+
const retailerId = activation?.getVariable('retailerId');
|
|
682
|
+
const pageSize = parseInt(activation?.getVariable('pageSize') || '200', 10);
|
|
683
|
+
const maxRecords = parseInt(activation?.getVariable('maxRecords') || '50000', 10);
|
|
684
|
+
const fallbackStartDate =
|
|
685
|
+
activation?.getVariable('fallbackStartDate') || '2024-01-01T00:00:00Z';
|
|
686
|
+
const prettyPrint = activation?.getVariable('prettyPrint') === 'true';
|
|
687
|
+
|
|
688
|
+
const s3Config = {
|
|
689
|
+
bucket: activation?.getVariable('s3BucketName'),
|
|
690
|
+
region: activation?.getVariable('awsRegion') || 'us-east-1',
|
|
691
|
+
accessKeyId: activation?.getVariable('awsAccessKeyId'),
|
|
692
|
+
secretAccessKey: activation?.getVariable('awsSecretAccessKey'),
|
|
693
|
+
};
|
|
694
|
+
const s3Prefix = activation?.getVariable('s3Prefix') || 'products/daily/';
|
|
695
|
+
|
|
696
|
+
// Validate required variables
|
|
697
|
+
const missing: string[] = [];
|
|
698
|
+
if (!retailerId) missing.push('retailerId');
|
|
699
|
+
if (!s3Config.bucket) missing.push('s3BucketName');
|
|
700
|
+
if (!s3Config.accessKeyId) missing.push('awsAccessKeyId');
|
|
701
|
+
if (!s3Config.secretAccessKey) missing.push('awsSecretAccessKey');
|
|
702
|
+
if (missing.length) {
|
|
703
|
+
return { success: false, error: `Missing required variables: ${missing.join(', ')}` };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
try {
|
|
707
|
+
// STEP 2/8: Initialize JobTracker and start tracking
|
|
708
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
709
|
+
const jobId = `products-extraction-${Date.now()}`;
|
|
710
|
+
|
|
711
|
+
await tracker.createJob(jobId, {
|
|
712
|
+
type: 'extraction',
|
|
713
|
+
entity: 'products',
|
|
714
|
+
mode: 'scheduled',
|
|
715
|
+
retailerId,
|
|
716
|
+
startTime: executionStartTime,
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// STEP 3/8: Load last successful extraction timestamp with overlap buffer
|
|
720
|
+
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
721
|
+
const stateKey = ['extraction', 'products', 'lastRunTime'];
|
|
722
|
+
const lastRunState = await kv.get(stateKey);
|
|
723
|
+
const rawLastRunTime = lastRunState?.value?.timestamp || fallbackStartDate;
|
|
724
|
+
|
|
725
|
+
// Overlap buffer configuration (default: 60 seconds)
|
|
726
|
+
const overlapBufferSeconds = parseInt(
|
|
727
|
+
activation?.getVariable('overlapBufferSeconds') || '60',
|
|
728
|
+
10
|
|
729
|
+
);
|
|
730
|
+
const OVERLAP_BUFFER_MS = overlapBufferSeconds * 1000;
|
|
731
|
+
|
|
732
|
+
// Apply overlap buffer for query (safety window)
|
|
733
|
+
const bufferedLastRunTime = new Date(
|
|
734
|
+
new Date(rawLastRunTime).getTime() - OVERLAP_BUFFER_MS
|
|
735
|
+
).toISOString();
|
|
736
|
+
|
|
737
|
+
const toDate = undefined; // No manual override for scheduled extraction
|
|
738
|
+
|
|
739
|
+
log.info('🔍 Starting incremental products extraction with overlap buffer', {
|
|
740
|
+
jobId,
|
|
741
|
+
rawLastRunTime,
|
|
742
|
+
bufferedLastRunTime,
|
|
743
|
+
effectiveEndTime: toDate || new Date().toISOString(),
|
|
744
|
+
overlapBufferSeconds,
|
|
745
|
+
retailerId,
|
|
746
|
+
maxRecords,
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// STEP 4/8: Initialize Fluent client + ExtractionOrchestrator
|
|
750
|
+
// ✅ Optional: Validate connection immediately (fail-fast mode)
|
|
751
|
+
// Set activation variable 'validateConnectionOnStart' = 'true' to enable
|
|
752
|
+
// When enabled: Executes query { me { ref } } to verify authentication
|
|
753
|
+
// When disabled: Fast creation, validation happens on first API call (default)
|
|
754
|
+
const validateConnection = activation.getVariable('validateConnectionOnStart') === 'true';
|
|
755
|
+
const client = await createClient(ctx, validateConnection ? { validateConnection: true } : undefined);
|
|
756
|
+
|
|
757
|
+
if (validateConnection) {
|
|
758
|
+
log.info('✅ Connection validated successfully - authentication confirmed', { jobId });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const orchestrator = new ExtractionOrchestrator(client, log);
|
|
762
|
+
|
|
763
|
+
// STEP 5/8: Execute extraction with auto-pagination (WITH overlap buffer)
|
|
764
|
+
const effectiveEndTime = toDate || new Date().toISOString();
|
|
765
|
+
|
|
766
|
+
// ? Enhanced: Extract context for progress logging
|
|
767
|
+
const dateRangeInfo = {
|
|
768
|
+
start: bufferedLastRunTime,
|
|
769
|
+
end: effectiveEndTime,
|
|
770
|
+
retailerId
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// ? Enhanced: Start logging with context
|
|
774
|
+
log.info(`📊 [ExtractionOrchestrator] Starting extraction`, {
|
|
775
|
+
query: 'products',
|
|
776
|
+
pageSize,
|
|
777
|
+
maxRecords,
|
|
778
|
+
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
779
|
+
retailerId: dateRangeInfo.retailerId,
|
|
780
|
+
jobId
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const extractionResult = await orchestrator.extract({
|
|
784
|
+
query: PRODUCTS_QUERY,
|
|
785
|
+
resultPath: 'products.edges.node',
|
|
786
|
+
variables: {
|
|
787
|
+
retailerId,
|
|
788
|
+
dateRangeFilter: {
|
|
789
|
+
after: bufferedLastRunTime,
|
|
790
|
+
before: effectiveEndTime, // End of extraction window
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
pageSize,
|
|
794
|
+
maxRecords,
|
|
795
|
+
validateItem: item => !!item.ref,
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
const rawRecords = extractionResult.data;
|
|
799
|
+
|
|
800
|
+
log.info('Products extraction completed', {
|
|
801
|
+
jobId,
|
|
802
|
+
totalRecords: extractionResult.stats.totalRecords,
|
|
803
|
+
totalPages: extractionResult.stats.totalPages,
|
|
804
|
+
validRecords: extractionResult.stats.validRecords ?? rawRecords.length,
|
|
805
|
+
errors: extractionResult.errors ? extractionResult.errors.length : 0,
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// ? Enhanced: Completion logging with summary
|
|
809
|
+
log.info(`✅ [ExtractionOrchestrator] Extraction completed`, {
|
|
810
|
+
totalRecords: extractionResult.stats.totalRecords,
|
|
811
|
+
totalPages: extractionResult.stats.totalPages,
|
|
812
|
+
validRecords: extractionResult.stats.validRecords ?? rawRecords.length,
|
|
813
|
+
failedValidations: extractionResult.stats.failedValidations,
|
|
814
|
+
truncated: extractionResult.stats.truncated,
|
|
815
|
+
truncationReason: extractionResult.stats.truncationReason,
|
|
816
|
+
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
817
|
+
jobId
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
if (extractionResult.errors && extractionResult.errors.length > 0) {
|
|
821
|
+
log.warn('Non-fatal extraction errors encountered', {
|
|
822
|
+
jobId,
|
|
823
|
+
errorCount: extractionResult.errors.length,
|
|
824
|
+
sampleErrors: extractionResult.errors.slice(0, 3),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (rawRecords.length === 0) {
|
|
829
|
+
log.info('No new products to extract');
|
|
830
|
+
await kv.set(stateKey, {
|
|
831
|
+
timestamp: new Date().toISOString(),
|
|
832
|
+
productCount: 0,
|
|
833
|
+
extractedAt: new Date().toISOString(),
|
|
834
|
+
});
|
|
835
|
+
await tracker.markCompleted(jobId, {
|
|
836
|
+
recordsProcessed: 0,
|
|
837
|
+
message: 'No new products to extract',
|
|
838
|
+
});
|
|
839
|
+
return {
|
|
840
|
+
success: true,
|
|
841
|
+
message: 'No new products to extract',
|
|
842
|
+
jobId,
|
|
843
|
+
lastRunTime: rawLastRunTime,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
log.info('Products retrieved', { jobId, count: rawRecords.length });
|
|
848
|
+
|
|
849
|
+
// STEP 6/8: Validate extraction limits
|
|
850
|
+
const MAX_RECORDS_PER_RUN = 100000;
|
|
851
|
+
const ESTIMATED_BYTES_PER_PRODUCT = 2048;
|
|
852
|
+
const estimatedSizeMB = (rawRecords.length * ESTIMATED_BYTES_PER_PRODUCT) / (1024 * 1024);
|
|
853
|
+
const MAX_JSON_SIZE_MB = 500;
|
|
854
|
+
|
|
855
|
+
if (rawRecords.length > MAX_RECORDS_PER_RUN) {
|
|
856
|
+
await tracker.markFailed(jobId, {
|
|
857
|
+
error: 'Extraction limit exceeded',
|
|
858
|
+
recordCount: rawRecords.length,
|
|
859
|
+
maxAllowed: MAX_RECORDS_PER_RUN,
|
|
860
|
+
});
|
|
861
|
+
log.error('Extraction limit exceeded', {
|
|
862
|
+
recordCount: rawRecords.length,
|
|
863
|
+
maxAllowed: MAX_RECORDS_PER_RUN,
|
|
864
|
+
});
|
|
865
|
+
return {
|
|
866
|
+
success: false,
|
|
867
|
+
error: `Extraction limit exceeded: ${rawRecords.length} records (max: ${MAX_RECORDS_PER_RUN})`,
|
|
868
|
+
recommendation: `Catalog too large for single extraction. Consider:
|
|
869
|
+
1. Increase extraction frequency to reduce batch size
|
|
870
|
+
2. Filter by product status (ACTIVE only)
|
|
871
|
+
3. Split by product type or category
|
|
872
|
+
4. Use multiple extractions with different filters`,
|
|
873
|
+
recordCount: rawRecords.length,
|
|
874
|
+
maxAllowed: MAX_RECORDS_PER_RUN,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (estimatedSizeMB > MAX_JSON_SIZE_MB) {
|
|
879
|
+
log.warn('JSON size approaching limit', {
|
|
880
|
+
estimatedSizeMB: estimatedSizeMB.toFixed(2),
|
|
881
|
+
maxAllowed: MAX_JSON_SIZE_MB,
|
|
882
|
+
recommendation: 'Consider file splitting or category-based filtering',
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
await tracker.updateJobProgress(jobId, {
|
|
887
|
+
stage: 'validation_complete',
|
|
888
|
+
recordCount: rawRecords.length,
|
|
889
|
+
estimatedSizeMB: estimatedSizeMB.toFixed(2),
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// STEP 7/8: Transform with UniversalMapper
|
|
893
|
+
const mapper = new UniversalMapper(productsExportMapping);
|
|
894
|
+
const mappingResult = await mapper.map(rawRecords);
|
|
895
|
+
|
|
896
|
+
if (!mappingResult.success) {
|
|
897
|
+
const mappingErrors = mappingResult.errors || ['Unknown mapping failure'];
|
|
898
|
+
await tracker.markFailed(
|
|
899
|
+
jobId,
|
|
900
|
+
mappingErrors[0] || 'UniversalMapper returned unsuccessful result',
|
|
901
|
+
{
|
|
902
|
+
failedCount: mappingErrors.length,
|
|
903
|
+
errors: mappingErrors,
|
|
904
|
+
}
|
|
905
|
+
);
|
|
906
|
+
return {
|
|
907
|
+
success: false,
|
|
908
|
+
error: `Transformation failed: ${mappingErrors[0] || 'Unknown error'}`,
|
|
909
|
+
errors: mappingErrors,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const transformedProducts = Array.isArray(mappingResult.data) ? mappingResult.data : [];
|
|
914
|
+
const mappingErrors = mappingResult.errors || [];
|
|
915
|
+
|
|
916
|
+
if (mappingErrors.length > 0) {
|
|
917
|
+
log.warn('Some products failed transformation', {
|
|
918
|
+
jobId,
|
|
919
|
+
errorCount: mappingErrors.length,
|
|
920
|
+
sampleErrors: mappingErrors.slice(0, 3),
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
925
|
+
log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
926
|
+
jobId,
|
|
927
|
+
skippedFields: mappingResult.skippedFields,
|
|
928
|
+
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (transformedProducts.length === 0) {
|
|
933
|
+
await tracker.markFailed(jobId, 'All records failed mapping', {
|
|
934
|
+
failedCount: mappingErrors.length,
|
|
935
|
+
errors: mappingErrors,
|
|
936
|
+
});
|
|
937
|
+
return {
|
|
938
|
+
success: false,
|
|
939
|
+
error: 'All records failed mapping',
|
|
940
|
+
errors: mappingErrors,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
log.info('Records transformed', {
|
|
945
|
+
jobId,
|
|
946
|
+
successful: transformedProducts.length,
|
|
947
|
+
skippedRecords: rawRecords.length - transformedProducts.length,
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
await tracker.updateJobProgress(jobId, {
|
|
951
|
+
stage: 'transformation_complete',
|
|
952
|
+
transformedCount: transformedProducts.length,
|
|
953
|
+
failedCount: mappingErrors.length,
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
// Calculate max updatedOn for next run (WITHOUT buffer)
|
|
957
|
+
const maxUpdatedOn = transformedProducts.reduce((max, product) => {
|
|
958
|
+
const productTime = new Date(product.lastUpdated).getTime();
|
|
959
|
+
return productTime > max ? productTime : max;
|
|
960
|
+
}, new Date(rawLastRunTime).getTime());
|
|
961
|
+
|
|
962
|
+
const newTimestamp = new Date(maxUpdatedOn).toISOString();
|
|
963
|
+
|
|
964
|
+
// Build JSON with metadata
|
|
965
|
+
const jsonOutput = {
|
|
966
|
+
metadata: {
|
|
967
|
+
extractedAt: new Date().toISOString(),
|
|
968
|
+
productCount: transformedProducts.length,
|
|
969
|
+
incrementalFrom: rawLastRunTime,
|
|
970
|
+
incrementalTo: newTimestamp,
|
|
971
|
+
},
|
|
972
|
+
products: transformedProducts,
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
// Use JSONBuilder for consistent JSON generation
|
|
976
|
+
const jsonBuilder = new JSONBuilder({
|
|
977
|
+
prettyPrint,
|
|
978
|
+
indent: 2,
|
|
979
|
+
});
|
|
980
|
+
const jsonContent = jsonBuilder.build(jsonOutput);
|
|
981
|
+
|
|
982
|
+
// Generate timestamped filename
|
|
983
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
984
|
+
const fileName = `products-${timestamp}.json`;
|
|
985
|
+
const s3Key = `${s3Prefix}${fileName}`;
|
|
986
|
+
|
|
987
|
+
log.info('Generated JSON file', {
|
|
988
|
+
jobId,
|
|
989
|
+
fileName,
|
|
990
|
+
size: jsonContent.length,
|
|
991
|
+
productCount: transformedProducts.length,
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
// STEP 8/8: Upload to S3
|
|
995
|
+
const s3 = new S3DataSource(
|
|
996
|
+
{
|
|
997
|
+
type: 'S3_JSON',
|
|
998
|
+
connectionId: 's3-products-export',
|
|
999
|
+
name: 'S3 Products Export',
|
|
1000
|
+
s3Config,
|
|
1001
|
+
},
|
|
1002
|
+
log
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
await s3.upload(s3Key, Buffer.from(jsonContent, 'utf8'), {
|
|
1006
|
+
contentType: 'application/json',
|
|
1007
|
+
metadata: {
|
|
1008
|
+
productCount: String(transformedProducts.length),
|
|
1009
|
+
extractedAt: new Date().toISOString(),
|
|
1010
|
+
incrementalFrom: rawLastRunTime,
|
|
1011
|
+
incrementalTo: newTimestamp,
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
log.info('JSON file uploaded to S3', { jobId, s3Key });
|
|
1016
|
+
|
|
1017
|
+
// Update state with new timestamp (WITHOUT buffer)
|
|
1018
|
+
await kv.set(stateKey, {
|
|
1019
|
+
timestamp: newTimestamp, // ← NO buffer applied
|
|
1020
|
+
productCount: transformedProducts.length,
|
|
1021
|
+
extractedAt: new Date().toISOString(),
|
|
1022
|
+
overlapBufferSeconds,
|
|
1023
|
+
fileName,
|
|
1024
|
+
s3Key,
|
|
1025
|
+
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
log.info('State updated with new timestamp (without buffer)', {
|
|
1029
|
+
jobId,
|
|
1030
|
+
newTimestamp,
|
|
1031
|
+
overlapBufferSeconds,
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// Complete job tracking
|
|
1035
|
+
const executionDurationMs = Date.now() - executionStartTime;
|
|
1036
|
+
await tracker.markCompleted(jobId, {
|
|
1037
|
+
recordsProcessed: transformedProducts.length,
|
|
1038
|
+
recordsFailed: mappingErrors.length,
|
|
1039
|
+
fileName,
|
|
1040
|
+
s3Key,
|
|
1041
|
+
newTimestamp,
|
|
1042
|
+
executionDurationMs,
|
|
1043
|
+
errors: mappingErrors,
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
log.info('=== EXECUTION END ===', {
|
|
1047
|
+
timestamp: new Date().toISOString(),
|
|
1048
|
+
durationMs: executionDurationMs,
|
|
1049
|
+
success: true,
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
success: true,
|
|
1054
|
+
jobId,
|
|
1055
|
+
productsExtracted: transformedProducts.length,
|
|
1056
|
+
recordsFailed: mappingErrors.length,
|
|
1057
|
+
fileName,
|
|
1058
|
+
s3Key,
|
|
1059
|
+
lastRunTime: rawLastRunTime,
|
|
1060
|
+
newTimestamp,
|
|
1061
|
+
executionDurationMs,
|
|
1062
|
+
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1063
|
+
};
|
|
1064
|
+
} catch (error: any) {
|
|
1065
|
+
const executionDurationMs = Date.now() - executionStartTime;
|
|
1066
|
+
log.error('Extraction failed', error, {
|
|
1067
|
+
message: error?.message,
|
|
1068
|
+
executionDurationMs,
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
log.info('=== EXECUTION END ===', {
|
|
1072
|
+
timestamp: new Date().toISOString(),
|
|
1073
|
+
durationMs: executionDurationMs,
|
|
1074
|
+
success: false,
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
return {
|
|
1078
|
+
success: false,
|
|
1079
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1080
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1081
|
+
errorType: error instanceof Error ? error.constructor.name : 'UnknownError',
|
|
1082
|
+
executionDurationMs,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
})
|
|
1086
|
+
);
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
### Workflow 2: Ad Hoc HTTP Extraction
|
|
1090
|
+
**File:** `src/workflows/webhook/adhoc-products-extraction.ts`
|
|
1091
|
+
|
|
1092
|
+
```typescript
|
|
1093
|
+
/**
|
|
1094
|
+
* WORKFLOW 2/3: Ad Hoc Product Extraction (HTTP Webhook)
|
|
1095
|
+
*
|
|
1096
|
+
* Manual trigger for on-demand product catalog extraction
|
|
1097
|
+
*
|
|
1098
|
+
* Usage (payload examples):
|
|
1099
|
+
* 1) Incremental (use stored state):
|
|
1100
|
+
* {}
|
|
1101
|
+
*
|
|
1102
|
+
* 2) Date range override (preferred keys):
|
|
1103
|
+
* {
|
|
1104
|
+
* "fromDate": "2025-01-01T00:00:00Z",
|
|
1105
|
+
* "toDate": "2025-01-22T00:00:00Z",
|
|
1106
|
+
* "updateState": false
|
|
1107
|
+
* }
|
|
1108
|
+
*
|
|
1109
|
+
* 3) Backward-compatible keys (startDate/endDate) are also accepted and mapped:
|
|
1110
|
+
* {
|
|
1111
|
+
* "startDate": "2025-01-01T00:00:00Z",
|
|
1112
|
+
* "endDate": "2025-01-22T00:00:00Z"
|
|
1113
|
+
* }
|
|
1114
|
+
*/
|
|
1115
|
+
export const adHocProductsExtraction = webhook('extract-products-adhoc', {
|
|
1116
|
+
connection: 'products-adhoc',
|
|
1117
|
+
}).then(
|
|
1118
|
+
http('execute-adhoc-extraction', { connection: 'fluent_commerce' }, async ctx => {
|
|
1119
|
+
const { log, openKv, activation, data } = ctx;
|
|
1120
|
+
const executionStartTime = Date.now();
|
|
1121
|
+
|
|
1122
|
+
log.info('=== EXECUTION START ===', { timestamp: new Date().toISOString() });
|
|
1123
|
+
|
|
1124
|
+
// Parse request body and support both new (fromDate/toDate) and alternative (startDate/endDate) keys
|
|
1125
|
+
const requestData = typeof data === 'string' ? JSON.parse(data) : data;
|
|
1126
|
+
const fromDate = requestData?.fromDate || requestData?.startDate;
|
|
1127
|
+
const toDate = requestData?.toDate || requestData?.endDate;
|
|
1128
|
+
const updateState = requestData?.updateState === true; // default: false
|
|
1129
|
+
const maxRecordsOverride = requestData?.maxRecords;
|
|
1130
|
+
|
|
1131
|
+
const retailerId = activation?.getVariable('retailerId');
|
|
1132
|
+
const pageSize = parseInt(activation?.getVariable('pageSize') || '200', 10);
|
|
1133
|
+
const maxRecords =
|
|
1134
|
+
maxRecordsOverride || parseInt(activation?.getVariable('maxRecords') || '50000', 10);
|
|
1135
|
+
const prettyPrint = activation?.getVariable('prettyPrint') === 'true';
|
|
1136
|
+
|
|
1137
|
+
const s3Config = {
|
|
1138
|
+
bucket: activation?.getVariable('s3BucketName'),
|
|
1139
|
+
region: activation?.getVariable('awsRegion') || 'us-east-1',
|
|
1140
|
+
accessKeyId: activation?.getVariable('awsAccessKeyId'),
|
|
1141
|
+
secretAccessKey: activation?.getVariable('awsSecretAccessKey'),
|
|
1142
|
+
};
|
|
1143
|
+
const s3Prefix = activation?.getVariable('s3Prefix') || 'products/adhoc/';
|
|
1144
|
+
|
|
1145
|
+
// Validate required variables
|
|
1146
|
+
const missing: string[] = [];
|
|
1147
|
+
if (!retailerId) missing.push('retailerId');
|
|
1148
|
+
if (!s3Config.bucket) missing.push('s3BucketName');
|
|
1149
|
+
if (!s3Config.accessKeyId) missing.push('awsAccessKeyId');
|
|
1150
|
+
if (!s3Config.secretAccessKey) missing.push('awsSecretAccessKey');
|
|
1151
|
+
if (missing.length) {
|
|
1152
|
+
return { success: false, error: `Missing required variables: ${missing.join(', ')}` };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
try {
|
|
1156
|
+
// Initialize JobTracker
|
|
1157
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
1158
|
+
const jobId = `products-adhoc-${Date.now()}`;
|
|
1159
|
+
|
|
1160
|
+
await tracker.createJob(jobId, {
|
|
1161
|
+
type: 'extraction',
|
|
1162
|
+
entity: 'products',
|
|
1163
|
+
mode: 'adhoc',
|
|
1164
|
+
retailerId,
|
|
1165
|
+
fromDate,
|
|
1166
|
+
toDate,
|
|
1167
|
+
updateState,
|
|
1168
|
+
startTime: executionStartTime,
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
log.info('Starting ad hoc products extraction', {
|
|
1172
|
+
jobId,
|
|
1173
|
+
retailerId,
|
|
1174
|
+
fromDate: fromDate || 'not specified',
|
|
1175
|
+
toDate: toDate || 'not specified',
|
|
1176
|
+
maxRecords,
|
|
1177
|
+
updateState,
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
// Initialize Fluent client + Orchestrator
|
|
1181
|
+
// ✅ Optional: Validate connection immediately (fail-fast mode)
|
|
1182
|
+
// Set activation variable 'validateConnectionOnStart' = 'true' to enable
|
|
1183
|
+
// When enabled: Executes query { me { ref } } to verify authentication
|
|
1184
|
+
// When disabled: Fast creation, validation happens on first API call (default)
|
|
1185
|
+
const validateConnection = activation.getVariable('validateConnectionOnStart') === 'true';
|
|
1186
|
+
const client = await createClient(ctx, validateConnection ? { validateConnection: true } : undefined);
|
|
1187
|
+
|
|
1188
|
+
if (validateConnection) {
|
|
1189
|
+
log.info('✅ Connection validated successfully - authentication confirmed', { jobId });
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const orchestrator = new ExtractionOrchestrator(client, log);
|
|
1193
|
+
|
|
1194
|
+
// Execute extraction with auto-pagination
|
|
1195
|
+
// ? Enhanced: Extract context for progress logging
|
|
1196
|
+
const dateRangeInfo = {
|
|
1197
|
+
start: fromDate || 'all',
|
|
1198
|
+
end: toDate || 'now',
|
|
1199
|
+
retailerId
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
// ? Enhanced: Start logging with context
|
|
1203
|
+
log.info(`📊 [ExtractionOrchestrator] Starting extraction`, {
|
|
1204
|
+
query: 'products',
|
|
1205
|
+
pageSize,
|
|
1206
|
+
maxRecords,
|
|
1207
|
+
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
1208
|
+
retailerId: dateRangeInfo.retailerId,
|
|
1209
|
+
jobId
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
const extraction = await orchestrator.extract({
|
|
1213
|
+
query: PRODUCTS_QUERY,
|
|
1214
|
+
resultPath: 'products.edges.node',
|
|
1215
|
+
variables: {
|
|
1216
|
+
retailerId,
|
|
1217
|
+
...(fromDate ? { updatedAfter: fromDate } : {}),
|
|
1218
|
+
},
|
|
1219
|
+
pageSize,
|
|
1220
|
+
maxRecords,
|
|
1221
|
+
validateItem: (item: any) => !!item.ref,
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
const nodes = extraction.data || [];
|
|
1225
|
+
|
|
1226
|
+
// ? Enhanced: Completion logging with summary
|
|
1227
|
+
log.info(`✅ [ExtractionOrchestrator] Extraction completed`, {
|
|
1228
|
+
totalRecords: extraction.stats.totalRecords,
|
|
1229
|
+
totalPages: extraction.stats.totalPages,
|
|
1230
|
+
validRecords: extraction.stats.validRecords ?? nodes.length,
|
|
1231
|
+
failedValidations: extraction.stats.failedValidations,
|
|
1232
|
+
truncated: extraction.stats.truncated,
|
|
1233
|
+
truncationReason: extraction.stats.truncationReason,
|
|
1234
|
+
dateRange: `${dateRangeInfo.start} to ${dateRangeInfo.end}`,
|
|
1235
|
+
jobId
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
if (nodes.length === 0) {
|
|
1239
|
+
await tracker.markCompleted(jobId, {
|
|
1240
|
+
recordsProcessed: 0,
|
|
1241
|
+
message: 'No products found',
|
|
1242
|
+
});
|
|
1243
|
+
return {
|
|
1244
|
+
success: true,
|
|
1245
|
+
message: 'No products found',
|
|
1246
|
+
jobId,
|
|
1247
|
+
fromDate,
|
|
1248
|
+
toDate,
|
|
1249
|
+
stateUpdated: false,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Transform with UniversalMapper (bulk mapping)
|
|
1254
|
+
const mapper = new UniversalMapper(productsExportMapping);
|
|
1255
|
+
const mappingResult = await mapper.map(nodes);
|
|
1256
|
+
|
|
1257
|
+
if (!mappingResult.success) {
|
|
1258
|
+
const mappingErrors = mappingResult.errors || ['Unknown mapping failure'];
|
|
1259
|
+
log.error('Mapping failed - terminating job', {
|
|
1260
|
+
jobId,
|
|
1261
|
+
errorCount: mappingErrors.length,
|
|
1262
|
+
sampleErrors: mappingErrors.slice(0, 3),
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
await tracker.markFailed(
|
|
1266
|
+
jobId,
|
|
1267
|
+
mappingErrors[0] || 'UniversalMapper returned unsuccessful result',
|
|
1268
|
+
{
|
|
1269
|
+
errors: mappingErrors,
|
|
1270
|
+
failedCount: mappingErrors.length,
|
|
1271
|
+
}
|
|
1272
|
+
);
|
|
1273
|
+
return {
|
|
1274
|
+
success: false,
|
|
1275
|
+
error: `Transformation failed: ${mappingErrors[0] || 'Unknown error'}`,
|
|
1276
|
+
jobId,
|
|
1277
|
+
errors: mappingErrors,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const transformedProducts = mappingResult.data || [];
|
|
1282
|
+
const mappingErrors = mappingResult.errors || [];
|
|
1283
|
+
|
|
1284
|
+
if (mappingErrors.length > 0) {
|
|
1285
|
+
log.warn('Some records failed transformation', {
|
|
1286
|
+
jobId,
|
|
1287
|
+
errorCount: mappingErrors.length,
|
|
1288
|
+
sampleErrors: mappingErrors.slice(0, 3),
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
if (mappingResult.skippedFields && mappingResult.skippedFields.length > 0) {
|
|
1293
|
+
log.info('ℹ️ [MAPPING] Optional fields skipped (undefined values)', {
|
|
1294
|
+
jobId,
|
|
1295
|
+
skippedFields: mappingResult.skippedFields,
|
|
1296
|
+
note: 'These fields were not present in source data. Add defaultValue to mapping config if they should always appear.',
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (transformedProducts.length === 0) {
|
|
1301
|
+
await tracker.markFailed(jobId, 'All records failed mapping', {
|
|
1302
|
+
failedCount: mappingErrors.length,
|
|
1303
|
+
errors: mappingErrors,
|
|
1304
|
+
});
|
|
1305
|
+
return {
|
|
1306
|
+
success: false,
|
|
1307
|
+
error: 'All records failed mapping',
|
|
1308
|
+
jobId,
|
|
1309
|
+
errors: mappingErrors,
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Compute newTimestamp from transformed records (WITHOUT buffer)
|
|
1314
|
+
const newTimestamp = new Date(
|
|
1315
|
+
transformedProducts.reduce(
|
|
1316
|
+
(max, product) => {
|
|
1317
|
+
const t = new Date(product.lastUpdated).getTime();
|
|
1318
|
+
return t > max ? t : max;
|
|
1319
|
+
},
|
|
1320
|
+
fromDate ? new Date(fromDate).getTime() : 0
|
|
1321
|
+
)
|
|
1322
|
+
).toISOString();
|
|
1323
|
+
|
|
1324
|
+
// Build JSON output
|
|
1325
|
+
const jsonOutput = {
|
|
1326
|
+
metadata: {
|
|
1327
|
+
extractedAt: new Date().toISOString(),
|
|
1328
|
+
productCount: transformedProducts.length,
|
|
1329
|
+
mode: 'adhoc',
|
|
1330
|
+
...(fromDate && { fromDate }),
|
|
1331
|
+
...(toDate && { toDate }),
|
|
1332
|
+
stateUpdated: updateState,
|
|
1333
|
+
},
|
|
1334
|
+
products: transformedProducts,
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
// Use JSONBuilder for consistent JSON generation
|
|
1338
|
+
const jsonBuilder = new JSONBuilder({
|
|
1339
|
+
prettyPrint,
|
|
1340
|
+
indent: 2,
|
|
1341
|
+
});
|
|
1342
|
+
const jsonContent = jsonBuilder.build(jsonOutput);
|
|
1343
|
+
|
|
1344
|
+
// Upload to S3
|
|
1345
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1346
|
+
const fileName = `products-adhoc-${timestamp}.json`;
|
|
1347
|
+
const s3Key = `${s3Prefix}${fileName}`;
|
|
1348
|
+
|
|
1349
|
+
const s3 = new S3DataSource(
|
|
1350
|
+
{
|
|
1351
|
+
type: 'S3_JSON',
|
|
1352
|
+
connectionId: 's3-products-export',
|
|
1353
|
+
name: 'S3 Products Export',
|
|
1354
|
+
s3Config,
|
|
1355
|
+
},
|
|
1356
|
+
log
|
|
1357
|
+
);
|
|
1358
|
+
|
|
1359
|
+
await s3.upload(s3Key, Buffer.from(jsonContent, 'utf8'), {
|
|
1360
|
+
contentType: 'application/json',
|
|
1361
|
+
metadata: {
|
|
1362
|
+
productCount: String(transformedProducts.length),
|
|
1363
|
+
extractedAt: new Date().toISOString(),
|
|
1364
|
+
mode: 'adhoc',
|
|
1365
|
+
},
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
// Optionally update state (adhoc): respects updateState flag
|
|
1369
|
+
let stateUpdated = false;
|
|
1370
|
+
if (updateState) {
|
|
1371
|
+
const kv = new VersoriKVAdapter(openKv(':project:'));
|
|
1372
|
+
const stateKey = ['extraction', 'products', 'lastRunTime'];
|
|
1373
|
+
await kv.set(stateKey, {
|
|
1374
|
+
timestamp: newTimestamp, // WITHOUT buffer
|
|
1375
|
+
productCount: transformedProducts.length,
|
|
1376
|
+
extractedAt: new Date().toISOString(),
|
|
1377
|
+
fileName,
|
|
1378
|
+
s3Key,
|
|
1379
|
+
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1380
|
+
});
|
|
1381
|
+
stateUpdated = true;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Complete job tracking
|
|
1385
|
+
const executionDurationMs = Date.now() - executionStartTime;
|
|
1386
|
+
await tracker.markCompleted(jobId, {
|
|
1387
|
+
recordsProcessed: transformedProducts.length,
|
|
1388
|
+
recordsFailed: mappingErrors.length,
|
|
1389
|
+
fileName,
|
|
1390
|
+
s3Key,
|
|
1391
|
+
newTimestamp,
|
|
1392
|
+
stateUpdated,
|
|
1393
|
+
executionDurationMs,
|
|
1394
|
+
errors: mappingErrors,
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
log.info('=== EXECUTION END ===', {
|
|
1398
|
+
timestamp: new Date().toISOString(),
|
|
1399
|
+
durationMs: executionDurationMs,
|
|
1400
|
+
success: true,
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
return {
|
|
1404
|
+
success: true,
|
|
1405
|
+
jobId,
|
|
1406
|
+
productsExtracted: transformedProducts.length,
|
|
1407
|
+
recordsFailed: mappingErrors.length,
|
|
1408
|
+
fileName,
|
|
1409
|
+
s3Key,
|
|
1410
|
+
newTimestamp,
|
|
1411
|
+
stateUpdated,
|
|
1412
|
+
executionDurationMs,
|
|
1413
|
+
errors: mappingErrors.length > 0 ? mappingErrors : undefined,
|
|
1414
|
+
};
|
|
1415
|
+
} catch (error: any) {
|
|
1416
|
+
const executionDurationMs = Date.now() - executionStartTime;
|
|
1417
|
+
log.error('Ad hoc extraction failed', error, { executionDurationMs });
|
|
1418
|
+
|
|
1419
|
+
log.info('=== EXECUTION END ===', {
|
|
1420
|
+
timestamp: new Date().toISOString(),
|
|
1421
|
+
durationMs: executionDurationMs,
|
|
1422
|
+
success: false,
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
return {
|
|
1426
|
+
success: false,
|
|
1427
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1428
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1429
|
+
errorType: error instanceof Error ? error.constructor.name : 'UnknownError',
|
|
1430
|
+
executionDurationMs,
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
})
|
|
1434
|
+
);
|
|
1435
|
+
```
|
|
1436
|
+
|
|
1437
|
+
### Workflow 3: Job Status Checker
|
|
1438
|
+
**File:** `src/workflows/webhook/job-status-check.ts`
|
|
1439
|
+
|
|
1440
|
+
```typescript
|
|
1441
|
+
/**
|
|
1442
|
+
* WORKFLOW 3/3: Job Status Checker
|
|
1443
|
+
*
|
|
1444
|
+
* Check status of extraction job
|
|
1445
|
+
*
|
|
1446
|
+
* Usage:
|
|
1447
|
+
* POST /job-status
|
|
1448
|
+
* {
|
|
1449
|
+
* "jobId": "products-extraction-1234567890"
|
|
1450
|
+
* }
|
|
1451
|
+
*/
|
|
1452
|
+
export const checkJobStatus = webhook('products-job-status', {
|
|
1453
|
+
connection: 'products-job-status',
|
|
1454
|
+
}).then(
|
|
1455
|
+
http('query-job-status', {}, async ctx => {
|
|
1456
|
+
const { log, openKv, data } = ctx;
|
|
1457
|
+
|
|
1458
|
+
const requestData = typeof data === 'string' ? JSON.parse(data) : data;
|
|
1459
|
+
const jobId = requestData?.jobId;
|
|
1460
|
+
|
|
1461
|
+
if (!jobId) {
|
|
1462
|
+
return {
|
|
1463
|
+
success: false,
|
|
1464
|
+
error: 'Missing required field: jobId',
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
try {
|
|
1469
|
+
const tracker = new JobTracker(openKv(':project:'), log);
|
|
1470
|
+
const job = await tracker.getJob(jobId);
|
|
1471
|
+
|
|
1472
|
+
if (!job) {
|
|
1473
|
+
return {
|
|
1474
|
+
success: false,
|
|
1475
|
+
error: `Job not found: ${jobId}`,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
return {
|
|
1480
|
+
success: true,
|
|
1481
|
+
job,
|
|
1482
|
+
};
|
|
1483
|
+
} catch (error: any) {
|
|
1484
|
+
log.error('Failed to get job status', error);
|
|
1485
|
+
return {
|
|
1486
|
+
success: false,
|
|
1487
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1488
|
+
|
|
1489
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1490
|
+
|
|
1491
|
+
errorType: error instanceof Error ? error.constructor.name : 'UnknownError',
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
})
|
|
1495
|
+
);
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
### Entry Point (Workflow Registration)
|
|
1499
|
+
**File:** `index.ts`
|
|
1500
|
+
|
|
1501
|
+
```typescript
|
|
1502
|
+
/**
|
|
1503
|
+
* Entry point - Export all workflows for Versori platform
|
|
1504
|
+
*
|
|
1505
|
+
* This file exports all workflows to be registered with Versori.
|
|
1506
|
+
* Each workflow is defined in its own file for better organization.
|
|
1507
|
+
*/
|
|
1508
|
+
|
|
1509
|
+
// Scheduled workflows
|
|
1510
|
+
export { scheduledProductsExtraction } from './src/workflows/scheduled/daily-products-extraction';
|
|
1511
|
+
|
|
1512
|
+
// Webhook workflows
|
|
1513
|
+
export { adHocProductsExtraction } from './src/workflows/webhook/adhoc-products-extraction';
|
|
1514
|
+
export { checkJobStatus } from './src/workflows/webhook/job-status-check';
|
|
1515
|
+
```
|
|
1516
|
+
|
|
1517
|
+
---
|
|
1518
|
+
|
|
1519
|
+
## Important: Schema Verification
|
|
1520
|
+
|
|
1521
|
+
**Before using this template**, verify the GraphQL query structure against your actual Fluent Commerce schema:
|
|
1522
|
+
|
|
1523
|
+
```bash
|
|
1524
|
+
# Introspect your schema
|
|
1525
|
+
cd fc-connect-sdk
|
|
1526
|
+
npx fc-connect introspect-schema --url https://your-fluent-api.com/graphql
|
|
1527
|
+
|
|
1528
|
+
# Check available fields on Product type
|
|
1529
|
+
npx fc-connect introspect-schema --url <url> --output schema.json
|
|
1530
|
+
# Then search for "Product" type in schema.json
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
The query structure shown above is an **example**. Adjust field names to match your actual schema.
|
|
1534
|
+
|
|
1535
|
+
## Use Cases
|
|
1536
|
+
|
|
1537
|
+
**1. Amazon Seller Central Integration:**
|
|
1538
|
+
|
|
1539
|
+
- Daily product feed for listing updates
|
|
1540
|
+
- Include GTIN/UPC for catalog matching
|
|
1541
|
+
- Export pricing/inventory separately
|
|
1542
|
+
|
|
1543
|
+
**2. eBay Marketplace Sync:**
|
|
1544
|
+
|
|
1545
|
+
- Catalog updates for active listings
|
|
1546
|
+
- Include product descriptions and attributes
|
|
1547
|
+
- Handle variation products
|
|
1548
|
+
|
|
1549
|
+
**3. Internal Product Master:**
|
|
1550
|
+
|
|
1551
|
+
- Central product catalog for all systems
|
|
1552
|
+
- Include extended attributes
|
|
1553
|
+
- Version control for catalog changes
|
|
1554
|
+
|
|
1555
|
+
**4. Ad Hoc Refresh:**
|
|
1556
|
+
|
|
1557
|
+
- Manual catalog refresh for urgent updates
|
|
1558
|
+
- Integration testing with sample data
|
|
1559
|
+
- Troubleshooting specific product changes
|
|
1560
|
+
|
|
1561
|
+
## Production Checklist
|
|
1562
|
+
|
|
1563
|
+
- [ ] **Verify GraphQL query against actual schema** (use introspection)
|
|
1564
|
+
- [ ] Set appropriate extraction frequency (daily for catalog)
|
|
1565
|
+
- [ ] Configure correct product status filter (ACTIVE only?)
|
|
1566
|
+
- [ ] Test with real product data changes
|
|
1567
|
+
- [ ] Verify S3 bucket permissions
|
|
1568
|
+
- [ ] Set up monitoring/alerts for failed extractions
|
|
1569
|
+
- [ ] Document JSON schema for downstream consumers
|
|
1570
|
+
- [ ] Configure S3 lifecycle policy for old files
|
|
1571
|
+
- [ ] Test incremental extraction (only changed products)
|
|
1572
|
+
- [ ] Test ad hoc extraction workflow
|
|
1573
|
+
- [ ] Test job status monitoring
|
|
1574
|
+
- [ ] Verify overlap buffer prevents missed records
|
|
1575
|
+
|
|
1576
|
+
---
|
|
1577
|
+
|
|
1578
|
+
### Pattern 7: State Management & Date Overrides
|
|
1579
|
+
|
|
1580
|
+
**Use Case**: Understand how state management works with scheduled and ad-hoc extractions.
|
|
1581
|
+
|
|
1582
|
+
**How it works**:
|
|
1583
|
+
|
|
1584
|
+
VersoriKV stores the last successful extraction timestamp to enable incremental sync:
|
|
1585
|
+
|
|
1586
|
+
```typescript
|
|
1587
|
+
interface ExtractionState {
|
|
1588
|
+
timestamp: string; // Last run timestamp (WITHOUT overlap buffer)
|
|
1589
|
+
recordCount: number; // Number of records extracted
|
|
1590
|
+
extractedAt: string; // When extraction completed
|
|
1591
|
+
fileName?: string; // Generated filename
|
|
1592
|
+
s3Key?: string; // S3 upload path
|
|
1593
|
+
overlapBufferSeconds?: number; // Buffer configuration
|
|
1594
|
+
}
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
**State Priority Chain** (highest to lowest):
|
|
1598
|
+
|
|
1599
|
+
1. **`fromDate` override** (manual date in webhook payload) - Highest priority
|
|
1600
|
+
2. **Stored state** (`await kv.get(stateKey)`) - Normal incremental mode
|
|
1601
|
+
3. **`fallbackStartDate`** (activation variable) - First run fallback
|
|
1602
|
+
|
|
1603
|
+
**Three Scenarios**:
|
|
1604
|
+
|
|
1605
|
+
#### Scenario 1: Normal Scheduled Runs (Incremental)
|
|
1606
|
+
|
|
1607
|
+
```typescript
|
|
1608
|
+
// Payload: {} (empty - no overrides)
|
|
1609
|
+
|
|
1610
|
+
// Behavior:
|
|
1611
|
+
// 1. Load last timestamp from KV: "2025-01-22T10:00:00Z"
|
|
1612
|
+
// 2. Apply overlap buffer: "2025-01-22T09:59:00Z" (query WITH buffer)
|
|
1613
|
+
// 3. Extract records updated since buffered time
|
|
1614
|
+
// 4. Calculate MAX(updatedOn) from results: "2025-01-22T14:30:00Z"
|
|
1615
|
+
// 5. Save new timestamp WITHOUT buffer: "2025-01-22T14:30:00Z"
|
|
1616
|
+
// 6. Next run starts from "2025-01-22T14:29:00Z" (with buffer)
|
|
1617
|
+
```
|
|
1618
|
+
|
|
1619
|
+
**Test**:
|
|
1620
|
+
|
|
1621
|
+
```bash
|
|
1622
|
+
# Trigger scheduled run (no payload needed)
|
|
1623
|
+
# State advances automatically
|
|
1624
|
+
curl -X POST https://workspace.versori.run/products-extract-daily
|
|
1625
|
+
```
|
|
1626
|
+
|
|
1627
|
+
#### Scenario 2: Ad-hoc Extraction WITH State Update
|
|
1628
|
+
|
|
1629
|
+
```typescript
|
|
1630
|
+
// Payload: { "fromDate": "2025-01-01T00:00:00Z", "updateState": true }
|
|
1631
|
+
|
|
1632
|
+
// Behavior:
|
|
1633
|
+
// 1. Ignore stored state
|
|
1634
|
+
// 2. Use fromDate: "2025-01-01T00:00:00Z" (no buffer applied to manual dates)
|
|
1635
|
+
// 3. Extract all records since 2025-01-01
|
|
1636
|
+
// 4. Calculate MAX(updatedOn): "2025-01-22T14:30:00Z"
|
|
1637
|
+
// 5. Save new timestamp: "2025-01-22T14:30:00Z" (updates state!)
|
|
1638
|
+
// 6. Next scheduled run starts from this new timestamp
|
|
1639
|
+
```
|
|
1640
|
+
|
|
1641
|
+
**Use Case**: One-time catch-up extraction that advances the state pointer.
|
|
1642
|
+
|
|
1643
|
+
**Test**:
|
|
1644
|
+
|
|
1645
|
+
```bash
|
|
1646
|
+
curl -X POST https://workspace.versori.run/products-extract-webhook \
|
|
1647
|
+
-H "x-api-key: your-api-key" \
|
|
1648
|
+
-H "Content-Type: application/json" \
|
|
1649
|
+
-d '{
|
|
1650
|
+
"fromDate": "2025-01-01T00:00:00Z",
|
|
1651
|
+
"updateState": true
|
|
1652
|
+
}'
|
|
1653
|
+
```
|
|
1654
|
+
|
|
1655
|
+
#### Scenario 3: Ad-hoc Extraction WITHOUT State Update
|
|
1656
|
+
|
|
1657
|
+
```typescript
|
|
1658
|
+
// Payload: { "fromDate": "2025-01-01T00:00:00Z", "updateState": false }
|
|
1659
|
+
|
|
1660
|
+
// Behavior:
|
|
1661
|
+
// 1. Ignore stored state
|
|
1662
|
+
// 2. Use fromDate: "2025-01-01T00:00:00Z"
|
|
1663
|
+
// 3. Extract all records since 2025-01-01
|
|
1664
|
+
// 4. DO NOT update state
|
|
1665
|
+
// 5. Next scheduled run uses previous timestamp (unaffected)
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
**Use Case**: Historical backfill or testing without affecting incremental sync.
|
|
1669
|
+
|
|
1670
|
+
**Test**:
|
|
1671
|
+
|
|
1672
|
+
```bash
|
|
1673
|
+
curl -X POST https://workspace.versori.run/products-extract-webhook \
|
|
1674
|
+
-H "x-api-key: your-api-key" \
|
|
1675
|
+
-H "Content-Type: application/json" \
|
|
1676
|
+
-d '{
|
|
1677
|
+
"fromDate": "2025-01-01T00:00:00Z",
|
|
1678
|
+
"toDate": "2025-01-31T23:59:59Z",
|
|
1679
|
+
"updateState": false
|
|
1680
|
+
}'
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
**Why this matters**:
|
|
1684
|
+
|
|
1685
|
+
- **Incremental sync** relies on state continuity
|
|
1686
|
+
- **Manual overrides** allow catch-up without breaking incremental flow
|
|
1687
|
+
- **Overlap buffer** prevents missed records at time boundaries
|
|
1688
|
+
- **State isolation** lets you test/backfill without affecting production sync
|
|
1689
|
+
|
|
1690
|
+
---
|
|
1691
|
+
|
|
1692
|
+
### Pattern 8: Optional GraphQL Query Logging
|
|
1693
|
+
|
|
1694
|
+
**Use Case**: Debug extraction issues by logging the exact GraphQL query sent to Fluent Commerce API.
|
|
1695
|
+
|
|
1696
|
+
**When to use**:
|
|
1697
|
+
|
|
1698
|
+
- ✅ Debugging pagination issues
|
|
1699
|
+
- ✅ Verifying query variables (dates, filters, limits)
|
|
1700
|
+
- ✅ Development and testing
|
|
1701
|
+
- ❌ Production (verbose logs, potential secrets in variables)
|
|
1702
|
+
|
|
1703
|
+
**How to enable**:
|
|
1704
|
+
|
|
1705
|
+
Set `DEBUG_GRAPHQL=true` environment variable in Versori activation settings.
|
|
1706
|
+
|
|
1707
|
+
**Implementation**:
|
|
1708
|
+
|
|
1709
|
+
```typescript
|
|
1710
|
+
// In your extraction workflow
|
|
1711
|
+
const DEBUG_GRAPHQL = activation?.getVariable('DEBUG_GRAPHQL') === 'true';
|
|
1712
|
+
|
|
1713
|
+
if (DEBUG_GRAPHQL) {
|
|
1714
|
+
log.info('GraphQL Query Debug', {
|
|
1715
|
+
query: PRODUCTS_QUERY,
|
|
1716
|
+
variables: {
|
|
1717
|
+
catalogues,
|
|
1718
|
+
dateRangeFilter,
|
|
1719
|
+
first: pageSize,
|
|
1720
|
+
after: null, // First page
|
|
1721
|
+
},
|
|
1722
|
+
pagination: {
|
|
1723
|
+
pageSize,
|
|
1724
|
+
maxRecords,
|
|
1725
|
+
currentPage: 1,
|
|
1726
|
+
},
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const extractionResult = await orchestrator.extract({
|
|
1731
|
+
query: PRODUCTS_QUERY,
|
|
1732
|
+
resultPath: 'products.edges.node',
|
|
1733
|
+
variables: {
|
|
1734
|
+
catalogues,
|
|
1735
|
+
dateRangeFilter,
|
|
1736
|
+
},
|
|
1737
|
+
pageSize,
|
|
1738
|
+
maxRecords,
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
if (DEBUG_GRAPHQL) {
|
|
1742
|
+
log.info('GraphQL Response Debug', {
|
|
1743
|
+
totalRecords: extractionResult.stats.totalRecords,
|
|
1744
|
+
totalPages: extractionResult.stats.totalPages,
|
|
1745
|
+
truncated: extractionResult.stats.truncated,
|
|
1746
|
+
truncationReason: extractionResult.stats.truncationReason,
|
|
1747
|
+
validRecords: extractionResult.stats.validRecords ?? extractionResult.data.length,
|
|
1748
|
+
firstRecordId: extractionResult.data[0]?.id,
|
|
1749
|
+
lastRecordId: extractionResult.data[extractionResult.data.length - 1]?.id,
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
```
|
|
1753
|
+
|
|
1754
|
+
**What gets logged**:
|
|
1755
|
+
|
|
1756
|
+
```json
|
|
1757
|
+
{
|
|
1758
|
+
"level": "info",
|
|
1759
|
+
"message": "GraphQL Query Debug",
|
|
1760
|
+
"query": "query GetProducts($catalogues: [ProductCatalogueKey], $dateRangeFilter: DateRange, ...)",
|
|
1761
|
+
"variables": {
|
|
1762
|
+
"catalogues": [{ "ref": "DEFAULT_CATALOGUE" }],
|
|
1763
|
+
"dateRangeFilter": "2025-01-22T09:59:00Z",
|
|
1764
|
+
"first": 200,
|
|
1765
|
+
"after": null
|
|
1766
|
+
},
|
|
1767
|
+
"pagination": {
|
|
1768
|
+
"pageSize": 200,
|
|
1769
|
+
"maxRecords": 50000,
|
|
1770
|
+
"currentPage": 1
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
```
|
|
1774
|
+
|
|
1775
|
+
**Versori Environment Variables**:
|
|
1776
|
+
|
|
1777
|
+
Add to activation settings:
|
|
1778
|
+
|
|
1779
|
+
```json
|
|
1780
|
+
{
|
|
1781
|
+
"DEBUG_GRAPHQL": "true"
|
|
1782
|
+
}
|
|
1783
|
+
```
|
|
1784
|
+
|
|
1785
|
+
**Testing**:
|
|
1786
|
+
|
|
1787
|
+
```bash
|
|
1788
|
+
# Enable debug logging
|
|
1789
|
+
curl -X POST https://workspace.versori.run/products-extract-daily
|
|
1790
|
+
|
|
1791
|
+
# Check Versori logs for "GraphQL Query Debug" entries
|
|
1792
|
+
# Verify query structure and variables are correct
|
|
1793
|
+
```
|
|
1794
|
+
|
|
1795
|
+
**Sample Debug Output**:
|
|
1796
|
+
|
|
1797
|
+
```
|
|
1798
|
+
[INFO] GraphQL Query Debug
|
|
1799
|
+
query: "query GetProducts($catalogues: [ProductCatalogueKey], $dateRangeFilter: DateRange, ...)"
|
|
1800
|
+
variables: { catalogues: [{ ref: "DEFAULT_CATALOGUE" }], dateRangeFilter: "2025-01-22T09:59:00Z", first: 200, after: null }
|
|
1801
|
+
pagination: { pageSize: 200, maxRecords: 50000, currentPage: 1 }
|
|
1802
|
+
|
|
1803
|
+
[INFO] Extraction complete
|
|
1804
|
+
totalRecords: 1250
|
|
1805
|
+
totalPages: 7
|
|
1806
|
+
truncated: false
|
|
1807
|
+
validRecords: 1250
|
|
1808
|
+
|
|
1809
|
+
[INFO] GraphQL Response Debug
|
|
1810
|
+
totalRecords: 1250
|
|
1811
|
+
totalPages: 7
|
|
1812
|
+
truncated: false
|
|
1813
|
+
truncationReason: null
|
|
1814
|
+
validRecords: 1250
|
|
1815
|
+
firstRecordId: "product_abc"
|
|
1816
|
+
lastRecordId: "product_xyz"
|
|
1817
|
+
```
|
|
1818
|
+
|
|
1819
|
+
**Key Benefits**:
|
|
1820
|
+
|
|
1821
|
+
- Quickly identify pagination configuration issues
|
|
1822
|
+
- Verify date filters are applied correctly
|
|
1823
|
+
- Debug "no records found" scenarios
|
|
1824
|
+
- Validate ExtractionOrchestrator variable injection
|
|
1825
|
+
|
|
1826
|
+
**Production Best Practice**: Disable `DEBUG_GRAPHQL` in production to reduce log volume and avoid logging sensitive data.
|
|
1827
|
+
|
|
1828
|
+
---
|
|
1829
|
+
|
|
1830
|
+
## Troubleshooting Guide
|
|
1831
|
+
|
|
1832
|
+
**Issue**: "Extraction timeout after 10 minutes"
|
|
1833
|
+
|
|
1834
|
+
- **Cause**: Too many records
|
|
1835
|
+
- **Fix**: Reduce maxRecords, increase frequency
|
|
1836
|
+
|
|
1837
|
+
**Issue**: "Mapping errors for 50% of records"
|
|
1838
|
+
|
|
1839
|
+
- **Cause**: Schema mismatch
|
|
1840
|
+
- **Fix**: Run schema validation, check field names
|
|
1841
|
+
|
|
1842
|
+
**Issue**: "State not updating"
|
|
1843
|
+
|
|
1844
|
+
- **Cause**: KV write failure or intentional retry
|
|
1845
|
+
- **Fix**: Check KV logs, verify state update code
|
|
1846
|
+
|
|
1847
|
+
**Issue**: "First run exceeds limits"
|
|
1848
|
+
|
|
1849
|
+
- **Cause**: No previous timestamp, fetches all
|
|
1850
|
+
- **Fix**: Set fallbackStartDate close to current, apply filters
|
|
1851
|
+
|
|
1852
|
+
**Issue**: "Excessive duplicates"
|
|
1853
|
+
|
|
1854
|
+
- **Cause**: Overlap buffer (expected) or timestamp not saved
|
|
1855
|
+
- **Fix**: Verify newTimestamp saved WITHOUT buffer
|
|
1856
|
+
|
|
1857
|
+
**Issue**: "Job status not found"
|
|
1858
|
+
|
|
1859
|
+
- **Cause**: JobTracker not initialized or wrong jobId
|
|
1860
|
+
- **Fix**: Verify JobTracker initialization and jobId format
|
|
1861
|
+
|
|
1862
|
+
---
|
|
1863
|
+
|
|
1864
|
+
**Pattern**: Enterprise incremental extraction with overlap buffer for product catalog - Three-workflow approach
|
|
1865
|
+
**⚠️ Versori Sample**: Reference implementation - adapt for your production use case
|
|
1866
|
+
**Key Learning**: ALWAYS verify GraphQL schema before using queries
|
|
1867
|
+
**Critical**: Apply 60-second overlap buffer to prevent missed records due to clock skew
|
|
1868
|
+
**Buffer Pattern**: Query WITH buffer (`updatedOn >= lastRunTime - 60s`), save WITHOUT buffer (`MAX(updatedOn)`)
|
|
1869
|
+
**Schema**: Use schema introspection - don't guess field names
|
|
1870
|
+
**Three Workflows**: Scheduled incremental, Ad hoc HTTP trigger, Job status monitoring
|
|
1871
|
+
|
|
1872
|
+
---
|
|
1873
|
+
|
|
1874
|
+
## See Also
|
|
1875
|
+
|
|
1876
|
+
**Validation & Best Practices:**
|
|
1877
|
+
|
|
1878
|
+
- [CLI Validation Workflow](../../../../../02-CORE-GUIDES/api-reference/modules/api-reference-11-cli-tools.md) - Validate GraphQL queries and mappings before deployment
|
|
1879
|
+
- [Extraction Modes Guide](../extraction-modes-guide.md) - Comparison of incremental vs dateRange vs historical modes
|
|
1880
|
+
|
|
1881
|
+
**SDK Documentation:**
|
|
1882
|
+
|
|
1883
|
+
- [Universal Mapping Guide](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Complete field mapping documentation
|
|
1884
|
+
- [SDK CLI Tools](../../../../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Command-line tools reference
|
|
1885
|
+
|
|
1886
|
+
**Related Extraction Patterns:**
|
|
1887
|
+
|
|
1888
|
+
- [Products to SFTP XML](./template-extraction-products-to-sftp-xml.md) - Same entity, different format/destination
|
|
1889
|
+
- [Virtual Positions to S3 JSON](./template-extraction-virtual-positions-to-s3-json.md) - Different entity, JSON format
|
|
1890
|
+
|
|
1891
|
+
---
|
|
1892
|
+
|
|
1893
|
+
### Pattern 9: Backward Pagination (Optional - Advanced)
|
|
1894
|
+
|
|
1895
|
+
**Use Case**: Extract data in reverse chronological order (newest to oldest) instead of oldest to newest.
|
|
1896
|
+
|
|
1897
|
+
**When to Use**:
|
|
1898
|
+
|
|
1899
|
+
- ✅ Need most recent records first (e.g., latest orders, recent inventory updates)
|
|
1900
|
+
- ✅ Time-bounded reverse traversal for auditing
|
|
1901
|
+
- ✅ Display newest-first in UI/reports
|
|
1902
|
+
- ❌ **Don't use for standard incremental sync** - use forward pagination (default)
|
|
1903
|
+
|
|
1904
|
+
**GraphQL Query Requirements**:
|
|
1905
|
+
|
|
1906
|
+
Your query must support backward pagination by including `$last` and `$before`:
|
|
1907
|
+
|
|
1908
|
+
```graphql
|
|
1909
|
+
query GetData(
|
|
1910
|
+
$retailerId: ID!
|
|
1911
|
+
$first: Int # For forward pagination
|
|
1912
|
+
$after: String # For forward pagination
|
|
1913
|
+
$last: Int # For backward pagination
|
|
1914
|
+
$before: String # For backward pagination
|
|
1915
|
+
) {
|
|
1916
|
+
data(retailerId: $retailerId, first: $first, after: $after, last: $last, before: $before) {
|
|
1917
|
+
edges {
|
|
1918
|
+
cursor # ✅ REQUIRED
|
|
1919
|
+
node {
|
|
1920
|
+
id
|
|
1921
|
+
createdAt
|
|
1922
|
+
# ... other fields
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
pageInfo {
|
|
1926
|
+
hasNextPage # For forward
|
|
1927
|
+
hasPreviousPage # ✅ REQUIRED for backward
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
```
|
|
1932
|
+
|
|
1933
|
+
**Implementation**:
|
|
1934
|
+
|
|
1935
|
+
```typescript
|
|
1936
|
+
// Backward pagination - newest records first
|
|
1937
|
+
const result = await orchestrator.extract({
|
|
1938
|
+
query: YOUR_QUERY,
|
|
1939
|
+
resultPath: 'data.edges.node',
|
|
1940
|
+
variables: {
|
|
1941
|
+
retailerId,
|
|
1942
|
+
dateRangeFilter: { from: bufferedLastRunTime, to: effectiveEndTime },
|
|
1943
|
+
// ❌ Don't include last/before - orchestrator injects them
|
|
1944
|
+
},
|
|
1945
|
+
pageSize: 200,
|
|
1946
|
+
direction: 'backward', // ✅ Enable reverse pagination
|
|
1947
|
+
maxRecords: 10000,
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
// Records are returned in reverse chronological order
|
|
1951
|
+
log.info('Extraction order verification', {
|
|
1952
|
+
newest: result.data[0].createdAt,
|
|
1953
|
+
oldest: result.data[result.data.length - 1].createdAt
|
|
1954
|
+
});
|
|
1955
|
+
```
|
|
1956
|
+
|
|
1957
|
+
**Key Differences from Forward Pagination**:
|
|
1958
|
+
|
|
1959
|
+
| Aspect | Forward (Default) | Backward |
|
|
1960
|
+
| ---------------------- | -------------------------------- | ----------------------- |
|
|
1961
|
+
| **Direction** | `direction: 'forward'` (default) | `direction: 'backward'` |
|
|
1962
|
+
| **Variables Injected** | `first`, `after` | `last`, `before` |
|
|
1963
|
+
| **PageInfo Field** | `hasNextPage` | `hasPreviousPage` |
|
|
1964
|
+
| **Cursor Source** | Last edge of page | First edge of page |
|
|
1965
|
+
| **Record Order** | Oldest → Newest | Newest → Oldest |
|
|
1966
|
+
|
|
1967
|
+
**Important Notes**:
|
|
1968
|
+
|
|
1969
|
+
1. **Orchestrator injects variables**: Don't pass `last` or `before` in your variables object - the orchestrator injects them based on `pageSize` and cursor tracking.
|
|
1970
|
+
|
|
1971
|
+
2. **Query signature**: Your GraphQL query must declare `$last` and `$before` parameters even if you don't pass them explicitly.
|
|
1972
|
+
|
|
1973
|
+
3. **PageInfo requirement**: Response must include `pageInfo.hasPreviousPage` or the orchestrator will throw an error.
|
|
1974
|
+
|
|
1975
|
+
4. **Cursor requirement**: Each edge must include `cursor` field for pagination to work.
|
|
1976
|
+
|
|
1977
|
+
**Example: Extract Latest 1000 Orders**
|
|
1978
|
+
|
|
1979
|
+
```typescript
|
|
1980
|
+
const latestOrders = await orchestrator.extract({
|
|
1981
|
+
query: ORDERS_QUERY,
|
|
1982
|
+
resultPath: 'orders.edges.node',
|
|
1983
|
+
variables: {
|
|
1984
|
+
retailerId,
|
|
1985
|
+
statuses: ['BOOKED', 'ALLOCATED'],
|
|
1986
|
+
},
|
|
1987
|
+
direction: 'backward', // Start from newest
|
|
1988
|
+
maxRecords: 1000, // Stop after 1000 records
|
|
1989
|
+
pageSize: 100, // 100 per page = 10 pages
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
// latestOrders.data[0] is the newest order
|
|
1993
|
+
// latestOrders.data[999] is the 1000th newest order
|
|
1994
|
+
```
|
|
1995
|
+
|
|
1996
|
+
**When to Use Forward vs Backward**:
|
|
1997
|
+
|
|
1998
|
+
```typescript
|
|
1999
|
+
// ✅ Forward (default) - For incremental sync
|
|
2000
|
+
const incrementalData = await orchestrator.extract({
|
|
2001
|
+
query: YOUR_QUERY,
|
|
2002
|
+
resultPath: 'data.edges.node',
|
|
2003
|
+
variables: {
|
|
2004
|
+
dateRangeFilter: { from: lastSyncTime, to: now },
|
|
2005
|
+
},
|
|
2006
|
+
// direction defaults to 'forward'
|
|
2007
|
+
// Processes oldest → newest for proper sequencing
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
// ✅ Backward - For "latest N records" use cases
|
|
2011
|
+
const latestData = await orchestrator.extract({
|
|
2012
|
+
query: YOUR_QUERY,
|
|
2013
|
+
resultPath: 'data.edges.node',
|
|
2014
|
+
direction: 'backward',
|
|
2015
|
+
maxRecords: 100, // Just get latest 100
|
|
2016
|
+
// Gets newest → oldest
|
|
2017
|
+
});
|
|
2018
|
+
```
|
|
2019
|
+
|
|
2020
|
+
**Pagination Variables Reference**:
|
|
2021
|
+
|
|
2022
|
+
| Variable | Forward | Backward | Injected By | Notes |
|
|
2023
|
+
| -------- | ----------- | ----------- | ------------ | ------------------------ |
|
|
2024
|
+
| `first` | ✅ Used | ❌ Not used | Orchestrator | From `pageSize` |
|
|
2025
|
+
| `after` | ✅ Used | ❌ Not used | Orchestrator | From cursor (last edge) |
|
|
2026
|
+
| `last` | ❌ Not used | ✅ Used | Orchestrator | From `pageSize` |
|
|
2027
|
+
| `before` | ❌ Not used | ✅ Used | Orchestrator | From cursor (first edge) |
|
|
2028
|
+
|
|
2029
|
+
**Common Mistakes to Avoid**:
|
|
2030
|
+
|
|
2031
|
+
```typescript
|
|
2032
|
+
// ❌ WRONG - Don't pass pagination variables
|
|
2033
|
+
const result = await orchestrator.extract({
|
|
2034
|
+
variables: {
|
|
2035
|
+
last: 200, // ❌ Orchestrator will override this
|
|
2036
|
+
before: cursor, // ❌ Orchestrator manages cursor
|
|
2037
|
+
},
|
|
2038
|
+
direction: 'backward',
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
// ✅ CORRECT - Let orchestrator inject pagination
|
|
2042
|
+
const result = await orchestrator.extract({
|
|
2043
|
+
variables: {
|
|
2044
|
+
retailerId, // ✅ Your business variables only
|
|
2045
|
+
},
|
|
2046
|
+
pageSize: 200, // ✅ Orchestrator uses this for last/before
|
|
2047
|
+
direction: 'backward',
|
|
2048
|
+
});
|
|
2049
|
+
```
|
|
2050
|
+
|
|
2051
|
+
#### Optional: Reverse Pagination
|
|
2052
|
+
|
|
2053
|
+
- Default: forward ($first/$after) + pageInfo.hasNextPage.
|
|
2054
|
+
- Reverse: query must use $last/$before; response must include pageInfo.hasPreviousPage; set direction='backward'.
|
|
2055
|
+
|
|
2056
|
+
GraphQL:
|
|
2057
|
+
|
|
2058
|
+
```graphql
|
|
2059
|
+
query GetProductsBackward(
|
|
2060
|
+
$catalogues: [CatalogueKey]
|
|
2061
|
+
$dateRangeFilter: DateRange
|
|
2062
|
+
$last: Int!
|
|
2063
|
+
$before: String
|
|
2064
|
+
) {
|
|
2065
|
+
products(catalogues: $catalogues, updatedOn: $dateRangeFilter, last: $last, before: $before) {
|
|
2066
|
+
edges {
|
|
2067
|
+
cursor
|
|
2068
|
+
node {
|
|
2069
|
+
id
|
|
2070
|
+
ref
|
|
2071
|
+
updatedOn
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
pageInfo {
|
|
2075
|
+
hasPreviousPage
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
```
|
|
2080
|
+
|
|
2081
|
+
SDK:
|
|
2082
|
+
|
|
2083
|
+
```typescript
|
|
2084
|
+
await orchestrator.extract({
|
|
2085
|
+
query: PRODUCTS_BACKWARD_QUERY,
|
|
2086
|
+
resultPath: 'products.edges.node',
|
|
2087
|
+
variables: { catalogues, dateRangeFilter },
|
|
2088
|
+
pageSize,
|
|
2089
|
+
direction: 'backward',
|
|
2090
|
+
});
|
|
2091
|
+
```
|
|
2092
|
+
|
|
2093
|
+
---
|
|
2094
|
+
|
|
2095
|
+
## Testing Checklist
|
|
2096
|
+
|
|
2097
|
+
**Before production deployment:**
|
|
2098
|
+
|
|
2099
|
+
### 1. Schema Validation
|
|
2100
|
+
|
|
2101
|
+
- [ ] Run `npx fc-connect introspect-schema --url <your-graphql-url>`
|
|
2102
|
+
- [ ] Run `npx fc-connect validate-schema --mapping ./config/products.export.json --schema ./fluent-schema.json`
|
|
2103
|
+
- [ ] Run `npx fc-connect analyze-coverage --mapping ./config/products.export.json --schema ./fluent-schema.json`
|
|
2104
|
+
- [ ] Verify all `source` paths in mapping exist in GraphQL schema
|
|
2105
|
+
- [ ] Verify query structure matches schema (fields, types, filters)
|
|
2106
|
+
|
|
2107
|
+
### 2. Extraction Testing
|
|
2108
|
+
|
|
2109
|
+
- [ ] Test with small dataset first (maxRecords=10)
|
|
2110
|
+
- [ ] Verify ExtractionOrchestrator pagination works correctly
|
|
2111
|
+
- [ ] Test with multiple pages of data (verify cursor handling)
|
|
2112
|
+
- [ ] Verify date range filtering (updatedOn filter)
|
|
2113
|
+
- [ ] Test empty result handling (no records in date range)
|
|
2114
|
+
- [ ] Verify extraction stops at maxRecords limit
|
|
2115
|
+
|
|
2116
|
+
### 3. Mapping Testing
|
|
2117
|
+
|
|
2118
|
+
- [ ] Verify required fields are populated
|
|
2119
|
+
- [ ] Verify SDK resolvers work correctly (sdk.trim, sdk.parseInt, sdk.formatDate, etc.)
|
|
2120
|
+
- [ ] Test custom resolvers with edge cases (if any)
|
|
2121
|
+
- [ ] Verify nested field extraction
|
|
2122
|
+
- [ ] Test with null/missing fields
|
|
2123
|
+
- [ ] Verify mapping error collection works
|
|
2124
|
+
|
|
2125
|
+
### 4. JSON Generation Testing
|
|
2126
|
+
|
|
2127
|
+
- [ ] Verify JSON structure matches expected format
|
|
2128
|
+
- [ ] Test JSON validation against schema (if applicable)
|
|
2129
|
+
- [ ] Verify proper nesting and structure
|
|
2130
|
+
- [ ] Test with large datasets (>1000 records)
|
|
2131
|
+
- [ ] Verify UTF-8 encoding
|
|
2132
|
+
- [ ] Test special character escaping
|
|
2133
|
+
|
|
2134
|
+
### 5. S3 Upload Testing
|
|
2135
|
+
|
|
2136
|
+
- [ ] Test S3 connection and authentication
|
|
2137
|
+
- [ ] Verify file upload to correct bucket and path
|
|
2138
|
+
- [ ] Test file naming convention (timestamp format)
|
|
2139
|
+
- [ ] Verify S3 object metadata
|
|
2140
|
+
- [ ] Test upload retry logic (simulate network failure)
|
|
2141
|
+
- [ ] Verify file permissions and ACLs
|
|
2142
|
+
|
|
2143
|
+
### 6. State Management Testing
|
|
2144
|
+
|
|
2145
|
+
- [ ] Verify overlap buffer prevents missed records (60-second default)
|
|
2146
|
+
- [ ] Test state recovery after extraction failure
|
|
2147
|
+
- [ ] Verify timestamp saved WITHOUT buffer (MAX(updatedOn))
|
|
2148
|
+
- [ ] Test first run with no previous state (uses fallbackStartDate)
|
|
2149
|
+
- [ ] Verify state update only happens on successful upload
|
|
2150
|
+
- [ ] Test manual date override (doesn't update state)
|
|
2151
|
+
|
|
2152
|
+
### 7. Job Tracking Testing
|
|
2153
|
+
|
|
2154
|
+
- [ ] Test job creation with JobTracker
|
|
2155
|
+
- [ ] Verify job status updates at each stage
|
|
2156
|
+
- [ ] Test job completion with metadata
|
|
2157
|
+
- [ ] Test job failure handling
|
|
2158
|
+
- [ ] Query job status via webhook endpoint
|
|
2159
|
+
- [ ] Verify job status persists in KV store
|
|
2160
|
+
|
|
2161
|
+
### 8. Error Handling Testing
|
|
2162
|
+
|
|
2163
|
+
- [ ] Test with invalid GraphQL query
|
|
2164
|
+
- [ ] Test with mapping errors (invalid field paths)
|
|
2165
|
+
- [ ] Test with S3 connection failures
|
|
2166
|
+
- [ ] Test with authentication failures
|
|
2167
|
+
- [ ] Test with network timeouts
|
|
2168
|
+
- [ ] Verify error logging includes context (jobId, stage, error details)
|
|
2169
|
+
- [ ] Test error threshold logic (if applicable)
|
|
2170
|
+
|
|
2171
|
+
### 9. Staging Environment Testing
|
|
2172
|
+
|
|
2173
|
+
- [ ] Run full extraction in staging environment
|
|
2174
|
+
- [ ] Verify JSON file format with downstream system
|
|
2175
|
+
- [ ] Monitor extraction duration and resource usage
|
|
2176
|
+
- [ ] Test with production-like data volumes
|
|
2177
|
+
- [ ] Verify no performance degradation over time
|
|
2178
|
+
|
|
2179
|
+
### 10. Integration Testing
|
|
2180
|
+
|
|
2181
|
+
- [ ] Test scheduled workflow (cron trigger)
|
|
2182
|
+
- [ ] Test ad hoc webhook trigger
|
|
2183
|
+
- [ ] Test job status query webhook
|
|
2184
|
+
- [ ] Verify activation variables are read correctly
|
|
2185
|
+
- [ ] Test with different extraction modes (incremental, date range)
|
|
2186
|
+
- [ ] End-to-end test: trigger → extract → transform → upload → verify file
|
|
2187
|
+
|
|
2188
|
+
---
|
|
2189
|
+
## Monitoring & Alerting
|
|
2190
|
+
|
|
2191
|
+
### Success Response Example
|
|
2192
|
+
|
|
2193
|
+
```json
|
|
2194
|
+
{
|
|
2195
|
+
"success": true,
|
|
2196
|
+
"jobId": "SCHEDULED_PRD_20251102_140000_abc123",
|
|
2197
|
+
"recordsExtracted": 1523,
|
|
2198
|
+
"fileName": "products-2025-11-02T14-00-00-000Z.json",
|
|
2199
|
+
"s3Path": "s3://bucket/products/products-2025-11-02T14-00-00-000Z.json",
|
|
2200
|
+
"metrics": {
|
|
2201
|
+
"extractionDurationMs": 12543,
|
|
2202
|
+
"totalPages": 8,
|
|
2203
|
+
"pageSize": 200,
|
|
2204
|
+
"mappingErrors": 0,
|
|
2205
|
+
"fileSizeBytes": 524288,
|
|
2206
|
+
"uploadDurationMs": 1234
|
|
2207
|
+
},
|
|
2208
|
+
"timestamps": {
|
|
2209
|
+
"extractionStart": "2025-11-02T14:00:00.000Z",
|
|
2210
|
+
"extractionEnd": "2025-11-02T14:00:12.543Z",
|
|
2211
|
+
"uploadComplete": "2025-11-02T14:00:13.777Z"
|
|
2212
|
+
},
|
|
2213
|
+
"state": {
|
|
2214
|
+
"previousTimestamp": "2025-11-02T13:00:00.000Z",
|
|
2215
|
+
"newTimestamp": "2025-11-02T13:59:58.123Z",
|
|
2216
|
+
"stateUpdated": true,
|
|
2217
|
+
"overlapBufferSeconds": 60
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
```
|
|
2221
|
+
|
|
2222
|
+
### Error Response Example
|
|
2223
|
+
|
|
2224
|
+
```json
|
|
2225
|
+
{
|
|
2226
|
+
"success": false,
|
|
2227
|
+
"jobId": "ADHOC_PRD_20251102_140500_xyz789",
|
|
2228
|
+
"error": "S3 upload failed: Connection timeout",
|
|
2229
|
+
"errorCategory": "NETWORK",
|
|
2230
|
+
"recordsExtracted": 0,
|
|
2231
|
+
"stage": "s3_upload",
|
|
2232
|
+
"details": {
|
|
2233
|
+
"message": "Failed to upload file after 3 retry attempts",
|
|
2234
|
+
"retryAttempts": 3,
|
|
2235
|
+
"lastError": "ETIMEDOUT: Connection timed out after 30000ms"
|
|
2236
|
+
},
|
|
2237
|
+
"state": {
|
|
2238
|
+
"stateUpdated": false,
|
|
2239
|
+
"willRetryNextRun": true,
|
|
2240
|
+
"note": "State not advanced - next extraction will retry same time window"
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
```
|
|
2244
|
+
|
|
2245
|
+
### Key Metrics to Track
|
|
2246
|
+
|
|
2247
|
+
```typescript
|
|
2248
|
+
const METRICS = {
|
|
2249
|
+
// Extraction Performance
|
|
2250
|
+
extractionDurationMs: Date.now() - extractionStart,
|
|
2251
|
+
recordCount: records.length,
|
|
2252
|
+
pageCount: extractionResult.stats.totalPages,
|
|
2253
|
+
avgRecordsPerPage: records.length / extractionResult.stats.totalPages,
|
|
2254
|
+
|
|
2255
|
+
// Transformation Performance
|
|
2256
|
+
transformedCount: transformedRecords.length,
|
|
2257
|
+
failedCount: mappingErrors.length,
|
|
2258
|
+
errorRate: ((mappingErrors.length / records.length) * 100).toFixed(2) + '%',
|
|
2259
|
+
|
|
2260
|
+
// File Generation
|
|
2261
|
+
fileSizeMB: (jsonContent.length / (1024 * 1024)).toFixed(2),
|
|
2262
|
+
|
|
2263
|
+
// Upload Performance
|
|
2264
|
+
uploadDurationMs: uploadEnd - uploadStart,
|
|
2265
|
+
uploadSpeedMBps: (fileSizeMB / (uploadDurationMs / 1000)).toFixed(2),
|
|
2266
|
+
|
|
2267
|
+
// State Management
|
|
2268
|
+
timeSinceLastRun: Date.now() - new Date(lastTimestamp).getTime(),
|
|
2269
|
+
recordsPerMinute: (records.length / (extractionDurationMs / 60000)).toFixed(0),
|
|
2270
|
+
};
|
|
2271
|
+
|
|
2272
|
+
log.info('Extraction metrics', metrics);
|
|
2273
|
+
```
|
|
2274
|
+
|
|
2275
|
+
### Alert Thresholds
|
|
2276
|
+
|
|
2277
|
+
```typescript
|
|
2278
|
+
const ALERT_THRESHOLDS = {
|
|
2279
|
+
// Duration Alerts
|
|
2280
|
+
EXTRACTION_DURATION_MS: 5 * 60 * 1000, // 5 minutes
|
|
2281
|
+
UPLOAD_DURATION_MS: 2 * 60 * 1000, // 2 minutes
|
|
2282
|
+
TOTAL_DURATION_MS: 10 * 60 * 1000, // 10 minutes
|
|
2283
|
+
|
|
2284
|
+
// Error Rate Alerts
|
|
2285
|
+
MAX_ERROR_RATE: 0.05, // 5% mapping errors
|
|
2286
|
+
MAX_VALIDATION_FAILURES: 0.02, // 2% validation failures
|
|
2287
|
+
|
|
2288
|
+
// Volume Alerts
|
|
2289
|
+
MAX_RECORDS_PER_RUN: 100000,
|
|
2290
|
+
MIN_RECORDS_WARNING: 0, // Alert if no records found
|
|
2291
|
+
MAX_FILE_SIZE_MB: 150, // 150MB
|
|
2292
|
+
|
|
2293
|
+
// State Alerts
|
|
2294
|
+
MAX_TIME_SINCE_LAST_RUN_HOURS: 25, // Alert if >25 hours (should run hourly)
|
|
2295
|
+
MAX_OVERLAP_BUFFER_SECONDS: 300, // Alert if buffer >5 minutes
|
|
2296
|
+
};
|
|
2297
|
+
|
|
2298
|
+
// Check thresholds
|
|
2299
|
+
if (metrics.extractionDurationMs > ALERT_THRESHOLDS.EXTRACTION_DURATION_MS) {
|
|
2300
|
+
log.warn('Extraction duration exceeded threshold', {
|
|
2301
|
+
duration: metrics.extractionDurationMs,
|
|
2302
|
+
threshold: ALERT_THRESHOLDS.EXTRACTION_DURATION_MS,
|
|
2303
|
+
recommendation: 'Consider reducing maxRecords or increasing extraction frequency'
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
```
|
|
2307
|
+
|
|
2308
|
+
### Monitoring Dashboard Queries
|
|
2309
|
+
|
|
2310
|
+
**Versori Platform Logs Query:**
|
|
2311
|
+
|
|
2312
|
+
```
|
|
2313
|
+
# Successful extractions
|
|
2314
|
+
log_level:info AND message:"Extraction complete" AND jobId:*
|
|
2315
|
+
|
|
2316
|
+
# Failed extractions
|
|
2317
|
+
log_level:error AND message:"Extraction workflow failed" AND jobId:*
|
|
2318
|
+
|
|
2319
|
+
# Performance issues
|
|
2320
|
+
extractionDurationMs:>300000 OR uploadDurationMs:>120000
|
|
2321
|
+
|
|
2322
|
+
# High error rates
|
|
2323
|
+
errorRate:>5
|
|
2324
|
+
|
|
2325
|
+
# State management issues
|
|
2326
|
+
stateUpdated:false AND success:true
|
|
2327
|
+
```
|
|
2328
|
+
|
|
2329
|
+
### Common Issues and Solutions
|
|
2330
|
+
|
|
2331
|
+
**Issue**: "Extraction timeout after 10 minutes"
|
|
2332
|
+
|
|
2333
|
+
- **Cause**: Too many records in single extraction
|
|
2334
|
+
- **Fix**: Reduce maxRecords, increase extraction frequency, or optimize query filters
|
|
2335
|
+
- **Prevention**: Monitor recordCount trends, set appropriate maxRecords
|
|
2336
|
+
|
|
2337
|
+
**Issue**: "Mapping errors for 50% of records"
|
|
2338
|
+
|
|
2339
|
+
- **Cause**: Schema mismatch between GraphQL response and mapping config
|
|
2340
|
+
- **Fix**: Run schema validation, update mapping config paths
|
|
2341
|
+
- **Prevention**: Use `npx fc-connect validate-schema` before deployment
|
|
2342
|
+
|
|
2343
|
+
**Issue**: "S3 connection timeout"
|
|
2344
|
+
|
|
2345
|
+
- **Cause**: Network issues, firewall, or connection pool exhaustion
|
|
2346
|
+
- **Fix**: Check S3 credentials, verify network connectivity
|
|
2347
|
+
- **Prevention**: Implement connection health checks, monitor connection status
|
|
2348
|
+
|
|
2349
|
+
**Issue**: "State not updating after successful extraction"
|
|
2350
|
+
|
|
2351
|
+
- **Cause**: KV write failure or intentional retry logic
|
|
2352
|
+
- **Fix**: Check KV logs, verify state update code executed
|
|
2353
|
+
- **Prevention**: Add KV write verification, log state updates explicitly
|
|
2354
|
+
|
|
2355
|
+
**Issue**: "First run exceeds record limits"
|
|
2356
|
+
|
|
2357
|
+
- **Cause**: No previous timestamp, fetches all historical records
|
|
2358
|
+
- **Fix**: Set fallbackStartDate close to current date, apply additional filters
|
|
2359
|
+
- **Prevention**: Use appropriate fallbackStartDate for initial runs
|
|
2360
|
+
|
|
2361
|
+
**Issue**: "Excessive duplicate records in output"
|
|
2362
|
+
|
|
2363
|
+
- **Cause**: Overlap buffer (expected) or timestamp not saved correctly
|
|
2364
|
+
- **Fix**: Verify newTimestamp saved WITHOUT buffer, check state persistence
|
|
2365
|
+
- **Prevention**: Monitor duplicate rates, verify state update logic
|
|
2366
|
+
|
|
2367
|
+
---
|
|
2368
|
+
|
|
2369
|
+
## Troubleshooting Quick Reference
|
|
2370
|
+
|
|
2371
|
+
| Error Message | Likely Cause | Solution |
|
|
2372
|
+
|--------------|--------------|----------|
|
|
2373
|
+
| "Failed to create Fluent Commerce client" | Authentication failure | Check OAuth2 credentials, verify connection config |
|
|
2374
|
+
| "GraphQL query validation error" | Invalid query syntax | Validate query against schema with introspection tool |
|
|
2375
|
+
| "Pagination cursor invalid" | Stale cursor or query change | Reset extraction, verify cursor handling in query |
|
|
2376
|
+
| "Mapping failed: field not found" | Schema mismatch | Run schema validation, update mapping paths |
|
|
2377
|
+
| "S3 authentication failed" | Invalid credentials | Verify S3 credentials in activation variables |
|
|
2378
|
+
| "Connection pool exhausted" | Too many concurrent requests | Reduce concurrency, increase pool size |
|
|
2379
|
+
| "KV operation failed" | Versori KV issue | Check Versori platform status, retry operation |
|
|
2380
|
+
| "Job status not found" | Invalid jobId or expired | Verify jobId format, check KV retention policy |
|
|
2381
|
+
| "Memory limit exceeded" | Dataset too large | Reduce maxRecords, enable streaming mode |
|
|
2382
|
+
| "JSON generation failed" | Format-specific error | Check JSON generation logic, validate output |
|
|
2383
|
+
|
|
2384
|
+
---
|