@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,2399 +1,2399 @@
|
|
|
1
|
-
# Pattern: Master Data ETL - Generic Framework for Loading Any Entity
|
|
2
|
-
|
|
3
|
-
**FC Connect SDK Use Case Guide**
|
|
4
|
-
|
|
5
|
-
> **SDK**: [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk)
|
|
6
|
-
> **Version**: Use latest - `npm install @fluentcommerce/fc-connect-sdk@latest`
|
|
7
|
-
|
|
8
|
-
**Status**: Production Ready
|
|
9
|
-
|
|
10
|
-
**Complexity**: Intermediate
|
|
11
|
-
|
|
12
|
-
**Est. Time**: 30-60 minutes
|
|
13
|
-
|
|
14
|
-
**Use Cases**: Locations, Products, Controls, Carriers, Customers, Pricing, Categories, etc.
|
|
15
|
-
|
|
16
|
-
## Table of Contents
|
|
17
|
-
|
|
18
|
-
- [Overview](#overview)
|
|
19
|
-
- [What You'll Build](#what-youll-build)
|
|
20
|
-
- [SDK Methods Used](#sdk-methods-used)
|
|
21
|
-
- [The Generic Pattern](#the-generic-pattern)
|
|
22
|
-
- [Example 1: Location Master Data](#example-1-location-master-data)
|
|
23
|
-
- [Example 2: Product Catalog](#example-2-product-catalog)
|
|
24
|
-
- [Example 3: Control/Config Data](#example-3-controlconfig-data)
|
|
25
|
-
- [Source Strategies](#source-strategies)
|
|
26
|
-
- [Load Strategies](#load-strategies)
|
|
27
|
-
- [Configuration Schema](#configuration-schema)
|
|
28
|
-
- [Extending to Other Entities](#extending-to-other-entities)
|
|
29
|
-
- [Testing](#testing)
|
|
30
|
-
- [Common Issues](#common-issues)
|
|
31
|
-
- [Related Guides](#related-guides)
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## Overview
|
|
36
|
-
|
|
37
|
-
Master data ETL is the process of extracting reference data from external systems and loading it into Fluent Commerce. Unlike transactional data (orders, inventory updates), master data changes infrequently and defines the core entities of your commerce platform.
|
|
38
|
-
|
|
39
|
-
**Common Master Data Entities:**
|
|
40
|
-
|
|
41
|
-
- **Locations**: Stores, warehouses, distribution centers
|
|
42
|
-
- **Products**: SKUs, product catalogs, variants
|
|
43
|
-
- **Controls**: Business rules, configuration parameters
|
|
44
|
-
- **Carriers**: Shipping carriers, service levels
|
|
45
|
-
- **Customers**: Customer profiles, segments
|
|
46
|
-
- **Categories**: Product taxonomies, merchandising hierarchies
|
|
47
|
-
- **Pricing**: Price lists, promotional rules
|
|
48
|
-
|
|
49
|
-
**Why This Pattern?**
|
|
50
|
-
|
|
51
|
-
This guide provides a **generic, configuration-driven framework** that works for ANY master data entity. Instead of writing custom code for each entity type, you configure JSON mappings and reuse the same pipeline.
|
|
52
|
-
|
|
53
|
-
**Key Benefits:**
|
|
54
|
-
|
|
55
|
-
- ✅ **One Pattern for All Entities**: Same code works for locations, products, controls, etc.
|
|
56
|
-
- ✅ **Configuration-Driven**: No code changes needed for new entity types
|
|
57
|
-
- ✅ **Multiple Source Formats**: CSV, JSON, XML support out-of-the-box
|
|
58
|
-
- ✅ **Multiple Load Strategies**: GraphQL mutations or Event API
|
|
59
|
-
- ✅ **Automatic Deduplication**: State management prevents duplicate loads
|
|
60
|
-
- ✅ **Production-Ready Error Handling**: Comprehensive logging and retry logic
|
|
61
|
-
|
|
62
|
-
---
|
|
63
|
-
|
|
64
|
-
## What You'll Build
|
|
65
|
-
|
|
66
|
-
A **reusable ETL framework** with these capabilities:
|
|
67
|
-
|
|
68
|
-
1. **Extract**: Read master data from S3/SFTP in CSV, JSON, or XML format
|
|
69
|
-
2. **Parse**: Convert file format to JavaScript objects
|
|
70
|
-
3. **Transform**: Map source fields to Fluent schema using field mappings
|
|
71
|
-
4. **Load**: Submit to Fluent via GraphQL mutations or Event API
|
|
72
|
-
5. **Track**: Prevent duplicate processing using state management
|
|
73
|
-
|
|
74
|
-
**Pipeline Flow:**
|
|
75
|
-
|
|
76
|
-
```
|
|
77
|
-
Source File (S3/SFTP)
|
|
78
|
-
↓
|
|
79
|
-
Extract (DataSource)
|
|
80
|
-
↓
|
|
81
|
-
Parse (CSVParserService/JSONParser/XMLParser)
|
|
82
|
-
↓
|
|
83
|
-
Transform (UniversalMapper)
|
|
84
|
-
↓
|
|
85
|
-
Load (GraphQL Mutation or Event API)
|
|
86
|
-
↓
|
|
87
|
-
Track (StateService)
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
**Configuration-Driven Design:**
|
|
91
|
-
|
|
92
|
-
Instead of hardcoded logic:
|
|
93
|
-
|
|
94
|
-
```typescript
|
|
95
|
-
// ❌ WRONG: Hardcoded for each entity type
|
|
96
|
-
if (entityType === 'location') {
|
|
97
|
-
// Custom location loading logic
|
|
98
|
-
} else if (entityType === 'product') {
|
|
99
|
-
// Custom product loading logic
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
You use JSON configuration:
|
|
104
|
-
|
|
105
|
-
```typescript
|
|
106
|
-
// ✅ CORRECT: Generic pipeline with config
|
|
107
|
-
const config = loadConfig('location-mapping.json');
|
|
108
|
-
await etlPipeline.run(sourceFile, config);
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
> Tips:
|
|
112
|
-
>
|
|
113
|
-
> - For identifiers that may look numeric (SKU/GTIN/UPC), add `resolver: "sdk.toString"` in mappings to force string output.
|
|
114
|
-
> - When parsing XML sources where leading zeros matter, configure the XML parser with `parseNumbers: false` to prevent numeric coercion.
|
|
115
|
-
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
## SDK Methods Used
|
|
119
|
-
|
|
120
|
-
| Method | Purpose | Pattern |
|
|
121
|
-
| ---------------------------------------------------------- | -------------------------------------- | --------------- |
|
|
122
|
-
| `S3DataSource.downloadFile()` | Download master data file from S3 | Source |
|
|
123
|
-
| `SftpDataSource.downloadFile()` | Download master data file from SFTP | Source |
|
|
124
|
-
| `CSVParserService.parse()` | Parse CSV master data | Parse |
|
|
125
|
-
| `JSONParserService.parse()` | Parse JSON master data | Parse |
|
|
126
|
-
| `XMLParserService.parse()` | Parse XML master data | Parse |
|
|
127
|
-
| `UniversalMapper.map()` | Transform source data to Fluent schema | Transform |
|
|
128
|
-
| `GraphQLMutationMapper.map()` | Generate GraphQL mutations from data | Load (Option 1) |
|
|
129
|
-
| `FluentClient.graphql()` | Execute GraphQL mutations | Load (Option 1) |
|
|
130
|
-
| `FluentClient.sendEvent()` | Send events to Event API | Load (Option 2) |
|
|
131
|
-
| `StateService.markFileProcessed(kv, fileName, workflowId)` | Prevent duplicate processing | Track |
|
|
132
|
-
|
|
133
|
-
---
|
|
134
|
-
|
|
135
|
-
## The Generic Pattern
|
|
136
|
-
|
|
137
|
-
### Architecture Overview
|
|
138
|
-
|
|
139
|
-
The master data ETL pattern follows a **four-phase pipeline** that adapts to any entity type through configuration:
|
|
140
|
-
|
|
141
|
-
```
|
|
142
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
143
|
-
│ PHASE 1: EXTRACT │
|
|
144
|
-
│ - List files from S3/SFTP │
|
|
145
|
-
│ - Filter by pattern (*.csv, locations_*.json, etc.) │
|
|
146
|
-
│ - Download file content │
|
|
147
|
-
│ - Skip already-processed files (state check) │
|
|
148
|
-
└──────────────────────────────────────────────────────────────┘
|
|
149
|
-
↓
|
|
150
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
151
|
-
│ PHASE 2: PARSE │
|
|
152
|
-
│ - Auto-detect format (CSV, JSON, XML) │
|
|
153
|
-
│ - Parse content to JavaScript objects │
|
|
154
|
-
│ - Validate structure (required fields, types) │
|
|
155
|
-
│ - Handle parsing errors gracefully │
|
|
156
|
-
└──────────────────────────────────────────────────────────────┘
|
|
157
|
-
↓
|
|
158
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
159
|
-
│ PHASE 3: TRANSFORM │
|
|
160
|
-
│ - Apply field mappings (source to Fluent schema) │
|
|
161
|
-
│ - Execute resolvers (transformations, calculations) │
|
|
162
|
-
│ - Validate required fields │
|
|
163
|
-
│ - Enrich with defaults/constants │
|
|
164
|
-
└──────────────────────────────────────────────────────────────┘
|
|
165
|
-
↓
|
|
166
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
167
|
-
│ PHASE 4: LOAD │
|
|
168
|
-
│ - Choose strategy (GraphQL Mutation vs Event API) │
|
|
169
|
-
│ - Batch if needed (large datasets) │
|
|
170
|
-
│ - Execute load operation │
|
|
171
|
-
│ - Handle errors and retries │
|
|
172
|
-
│ - Mark file as processed (state update) │
|
|
173
|
-
└──────────────────────────────────────────────────────────────┘
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
### Configuration-Driven Design
|
|
177
|
-
|
|
178
|
-
**Core Principle**: All entity-specific logic lives in JSON configuration files, not in code.
|
|
179
|
-
|
|
180
|
-
**Generic Configuration Structure:**
|
|
181
|
-
|
|
182
|
-
```json
|
|
183
|
-
{
|
|
184
|
-
"entityType": "location", // What you're loading
|
|
185
|
-
"sourceConfig": {
|
|
186
|
-
// Where to get data
|
|
187
|
-
"type": "S3_CSV",
|
|
188
|
-
"bucket": "master-data",
|
|
189
|
-
"prefix": "locations/",
|
|
190
|
-
"filePattern": "*.csv"
|
|
191
|
-
},
|
|
192
|
-
"parseConfig": {
|
|
193
|
-
// How to parse data
|
|
194
|
-
"format": "csv",
|
|
195
|
-
"delimiter": ",",
|
|
196
|
-
"headers": true
|
|
197
|
-
},
|
|
198
|
-
"mappingConfig": {
|
|
199
|
-
// How to transform data
|
|
200
|
-
"version": "1.0",
|
|
201
|
-
"fields": {
|
|
202
|
-
"ref": { "source": "location_id", "required": true },
|
|
203
|
-
"name": { "source": "location_name" },
|
|
204
|
-
"status": { "value": "ACTIVE" }
|
|
205
|
-
}
|
|
206
|
-
},
|
|
207
|
-
"loadConfig": {
|
|
208
|
-
// How to load into Fluent
|
|
209
|
-
"strategy": "graphql",
|
|
210
|
-
"mutation": "createLocation",
|
|
211
|
-
"batchSize": 100
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
**Reusability**: Change `entityType` to "product", update field mappings → same code loads products!
|
|
217
|
-
|
|
218
|
-
### Works for ANY Entity Type
|
|
219
|
-
|
|
220
|
-
This pattern is entity-agnostic because:
|
|
221
|
-
|
|
222
|
-
1. **Generic Source Reading**: S3DataSource/SftpDataSource work with any file
|
|
223
|
-
2. **Format-Agnostic Parsing**: Parsers handle CSV/JSON/XML regardless of entity type
|
|
224
|
-
3. **Flexible Mapping**: UniversalMapper adapts to any source→target schema
|
|
225
|
-
4. **Mutation Generation**: GraphQLMutationMapper works with any mutation
|
|
226
|
-
5. **State Management**: StateService tracks processing for any entity
|
|
227
|
-
|
|
228
|
-
**Example Entity Types:**
|
|
229
|
-
|
|
230
|
-
| Entity | Source Format | Mutation | Complexity |
|
|
231
|
-
| ---------- | ------------- | ---------------- | ------------------- |
|
|
232
|
-
| Locations | CSV | `createLocation` | Simple |
|
|
233
|
-
| Products | JSON | `createProduct` | Medium (variants) |
|
|
234
|
-
| Controls | JSON | `createControl` | Simple |
|
|
235
|
-
| Carriers | XML | `createCarrier` | Simple |
|
|
236
|
-
| Categories | JSON | `createCategory` | Medium (hierarchy) |
|
|
237
|
-
| Customers | CSV | `createCustomer` | Medium (attributes) |
|
|
238
|
-
|
|
239
|
-
All use the **same pipeline** with different **configurations**.
|
|
240
|
-
|
|
241
|
-
---
|
|
242
|
-
|
|
243
|
-
## Example 1: Location Master Data
|
|
244
|
-
|
|
245
|
-
### Overview
|
|
246
|
-
|
|
247
|
-
Load store/warehouse locations from CSV files into Fluent Commerce.
|
|
248
|
-
|
|
249
|
-
**Business Context:**
|
|
250
|
-
|
|
251
|
-
- Retail locations change infrequently (openings, closings, updates)
|
|
252
|
-
- Source: Retail management system exports CSV daily
|
|
253
|
-
- Destination: Fluent Location entities
|
|
254
|
-
- Frequency: Daily batch, event-driven on new file
|
|
255
|
-
|
|
256
|
-
### Source Data Formats
|
|
257
|
-
|
|
258
|
-
**CSV Format:**
|
|
259
|
-
|
|
260
|
-
```csv
|
|
261
|
-
location_id,location_name,type,address_line1,city,state,zip,country,latitude,longitude,status
|
|
262
|
-
LOC001,Downtown Store,STORE,123 Main St,New York,NY,10001,US,40.7128,-74.0060,ACTIVE
|
|
263
|
-
LOC002,Warehouse East,WAREHOUSE,456 Industrial Rd,Newark,NJ,07102,US,40.7357,-74.1724,ACTIVE
|
|
264
|
-
LOC003,Pop-Up Shop,STORE,789 Fashion Ave,New York,NY,10018,US,40.7549,-73.9840,INACTIVE
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
**JSON Format:**
|
|
268
|
-
|
|
269
|
-
```json
|
|
270
|
-
{
|
|
271
|
-
"locations": [
|
|
272
|
-
{
|
|
273
|
-
"locationId": "LOC001",
|
|
274
|
-
"name": "Downtown Store",
|
|
275
|
-
"type": "STORE",
|
|
276
|
-
"address": {
|
|
277
|
-
"street": "123 Main St",
|
|
278
|
-
"city": "New York",
|
|
279
|
-
"state": "NY",
|
|
280
|
-
"zip": "10001",
|
|
281
|
-
"country": "US"
|
|
282
|
-
},
|
|
283
|
-
"coordinates": {
|
|
284
|
-
"lat": 40.7128,
|
|
285
|
-
"lng": -74.006
|
|
286
|
-
},
|
|
287
|
-
"status": "ACTIVE"
|
|
288
|
-
}
|
|
289
|
-
]
|
|
290
|
-
}
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
**XML Format:**
|
|
294
|
-
|
|
295
|
-
```xml
|
|
296
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
297
|
-
<LocationFeed>
|
|
298
|
-
<Location>
|
|
299
|
-
<ID>LOC001</ID>
|
|
300
|
-
<Name>Downtown Store</Name>
|
|
301
|
-
<Type>STORE</Type>
|
|
302
|
-
<Address>
|
|
303
|
-
<Line1>123 Main St</Line1>
|
|
304
|
-
<City>New York</City>
|
|
305
|
-
<State>NY</State>
|
|
306
|
-
<Zip>10001</Zip>
|
|
307
|
-
<Country>US</Country>
|
|
308
|
-
</Address>
|
|
309
|
-
<Latitude>40.7128</Latitude>
|
|
310
|
-
<Longitude>-74.0060</Longitude>
|
|
311
|
-
<Status>ACTIVE</Status>
|
|
312
|
-
</Location>
|
|
313
|
-
</LocationFeed>
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
### Field Mapping Configuration
|
|
317
|
-
|
|
318
|
-
**`config/location-mapping.json`:**
|
|
319
|
-
|
|
320
|
-
```json
|
|
321
|
-
{
|
|
322
|
-
"version": "1.0",
|
|
323
|
-
"description": "Map external location data to Fluent Location schema",
|
|
324
|
-
"fields": {
|
|
325
|
-
"ref": {
|
|
326
|
-
"source": "location_id",
|
|
327
|
-
"required": true,
|
|
328
|
-
"resolver": "sdk.trim"
|
|
329
|
-
},
|
|
330
|
-
"type": {
|
|
331
|
-
"source": "type",
|
|
332
|
-
"required": true,
|
|
333
|
-
"resolver": "sdk.uppercase"
|
|
334
|
-
},
|
|
335
|
-
"name": {
|
|
336
|
-
"source": "location_name",
|
|
337
|
-
"required": true
|
|
338
|
-
},
|
|
339
|
-
"status": {
|
|
340
|
-
"source": "status",
|
|
341
|
-
"defaultValue": "ACTIVE",
|
|
342
|
-
"resolver": "sdk.uppercase"
|
|
343
|
-
},
|
|
344
|
-
"primaryAddress": {
|
|
345
|
-
"fields": {
|
|
346
|
-
"street": { "source": "address_line1" },
|
|
347
|
-
"city": { "source": "city" },
|
|
348
|
-
"state": { "source": "state" },
|
|
349
|
-
"postcode": { "source": "zip" },
|
|
350
|
-
"country": { "source": "country" }
|
|
351
|
-
}
|
|
352
|
-
},
|
|
353
|
-
"coordinates": {
|
|
354
|
-
"fields": {
|
|
355
|
-
"latitude": { "source": "latitude", "resolver": "sdk.parseFloat" },
|
|
356
|
-
"longitude": { "source": "longitude", "resolver": "sdk.parseFloat" }
|
|
357
|
-
}
|
|
358
|
-
},
|
|
359
|
-
"retailerId": {
|
|
360
|
-
"value": "${RETAILER_ID}",
|
|
361
|
-
"required": true
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
```
|
|
366
|
-
|
|
367
|
-
**Key Features:**
|
|
368
|
-
|
|
369
|
-
- ✅ Required field validation (`ref`, `type`, `name`)
|
|
370
|
-
- ✅ Default values (`status` defaults to "ACTIVE")
|
|
371
|
-
- ✅ Built-in resolvers (`sdk.trim`, `sdk.uppercase`, `sdk.parseFloat`)
|
|
372
|
-
- ✅ Nested object mapping (`primaryAddress`, `coordinates`)
|
|
373
|
-
- ✅ Environment variable support (`${RETAILER_ID}`)
|
|
374
|
-
|
|
375
|
-
### GraphQL Mutation Approach
|
|
376
|
-
|
|
377
|
-
**Target Mutation:**
|
|
378
|
-
|
|
379
|
-
```graphql
|
|
380
|
-
mutation CreateLocation($input: CreateLocationInput!) {
|
|
381
|
-
createLocation(input: $input) {
|
|
382
|
-
id
|
|
383
|
-
ref
|
|
384
|
-
name
|
|
385
|
-
status
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
**Mutation Variables (after transformation):**
|
|
391
|
-
|
|
392
|
-
```json
|
|
393
|
-
{
|
|
394
|
-
"input": {
|
|
395
|
-
"ref": "LOC001",
|
|
396
|
-
"type": "STORE",
|
|
397
|
-
"name": "Downtown Store",
|
|
398
|
-
"status": "ACTIVE",
|
|
399
|
-
"primaryAddress": {
|
|
400
|
-
"street": "123 Main St",
|
|
401
|
-
"city": "New York",
|
|
402
|
-
"state": "NY",
|
|
403
|
-
"postcode": "10001",
|
|
404
|
-
"country": "US"
|
|
405
|
-
},
|
|
406
|
-
"coordinates": {
|
|
407
|
-
"latitude": 40.7128,
|
|
408
|
-
"longitude": -74.006
|
|
409
|
-
},
|
|
410
|
-
"retailerId": "my-retailer"
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
### Complete Working Code
|
|
416
|
-
|
|
417
|
-
**`location-etl.ts`:**
|
|
418
|
-
|
|
419
|
-
```typescript
|
|
420
|
-
// FC Connect SDK+
|
|
421
|
-
// Install: npm install @fluentcommerce/fc-connect-sdk@latest
|
|
422
|
-
// Docs: https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk
|
|
423
|
-
// GitHub: https://github.com/fluentcommerce/fc-connect-sdk
|
|
424
|
-
|
|
425
|
-
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
426
|
-
import { S3DataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
427
|
-
import { CSVParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
428
|
-
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
429
|
-
import { StateService, VersoriKVAdapter } from '@fluentcommerce/fc-connect-sdk';
|
|
430
|
-
// Access openKv from context: const { openKv } = ctx;
|
|
431
|
-
import * as fs from 'fs';
|
|
432
|
-
|
|
433
|
-
// Initialize state service (prevents duplicate processing)
|
|
434
|
-
// ✅ CORRECT: Access openKv from Versori context
|
|
435
|
-
export async function masterDataETL(ctx: any, configPath: string) {
|
|
436
|
-
// Load configuration
|
|
437
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
438
|
-
|
|
439
|
-
// Initialize SDK components
|
|
440
|
-
const logger = console; // Replace with proper logger
|
|
441
|
-
const { openKv } = ctx;
|
|
442
|
-
const fluentClient = await createClient({
|
|
443
|
-
config: {
|
|
444
|
-
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
445
|
-
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
446
|
-
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
447
|
-
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
448
|
-
},
|
|
449
|
-
logger,
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
// Initialize data source (S3 in this example)
|
|
453
|
-
const s3DataSource = new S3DataSource(
|
|
454
|
-
{
|
|
455
|
-
type: 'S3_CSV',
|
|
456
|
-
s3Config: {
|
|
457
|
-
bucket: config.sourceConfig.bucket,
|
|
458
|
-
region: process.env.AWS_REGION!,
|
|
459
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
460
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
461
|
-
},
|
|
462
|
-
},
|
|
463
|
-
logger
|
|
464
|
-
);
|
|
465
|
-
|
|
466
|
-
// Initialize state service (prevents duplicate processing)
|
|
467
|
-
const kvAdapter = new VersoriKVAdapter(openKv(':project:'));
|
|
468
|
-
const stateService = new StateService(logger);
|
|
469
|
-
|
|
470
|
-
// Initialize parser based on format
|
|
471
|
-
const parser = config.parseConfig.format === 'csv' ? new CSVParserService() : null; // Add JSON/XML parsers as needed
|
|
472
|
-
|
|
473
|
-
// Initialize mapper
|
|
474
|
-
const mapper = new UniversalMapper(config.mappingConfig, { logger, fluentClient });
|
|
475
|
-
|
|
476
|
-
logger.info(`Starting ${config.entityType} ETL process`);
|
|
477
|
-
|
|
478
|
-
try {
|
|
479
|
-
// PHASE 1: EXTRACT - List files from source
|
|
480
|
-
const files = await s3DataSource.listFiles({
|
|
481
|
-
prefix: config.sourceConfig.prefix,
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
logger.info(`Found ${files.length} files to process`);
|
|
485
|
-
|
|
486
|
-
// Process each file
|
|
487
|
-
for (const file of files) {
|
|
488
|
-
const fileKey = `${config.entityType}:${file.name}`;
|
|
489
|
-
|
|
490
|
-
// Check if already processed
|
|
491
|
-
const alreadyProcessed = await stateService.isFileProcessed(kvAdapter, fileKey);
|
|
492
|
-
if (alreadyProcessed) {
|
|
493
|
-
logger.info(`Skipping already processed file: ${file.name}`);
|
|
494
|
-
continue;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
logger.info(`Processing file: ${file.name}`);
|
|
498
|
-
|
|
499
|
-
try {
|
|
500
|
-
// PHASE 2: PARSE - Download and parse file
|
|
501
|
-
const fileContent = await s3DataSource.downloadFile(file.path);
|
|
502
|
-
const records = await parser!.parse(fileContent as string);
|
|
503
|
-
|
|
504
|
-
logger.info(`Parsed ${records.length} records from ${file.name}`);
|
|
505
|
-
|
|
506
|
-
// PHASE 3: TRANSFORM - Map each record
|
|
507
|
-
const transformedRecords = [];
|
|
508
|
-
for (const record of records) {
|
|
509
|
-
const result = await mapper.map(record);
|
|
510
|
-
if (result.success) {
|
|
511
|
-
transformedRecords.push(result.data);
|
|
512
|
-
} else {
|
|
513
|
-
logger.error(`Mapping failed for record:`, {
|
|
514
|
-
record,
|
|
515
|
-
errors: result.errors,
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
logger.info(`Transformed ${transformedRecords.length} records successfully`);
|
|
521
|
-
|
|
522
|
-
// PHASE 4: LOAD - Submit to Fluent Commerce
|
|
523
|
-
if (config.loadConfig.strategy === 'graphql') {
|
|
524
|
-
await loadViaGraphQL(fluentClient, transformedRecords, config.loadConfig, logger);
|
|
525
|
-
} else if (config.loadConfig.strategy === 'event') {
|
|
526
|
-
await loadViaEventAPI(fluentClient, transformedRecords, config.loadConfig, logger);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Update sync state with processed file metadata
|
|
530
|
-
await stateService.updateSyncState(
|
|
531
|
-
kvAdapter,
|
|
532
|
-
[
|
|
533
|
-
{
|
|
534
|
-
fileName: file.name,
|
|
535
|
-
lastModified: new Date().toISOString(),
|
|
536
|
-
recordCount: transformedRecords.length,
|
|
537
|
-
},
|
|
538
|
-
],
|
|
539
|
-
'master-data-etl'
|
|
540
|
-
);
|
|
541
|
-
|
|
542
|
-
logger.info(`Successfully processed file: ${file.name}`);
|
|
543
|
-
} catch (error) {
|
|
544
|
-
logger.error(`Failed to process file: ${file.name}`, error);
|
|
545
|
-
// Continue to next file instead of failing entire batch
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
logger.info(`${config.entityType} ETL process completed`);
|
|
551
|
-
} catch (error) {
|
|
552
|
-
logger.error(`${config.entityType} ETL process failed`, error);
|
|
553
|
-
throw error;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Load data via GraphQL mutations
|
|
559
|
-
*/
|
|
560
|
-
async function loadViaGraphQL(client: any, records: any[], loadConfig: any, logger: any) {
|
|
561
|
-
const batchSize = loadConfig.batchSize || 100;
|
|
562
|
-
const mutation = loadConfig.mutation;
|
|
563
|
-
|
|
564
|
-
logger.info(`Loading ${records.length} records via GraphQL mutation: ${mutation}`);
|
|
565
|
-
|
|
566
|
-
// Process in batches
|
|
567
|
-
for (let i = 0; i < records.length; i += batchSize) {
|
|
568
|
-
const batch = records.slice(i, i + batchSize);
|
|
569
|
-
logger.info(`Processing batch ${i / batchSize + 1} (${batch.length} records)`);
|
|
570
|
-
|
|
571
|
-
// Execute mutations for batch
|
|
572
|
-
for (const record of batch) {
|
|
573
|
-
const query = `
|
|
574
|
-
mutation ${mutation}($input: ${capitalize(mutation)}Input!) {
|
|
575
|
-
${mutation}(input: $input) {
|
|
576
|
-
id
|
|
577
|
-
ref
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
`;
|
|
581
|
-
|
|
582
|
-
try {
|
|
583
|
-
const result = await client.graphql({
|
|
584
|
-
query,
|
|
585
|
-
variables: { input: record },
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
logger.debug(`Created ${mutation}:`, result.data[mutation]);
|
|
589
|
-
} catch (error) {
|
|
590
|
-
logger.error(`Failed to create ${mutation}:`, { record, error });
|
|
591
|
-
// Continue to next record instead of failing entire batch
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
logger.info(`GraphQL load completed`);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* Load data via Event API
|
|
601
|
-
*/
|
|
602
|
-
async function loadViaEventAPI(client: any, records: any[], loadConfig: any, logger: any) {
|
|
603
|
-
const eventName = loadConfig.eventName;
|
|
604
|
-
|
|
605
|
-
logger.info(`Loading ${records.length} records via Event API: ${eventName}`);
|
|
606
|
-
|
|
607
|
-
for (const record of records) {
|
|
608
|
-
try {
|
|
609
|
-
await client.sendEvent({
|
|
610
|
-
name: eventName,
|
|
611
|
-
entityRef: record.ref,
|
|
612
|
-
entityType: loadConfig.entityType,
|
|
613
|
-
retailerId: record.retailerId,
|
|
614
|
-
attributes: record,
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
logger.debug(`Sent event ${eventName} for ${record.ref}`);
|
|
618
|
-
} catch (error) {
|
|
619
|
-
logger.error(`Failed to send event for ${record.ref}:`, error);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
logger.info(`Event API load completed`);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// Helper function
|
|
627
|
-
function capitalize(str: string): string {
|
|
628
|
-
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// Example usage
|
|
632
|
-
if (require.main === module) {
|
|
633
|
-
masterDataETL('config/location-etl-config.json')
|
|
634
|
-
.then(() => console.log('ETL completed'))
|
|
635
|
-
.catch(err => {
|
|
636
|
-
console.error('ETL failed:', err);
|
|
637
|
-
process.exit(1);
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
```
|
|
641
|
-
|
|
642
|
-
**Configuration File (`config/location-etl-config.json`):**
|
|
643
|
-
|
|
644
|
-
```json
|
|
645
|
-
{
|
|
646
|
-
"entityType": "location",
|
|
647
|
-
"sourceConfig": {
|
|
648
|
-
"type": "S3_CSV",
|
|
649
|
-
"bucket": "master-data",
|
|
650
|
-
"prefix": "locations/",
|
|
651
|
-
"filePattern": "*.csv"
|
|
652
|
-
},
|
|
653
|
-
"parseConfig": {
|
|
654
|
-
"format": "csv",
|
|
655
|
-
"delimiter": ",",
|
|
656
|
-
"headers": true
|
|
657
|
-
},
|
|
658
|
-
"mappingConfig": {
|
|
659
|
-
"version": "1.0",
|
|
660
|
-
"fields": {
|
|
661
|
-
"ref": { "source": "location_id", "required": true },
|
|
662
|
-
"name": { "source": "location_name", "required": true },
|
|
663
|
-
"type": { "source": "type", "required": true },
|
|
664
|
-
"status": { "source": "status", "defaultValue": "ACTIVE" },
|
|
665
|
-
"primaryAddress": {
|
|
666
|
-
"fields": {
|
|
667
|
-
"street": { "source": "address_line1" },
|
|
668
|
-
"city": { "source": "city" },
|
|
669
|
-
"state": { "source": "state" },
|
|
670
|
-
"postcode": { "source": "zip" },
|
|
671
|
-
"country": { "source": "country" }
|
|
672
|
-
}
|
|
673
|
-
},
|
|
674
|
-
"retailerId": { "value": "${RETAILER_ID}" }
|
|
675
|
-
}
|
|
676
|
-
},
|
|
677
|
-
"loadConfig": {
|
|
678
|
-
"strategy": "graphql",
|
|
679
|
-
"mutation": "createLocation",
|
|
680
|
-
"batchSize": 100
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
```
|
|
684
|
-
|
|
685
|
-
**Key Design Decisions:**
|
|
686
|
-
|
|
687
|
-
1. **Configuration-Driven**: All entity logic in JSON config, not code
|
|
688
|
-
2. **Error Handling**: Continue processing on individual record failures
|
|
689
|
-
3. **State Management**: Prevent duplicate processing of files
|
|
690
|
-
4. **Batching**: Process large datasets in manageable chunks
|
|
691
|
-
5. **Logging**: Comprehensive logging for debugging and monitoring
|
|
692
|
-
|
|
693
|
-
---
|
|
694
|
-
|
|
695
|
-
## Example 2: Product Catalog
|
|
696
|
-
|
|
697
|
-
### Overview
|
|
698
|
-
|
|
699
|
-
Load product catalog with variants (parent-child relationships) from JSON files.
|
|
700
|
-
|
|
701
|
-
**Business Context:**
|
|
702
|
-
|
|
703
|
-
- Product data includes parent products and child variants (size, color, etc.)
|
|
704
|
-
- Source: Product Information Management (PIM) system exports JSON
|
|
705
|
-
- Destination: Fluent Product entities
|
|
706
|
-
- Complexity: Parent-child relationships require nested mapping
|
|
707
|
-
|
|
708
|
-
### Source Data Formats
|
|
709
|
-
|
|
710
|
-
**JSON Format (with variants):**
|
|
711
|
-
|
|
712
|
-
```json
|
|
713
|
-
{
|
|
714
|
-
"products": [
|
|
715
|
-
{
|
|
716
|
-
"productId": "PROD001",
|
|
717
|
-
"name": "Classic T-Shirt",
|
|
718
|
-
"description": "Premium cotton t-shirt",
|
|
719
|
-
"brand": "MyBrand",
|
|
720
|
-
"category": "Apparel",
|
|
721
|
-
"variants": [
|
|
722
|
-
{
|
|
723
|
-
"sku": "PROD001-S-RED",
|
|
724
|
-
"size": "S",
|
|
725
|
-
"color": "Red",
|
|
726
|
-
"price": 29.99,
|
|
727
|
-
"barcode": "123456789001"
|
|
728
|
-
},
|
|
729
|
-
{
|
|
730
|
-
"sku": "PROD001-M-RED",
|
|
731
|
-
"size": "M",
|
|
732
|
-
"color": "Red",
|
|
733
|
-
"price": 29.99,
|
|
734
|
-
"barcode": "123456789002"
|
|
735
|
-
}
|
|
736
|
-
]
|
|
737
|
-
}
|
|
738
|
-
]
|
|
739
|
-
}
|
|
740
|
-
```
|
|
741
|
-
|
|
742
|
-
**CSV Format (flattened variants):**
|
|
743
|
-
|
|
744
|
-
```csv
|
|
745
|
-
product_id,product_name,sku,size,color,price,barcode
|
|
746
|
-
PROD001,Classic T-Shirt,PROD001-S-RED,S,Red,29.99,123456789001
|
|
747
|
-
PROD001,Classic T-Shirt,PROD001-M-RED,M,Red,29.99,123456789002
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
### Field Mapping Configuration
|
|
751
|
-
|
|
752
|
-
**`config/product-mapping.json`:**
|
|
753
|
-
|
|
754
|
-
```json
|
|
755
|
-
{
|
|
756
|
-
"version": "1.0",
|
|
757
|
-
"description": "Map PIM product data to Fluent Product schema",
|
|
758
|
-
"fields": {
|
|
759
|
-
"ref": {
|
|
760
|
-
"source": "productId",
|
|
761
|
-
"required": true
|
|
762
|
-
},
|
|
763
|
-
"type": {
|
|
764
|
-
"value": "STANDARD",
|
|
765
|
-
"required": true
|
|
766
|
-
},
|
|
767
|
-
"name": {
|
|
768
|
-
"source": "name",
|
|
769
|
-
"required": true
|
|
770
|
-
},
|
|
771
|
-
"summary": {
|
|
772
|
-
"source": "description"
|
|
773
|
-
},
|
|
774
|
-
"gtin": {
|
|
775
|
-
"source": "barcode"
|
|
776
|
-
},
|
|
777
|
-
"status": {
|
|
778
|
-
"value": "ACTIVE"
|
|
779
|
-
},
|
|
780
|
-
"retailerId": {
|
|
781
|
-
"value": "${RETAILER_ID}"
|
|
782
|
-
},
|
|
783
|
-
"attributes": {
|
|
784
|
-
"fields": {
|
|
785
|
-
"brand": { "source": "brand" },
|
|
786
|
-
"category": { "source": "category" }
|
|
787
|
-
}
|
|
788
|
-
},
|
|
789
|
-
"variants": {
|
|
790
|
-
"source": "variants",
|
|
791
|
-
"isArray": true,
|
|
792
|
-
"fields": {
|
|
793
|
-
"ref": { "source": "$.sku", "required": true },
|
|
794
|
-
"attributes": {
|
|
795
|
-
"fields": {
|
|
796
|
-
"size": { "source": "$.size" },
|
|
797
|
-
"color": { "source": "$.color" }
|
|
798
|
-
}
|
|
799
|
-
},
|
|
800
|
-
"prices": {
|
|
801
|
-
"fields": {
|
|
802
|
-
"currency": { "value": "USD" },
|
|
803
|
-
"value": { "source": "$.price", "resolver": "sdk.parseFloat" }
|
|
804
|
-
}
|
|
805
|
-
},
|
|
806
|
-
"gtin": { "source": "$.barcode" }
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
```
|
|
812
|
-
|
|
813
|
-
**Key Features:**
|
|
814
|
-
|
|
815
|
-
- ✅ Parent-child relationships (`variants[]` array mapping)
|
|
816
|
-
- ✅ Relative paths within arrays (`$.sku` references current variant)
|
|
817
|
-
- ✅ Nested objects (`attributes`, `prices`)
|
|
818
|
-
- ✅ Static values (`type: "STANDARD"`, `currency: "USD"`)
|
|
819
|
-
|
|
820
|
-
### Complete Working Code
|
|
821
|
-
|
|
822
|
-
**`product-etl.ts`:**
|
|
823
|
-
|
|
824
|
-
```typescript
|
|
825
|
-
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
826
|
-
import { S3DataSource, JSONParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
827
|
-
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
828
|
-
import { StateService, VersoriKVAdapter } from '@fluentcommerce/fc-connect-sdk';
|
|
829
|
-
// Access openKv from context: const { openKv } = ctx;
|
|
830
|
-
import * as fs from 'fs';
|
|
831
|
-
|
|
832
|
-
/**
|
|
833
|
-
* Product Catalog ETL with Parent-Child Relationships
|
|
834
|
-
*/
|
|
835
|
-
export async function productCatalogETL(ctx: any) {
|
|
836
|
-
const logger = console;
|
|
837
|
-
const { openKv } = ctx;
|
|
838
|
-
const fluentClient = await createClient({
|
|
839
|
-
config: {
|
|
840
|
-
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
841
|
-
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
842
|
-
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
843
|
-
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
844
|
-
},
|
|
845
|
-
logger,
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
// Initialize components
|
|
849
|
-
const s3DataSource = new S3DataSource(
|
|
850
|
-
{
|
|
851
|
-
type: 'S3_CSV',
|
|
852
|
-
s3Config: {
|
|
853
|
-
bucket: 'product-catalog',
|
|
854
|
-
region: process.env.AWS_REGION!,
|
|
855
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
856
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
857
|
-
},
|
|
858
|
-
},
|
|
859
|
-
logger
|
|
860
|
-
);
|
|
861
|
-
|
|
862
|
-
const kvAdapter = new VersoriKVAdapter(openKv(':project:'));
|
|
863
|
-
const stateService = new StateService(logger);
|
|
864
|
-
|
|
865
|
-
const jsonParser = new JSONParserService();
|
|
866
|
-
|
|
867
|
-
// Load mapping configuration
|
|
868
|
-
const mappingConfig = JSON.parse(fs.readFileSync('config/product-mapping.json', 'utf-8'));
|
|
869
|
-
|
|
870
|
-
const mapper = new UniversalMapper(mappingConfig, { logger, fluentClient });
|
|
871
|
-
|
|
872
|
-
logger.info('Starting product catalog ETL');
|
|
873
|
-
|
|
874
|
-
try {
|
|
875
|
-
// List JSON files
|
|
876
|
-
const files = await s3DataSource.listFiles({ prefix: 'products/' });
|
|
877
|
-
|
|
878
|
-
for (const file of files) {
|
|
879
|
-
if (!file.name.endsWith('.json')) continue;
|
|
880
|
-
|
|
881
|
-
const fileKey = `product:${file.name}`;
|
|
882
|
-
|
|
883
|
-
// Check if processed
|
|
884
|
-
if (await stateService.isFileProcessed(kvAdapter, fileKey)) {
|
|
885
|
-
logger.info(`Skipping processed file: ${file.name}`);
|
|
886
|
-
continue;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
logger.info(`Processing file: ${file.name}`);
|
|
890
|
-
|
|
891
|
-
try {
|
|
892
|
-
// Download and parse JSON
|
|
893
|
-
const fileContent = await s3DataSource.downloadFile(file.path);
|
|
894
|
-
const data = await jsonParser.parse(fileContent as string, {
|
|
895
|
-
dataPath: 'products', // Extract products array from root
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
const products = Array.isArray(data) ? data : [data];
|
|
899
|
-
logger.info(`Parsed ${products.length} products from ${file.name}`);
|
|
900
|
-
|
|
901
|
-
// Transform and load each product
|
|
902
|
-
for (const product of products) {
|
|
903
|
-
const result = await mapper.map(product);
|
|
904
|
-
|
|
905
|
-
if (result.success) {
|
|
906
|
-
// Create parent product
|
|
907
|
-
await createProduct(fluentClient, result.data, logger);
|
|
908
|
-
|
|
909
|
-
// Create variants if present
|
|
910
|
-
if (result.data.variants && result.data.variants.length > 0) {
|
|
911
|
-
for (const variant of result.data.variants) {
|
|
912
|
-
await createVariant(fluentClient, result.data.ref, variant, logger);
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
} else {
|
|
916
|
-
logger.error('Product mapping failed:', {
|
|
917
|
-
product,
|
|
918
|
-
errors: result.errors,
|
|
919
|
-
});
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
// Mark as processed
|
|
924
|
-
await stateService.updateSyncState(
|
|
925
|
-
kvAdapter,
|
|
926
|
-
[
|
|
927
|
-
{
|
|
928
|
-
fileName: file.name,
|
|
929
|
-
lastModified: new Date().toISOString(),
|
|
930
|
-
recordCount: products.length,
|
|
931
|
-
},
|
|
932
|
-
],
|
|
933
|
-
'product-catalog-etl'
|
|
934
|
-
);
|
|
935
|
-
|
|
936
|
-
logger.info(`Successfully processed: ${file.name}`);
|
|
937
|
-
} catch (error) {
|
|
938
|
-
logger.error(`Failed to process file: ${file.name}`, error);
|
|
939
|
-
continue;
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
logger.info('Product catalog ETL completed');
|
|
944
|
-
} catch (error) {
|
|
945
|
-
logger.error('Product catalog ETL failed', error);
|
|
946
|
-
throw error;
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* Create product via GraphQL
|
|
952
|
-
*/
|
|
953
|
-
async function createProduct(client: any, productData: any, logger: any) {
|
|
954
|
-
const mutation = `
|
|
955
|
-
mutation CreateProduct($input: CreateProductInput!) {
|
|
956
|
-
createProduct(input: $input) {
|
|
957
|
-
id
|
|
958
|
-
ref
|
|
959
|
-
name
|
|
960
|
-
status
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
`;
|
|
964
|
-
|
|
965
|
-
try {
|
|
966
|
-
const result = await client.graphql({
|
|
967
|
-
query: mutation,
|
|
968
|
-
variables: { input: productData },
|
|
969
|
-
});
|
|
970
|
-
|
|
971
|
-
logger.info(`Created product: ${productData.ref}`, result.data.createProduct);
|
|
972
|
-
} catch (error) {
|
|
973
|
-
logger.error(`Failed to create product: ${productData.ref}`, error);
|
|
974
|
-
throw error;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
/**
|
|
979
|
-
* Create variant via GraphQL
|
|
980
|
-
*/
|
|
981
|
-
async function createVariant(client: any, parentRef: string, variantData: any, logger: any) {
|
|
982
|
-
const mutation = `
|
|
983
|
-
mutation CreateVariant($input: CreateVariantInput!) {
|
|
984
|
-
createVariant(input: $input) {
|
|
985
|
-
id
|
|
986
|
-
ref
|
|
987
|
-
attributes
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
`;
|
|
991
|
-
|
|
992
|
-
try {
|
|
993
|
-
const result = await client.graphql({
|
|
994
|
-
query: mutation,
|
|
995
|
-
variables: {
|
|
996
|
-
input: {
|
|
997
|
-
...variantData,
|
|
998
|
-
parentRef,
|
|
999
|
-
},
|
|
1000
|
-
},
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
|
-
logger.info(`Created variant: ${variantData.ref}`, result.data.createVariant);
|
|
1004
|
-
} catch (error) {
|
|
1005
|
-
logger.error(`Failed to create variant: ${variantData.ref}`, error);
|
|
1006
|
-
// Don't throw - continue with other variants
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// Example usage
|
|
1011
|
-
if (require.main === module) {
|
|
1012
|
-
productCatalogETL()
|
|
1013
|
-
.then(() => console.log('ETL completed'))
|
|
1014
|
-
.catch(err => {
|
|
1015
|
-
console.error('ETL failed:', err);
|
|
1016
|
-
process.exit(1);
|
|
1017
|
-
});
|
|
1018
|
-
}
|
|
1019
|
-
```
|
|
1020
|
-
|
|
1021
|
-
**Key Design Decisions:**
|
|
1022
|
-
|
|
1023
|
-
1. **Parent-Child Processing**: Create parent product first, then variants
|
|
1024
|
-
2. **Array Mapping**: `variants[]` notation handles nested arrays
|
|
1025
|
-
3. **Relative Paths**: `$.sku` resolves relative to current variant item
|
|
1026
|
-
4. **Error Isolation**: Variant creation failures don't stop parent processing
|
|
1027
|
-
|
|
1028
|
-
---
|
|
1029
|
-
|
|
1030
|
-
## Example 3: Control/Config Data
|
|
1031
|
-
|
|
1032
|
-
### Overview
|
|
1033
|
-
|
|
1034
|
-
Load business rules and configuration parameters from JSON files.
|
|
1035
|
-
|
|
1036
|
-
**Business Context:**
|
|
1037
|
-
|
|
1038
|
-
- Controls define business rules, thresholds, flags
|
|
1039
|
-
- Source: Configuration management system exports JSON
|
|
1040
|
-
- Destination: Fluent Control entities
|
|
1041
|
-
- Characteristics: Simple structure, validation logic important
|
|
1042
|
-
|
|
1043
|
-
### Source Data Format
|
|
1044
|
-
|
|
1045
|
-
**JSON Format:**
|
|
1046
|
-
|
|
1047
|
-
```json
|
|
1048
|
-
{
|
|
1049
|
-
"controls": [
|
|
1050
|
-
{
|
|
1051
|
-
"name": "ORDER_TIMEOUT_MINUTES",
|
|
1052
|
-
"value": "30",
|
|
1053
|
-
"type": "INTEGER",
|
|
1054
|
-
"context": "ORDER_MANAGEMENT",
|
|
1055
|
-
"description": "Order allocation timeout in minutes"
|
|
1056
|
-
},
|
|
1057
|
-
{
|
|
1058
|
-
"name": "ENABLE_AUTO_ALLOCATION",
|
|
1059
|
-
"value": "true",
|
|
1060
|
-
"type": "BOOLEAN",
|
|
1061
|
-
"context": "ORDER_MANAGEMENT",
|
|
1062
|
-
"description": "Enable automatic order allocation"
|
|
1063
|
-
},
|
|
1064
|
-
{
|
|
1065
|
-
"name": "DEFAULT_CARRIER",
|
|
1066
|
-
"value": "FEDEX",
|
|
1067
|
-
"type": "STRING",
|
|
1068
|
-
"context": "FULFILLMENT",
|
|
1069
|
-
"description": "Default shipping carrier"
|
|
1070
|
-
}
|
|
1071
|
-
]
|
|
1072
|
-
}
|
|
1073
|
-
```
|
|
1074
|
-
|
|
1075
|
-
### Field Mapping Configuration
|
|
1076
|
-
|
|
1077
|
-
**`config/control-mapping.json`:**
|
|
1078
|
-
|
|
1079
|
-
```json
|
|
1080
|
-
{
|
|
1081
|
-
"version": "1.0",
|
|
1082
|
-
"description": "Map configuration controls to Fluent Control schema",
|
|
1083
|
-
"fields": {
|
|
1084
|
-
"name": {
|
|
1085
|
-
"source": "name",
|
|
1086
|
-
"required": true,
|
|
1087
|
-
"resolver": "sdk.uppercase"
|
|
1088
|
-
},
|
|
1089
|
-
"value": {
|
|
1090
|
-
"source": "value",
|
|
1091
|
-
"required": true,
|
|
1092
|
-
"resolver": "custom.validateControlValue"
|
|
1093
|
-
},
|
|
1094
|
-
"type": {
|
|
1095
|
-
"source": "type",
|
|
1096
|
-
"required": true,
|
|
1097
|
-
"resolver": "sdk.uppercase"
|
|
1098
|
-
},
|
|
1099
|
-
"context": {
|
|
1100
|
-
"source": "context",
|
|
1101
|
-
"defaultValue": "GLOBAL"
|
|
1102
|
-
},
|
|
1103
|
-
"description": {
|
|
1104
|
-
"source": "description"
|
|
1105
|
-
},
|
|
1106
|
-
"status": {
|
|
1107
|
-
"value": "ACTIVE"
|
|
1108
|
-
},
|
|
1109
|
-
"retailerId": {
|
|
1110
|
-
"value": "${RETAILER_ID}"
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
```
|
|
1115
|
-
|
|
1116
|
-
### Validation Logic
|
|
1117
|
-
|
|
1118
|
-
**Custom resolver for control value validation:**
|
|
1119
|
-
|
|
1120
|
-
```typescript
|
|
1121
|
-
import { FieldResolverFunction } from '@fluentcommerce/fc-connect-sdk';
|
|
1122
|
-
|
|
1123
|
-
/**
|
|
1124
|
-
* Validate control value based on type
|
|
1125
|
-
*/
|
|
1126
|
-
export const validateControlValue: FieldResolverFunction = (
|
|
1127
|
-
value: any,
|
|
1128
|
-
sourceData: any,
|
|
1129
|
-
config: any,
|
|
1130
|
-
helpers: any
|
|
1131
|
-
) => {
|
|
1132
|
-
const type = sourceData.type;
|
|
1133
|
-
|
|
1134
|
-
switch (type) {
|
|
1135
|
-
case 'INTEGER':
|
|
1136
|
-
const intValue = helpers.parseIntSafe(value, null);
|
|
1137
|
-
if (intValue === null) {
|
|
1138
|
-
throw new Error(`Invalid INTEGER value: ${value}`);
|
|
1139
|
-
}
|
|
1140
|
-
return intValue.toString();
|
|
1141
|
-
|
|
1142
|
-
case 'FLOAT':
|
|
1143
|
-
const floatValue = helpers.parseFloatSafe(value, null);
|
|
1144
|
-
if (floatValue === null) {
|
|
1145
|
-
throw new Error(`Invalid FLOAT value: ${value}`);
|
|
1146
|
-
}
|
|
1147
|
-
return floatValue.toString();
|
|
1148
|
-
|
|
1149
|
-
case 'BOOLEAN':
|
|
1150
|
-
if (!['true', 'false'].includes(String(value).toLowerCase())) {
|
|
1151
|
-
throw new Error(`Invalid BOOLEAN value: ${value}`);
|
|
1152
|
-
}
|
|
1153
|
-
return String(value).toLowerCase();
|
|
1154
|
-
|
|
1155
|
-
case 'STRING':
|
|
1156
|
-
return String(value);
|
|
1157
|
-
|
|
1158
|
-
case 'JSON':
|
|
1159
|
-
try {
|
|
1160
|
-
JSON.parse(value);
|
|
1161
|
-
return value;
|
|
1162
|
-
} catch (error) {
|
|
1163
|
-
throw new Error(`Invalid JSON value: ${value}`);
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
default:
|
|
1167
|
-
helpers.log.warn(`Unknown control type: ${type}, treating as STRING`);
|
|
1168
|
-
return String(value);
|
|
1169
|
-
}
|
|
1170
|
-
};
|
|
1171
|
-
```
|
|
1172
|
-
|
|
1173
|
-
### Complete Working Code
|
|
1174
|
-
|
|
1175
|
-
**`control-etl.ts`:**
|
|
1176
|
-
|
|
1177
|
-
```typescript
|
|
1178
|
-
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1179
|
-
import { S3DataSource, JSONParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
1180
|
-
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
1181
|
-
import { StateService, VersoriKVAdapter } from '@fluentcommerce/fc-connect-sdk';
|
|
1182
|
-
// Access openKv from context: const { openKv } = ctx;
|
|
1183
|
-
import * as fs from 'fs';
|
|
1184
|
-
import { validateControlValue } from './resolvers/control-validators';
|
|
1185
|
-
|
|
1186
|
-
/**
|
|
1187
|
-
* Control/Config Data ETL
|
|
1188
|
-
*/
|
|
1189
|
-
export async function controlDataETL(ctx: any) {
|
|
1190
|
-
const logger = console;
|
|
1191
|
-
const { openKv } = ctx;
|
|
1192
|
-
const fluentClient = await createClient({
|
|
1193
|
-
config: {
|
|
1194
|
-
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
1195
|
-
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
1196
|
-
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
1197
|
-
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
1198
|
-
},
|
|
1199
|
-
logger,
|
|
1200
|
-
});
|
|
1201
|
-
|
|
1202
|
-
// Initialize components
|
|
1203
|
-
const s3DataSource = new S3DataSource(
|
|
1204
|
-
{
|
|
1205
|
-
type: 'S3_CSV',
|
|
1206
|
-
s3Config: {
|
|
1207
|
-
bucket: 'config-data',
|
|
1208
|
-
region: process.env.AWS_REGION!,
|
|
1209
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
1210
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
1211
|
-
},
|
|
1212
|
-
},
|
|
1213
|
-
logger
|
|
1214
|
-
);
|
|
1215
|
-
|
|
1216
|
-
const kvAdapter = new VersoriKVAdapter(openKv(':project:'));
|
|
1217
|
-
const stateService = new StateService(logger);
|
|
1218
|
-
|
|
1219
|
-
const jsonParser = new JSONParserService();
|
|
1220
|
-
|
|
1221
|
-
// Load mapping configuration
|
|
1222
|
-
const mappingConfig = JSON.parse(fs.readFileSync('config/control-mapping.json', 'utf-8'));
|
|
1223
|
-
|
|
1224
|
-
// Initialize mapper with custom resolvers
|
|
1225
|
-
const mapper = new UniversalMapper(mappingConfig, {
|
|
1226
|
-
logger,
|
|
1227
|
-
fluentClient,
|
|
1228
|
-
customResolvers: {
|
|
1229
|
-
'custom.validateControlValue': validateControlValue,
|
|
1230
|
-
},
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
logger.info('Starting control data ETL');
|
|
1234
|
-
|
|
1235
|
-
try {
|
|
1236
|
-
// List JSON files
|
|
1237
|
-
const files = await s3DataSource.listFiles({ prefix: 'controls/' });
|
|
1238
|
-
|
|
1239
|
-
for (const file of files) {
|
|
1240
|
-
if (!file.name.endsWith('.json')) continue;
|
|
1241
|
-
|
|
1242
|
-
const fileKey = `control:${file.name}`;
|
|
1243
|
-
|
|
1244
|
-
// Check if processed
|
|
1245
|
-
if (await stateService.isFileProcessed(kvAdapter, fileKey)) {
|
|
1246
|
-
logger.info(`Skipping processed file: ${file.name}`);
|
|
1247
|
-
continue;
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
logger.info(`Processing file: ${file.name}`);
|
|
1251
|
-
|
|
1252
|
-
try {
|
|
1253
|
-
// Download and parse JSON
|
|
1254
|
-
const fileContent = await s3DataSource.downloadFile(file.path);
|
|
1255
|
-
const data = await jsonParser.parse(fileContent as string, {
|
|
1256
|
-
dataPath: 'controls',
|
|
1257
|
-
});
|
|
1258
|
-
|
|
1259
|
-
const controls = Array.isArray(data) ? data : [data];
|
|
1260
|
-
logger.info(`Parsed ${controls.length} controls from ${file.name}`);
|
|
1261
|
-
|
|
1262
|
-
// Transform and load each control
|
|
1263
|
-
let successCount = 0;
|
|
1264
|
-
let failureCount = 0;
|
|
1265
|
-
|
|
1266
|
-
for (const control of controls) {
|
|
1267
|
-
try {
|
|
1268
|
-
const result = await mapper.map(control);
|
|
1269
|
-
|
|
1270
|
-
if (result.success) {
|
|
1271
|
-
await createControl(fluentClient, result.data, logger);
|
|
1272
|
-
successCount++;
|
|
1273
|
-
} else {
|
|
1274
|
-
logger.error('Control mapping failed:', {
|
|
1275
|
-
control,
|
|
1276
|
-
errors: result.errors,
|
|
1277
|
-
});
|
|
1278
|
-
failureCount++;
|
|
1279
|
-
}
|
|
1280
|
-
} catch (error) {
|
|
1281
|
-
logger.error(`Failed to process control: ${control.name}`, error);
|
|
1282
|
-
failureCount++;
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
logger.info(`Control processing summary:`, {
|
|
1287
|
-
total: controls.length,
|
|
1288
|
-
success: successCount,
|
|
1289
|
-
failures: failureCount,
|
|
1290
|
-
});
|
|
1291
|
-
|
|
1292
|
-
// Mark as processed
|
|
1293
|
-
await stateService.updateSyncState(
|
|
1294
|
-
kvAdapter,
|
|
1295
|
-
[
|
|
1296
|
-
{
|
|
1297
|
-
fileName: file.name,
|
|
1298
|
-
lastModified: new Date().toISOString(),
|
|
1299
|
-
recordCount: controls.length,
|
|
1300
|
-
},
|
|
1301
|
-
],
|
|
1302
|
-
'control-data-etl'
|
|
1303
|
-
);
|
|
1304
|
-
|
|
1305
|
-
logger.info(`Successfully processed: ${file.name}`);
|
|
1306
|
-
} catch (error) {
|
|
1307
|
-
logger.error(`Failed to process file: ${file.name}`, error);
|
|
1308
|
-
continue;
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
logger.info('Control data ETL completed');
|
|
1313
|
-
} catch (error) {
|
|
1314
|
-
logger.error('Control data ETL failed', error);
|
|
1315
|
-
throw error;
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
/**
|
|
1320
|
-
* Create control via GraphQL
|
|
1321
|
-
*/
|
|
1322
|
-
async function createControl(client: any, controlData: any, logger: any) {
|
|
1323
|
-
const mutation = `
|
|
1324
|
-
mutation CreateControl($input: CreateControlInput!) {
|
|
1325
|
-
createControl(input: $input) {
|
|
1326
|
-
id
|
|
1327
|
-
name
|
|
1328
|
-
value
|
|
1329
|
-
type
|
|
1330
|
-
context
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
`;
|
|
1334
|
-
|
|
1335
|
-
try {
|
|
1336
|
-
const result = await client.graphql({
|
|
1337
|
-
query: mutation,
|
|
1338
|
-
variables: { input: controlData },
|
|
1339
|
-
});
|
|
1340
|
-
|
|
1341
|
-
logger.info(`Created control: ${controlData.name}`, {
|
|
1342
|
-
value: controlData.value,
|
|
1343
|
-
type: controlData.type,
|
|
1344
|
-
});
|
|
1345
|
-
} catch (error) {
|
|
1346
|
-
logger.error(`Failed to create control: ${controlData.name}`, error);
|
|
1347
|
-
throw error;
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
// Example usage
|
|
1352
|
-
if (require.main === module) {
|
|
1353
|
-
controlDataETL()
|
|
1354
|
-
.then(() => console.log('ETL completed'))
|
|
1355
|
-
.catch(err => {
|
|
1356
|
-
console.error('ETL failed:', err);
|
|
1357
|
-
process.exit(1);
|
|
1358
|
-
});
|
|
1359
|
-
}
|
|
1360
|
-
```
|
|
1361
|
-
|
|
1362
|
-
**Key Design Decisions:**
|
|
1363
|
-
|
|
1364
|
-
1. **Type Validation**: Custom resolver validates value based on type field
|
|
1365
|
-
2. **Error Tracking**: Count successes/failures for summary reporting
|
|
1366
|
-
3. **Atomic Processing**: Each control processed independently
|
|
1367
|
-
4. **Detailed Logging**: Track validation failures with context
|
|
1368
|
-
|
|
1369
|
-
---
|
|
1370
|
-
|
|
1371
|
-
## Source Strategies
|
|
1372
|
-
|
|
1373
|
-
### S3 with Event Notifications
|
|
1374
|
-
|
|
1375
|
-
**Use Case**: Process files as soon as they're uploaded to S3
|
|
1376
|
-
|
|
1377
|
-
**Setup:**
|
|
1378
|
-
|
|
1379
|
-
```typescript
|
|
1380
|
-
import { webhook } from '@versori/run/webhooks';
|
|
1381
|
-
import { masterDataETL } from './location-etl';
|
|
1382
|
-
|
|
1383
|
-
export const s3LocationETL = webhook('s3-location-upload', {
|
|
1384
|
-
response: { mode: 'sync' },
|
|
1385
|
-
})
|
|
1386
|
-
.then(async ({ data }) => {
|
|
1387
|
-
// Parse S3 event notification
|
|
1388
|
-
const s3Event = data.Records[0].s3;
|
|
1389
|
-
const bucket = s3Event.bucket.name;
|
|
1390
|
-
const key = decodeURIComponent(s3Event.object.key.replace(/\+/g, ' '));
|
|
1391
|
-
|
|
1392
|
-
console.log(`S3 event received: ${bucket}/${key}`);
|
|
1393
|
-
|
|
1394
|
-
// Run ETL for this specific file
|
|
1395
|
-
await masterDataETL('config/location-etl-config.json');
|
|
1396
|
-
|
|
1397
|
-
return { success: true, message: 'Location ETL completed' };
|
|
1398
|
-
})
|
|
1399
|
-
.catch(({ error }) => {
|
|
1400
|
-
console.error('S3 location ETL failed:', error);
|
|
1401
|
-
return { success: false, error: error.message };
|
|
1402
|
-
});
|
|
1403
|
-
```
|
|
1404
|
-
|
|
1405
|
-
**S3 Bucket Configuration:**
|
|
1406
|
-
|
|
1407
|
-
```json
|
|
1408
|
-
{
|
|
1409
|
-
"LambdaFunctionConfigurations": [
|
|
1410
|
-
{
|
|
1411
|
-
"LambdaFunctionArn": "arn:aws:lambda:...:function:versori-webhook",
|
|
1412
|
-
"Events": ["s3:ObjectCreated:*"],
|
|
1413
|
-
"Filter": {
|
|
1414
|
-
"Key": {
|
|
1415
|
-
"FilterRules": [
|
|
1416
|
-
{
|
|
1417
|
-
"Name": "prefix",
|
|
1418
|
-
"Value": "locations/"
|
|
1419
|
-
},
|
|
1420
|
-
{
|
|
1421
|
-
"Name": "suffix",
|
|
1422
|
-
"Value": ".csv"
|
|
1423
|
-
}
|
|
1424
|
-
]
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
]
|
|
1429
|
-
}
|
|
1430
|
-
```
|
|
1431
|
-
|
|
1432
|
-
### SFTP with Polling
|
|
1433
|
-
|
|
1434
|
-
**Use Case**: Poll SFTP server periodically for new files
|
|
1435
|
-
|
|
1436
|
-
#### SFTP Credential Access
|
|
1437
|
-
|
|
1438
|
-
**Versori Platform** has three methods for accessing SFTP credentials:
|
|
1439
|
-
|
|
1440
|
-
1. **Connection Variables (Recommended)** - Direct access to connection config:
|
|
1441
|
-
```typescript
|
|
1442
|
-
const { host, port, username, password, privateKey } = ctx.activation.connections.sftp_server;
|
|
1443
|
-
```
|
|
1444
|
-
|
|
1445
|
-
2. **Credentials API** - For base64-encoded credentials:
|
|
1446
|
-
```typescript
|
|
1447
|
-
const creds = await ctx.credentials().getAccessToken('sftp_server');
|
|
1448
|
-
```
|
|
1449
|
-
|
|
1450
|
-
3. **Connection String Parsing** - Decode `connectionVariables.connectionString`:
|
|
1451
|
-
```typescript
|
|
1452
|
-
const connStr = ctx.activation.connections.sftp_server.connectionString;
|
|
1453
|
-
// Parse: sftp://username:password@host:port
|
|
1454
|
-
```
|
|
1455
|
-
|
|
1456
|
-
**Standalone Node.js/Deno**: Use environment variables directly:
|
|
1457
|
-
```typescript
|
|
1458
|
-
const config = {
|
|
1459
|
-
host: process.env.SFTP_HOST!,
|
|
1460
|
-
port: parseInt(process.env.SFTP_PORT || '22'),
|
|
1461
|
-
username: process.env.SFTP_USERNAME!,
|
|
1462
|
-
password: process.env.SFTP_PASSWORD,
|
|
1463
|
-
privateKey: process.env.SFTP_PRIVATE_KEY,
|
|
1464
|
-
};
|
|
1465
|
-
```
|
|
1466
|
-
|
|
1467
|
-
**Security Best Practices:**
|
|
1468
|
-
- Always prefer SSH keys over passwords
|
|
1469
|
-
- Never log credential values
|
|
1470
|
-
- Use scoped credentials (read-only when possible)
|
|
1471
|
-
- Rotate credentials regularly
|
|
1472
|
-
|
|
1473
|
-
**See:** [SFTP Credential Access Security Guide](../../02-CORE-GUIDES/data-sources/data-sources-sftp-credential-access-security.md) for complete details.
|
|
1474
|
-
|
|
1475
|
-
#### Setup Example
|
|
1476
|
-
|
|
1477
|
-
```typescript
|
|
1478
|
-
import { schedule } from '@versori/run/schedule';
|
|
1479
|
-
import { SftpDataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
1480
|
-
import { Buffer } from 'node:buffer'; // Required for Deno/Versori
|
|
1481
|
-
|
|
1482
|
-
export const sftpPolling = schedule('location-sftp-poll', {
|
|
1483
|
-
cron: '0 */6 * * *', // Every 6 hours
|
|
1484
|
-
retry: { attempts: 3 },
|
|
1485
|
-
}).then(async (ctx) => {
|
|
1486
|
-
const { log, activation } = ctx;
|
|
1487
|
-
|
|
1488
|
-
// Access SFTP credentials (Versori)
|
|
1489
|
-
const { host, port, username, password, privateKey } = activation.connections.sftp_server;
|
|
1490
|
-
|
|
1491
|
-
// Initialize SFTP data source
|
|
1492
|
-
const sftpSource = new SftpDataSource(
|
|
1493
|
-
{
|
|
1494
|
-
type: 'SFTP_CSV',
|
|
1495
|
-
settings: {
|
|
1496
|
-
host,
|
|
1497
|
-
port: port || 22,
|
|
1498
|
-
username,
|
|
1499
|
-
password,
|
|
1500
|
-
privateKey,
|
|
1501
|
-
remotePath: '/data/locations',
|
|
1502
|
-
filePattern: '*.csv',
|
|
1503
|
-
},
|
|
1504
|
-
},
|
|
1505
|
-
log
|
|
1506
|
-
);
|
|
1507
|
-
|
|
1508
|
-
// List new files
|
|
1509
|
-
const files = await sftpSource.listFiles();
|
|
1510
|
-
log.info(`Found ${files.length} files on SFTP`);
|
|
1511
|
-
|
|
1512
|
-
for (const file of files) {
|
|
1513
|
-
try {
|
|
1514
|
-
// Download file
|
|
1515
|
-
const content = await sftpSource.downloadFile(file.path);
|
|
1516
|
-
|
|
1517
|
-
// Process file (same ETL logic as S3)
|
|
1518
|
-
// ... (extract, parse, transform, load)
|
|
1519
|
-
|
|
1520
|
-
// Move to processed folder
|
|
1521
|
-
await sftpSource.moveFile(file.path, `/data/locations/processed/${file.name}`);
|
|
1522
|
-
} catch (error) {
|
|
1523
|
-
log.error(`Failed to process ${file.name}:`, error);
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
return { success: true, filesProcessed: files.length };
|
|
1528
|
-
});
|
|
1529
|
-
```
|
|
1530
|
-
|
|
1531
|
-
---
|
|
1532
|
-
|
|
1533
|
-
## Load Strategies
|
|
1534
|
-
|
|
1535
|
-
### GraphQL Mutation Approach
|
|
1536
|
-
|
|
1537
|
-
**When to Use:**
|
|
1538
|
-
|
|
1539
|
-
- Simple entity creation/updates
|
|
1540
|
-
- Direct control over mutations
|
|
1541
|
-
- Schema validation needed
|
|
1542
|
-
- Small to medium datasets (<10K records)
|
|
1543
|
-
|
|
1544
|
-
**Advantages:**
|
|
1545
|
-
|
|
1546
|
-
- ✅ Type-safe with GraphQL schema
|
|
1547
|
-
- ✅ Immediate validation feedback
|
|
1548
|
-
- ✅ Fine-grained control over mutations
|
|
1549
|
-
- ✅ Can return created IDs
|
|
1550
|
-
|
|
1551
|
-
**Example:**
|
|
1552
|
-
|
|
1553
|
-
```typescript
|
|
1554
|
-
async function loadViaGraphQL(records: any[], mutation: string, logger: any) {
|
|
1555
|
-
const client = await createClient({
|
|
1556
|
-
config: {
|
|
1557
|
-
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
1558
|
-
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
1559
|
-
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
1560
|
-
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
1561
|
-
},
|
|
1562
|
-
});
|
|
1563
|
-
|
|
1564
|
-
for (const record of records) {
|
|
1565
|
-
const query = `
|
|
1566
|
-
mutation ${mutation}($input: ${capitalize(mutation)}Input!) {
|
|
1567
|
-
${mutation}(input: $input) {
|
|
1568
|
-
id
|
|
1569
|
-
ref
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1572
|
-
`;
|
|
1573
|
-
|
|
1574
|
-
try {
|
|
1575
|
-
const result = await client.graphql({
|
|
1576
|
-
query,
|
|
1577
|
-
variables: { input: record },
|
|
1578
|
-
});
|
|
1579
|
-
|
|
1580
|
-
logger.info(`Created ${mutation}:`, result.data[mutation]);
|
|
1581
|
-
} catch (error: any) {
|
|
1582
|
-
logger.error(`Failed ${mutation}:`, {
|
|
1583
|
-
record,
|
|
1584
|
-
error: error.message,
|
|
1585
|
-
details: error.response?.errors,
|
|
1586
|
-
});
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
```
|
|
1591
|
-
|
|
1592
|
-
### Event API Approach
|
|
1593
|
-
|
|
1594
|
-
**When to Use:**
|
|
1595
|
-
|
|
1596
|
-
- Asynchronous processing acceptable
|
|
1597
|
-
- Triggering workflows/rules needed
|
|
1598
|
-
- Large datasets (>10K records)
|
|
1599
|
-
- Need event-driven architecture
|
|
1600
|
-
|
|
1601
|
-
**Advantages:**
|
|
1602
|
-
|
|
1603
|
-
- ✅ Asynchronous (better for large datasets)
|
|
1604
|
-
- ✅ Triggers workflows and rules
|
|
1605
|
-
- ✅ Decoupled from mutations
|
|
1606
|
-
- ✅ Better performance for bulk loads
|
|
1607
|
-
|
|
1608
|
-
**Example:**
|
|
1609
|
-
|
|
1610
|
-
```typescript
|
|
1611
|
-
async function loadViaEventAPI(records: any[], eventName: string, logger: any) {
|
|
1612
|
-
const client = await createClient({
|
|
1613
|
-
config: {
|
|
1614
|
-
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
1615
|
-
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
1616
|
-
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
1617
|
-
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
1618
|
-
},
|
|
1619
|
-
});
|
|
1620
|
-
|
|
1621
|
-
for (const record of records) {
|
|
1622
|
-
try {
|
|
1623
|
-
await client.sendEvent({
|
|
1624
|
-
name: eventName,
|
|
1625
|
-
entityRef: record.ref,
|
|
1626
|
-
entityType: 'LOCATION',
|
|
1627
|
-
retailerId: record.retailerId,
|
|
1628
|
-
attributes: record,
|
|
1629
|
-
});
|
|
1630
|
-
|
|
1631
|
-
logger.info(`Sent event ${eventName} for ${record.ref}`);
|
|
1632
|
-
} catch (error: any) {
|
|
1633
|
-
logger.error(`Failed to send event for ${record.ref}:`, error);
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
|
-
```
|
|
1638
|
-
|
|
1639
|
-
**Event-Driven Workflow:**
|
|
1640
|
-
|
|
1641
|
-
```
|
|
1642
|
-
ETL Process Fluent Commerce
|
|
1643
|
-
↓
|
|
1644
|
-
Send Event (LOCATION_CREATED)
|
|
1645
|
-
↓ ↓
|
|
1646
|
-
Workflow Triggers
|
|
1647
|
-
↓
|
|
1648
|
-
Process Event
|
|
1649
|
-
↓
|
|
1650
|
-
Create Location
|
|
1651
|
-
↓
|
|
1652
|
-
Apply Business Rules
|
|
1653
|
-
↓
|
|
1654
|
-
Send Notifications
|
|
1655
|
-
```
|
|
1656
|
-
|
|
1657
|
-
---
|
|
1658
|
-
|
|
1659
|
-
## Configuration Schema
|
|
1660
|
-
|
|
1661
|
-
### Generic Configuration Template
|
|
1662
|
-
|
|
1663
|
-
Use this template for ANY entity type:
|
|
1664
|
-
|
|
1665
|
-
```json
|
|
1666
|
-
{
|
|
1667
|
-
"entityType": "<entity-name>",
|
|
1668
|
-
"description": "<what this ETL does>",
|
|
1669
|
-
"sourceConfig": {
|
|
1670
|
-
"type": "S3_CSV | SFTP_CSV | S3_JSON | SFTP_JSON",
|
|
1671
|
-
"bucket": "<s3-bucket-name>",
|
|
1672
|
-
"prefix": "<folder-prefix>",
|
|
1673
|
-
"filePattern": "*.csv | *.json | *.xml",
|
|
1674
|
-
"sftp": {
|
|
1675
|
-
"host": "${SFTP_HOST}",
|
|
1676
|
-
"port": 22,
|
|
1677
|
-
"username": "${SFTP_USERNAME}",
|
|
1678
|
-
"password": "${SFTP_PASSWORD}",
|
|
1679
|
-
"privateKey": "${SFTP_PRIVATE_KEY}", // Recommended over password
|
|
1680
|
-
"remotePath": "/data/<entity>",
|
|
1681
|
-
"filePattern": "*.<format>"
|
|
1682
|
-
}
|
|
1683
|
-
},
|
|
1684
|
-
"parseConfig": {
|
|
1685
|
-
"format": "csv | json | xml",
|
|
1686
|
-
"delimiter": "," | "|" | "\t",
|
|
1687
|
-
"headers": true | false,
|
|
1688
|
-
"encoding": "utf8 | utf16",
|
|
1689
|
-
"json": {
|
|
1690
|
-
"dataPath": "root.path.to.array",
|
|
1691
|
-
"jsonLines": false
|
|
1692
|
-
},
|
|
1693
|
-
"xml": {
|
|
1694
|
-
"itemPath": "//Item",
|
|
1695
|
-
"includeAttributes": true
|
|
1696
|
-
}
|
|
1697
|
-
},
|
|
1698
|
-
"mappingConfig": {
|
|
1699
|
-
"version": "1.0",
|
|
1700
|
-
"description": "Field mappings",
|
|
1701
|
-
"fields": {
|
|
1702
|
-
"targetField": {
|
|
1703
|
-
"source": "sourceField",
|
|
1704
|
-
"required": true | false,
|
|
1705
|
-
"defaultValue": "default",
|
|
1706
|
-
"resolver": "sdk.* | custom.*"
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
},
|
|
1710
|
-
"loadConfig": {
|
|
1711
|
-
"strategy": "graphql | event",
|
|
1712
|
-
"mutation": "createEntity | updateEntity",
|
|
1713
|
-
"eventName": "ENTITY_CREATED",
|
|
1714
|
-
"batchSize": 100,
|
|
1715
|
-
"retryAttempts": 3
|
|
1716
|
-
},
|
|
1717
|
-
"scheduleConfig": {
|
|
1718
|
-
"enabled": true,
|
|
1719
|
-
"cron": "0 0 * * *",
|
|
1720
|
-
"timezone": "UTC"
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1723
|
-
```
|
|
1724
|
-
|
|
1725
|
-
### How to Adapt for New Entity Types
|
|
1726
|
-
|
|
1727
|
-
**Step-by-Step Guide:**
|
|
1728
|
-
|
|
1729
|
-
1. **Copy Template**: Start with generic configuration template
|
|
1730
|
-
2. **Set Entity Type**: Change `entityType` to your entity name
|
|
1731
|
-
3. **Configure Source**: Set bucket/path for your data source
|
|
1732
|
-
4. **Configure Parser**: Set format and parsing options
|
|
1733
|
-
5. **Define Field Mappings**: Map source fields to Fluent schema
|
|
1734
|
-
6. **Configure Load Strategy**: Choose GraphQL or Event API
|
|
1735
|
-
7. **Test**: Run ETL with sample data
|
|
1736
|
-
8. **Deploy**: Schedule or trigger via webhook
|
|
1737
|
-
|
|
1738
|
-
**Example Adaptation (Customer Entity):**
|
|
1739
|
-
|
|
1740
|
-
```json
|
|
1741
|
-
{
|
|
1742
|
-
"entityType": "customer",
|
|
1743
|
-
"description": "Load customer data from CRM system",
|
|
1744
|
-
"sourceConfig": {
|
|
1745
|
-
"type": "S3_CSV",
|
|
1746
|
-
"bucket": "crm-exports",
|
|
1747
|
-
"prefix": "customers/",
|
|
1748
|
-
"filePattern": "customers_*.csv"
|
|
1749
|
-
},
|
|
1750
|
-
"parseConfig": {
|
|
1751
|
-
"format": "csv",
|
|
1752
|
-
"delimiter": ",",
|
|
1753
|
-
"headers": true
|
|
1754
|
-
},
|
|
1755
|
-
"mappingConfig": {
|
|
1756
|
-
"version": "1.0",
|
|
1757
|
-
"fields": {
|
|
1758
|
-
"ref": {
|
|
1759
|
-
"source": "customer_id",
|
|
1760
|
-
"required": true
|
|
1761
|
-
},
|
|
1762
|
-
"firstName": {
|
|
1763
|
-
"source": "first_name",
|
|
1764
|
-
"required": true
|
|
1765
|
-
},
|
|
1766
|
-
"lastName": {
|
|
1767
|
-
"source": "last_name",
|
|
1768
|
-
"required": true
|
|
1769
|
-
},
|
|
1770
|
-
"email": {
|
|
1771
|
-
"source": "email_address",
|
|
1772
|
-
"required": true,
|
|
1773
|
-
"resolver": "sdk.lowercase"
|
|
1774
|
-
},
|
|
1775
|
-
"primaryPhone": {
|
|
1776
|
-
"source": "phone_number",
|
|
1777
|
-
"resolver": "custom.formatPhoneNumber"
|
|
1778
|
-
},
|
|
1779
|
-
"retailerId": {
|
|
1780
|
-
"value": "${RETAILER_ID}"
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
},
|
|
1784
|
-
"loadConfig": {
|
|
1785
|
-
"strategy": "graphql",
|
|
1786
|
-
"mutation": "createCustomer",
|
|
1787
|
-
"batchSize": 50
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
```
|
|
1791
|
-
|
|
1792
|
-
---
|
|
1793
|
-
|
|
1794
|
-
## Extending to Other Entities
|
|
1795
|
-
|
|
1796
|
-
### Step-by-Step Guide
|
|
1797
|
-
|
|
1798
|
-
**1. Identify Entity Schema**
|
|
1799
|
-
|
|
1800
|
-
Understand the Fluent schema for your entity:
|
|
1801
|
-
|
|
1802
|
-
```graphql
|
|
1803
|
-
# Example: Carrier schema
|
|
1804
|
-
type Carrier {
|
|
1805
|
-
id: ID!
|
|
1806
|
-
ref: String!
|
|
1807
|
-
name: String!
|
|
1808
|
-
type: String!
|
|
1809
|
-
status: String
|
|
1810
|
-
services: [CarrierService]
|
|
1811
|
-
retailerId: ID!
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
input CreateCarrierInput {
|
|
1815
|
-
ref: String!
|
|
1816
|
-
name: String!
|
|
1817
|
-
type: String!
|
|
1818
|
-
status: String
|
|
1819
|
-
services: [CarrierServiceInput]
|
|
1820
|
-
retailerId: ID!
|
|
1821
|
-
}
|
|
1822
|
-
```
|
|
1823
|
-
|
|
1824
|
-
**2. Create Field Mapping Configuration**
|
|
1825
|
-
|
|
1826
|
-
Map source data to schema:
|
|
1827
|
-
|
|
1828
|
-
```json
|
|
1829
|
-
{
|
|
1830
|
-
"version": "1.0",
|
|
1831
|
-
"fields": {
|
|
1832
|
-
"ref": {
|
|
1833
|
-
"source": "carrier_code",
|
|
1834
|
-
"required": true,
|
|
1835
|
-
"resolver": "sdk.uppercase"
|
|
1836
|
-
},
|
|
1837
|
-
"name": {
|
|
1838
|
-
"source": "carrier_name",
|
|
1839
|
-
"required": true
|
|
1840
|
-
},
|
|
1841
|
-
"type": {
|
|
1842
|
-
"source": "carrier_type",
|
|
1843
|
-
"required": true
|
|
1844
|
-
},
|
|
1845
|
-
"status": {
|
|
1846
|
-
"value": "ACTIVE"
|
|
1847
|
-
},
|
|
1848
|
-
"services": {
|
|
1849
|
-
"source": "services",
|
|
1850
|
-
"isArray": true,
|
|
1851
|
-
"fields": {
|
|
1852
|
-
"name": { "source": "$.service_name" },
|
|
1853
|
-
"code": { "source": "$.service_code" },
|
|
1854
|
-
"deliveryDays": { "source": "$.delivery_days", "resolver": "sdk.parseInt" }
|
|
1855
|
-
}
|
|
1856
|
-
},
|
|
1857
|
-
"retailerId": {
|
|
1858
|
-
"value": "${RETAILER_ID}"
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1861
|
-
}
|
|
1862
|
-
```
|
|
1863
|
-
|
|
1864
|
-
**3. Set Up Data Source**
|
|
1865
|
-
|
|
1866
|
-
Configure where data comes from:
|
|
1867
|
-
|
|
1868
|
-
```typescript
|
|
1869
|
-
const carrierSource = new S3DataSource(
|
|
1870
|
-
{
|
|
1871
|
-
type: 'S3_CSV',
|
|
1872
|
-
s3Config: {
|
|
1873
|
-
bucket: 'carrier-data',
|
|
1874
|
-
region: process.env.AWS_REGION!,
|
|
1875
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
1876
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
1877
|
-
},
|
|
1878
|
-
},
|
|
1879
|
-
logger
|
|
1880
|
-
);
|
|
1881
|
-
```
|
|
1882
|
-
|
|
1883
|
-
**4. Run Generic ETL Pipeline**
|
|
1884
|
-
|
|
1885
|
-
Use the same ETL code:
|
|
1886
|
-
|
|
1887
|
-
```typescript
|
|
1888
|
-
await masterDataETL('config/carrier-etl-config.json');
|
|
1889
|
-
```
|
|
1890
|
-
|
|
1891
|
-
### Customer Example
|
|
1892
|
-
|
|
1893
|
-
**Source CSV:**
|
|
1894
|
-
|
|
1895
|
-
```csv
|
|
1896
|
-
customer_id,first_name,last_name,email,phone,segment,status
|
|
1897
|
-
CUST001,John,Doe,john.doe@email.com,555-1234,VIP,ACTIVE
|
|
1898
|
-
CUST002,Jane,Smith,jane.smith@email.com,555-5678,STANDARD,ACTIVE
|
|
1899
|
-
```
|
|
1900
|
-
|
|
1901
|
-
**Mapping Configuration:**
|
|
1902
|
-
|
|
1903
|
-
```json
|
|
1904
|
-
{
|
|
1905
|
-
"version": "1.0",
|
|
1906
|
-
"fields": {
|
|
1907
|
-
"ref": { "source": "customer_id", "required": true },
|
|
1908
|
-
"firstName": { "source": "first_name", "required": true },
|
|
1909
|
-
"lastName": { "source": "last_name", "required": true },
|
|
1910
|
-
"email": { "source": "email", "required": true, "resolver": "sdk.lowercase" },
|
|
1911
|
-
"primaryPhone": { "source": "phone" },
|
|
1912
|
-
"status": { "source": "status", "defaultValue": "ACTIVE" },
|
|
1913
|
-
"attributes": {
|
|
1914
|
-
"fields": {
|
|
1915
|
-
"segment": { "source": "segment" }
|
|
1916
|
-
}
|
|
1917
|
-
},
|
|
1918
|
-
"retailerId": { "value": "${RETAILER_ID}" }
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
```
|
|
1922
|
-
|
|
1923
|
-
### Carrier Example
|
|
1924
|
-
|
|
1925
|
-
**Source JSON:**
|
|
1926
|
-
|
|
1927
|
-
```json
|
|
1928
|
-
{
|
|
1929
|
-
"carriers": [
|
|
1930
|
-
{
|
|
1931
|
-
"code": "FEDEX",
|
|
1932
|
-
"name": "FedEx",
|
|
1933
|
-
"type": "PARCEL",
|
|
1934
|
-
"services": [
|
|
1935
|
-
{ "service_code": "GROUND", "service_name": "FedEx Ground", "delivery_days": 3 },
|
|
1936
|
-
{ "service_code": "2DAY", "service_name": "FedEx 2 Day", "delivery_days": 2 }
|
|
1937
|
-
]
|
|
1938
|
-
}
|
|
1939
|
-
]
|
|
1940
|
-
}
|
|
1941
|
-
```
|
|
1942
|
-
|
|
1943
|
-
**Mapping Configuration:**
|
|
1944
|
-
|
|
1945
|
-
```json
|
|
1946
|
-
{
|
|
1947
|
-
"version": "1.0",
|
|
1948
|
-
"fields": {
|
|
1949
|
-
"ref": { "source": "code", "required": true },
|
|
1950
|
-
"name": { "source": "name", "required": true },
|
|
1951
|
-
"type": { "source": "type", "required": true },
|
|
1952
|
-
"status": { "value": "ACTIVE" },
|
|
1953
|
-
"services": {
|
|
1954
|
-
"source": "services",
|
|
1955
|
-
"isArray": true,
|
|
1956
|
-
"fields": {
|
|
1957
|
-
"code": { "source": "$.service_code" },
|
|
1958
|
-
"name": { "source": "$.service_name" },
|
|
1959
|
-
"transitDays": { "source": "$.delivery_days", "resolver": "sdk.parseInt" }
|
|
1960
|
-
}
|
|
1961
|
-
},
|
|
1962
|
-
"retailerId": { "value": "${RETAILER_ID}" }
|
|
1963
|
-
}
|
|
1964
|
-
}
|
|
1965
|
-
```
|
|
1966
|
-
|
|
1967
|
-
### Pricing Example
|
|
1968
|
-
|
|
1969
|
-
**Source CSV:**
|
|
1970
|
-
|
|
1971
|
-
```csv
|
|
1972
|
-
sku,price_list,currency,base_price,sale_price,start_date,end_date
|
|
1973
|
-
PROD001-S-RED,US_RETAIL,USD,29.99,24.99,2024-01-01,2024-12-31
|
|
1974
|
-
PROD001-M-RED,US_RETAIL,USD,29.99,24.99,2024-01-01,2024-12-31
|
|
1975
|
-
```
|
|
1976
|
-
|
|
1977
|
-
**Mapping Configuration:**
|
|
1978
|
-
|
|
1979
|
-
```json
|
|
1980
|
-
{
|
|
1981
|
-
"version": "1.0",
|
|
1982
|
-
"fields": {
|
|
1983
|
-
"ref": {
|
|
1984
|
-
"resolver": "custom.generatePriceRef",
|
|
1985
|
-
"required": true
|
|
1986
|
-
},
|
|
1987
|
-
"sku": {
|
|
1988
|
-
"source": "sku",
|
|
1989
|
-
"required": true
|
|
1990
|
-
},
|
|
1991
|
-
"priceList": {
|
|
1992
|
-
"source": "price_list",
|
|
1993
|
-
"required": true
|
|
1994
|
-
},
|
|
1995
|
-
"currency": {
|
|
1996
|
-
"source": "currency",
|
|
1997
|
-
"required": true,
|
|
1998
|
-
"resolver": "sdk.uppercase"
|
|
1999
|
-
},
|
|
2000
|
-
"value": {
|
|
2001
|
-
"source": "base_price",
|
|
2002
|
-
"required": true,
|
|
2003
|
-
"resolver": "sdk.parseFloat"
|
|
2004
|
-
},
|
|
2005
|
-
"salePrice": {
|
|
2006
|
-
"source": "sale_price",
|
|
2007
|
-
"resolver": "sdk.parseFloat"
|
|
2008
|
-
},
|
|
2009
|
-
"validFrom": {
|
|
2010
|
-
"source": "start_date",
|
|
2011
|
-
"resolver": "sdk.formatDate"
|
|
2012
|
-
},
|
|
2013
|
-
"validTo": {
|
|
2014
|
-
"source": "end_date",
|
|
2015
|
-
"resolver": "sdk.formatDate"
|
|
2016
|
-
},
|
|
2017
|
-
"retailerId": {
|
|
2018
|
-
"value": "${RETAILER_ID}"
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
}
|
|
2022
|
-
```
|
|
2023
|
-
|
|
2024
|
-
**Custom Resolver:**
|
|
2025
|
-
|
|
2026
|
-
```typescript
|
|
2027
|
-
export const generatePriceRef: FieldResolverFunction = (
|
|
2028
|
-
value: any,
|
|
2029
|
-
sourceData: any,
|
|
2030
|
-
config: any,
|
|
2031
|
-
helpers: any
|
|
2032
|
-
) => {
|
|
2033
|
-
// Generate unique ref: SKU-PRICELIST
|
|
2034
|
-
return `${sourceData.sku}-${sourceData.price_list}`;
|
|
2035
|
-
};
|
|
2036
|
-
```
|
|
2037
|
-
|
|
2038
|
-
---
|
|
2039
|
-
|
|
2040
|
-
## Testing
|
|
2041
|
-
|
|
2042
|
-
### Unit Testing Field Mappings
|
|
2043
|
-
|
|
2044
|
-
```typescript
|
|
2045
|
-
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
2046
|
-
import * as fs from 'fs';
|
|
2047
|
-
|
|
2048
|
-
describe('Location Mapping', () => {
|
|
2049
|
-
let mapper: UniversalMapper;
|
|
2050
|
-
|
|
2051
|
-
beforeEach(() => {
|
|
2052
|
-
const config = JSON.parse(fs.readFileSync('config/location-mapping.json', 'utf-8'));
|
|
2053
|
-
mapper = new UniversalMapper(config);
|
|
2054
|
-
});
|
|
2055
|
-
|
|
2056
|
-
it('should map location data correctly', async () => {
|
|
2057
|
-
const sourceData = {
|
|
2058
|
-
location_id: 'LOC001',
|
|
2059
|
-
location_name: 'Downtown Store',
|
|
2060
|
-
type: 'store',
|
|
2061
|
-
status: 'active',
|
|
2062
|
-
address_line1: '123 Main St',
|
|
2063
|
-
city: 'New York',
|
|
2064
|
-
state: 'NY',
|
|
2065
|
-
zip: '10001',
|
|
2066
|
-
country: 'US',
|
|
2067
|
-
latitude: '40.7128',
|
|
2068
|
-
longitude: '-74.0060',
|
|
2069
|
-
};
|
|
2070
|
-
|
|
2071
|
-
const result = await mapper.map(sourceData);
|
|
2072
|
-
|
|
2073
|
-
expect(result.success).toBe(true);
|
|
2074
|
-
expect(result.data).toMatchObject({
|
|
2075
|
-
ref: 'LOC001',
|
|
2076
|
-
name: 'Downtown Store',
|
|
2077
|
-
type: 'STORE',
|
|
2078
|
-
status: 'ACTIVE',
|
|
2079
|
-
primaryAddress: {
|
|
2080
|
-
street: '123 Main St',
|
|
2081
|
-
city: 'New York',
|
|
2082
|
-
state: 'NY',
|
|
2083
|
-
postcode: '10001',
|
|
2084
|
-
country: 'US',
|
|
2085
|
-
},
|
|
2086
|
-
coordinates: {
|
|
2087
|
-
latitude: 40.7128,
|
|
2088
|
-
longitude: -74.006,
|
|
2089
|
-
},
|
|
2090
|
-
});
|
|
2091
|
-
});
|
|
2092
|
-
|
|
2093
|
-
it('should handle required field validation', async () => {
|
|
2094
|
-
const sourceData = {
|
|
2095
|
-
location_name: 'Store Without ID',
|
|
2096
|
-
// Missing location_id (required)
|
|
2097
|
-
};
|
|
2098
|
-
|
|
2099
|
-
const result = await mapper.map(sourceData);
|
|
2100
|
-
|
|
2101
|
-
expect(result.success).toBe(false);
|
|
2102
|
-
expect(result.errors).toContain("Required field 'ref' is missing or empty");
|
|
2103
|
-
});
|
|
2104
|
-
|
|
2105
|
-
it('should apply default values', async () => {
|
|
2106
|
-
const sourceData = {
|
|
2107
|
-
location_id: 'LOC001',
|
|
2108
|
-
location_name: 'Store',
|
|
2109
|
-
type: 'STORE',
|
|
2110
|
-
// Missing status - should default to "ACTIVE"
|
|
2111
|
-
};
|
|
2112
|
-
|
|
2113
|
-
const result = await mapper.map(sourceData);
|
|
2114
|
-
|
|
2115
|
-
expect(result.success).toBe(true);
|
|
2116
|
-
expect(result.data.status).toBe('ACTIVE');
|
|
2117
|
-
});
|
|
2118
|
-
});
|
|
2119
|
-
```
|
|
2120
|
-
|
|
2121
|
-
### Integration Testing ETL Pipeline
|
|
2122
|
-
|
|
2123
|
-
```typescript
|
|
2124
|
-
import { masterDataETL } from './location-etl';
|
|
2125
|
-
import { S3DataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
2126
|
-
import * as fs from 'fs';
|
|
2127
|
-
|
|
2128
|
-
describe('Location ETL Integration', () => {
|
|
2129
|
-
let s3DataSource: S3DataSource;
|
|
2130
|
-
|
|
2131
|
-
beforeEach(() => {
|
|
2132
|
-
s3DataSource = new S3DataSource(
|
|
2133
|
-
{
|
|
2134
|
-
type: 'S3_CSV',
|
|
2135
|
-
s3Config: {
|
|
2136
|
-
bucket: 'test-bucket',
|
|
2137
|
-
region: 'us-east-1',
|
|
2138
|
-
accessKeyId: process.env.TEST_AWS_KEY!,
|
|
2139
|
-
secretAccessKey: process.env.TEST_AWS_SECRET!,
|
|
2140
|
-
},
|
|
2141
|
-
},
|
|
2142
|
-
console
|
|
2143
|
-
);
|
|
2144
|
-
});
|
|
2145
|
-
|
|
2146
|
-
it('should process location CSV file end-to-end', async () => {
|
|
2147
|
-
// Upload test file to S3
|
|
2148
|
-
const testData = `location_id,location_name,type,status
|
|
2149
|
-
LOC001,Test Store,STORE,ACTIVE
|
|
2150
|
-
LOC002,Test Warehouse,WAREHOUSE,ACTIVE`;
|
|
2151
|
-
|
|
2152
|
-
await s3DataSource.uploadFile('locations/test.csv', testData, { contentType: 'text/csv' });
|
|
2153
|
-
|
|
2154
|
-
// Run ETL
|
|
2155
|
-
await masterDataETL('config/location-etl-config.json');
|
|
2156
|
-
|
|
2157
|
-
// Verify locations were created (query Fluent API)
|
|
2158
|
-
const result = await fluentClient.graphql({
|
|
2159
|
-
query: `
|
|
2160
|
-
query {
|
|
2161
|
-
locations(first: 10, filter: { ref: { in: ["LOC001", "LOC002"] } }) {
|
|
2162
|
-
edges {
|
|
2163
|
-
node {
|
|
2164
|
-
ref
|
|
2165
|
-
name
|
|
2166
|
-
type
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
`,
|
|
2172
|
-
});
|
|
2173
|
-
|
|
2174
|
-
expect(result.data.locations.edges).toHaveLength(2);
|
|
2175
|
-
expect(result.data.locations.edges[0].node.ref).toBe('LOC001');
|
|
2176
|
-
}, 30000); // 30s timeout for integration test
|
|
2177
|
-
});
|
|
2178
|
-
```
|
|
2179
|
-
|
|
2180
|
-
### End-to-End Testing
|
|
2181
|
-
|
|
2182
|
-
```typescript
|
|
2183
|
-
import { masterDataETL } from './location-etl';
|
|
2184
|
-
|
|
2185
|
-
describe('Location ETL E2E', () => {
|
|
2186
|
-
it('should handle full ETL lifecycle', async () => {
|
|
2187
|
-
// 1. Upload test data to S3
|
|
2188
|
-
// 2. Trigger ETL process
|
|
2189
|
-
// 3. Verify data in Fluent
|
|
2190
|
-
// 4. Verify state tracking (file marked as processed)
|
|
2191
|
-
// 5. Verify idempotency (re-running doesn't duplicate)
|
|
2192
|
-
// TODO: Implement full E2E test scenario
|
|
2193
|
-
});
|
|
2194
|
-
});
|
|
2195
|
-
```
|
|
2196
|
-
|
|
2197
|
-
---
|
|
2198
|
-
|
|
2199
|
-
## Common Issues
|
|
2200
|
-
|
|
2201
|
-
### Issue: Duplicate Records Created
|
|
2202
|
-
|
|
2203
|
-
**Symptom**: Same entity created multiple times
|
|
2204
|
-
|
|
2205
|
-
**Root Cause**: State management not working or file processed multiple times
|
|
2206
|
-
|
|
2207
|
-
**Solution:**
|
|
2208
|
-
|
|
2209
|
-
```typescript
|
|
2210
|
-
// Ensure state service is initialized
|
|
2211
|
-
const kvAdapter = new VersoriKVAdapter(openKv());
|
|
2212
|
-
const stateService = new StateService(logger);
|
|
2213
|
-
|
|
2214
|
-
// Check BEFORE processing
|
|
2215
|
-
const fileKey = `${entityType}:${file.name}`;
|
|
2216
|
-
if (await stateService.isFileProcessed(kvAdapter, fileKey)) {
|
|
2217
|
-
logger.info(`Skipping already processed file: ${file.name}`);
|
|
2218
|
-
continue;
|
|
2219
|
-
}
|
|
2220
|
-
|
|
2221
|
-
// Mark AFTER successful processing
|
|
2222
|
-
await stateService.updateSyncState(
|
|
2223
|
-
kvAdapter,
|
|
2224
|
-
[
|
|
2225
|
-
{
|
|
2226
|
-
fileName: file.name,
|
|
2227
|
-
lastModified: new Date().toISOString(),
|
|
2228
|
-
recordCount: records.length,
|
|
2229
|
-
},
|
|
2230
|
-
],
|
|
2231
|
-
'master-data-etl'
|
|
2232
|
-
);
|
|
2233
|
-
```
|
|
2234
|
-
|
|
2235
|
-
### Issue: Field Mapping Errors
|
|
2236
|
-
|
|
2237
|
-
**Symptom**: "Required field missing" or "Mapping failed"
|
|
2238
|
-
|
|
2239
|
-
**Root Cause**: Source field name doesn't match configuration
|
|
2240
|
-
|
|
2241
|
-
**Solution:**
|
|
2242
|
-
|
|
2243
|
-
```typescript
|
|
2244
|
-
// Debug source data structure
|
|
2245
|
-
logger.debug('Source data:', JSON.stringify(sourceData, null, 2));
|
|
2246
|
-
|
|
2247
|
-
// Check field names match exactly (case-sensitive)
|
|
2248
|
-
{
|
|
2249
|
-
"ref": {
|
|
2250
|
-
"source": "location_id", // Must match CSV header exactly
|
|
2251
|
-
"required": true
|
|
2252
|
-
}
|
|
2253
|
-
}
|
|
2254
|
-
|
|
2255
|
-
// Use custom resolver for flexible field access
|
|
2256
|
-
{
|
|
2257
|
-
"ref": {
|
|
2258
|
-
"resolver": "custom.extractRef",
|
|
2259
|
-
"required": true
|
|
2260
|
-
}
|
|
2261
|
-
}
|
|
2262
|
-
```
|
|
2263
|
-
|
|
2264
|
-
### Issue: Large File Memory Issues
|
|
2265
|
-
|
|
2266
|
-
**Symptom**: Out of memory errors with large CSV/JSON files
|
|
2267
|
-
|
|
2268
|
-
**Root Cause**: Loading entire file into memory
|
|
2269
|
-
|
|
2270
|
-
**Solution:**
|
|
2271
|
-
|
|
2272
|
-
```typescript
|
|
2273
|
-
// Use streaming parsers for large files
|
|
2274
|
-
const csvParser = new CSVParserService();
|
|
2275
|
-
|
|
2276
|
-
// Parse with streaming (yields records one-by-one)
|
|
2277
|
-
for await (const record of csvParser.parseStreaming(fileContent)) {
|
|
2278
|
-
const result = await mapper.map(record);
|
|
2279
|
-
if (result.success) {
|
|
2280
|
-
await loadRecord(result.data);
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
// Or batch process
|
|
2285
|
-
for await (const batch of csvParser.parseStreaming(fileContent, {}, 100)) {
|
|
2286
|
-
await loadBatch(batch);
|
|
2287
|
-
}
|
|
2288
|
-
```
|
|
2289
|
-
|
|
2290
|
-
### Issue: GraphQL Mutation Timeouts
|
|
2291
|
-
|
|
2292
|
-
**Symptom**: Mutations timing out for large datasets
|
|
2293
|
-
|
|
2294
|
-
**Root Cause**: Synchronous processing of many records
|
|
2295
|
-
|
|
2296
|
-
**Solution:**
|
|
2297
|
-
|
|
2298
|
-
```typescript
|
|
2299
|
-
// Use batching and concurrency limits
|
|
2300
|
-
import pLimit from 'p-limit';
|
|
2301
|
-
|
|
2302
|
-
const limit = pLimit(5); // Max 5 concurrent mutations
|
|
2303
|
-
|
|
2304
|
-
const promises = records.map(record => limit(() => createViaGraphQL(record)));
|
|
2305
|
-
|
|
2306
|
-
await Promise.all(promises);
|
|
2307
|
-
|
|
2308
|
-
// Or use Event API for async processing
|
|
2309
|
-
await loadViaEventAPI(records, 'LOCATION_CREATED', logger);
|
|
2310
|
-
```
|
|
2311
|
-
|
|
2312
|
-
### Issue: Type Coercion Errors
|
|
2313
|
-
|
|
2314
|
-
**Symptom**: "Expected number, got string" in GraphQL mutations
|
|
2315
|
-
|
|
2316
|
-
**Root Cause**: CSV parsers return all values as strings
|
|
2317
|
-
|
|
2318
|
-
**Solution:**
|
|
2319
|
-
|
|
2320
|
-
```typescript
|
|
2321
|
-
// Use SDK resolvers for type coercion
|
|
2322
|
-
{
|
|
2323
|
-
"latitude": {
|
|
2324
|
-
"source": "latitude",
|
|
2325
|
-
"resolver": "sdk.parseFloat" // String → number
|
|
2326
|
-
},
|
|
2327
|
-
"active": {
|
|
2328
|
-
"source": "is_active",
|
|
2329
|
-
"resolver": "sdk.boolean" // "true" → true
|
|
2330
|
-
},
|
|
2331
|
-
"quantity": {
|
|
2332
|
-
"source": "qty",
|
|
2333
|
-
"resolver": "sdk.parseInt" // "10" → 10
|
|
2334
|
-
}
|
|
2335
|
-
}
|
|
2336
|
-
```
|
|
2337
|
-
|
|
2338
|
-
---
|
|
2339
|
-
|
|
2340
|
-
## Related Guides
|
|
2341
|
-
|
|
2342
|
-
### SDK Documentation
|
|
2343
|
-
|
|
2344
|
-
- [Universal Mapping Guide](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Complete field mapping reference
|
|
2345
|
-
- [S3 Data Source](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - S3 integration details
|
|
2346
|
-
- [SFTP Data Source](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - SFTP integration details
|
|
2347
|
-
- [SFTP Credential Access Security](../../02-CORE-GUIDES/data-sources/data-sources-sftp-credential-access-security.md) - Secure credential handling for SFTP
|
|
2348
|
-
- [CSV Parser](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - CSV parsing options
|
|
2349
|
-
- [JSON Parser](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - JSON parsing options
|
|
2350
|
-
- [State Management](../../02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-state-management.md) - Preventing duplicates
|
|
2351
|
-
|
|
2352
|
-
### Use Case Patterns
|
|
2353
|
-
|
|
2354
|
-
- [Inventory Ingestion](../../02-CORE-GUIDES/ingestion/ingestion-readme.md) - Similar pattern for inventory data
|
|
2355
|
-
- [Order Integration](../../03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-readme.md) - Transaction data vs master data
|
|
2356
|
-
- [Catalog Sync](../../01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-location-graphql.md) - Product catalog patterns
|
|
2357
|
-
|
|
2358
|
-
### Platform Integration
|
|
2359
|
-
|
|
2360
|
-
- [Versori Connector Guide](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Deploy ETL as connector
|
|
2361
|
-
- [Webhook Triggers](../../04-REFERENCE/platforms/versori/modules/platforms-versori-04-workflows.md#webhook-functions-receiving-external-requests) - Event-driven ETL
|
|
2362
|
-
- [Scheduled Jobs](../../04-REFERENCE/platforms/versori/modules/platforms-versori-04-workflows.md#scheduled-functions-time-based-recurring-tasks) - Periodic ETL execution
|
|
2363
|
-
|
|
2364
|
-
---
|
|
2365
|
-
|
|
2366
|
-
## Summary
|
|
2367
|
-
|
|
2368
|
-
This **Master Data ETL Pattern** provides a **generic, configuration-driven framework** for loading ANY entity type into Fluent Commerce.
|
|
2369
|
-
|
|
2370
|
-
**Key Takeaways:**
|
|
2371
|
-
|
|
2372
|
-
1. ✅ **One Pattern for All Entities**: Same code works for locations, products, controls, carriers, etc.
|
|
2373
|
-
2. ✅ **Configuration-Driven**: No code changes needed for new entity types
|
|
2374
|
-
3. ✅ **Four-Phase Pipeline**: Extract → Parse → Transform → Load
|
|
2375
|
-
4. ✅ **Multiple Source Formats**: CSV, JSON, XML support
|
|
2376
|
-
5. ✅ **Multiple Load Strategies**: GraphQL mutations or Event API
|
|
2377
|
-
6. ✅ **Production-Ready**: State management, error handling, logging
|
|
2378
|
-
|
|
2379
|
-
**Getting Started:**
|
|
2380
|
-
|
|
2381
|
-
1. Copy the generic ETL code
|
|
2382
|
-
2. Create field mapping configuration for your entity
|
|
2383
|
-
3. Configure data source (S3/SFTP)
|
|
2384
|
-
4. Run ETL pipeline
|
|
2385
|
-
5. Monitor logs and verify data in Fluent
|
|
2386
|
-
|
|
2387
|
-
**Next Steps:**
|
|
2388
|
-
|
|
2389
|
-
- Review the [Universal Mapping Guide](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) for advanced mapping patterns
|
|
2390
|
-
- Explore [S3 Data Source](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) for source configuration options
|
|
2391
|
-
- Check [Versori Connector Guide](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) to deploy as a production connector
|
|
2392
|
-
|
|
2393
|
-
---
|
|
2394
|
-
|
|
2395
|
-
**Need Help?**
|
|
2396
|
-
|
|
2397
|
-
- 📖 Documentation: `fc-connect-sdk/docs/`
|
|
2398
|
-
- 💬 Support: Fluent Commerce support team
|
|
2399
|
-
- 🐛 Issues: GitHub repository issues
|
|
1
|
+
# Pattern: Master Data ETL - Generic Framework for Loading Any Entity
|
|
2
|
+
|
|
3
|
+
**FC Connect SDK Use Case Guide**
|
|
4
|
+
|
|
5
|
+
> **SDK**: [@fluentcommerce/fc-connect-sdk](https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk)
|
|
6
|
+
> **Version**: Use latest - `npm install @fluentcommerce/fc-connect-sdk@latest`
|
|
7
|
+
|
|
8
|
+
**Status**: Production Ready
|
|
9
|
+
|
|
10
|
+
**Complexity**: Intermediate
|
|
11
|
+
|
|
12
|
+
**Est. Time**: 30-60 minutes
|
|
13
|
+
|
|
14
|
+
**Use Cases**: Locations, Products, Controls, Carriers, Customers, Pricing, Categories, etc.
|
|
15
|
+
|
|
16
|
+
## Table of Contents
|
|
17
|
+
|
|
18
|
+
- [Overview](#overview)
|
|
19
|
+
- [What You'll Build](#what-youll-build)
|
|
20
|
+
- [SDK Methods Used](#sdk-methods-used)
|
|
21
|
+
- [The Generic Pattern](#the-generic-pattern)
|
|
22
|
+
- [Example 1: Location Master Data](#example-1-location-master-data)
|
|
23
|
+
- [Example 2: Product Catalog](#example-2-product-catalog)
|
|
24
|
+
- [Example 3: Control/Config Data](#example-3-controlconfig-data)
|
|
25
|
+
- [Source Strategies](#source-strategies)
|
|
26
|
+
- [Load Strategies](#load-strategies)
|
|
27
|
+
- [Configuration Schema](#configuration-schema)
|
|
28
|
+
- [Extending to Other Entities](#extending-to-other-entities)
|
|
29
|
+
- [Testing](#testing)
|
|
30
|
+
- [Common Issues](#common-issues)
|
|
31
|
+
- [Related Guides](#related-guides)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Overview
|
|
36
|
+
|
|
37
|
+
Master data ETL is the process of extracting reference data from external systems and loading it into Fluent Commerce. Unlike transactional data (orders, inventory updates), master data changes infrequently and defines the core entities of your commerce platform.
|
|
38
|
+
|
|
39
|
+
**Common Master Data Entities:**
|
|
40
|
+
|
|
41
|
+
- **Locations**: Stores, warehouses, distribution centers
|
|
42
|
+
- **Products**: SKUs, product catalogs, variants
|
|
43
|
+
- **Controls**: Business rules, configuration parameters
|
|
44
|
+
- **Carriers**: Shipping carriers, service levels
|
|
45
|
+
- **Customers**: Customer profiles, segments
|
|
46
|
+
- **Categories**: Product taxonomies, merchandising hierarchies
|
|
47
|
+
- **Pricing**: Price lists, promotional rules
|
|
48
|
+
|
|
49
|
+
**Why This Pattern?**
|
|
50
|
+
|
|
51
|
+
This guide provides a **generic, configuration-driven framework** that works for ANY master data entity. Instead of writing custom code for each entity type, you configure JSON mappings and reuse the same pipeline.
|
|
52
|
+
|
|
53
|
+
**Key Benefits:**
|
|
54
|
+
|
|
55
|
+
- ✅ **One Pattern for All Entities**: Same code works for locations, products, controls, etc.
|
|
56
|
+
- ✅ **Configuration-Driven**: No code changes needed for new entity types
|
|
57
|
+
- ✅ **Multiple Source Formats**: CSV, JSON, XML support out-of-the-box
|
|
58
|
+
- ✅ **Multiple Load Strategies**: GraphQL mutations or Event API
|
|
59
|
+
- ✅ **Automatic Deduplication**: State management prevents duplicate loads
|
|
60
|
+
- ✅ **Production-Ready Error Handling**: Comprehensive logging and retry logic
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## What You'll Build
|
|
65
|
+
|
|
66
|
+
A **reusable ETL framework** with these capabilities:
|
|
67
|
+
|
|
68
|
+
1. **Extract**: Read master data from S3/SFTP in CSV, JSON, or XML format
|
|
69
|
+
2. **Parse**: Convert file format to JavaScript objects
|
|
70
|
+
3. **Transform**: Map source fields to Fluent schema using field mappings
|
|
71
|
+
4. **Load**: Submit to Fluent via GraphQL mutations or Event API
|
|
72
|
+
5. **Track**: Prevent duplicate processing using state management
|
|
73
|
+
|
|
74
|
+
**Pipeline Flow:**
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Source File (S3/SFTP)
|
|
78
|
+
↓
|
|
79
|
+
Extract (DataSource)
|
|
80
|
+
↓
|
|
81
|
+
Parse (CSVParserService/JSONParser/XMLParser)
|
|
82
|
+
↓
|
|
83
|
+
Transform (UniversalMapper)
|
|
84
|
+
↓
|
|
85
|
+
Load (GraphQL Mutation or Event API)
|
|
86
|
+
↓
|
|
87
|
+
Track (StateService)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Configuration-Driven Design:**
|
|
91
|
+
|
|
92
|
+
Instead of hardcoded logic:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// ❌ WRONG: Hardcoded for each entity type
|
|
96
|
+
if (entityType === 'location') {
|
|
97
|
+
// Custom location loading logic
|
|
98
|
+
} else if (entityType === 'product') {
|
|
99
|
+
// Custom product loading logic
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
You use JSON configuration:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// ✅ CORRECT: Generic pipeline with config
|
|
107
|
+
const config = loadConfig('location-mapping.json');
|
|
108
|
+
await etlPipeline.run(sourceFile, config);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
> Tips:
|
|
112
|
+
>
|
|
113
|
+
> - For identifiers that may look numeric (SKU/GTIN/UPC), add `resolver: "sdk.toString"` in mappings to force string output.
|
|
114
|
+
> - When parsing XML sources where leading zeros matter, configure the XML parser with `parseNumbers: false` to prevent numeric coercion.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## SDK Methods Used
|
|
119
|
+
|
|
120
|
+
| Method | Purpose | Pattern |
|
|
121
|
+
| ---------------------------------------------------------- | -------------------------------------- | --------------- |
|
|
122
|
+
| `S3DataSource.downloadFile()` | Download master data file from S3 | Source |
|
|
123
|
+
| `SftpDataSource.downloadFile()` | Download master data file from SFTP | Source |
|
|
124
|
+
| `CSVParserService.parse()` | Parse CSV master data | Parse |
|
|
125
|
+
| `JSONParserService.parse()` | Parse JSON master data | Parse |
|
|
126
|
+
| `XMLParserService.parse()` | Parse XML master data | Parse |
|
|
127
|
+
| `UniversalMapper.map()` | Transform source data to Fluent schema | Transform |
|
|
128
|
+
| `GraphQLMutationMapper.map()` | Generate GraphQL mutations from data | Load (Option 1) |
|
|
129
|
+
| `FluentClient.graphql()` | Execute GraphQL mutations | Load (Option 1) |
|
|
130
|
+
| `FluentClient.sendEvent()` | Send events to Event API | Load (Option 2) |
|
|
131
|
+
| `StateService.markFileProcessed(kv, fileName, workflowId)` | Prevent duplicate processing | Track |
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## The Generic Pattern
|
|
136
|
+
|
|
137
|
+
### Architecture Overview
|
|
138
|
+
|
|
139
|
+
The master data ETL pattern follows a **four-phase pipeline** that adapts to any entity type through configuration:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
143
|
+
│ PHASE 1: EXTRACT │
|
|
144
|
+
│ - List files from S3/SFTP │
|
|
145
|
+
│ - Filter by pattern (*.csv, locations_*.json, etc.) │
|
|
146
|
+
│ - Download file content │
|
|
147
|
+
│ - Skip already-processed files (state check) │
|
|
148
|
+
└──────────────────────────────────────────────────────────────┘
|
|
149
|
+
↓
|
|
150
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
151
|
+
│ PHASE 2: PARSE │
|
|
152
|
+
│ - Auto-detect format (CSV, JSON, XML) │
|
|
153
|
+
│ - Parse content to JavaScript objects │
|
|
154
|
+
│ - Validate structure (required fields, types) │
|
|
155
|
+
│ - Handle parsing errors gracefully │
|
|
156
|
+
└──────────────────────────────────────────────────────────────┘
|
|
157
|
+
↓
|
|
158
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
159
|
+
│ PHASE 3: TRANSFORM │
|
|
160
|
+
│ - Apply field mappings (source to Fluent schema) │
|
|
161
|
+
│ - Execute resolvers (transformations, calculations) │
|
|
162
|
+
│ - Validate required fields │
|
|
163
|
+
│ - Enrich with defaults/constants │
|
|
164
|
+
└──────────────────────────────────────────────────────────────┘
|
|
165
|
+
↓
|
|
166
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
167
|
+
│ PHASE 4: LOAD │
|
|
168
|
+
│ - Choose strategy (GraphQL Mutation vs Event API) │
|
|
169
|
+
│ - Batch if needed (large datasets) │
|
|
170
|
+
│ - Execute load operation │
|
|
171
|
+
│ - Handle errors and retries │
|
|
172
|
+
│ - Mark file as processed (state update) │
|
|
173
|
+
└──────────────────────────────────────────────────────────────┘
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Configuration-Driven Design
|
|
177
|
+
|
|
178
|
+
**Core Principle**: All entity-specific logic lives in JSON configuration files, not in code.
|
|
179
|
+
|
|
180
|
+
**Generic Configuration Structure:**
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"entityType": "location", // What you're loading
|
|
185
|
+
"sourceConfig": {
|
|
186
|
+
// Where to get data
|
|
187
|
+
"type": "S3_CSV",
|
|
188
|
+
"bucket": "master-data",
|
|
189
|
+
"prefix": "locations/",
|
|
190
|
+
"filePattern": "*.csv"
|
|
191
|
+
},
|
|
192
|
+
"parseConfig": {
|
|
193
|
+
// How to parse data
|
|
194
|
+
"format": "csv",
|
|
195
|
+
"delimiter": ",",
|
|
196
|
+
"headers": true
|
|
197
|
+
},
|
|
198
|
+
"mappingConfig": {
|
|
199
|
+
// How to transform data
|
|
200
|
+
"version": "1.0",
|
|
201
|
+
"fields": {
|
|
202
|
+
"ref": { "source": "location_id", "required": true },
|
|
203
|
+
"name": { "source": "location_name" },
|
|
204
|
+
"status": { "value": "ACTIVE" }
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
"loadConfig": {
|
|
208
|
+
// How to load into Fluent
|
|
209
|
+
"strategy": "graphql",
|
|
210
|
+
"mutation": "createLocation",
|
|
211
|
+
"batchSize": 100
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Reusability**: Change `entityType` to "product", update field mappings → same code loads products!
|
|
217
|
+
|
|
218
|
+
### Works for ANY Entity Type
|
|
219
|
+
|
|
220
|
+
This pattern is entity-agnostic because:
|
|
221
|
+
|
|
222
|
+
1. **Generic Source Reading**: S3DataSource/SftpDataSource work with any file
|
|
223
|
+
2. **Format-Agnostic Parsing**: Parsers handle CSV/JSON/XML regardless of entity type
|
|
224
|
+
3. **Flexible Mapping**: UniversalMapper adapts to any source→target schema
|
|
225
|
+
4. **Mutation Generation**: GraphQLMutationMapper works with any mutation
|
|
226
|
+
5. **State Management**: StateService tracks processing for any entity
|
|
227
|
+
|
|
228
|
+
**Example Entity Types:**
|
|
229
|
+
|
|
230
|
+
| Entity | Source Format | Mutation | Complexity |
|
|
231
|
+
| ---------- | ------------- | ---------------- | ------------------- |
|
|
232
|
+
| Locations | CSV | `createLocation` | Simple |
|
|
233
|
+
| Products | JSON | `createProduct` | Medium (variants) |
|
|
234
|
+
| Controls | JSON | `createControl` | Simple |
|
|
235
|
+
| Carriers | XML | `createCarrier` | Simple |
|
|
236
|
+
| Categories | JSON | `createCategory` | Medium (hierarchy) |
|
|
237
|
+
| Customers | CSV | `createCustomer` | Medium (attributes) |
|
|
238
|
+
|
|
239
|
+
All use the **same pipeline** with different **configurations**.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Example 1: Location Master Data
|
|
244
|
+
|
|
245
|
+
### Overview
|
|
246
|
+
|
|
247
|
+
Load store/warehouse locations from CSV files into Fluent Commerce.
|
|
248
|
+
|
|
249
|
+
**Business Context:**
|
|
250
|
+
|
|
251
|
+
- Retail locations change infrequently (openings, closings, updates)
|
|
252
|
+
- Source: Retail management system exports CSV daily
|
|
253
|
+
- Destination: Fluent Location entities
|
|
254
|
+
- Frequency: Daily batch, event-driven on new file
|
|
255
|
+
|
|
256
|
+
### Source Data Formats
|
|
257
|
+
|
|
258
|
+
**CSV Format:**
|
|
259
|
+
|
|
260
|
+
```csv
|
|
261
|
+
location_id,location_name,type,address_line1,city,state,zip,country,latitude,longitude,status
|
|
262
|
+
LOC001,Downtown Store,STORE,123 Main St,New York,NY,10001,US,40.7128,-74.0060,ACTIVE
|
|
263
|
+
LOC002,Warehouse East,WAREHOUSE,456 Industrial Rd,Newark,NJ,07102,US,40.7357,-74.1724,ACTIVE
|
|
264
|
+
LOC003,Pop-Up Shop,STORE,789 Fashion Ave,New York,NY,10018,US,40.7549,-73.9840,INACTIVE
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**JSON Format:**
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"locations": [
|
|
272
|
+
{
|
|
273
|
+
"locationId": "LOC001",
|
|
274
|
+
"name": "Downtown Store",
|
|
275
|
+
"type": "STORE",
|
|
276
|
+
"address": {
|
|
277
|
+
"street": "123 Main St",
|
|
278
|
+
"city": "New York",
|
|
279
|
+
"state": "NY",
|
|
280
|
+
"zip": "10001",
|
|
281
|
+
"country": "US"
|
|
282
|
+
},
|
|
283
|
+
"coordinates": {
|
|
284
|
+
"lat": 40.7128,
|
|
285
|
+
"lng": -74.006
|
|
286
|
+
},
|
|
287
|
+
"status": "ACTIVE"
|
|
288
|
+
}
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**XML Format:**
|
|
294
|
+
|
|
295
|
+
```xml
|
|
296
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
297
|
+
<LocationFeed>
|
|
298
|
+
<Location>
|
|
299
|
+
<ID>LOC001</ID>
|
|
300
|
+
<Name>Downtown Store</Name>
|
|
301
|
+
<Type>STORE</Type>
|
|
302
|
+
<Address>
|
|
303
|
+
<Line1>123 Main St</Line1>
|
|
304
|
+
<City>New York</City>
|
|
305
|
+
<State>NY</State>
|
|
306
|
+
<Zip>10001</Zip>
|
|
307
|
+
<Country>US</Country>
|
|
308
|
+
</Address>
|
|
309
|
+
<Latitude>40.7128</Latitude>
|
|
310
|
+
<Longitude>-74.0060</Longitude>
|
|
311
|
+
<Status>ACTIVE</Status>
|
|
312
|
+
</Location>
|
|
313
|
+
</LocationFeed>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Field Mapping Configuration
|
|
317
|
+
|
|
318
|
+
**`config/location-mapping.json`:**
|
|
319
|
+
|
|
320
|
+
```json
|
|
321
|
+
{
|
|
322
|
+
"version": "1.0",
|
|
323
|
+
"description": "Map external location data to Fluent Location schema",
|
|
324
|
+
"fields": {
|
|
325
|
+
"ref": {
|
|
326
|
+
"source": "location_id",
|
|
327
|
+
"required": true,
|
|
328
|
+
"resolver": "sdk.trim"
|
|
329
|
+
},
|
|
330
|
+
"type": {
|
|
331
|
+
"source": "type",
|
|
332
|
+
"required": true,
|
|
333
|
+
"resolver": "sdk.uppercase"
|
|
334
|
+
},
|
|
335
|
+
"name": {
|
|
336
|
+
"source": "location_name",
|
|
337
|
+
"required": true
|
|
338
|
+
},
|
|
339
|
+
"status": {
|
|
340
|
+
"source": "status",
|
|
341
|
+
"defaultValue": "ACTIVE",
|
|
342
|
+
"resolver": "sdk.uppercase"
|
|
343
|
+
},
|
|
344
|
+
"primaryAddress": {
|
|
345
|
+
"fields": {
|
|
346
|
+
"street": { "source": "address_line1" },
|
|
347
|
+
"city": { "source": "city" },
|
|
348
|
+
"state": { "source": "state" },
|
|
349
|
+
"postcode": { "source": "zip" },
|
|
350
|
+
"country": { "source": "country" }
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
"coordinates": {
|
|
354
|
+
"fields": {
|
|
355
|
+
"latitude": { "source": "latitude", "resolver": "sdk.parseFloat" },
|
|
356
|
+
"longitude": { "source": "longitude", "resolver": "sdk.parseFloat" }
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
"retailerId": {
|
|
360
|
+
"value": "${RETAILER_ID}",
|
|
361
|
+
"required": true
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Key Features:**
|
|
368
|
+
|
|
369
|
+
- ✅ Required field validation (`ref`, `type`, `name`)
|
|
370
|
+
- ✅ Default values (`status` defaults to "ACTIVE")
|
|
371
|
+
- ✅ Built-in resolvers (`sdk.trim`, `sdk.uppercase`, `sdk.parseFloat`)
|
|
372
|
+
- ✅ Nested object mapping (`primaryAddress`, `coordinates`)
|
|
373
|
+
- ✅ Environment variable support (`${RETAILER_ID}`)
|
|
374
|
+
|
|
375
|
+
### GraphQL Mutation Approach
|
|
376
|
+
|
|
377
|
+
**Target Mutation:**
|
|
378
|
+
|
|
379
|
+
```graphql
|
|
380
|
+
mutation CreateLocation($input: CreateLocationInput!) {
|
|
381
|
+
createLocation(input: $input) {
|
|
382
|
+
id
|
|
383
|
+
ref
|
|
384
|
+
name
|
|
385
|
+
status
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
**Mutation Variables (after transformation):**
|
|
391
|
+
|
|
392
|
+
```json
|
|
393
|
+
{
|
|
394
|
+
"input": {
|
|
395
|
+
"ref": "LOC001",
|
|
396
|
+
"type": "STORE",
|
|
397
|
+
"name": "Downtown Store",
|
|
398
|
+
"status": "ACTIVE",
|
|
399
|
+
"primaryAddress": {
|
|
400
|
+
"street": "123 Main St",
|
|
401
|
+
"city": "New York",
|
|
402
|
+
"state": "NY",
|
|
403
|
+
"postcode": "10001",
|
|
404
|
+
"country": "US"
|
|
405
|
+
},
|
|
406
|
+
"coordinates": {
|
|
407
|
+
"latitude": 40.7128,
|
|
408
|
+
"longitude": -74.006
|
|
409
|
+
},
|
|
410
|
+
"retailerId": "my-retailer"
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Complete Working Code
|
|
416
|
+
|
|
417
|
+
**`location-etl.ts`:**
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
// FC Connect SDK+
|
|
421
|
+
// Install: npm install @fluentcommerce/fc-connect-sdk@latest
|
|
422
|
+
// Docs: https://www.npmjs.com/package/@fluentcommerce/fc-connect-sdk
|
|
423
|
+
// GitHub: https://github.com/fluentcommerce/fc-connect-sdk
|
|
424
|
+
|
|
425
|
+
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
426
|
+
import { S3DataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
427
|
+
import { CSVParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
428
|
+
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
429
|
+
import { StateService, VersoriKVAdapter } from '@fluentcommerce/fc-connect-sdk';
|
|
430
|
+
// Access openKv from context: const { openKv } = ctx;
|
|
431
|
+
import * as fs from 'fs';
|
|
432
|
+
|
|
433
|
+
// Initialize state service (prevents duplicate processing)
|
|
434
|
+
// ✅ CORRECT: Access openKv from Versori context
|
|
435
|
+
export async function masterDataETL(ctx: any, configPath: string) {
|
|
436
|
+
// Load configuration
|
|
437
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
438
|
+
|
|
439
|
+
// Initialize SDK components
|
|
440
|
+
const logger = console; // Replace with proper logger
|
|
441
|
+
const { openKv } = ctx;
|
|
442
|
+
const fluentClient = await createClient({
|
|
443
|
+
config: {
|
|
444
|
+
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
445
|
+
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
446
|
+
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
447
|
+
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
448
|
+
},
|
|
449
|
+
logger,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Initialize data source (S3 in this example)
|
|
453
|
+
const s3DataSource = new S3DataSource(
|
|
454
|
+
{
|
|
455
|
+
type: 'S3_CSV',
|
|
456
|
+
s3Config: {
|
|
457
|
+
bucket: config.sourceConfig.bucket,
|
|
458
|
+
region: process.env.AWS_REGION!,
|
|
459
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
460
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
logger
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
// Initialize state service (prevents duplicate processing)
|
|
467
|
+
const kvAdapter = new VersoriKVAdapter(openKv(':project:'));
|
|
468
|
+
const stateService = new StateService(logger);
|
|
469
|
+
|
|
470
|
+
// Initialize parser based on format
|
|
471
|
+
const parser = config.parseConfig.format === 'csv' ? new CSVParserService() : null; // Add JSON/XML parsers as needed
|
|
472
|
+
|
|
473
|
+
// Initialize mapper
|
|
474
|
+
const mapper = new UniversalMapper(config.mappingConfig, { logger, fluentClient });
|
|
475
|
+
|
|
476
|
+
logger.info(`Starting ${config.entityType} ETL process`);
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
// PHASE 1: EXTRACT - List files from source
|
|
480
|
+
const files = await s3DataSource.listFiles({
|
|
481
|
+
prefix: config.sourceConfig.prefix,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
logger.info(`Found ${files.length} files to process`);
|
|
485
|
+
|
|
486
|
+
// Process each file
|
|
487
|
+
for (const file of files) {
|
|
488
|
+
const fileKey = `${config.entityType}:${file.name}`;
|
|
489
|
+
|
|
490
|
+
// Check if already processed
|
|
491
|
+
const alreadyProcessed = await stateService.isFileProcessed(kvAdapter, fileKey);
|
|
492
|
+
if (alreadyProcessed) {
|
|
493
|
+
logger.info(`Skipping already processed file: ${file.name}`);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
logger.info(`Processing file: ${file.name}`);
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
// PHASE 2: PARSE - Download and parse file
|
|
501
|
+
const fileContent = await s3DataSource.downloadFile(file.path);
|
|
502
|
+
const records = await parser!.parse(fileContent as string);
|
|
503
|
+
|
|
504
|
+
logger.info(`Parsed ${records.length} records from ${file.name}`);
|
|
505
|
+
|
|
506
|
+
// PHASE 3: TRANSFORM - Map each record
|
|
507
|
+
const transformedRecords = [];
|
|
508
|
+
for (const record of records) {
|
|
509
|
+
const result = await mapper.map(record);
|
|
510
|
+
if (result.success) {
|
|
511
|
+
transformedRecords.push(result.data);
|
|
512
|
+
} else {
|
|
513
|
+
logger.error(`Mapping failed for record:`, {
|
|
514
|
+
record,
|
|
515
|
+
errors: result.errors,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
logger.info(`Transformed ${transformedRecords.length} records successfully`);
|
|
521
|
+
|
|
522
|
+
// PHASE 4: LOAD - Submit to Fluent Commerce
|
|
523
|
+
if (config.loadConfig.strategy === 'graphql') {
|
|
524
|
+
await loadViaGraphQL(fluentClient, transformedRecords, config.loadConfig, logger);
|
|
525
|
+
} else if (config.loadConfig.strategy === 'event') {
|
|
526
|
+
await loadViaEventAPI(fluentClient, transformedRecords, config.loadConfig, logger);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Update sync state with processed file metadata
|
|
530
|
+
await stateService.updateSyncState(
|
|
531
|
+
kvAdapter,
|
|
532
|
+
[
|
|
533
|
+
{
|
|
534
|
+
fileName: file.name,
|
|
535
|
+
lastModified: new Date().toISOString(),
|
|
536
|
+
recordCount: transformedRecords.length,
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
'master-data-etl'
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
logger.info(`Successfully processed file: ${file.name}`);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
logger.error(`Failed to process file: ${file.name}`, error);
|
|
545
|
+
// Continue to next file instead of failing entire batch
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
logger.info(`${config.entityType} ETL process completed`);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
logger.error(`${config.entityType} ETL process failed`, error);
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Load data via GraphQL mutations
|
|
559
|
+
*/
|
|
560
|
+
async function loadViaGraphQL(client: any, records: any[], loadConfig: any, logger: any) {
|
|
561
|
+
const batchSize = loadConfig.batchSize || 100;
|
|
562
|
+
const mutation = loadConfig.mutation;
|
|
563
|
+
|
|
564
|
+
logger.info(`Loading ${records.length} records via GraphQL mutation: ${mutation}`);
|
|
565
|
+
|
|
566
|
+
// Process in batches
|
|
567
|
+
for (let i = 0; i < records.length; i += batchSize) {
|
|
568
|
+
const batch = records.slice(i, i + batchSize);
|
|
569
|
+
logger.info(`Processing batch ${i / batchSize + 1} (${batch.length} records)`);
|
|
570
|
+
|
|
571
|
+
// Execute mutations for batch
|
|
572
|
+
for (const record of batch) {
|
|
573
|
+
const query = `
|
|
574
|
+
mutation ${mutation}($input: ${capitalize(mutation)}Input!) {
|
|
575
|
+
${mutation}(input: $input) {
|
|
576
|
+
id
|
|
577
|
+
ref
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
`;
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
const result = await client.graphql({
|
|
584
|
+
query,
|
|
585
|
+
variables: { input: record },
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
logger.debug(`Created ${mutation}:`, result.data[mutation]);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
logger.error(`Failed to create ${mutation}:`, { record, error });
|
|
591
|
+
// Continue to next record instead of failing entire batch
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
logger.info(`GraphQL load completed`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Load data via Event API
|
|
601
|
+
*/
|
|
602
|
+
async function loadViaEventAPI(client: any, records: any[], loadConfig: any, logger: any) {
|
|
603
|
+
const eventName = loadConfig.eventName;
|
|
604
|
+
|
|
605
|
+
logger.info(`Loading ${records.length} records via Event API: ${eventName}`);
|
|
606
|
+
|
|
607
|
+
for (const record of records) {
|
|
608
|
+
try {
|
|
609
|
+
await client.sendEvent({
|
|
610
|
+
name: eventName,
|
|
611
|
+
entityRef: record.ref,
|
|
612
|
+
entityType: loadConfig.entityType,
|
|
613
|
+
retailerId: record.retailerId,
|
|
614
|
+
attributes: record,
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
logger.debug(`Sent event ${eventName} for ${record.ref}`);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
logger.error(`Failed to send event for ${record.ref}:`, error);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
logger.info(`Event API load completed`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Helper function
|
|
627
|
+
function capitalize(str: string): string {
|
|
628
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Example usage
|
|
632
|
+
if (require.main === module) {
|
|
633
|
+
masterDataETL('config/location-etl-config.json')
|
|
634
|
+
.then(() => console.log('ETL completed'))
|
|
635
|
+
.catch(err => {
|
|
636
|
+
console.error('ETL failed:', err);
|
|
637
|
+
process.exit(1);
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
**Configuration File (`config/location-etl-config.json`):**
|
|
643
|
+
|
|
644
|
+
```json
|
|
645
|
+
{
|
|
646
|
+
"entityType": "location",
|
|
647
|
+
"sourceConfig": {
|
|
648
|
+
"type": "S3_CSV",
|
|
649
|
+
"bucket": "master-data",
|
|
650
|
+
"prefix": "locations/",
|
|
651
|
+
"filePattern": "*.csv"
|
|
652
|
+
},
|
|
653
|
+
"parseConfig": {
|
|
654
|
+
"format": "csv",
|
|
655
|
+
"delimiter": ",",
|
|
656
|
+
"headers": true
|
|
657
|
+
},
|
|
658
|
+
"mappingConfig": {
|
|
659
|
+
"version": "1.0",
|
|
660
|
+
"fields": {
|
|
661
|
+
"ref": { "source": "location_id", "required": true },
|
|
662
|
+
"name": { "source": "location_name", "required": true },
|
|
663
|
+
"type": { "source": "type", "required": true },
|
|
664
|
+
"status": { "source": "status", "defaultValue": "ACTIVE" },
|
|
665
|
+
"primaryAddress": {
|
|
666
|
+
"fields": {
|
|
667
|
+
"street": { "source": "address_line1" },
|
|
668
|
+
"city": { "source": "city" },
|
|
669
|
+
"state": { "source": "state" },
|
|
670
|
+
"postcode": { "source": "zip" },
|
|
671
|
+
"country": { "source": "country" }
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
"retailerId": { "value": "${RETAILER_ID}" }
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
"loadConfig": {
|
|
678
|
+
"strategy": "graphql",
|
|
679
|
+
"mutation": "createLocation",
|
|
680
|
+
"batchSize": 100
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
**Key Design Decisions:**
|
|
686
|
+
|
|
687
|
+
1. **Configuration-Driven**: All entity logic in JSON config, not code
|
|
688
|
+
2. **Error Handling**: Continue processing on individual record failures
|
|
689
|
+
3. **State Management**: Prevent duplicate processing of files
|
|
690
|
+
4. **Batching**: Process large datasets in manageable chunks
|
|
691
|
+
5. **Logging**: Comprehensive logging for debugging and monitoring
|
|
692
|
+
|
|
693
|
+
---
|
|
694
|
+
|
|
695
|
+
## Example 2: Product Catalog
|
|
696
|
+
|
|
697
|
+
### Overview
|
|
698
|
+
|
|
699
|
+
Load product catalog with variants (parent-child relationships) from JSON files.
|
|
700
|
+
|
|
701
|
+
**Business Context:**
|
|
702
|
+
|
|
703
|
+
- Product data includes parent products and child variants (size, color, etc.)
|
|
704
|
+
- Source: Product Information Management (PIM) system exports JSON
|
|
705
|
+
- Destination: Fluent Product entities
|
|
706
|
+
- Complexity: Parent-child relationships require nested mapping
|
|
707
|
+
|
|
708
|
+
### Source Data Formats
|
|
709
|
+
|
|
710
|
+
**JSON Format (with variants):**
|
|
711
|
+
|
|
712
|
+
```json
|
|
713
|
+
{
|
|
714
|
+
"products": [
|
|
715
|
+
{
|
|
716
|
+
"productId": "PROD001",
|
|
717
|
+
"name": "Classic T-Shirt",
|
|
718
|
+
"description": "Premium cotton t-shirt",
|
|
719
|
+
"brand": "MyBrand",
|
|
720
|
+
"category": "Apparel",
|
|
721
|
+
"variants": [
|
|
722
|
+
{
|
|
723
|
+
"sku": "PROD001-S-RED",
|
|
724
|
+
"size": "S",
|
|
725
|
+
"color": "Red",
|
|
726
|
+
"price": 29.99,
|
|
727
|
+
"barcode": "123456789001"
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
"sku": "PROD001-M-RED",
|
|
731
|
+
"size": "M",
|
|
732
|
+
"color": "Red",
|
|
733
|
+
"price": 29.99,
|
|
734
|
+
"barcode": "123456789002"
|
|
735
|
+
}
|
|
736
|
+
]
|
|
737
|
+
}
|
|
738
|
+
]
|
|
739
|
+
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
**CSV Format (flattened variants):**
|
|
743
|
+
|
|
744
|
+
```csv
|
|
745
|
+
product_id,product_name,sku,size,color,price,barcode
|
|
746
|
+
PROD001,Classic T-Shirt,PROD001-S-RED,S,Red,29.99,123456789001
|
|
747
|
+
PROD001,Classic T-Shirt,PROD001-M-RED,M,Red,29.99,123456789002
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### Field Mapping Configuration
|
|
751
|
+
|
|
752
|
+
**`config/product-mapping.json`:**
|
|
753
|
+
|
|
754
|
+
```json
|
|
755
|
+
{
|
|
756
|
+
"version": "1.0",
|
|
757
|
+
"description": "Map PIM product data to Fluent Product schema",
|
|
758
|
+
"fields": {
|
|
759
|
+
"ref": {
|
|
760
|
+
"source": "productId",
|
|
761
|
+
"required": true
|
|
762
|
+
},
|
|
763
|
+
"type": {
|
|
764
|
+
"value": "STANDARD",
|
|
765
|
+
"required": true
|
|
766
|
+
},
|
|
767
|
+
"name": {
|
|
768
|
+
"source": "name",
|
|
769
|
+
"required": true
|
|
770
|
+
},
|
|
771
|
+
"summary": {
|
|
772
|
+
"source": "description"
|
|
773
|
+
},
|
|
774
|
+
"gtin": {
|
|
775
|
+
"source": "barcode"
|
|
776
|
+
},
|
|
777
|
+
"status": {
|
|
778
|
+
"value": "ACTIVE"
|
|
779
|
+
},
|
|
780
|
+
"retailerId": {
|
|
781
|
+
"value": "${RETAILER_ID}"
|
|
782
|
+
},
|
|
783
|
+
"attributes": {
|
|
784
|
+
"fields": {
|
|
785
|
+
"brand": { "source": "brand" },
|
|
786
|
+
"category": { "source": "category" }
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
"variants": {
|
|
790
|
+
"source": "variants",
|
|
791
|
+
"isArray": true,
|
|
792
|
+
"fields": {
|
|
793
|
+
"ref": { "source": "$.sku", "required": true },
|
|
794
|
+
"attributes": {
|
|
795
|
+
"fields": {
|
|
796
|
+
"size": { "source": "$.size" },
|
|
797
|
+
"color": { "source": "$.color" }
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
"prices": {
|
|
801
|
+
"fields": {
|
|
802
|
+
"currency": { "value": "USD" },
|
|
803
|
+
"value": { "source": "$.price", "resolver": "sdk.parseFloat" }
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
"gtin": { "source": "$.barcode" }
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
**Key Features:**
|
|
814
|
+
|
|
815
|
+
- ✅ Parent-child relationships (`variants[]` array mapping)
|
|
816
|
+
- ✅ Relative paths within arrays (`$.sku` references current variant)
|
|
817
|
+
- ✅ Nested objects (`attributes`, `prices`)
|
|
818
|
+
- ✅ Static values (`type: "STANDARD"`, `currency: "USD"`)
|
|
819
|
+
|
|
820
|
+
### Complete Working Code
|
|
821
|
+
|
|
822
|
+
**`product-etl.ts`:**
|
|
823
|
+
|
|
824
|
+
```typescript
|
|
825
|
+
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
826
|
+
import { S3DataSource, JSONParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
827
|
+
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
828
|
+
import { StateService, VersoriKVAdapter } from '@fluentcommerce/fc-connect-sdk';
|
|
829
|
+
// Access openKv from context: const { openKv } = ctx;
|
|
830
|
+
import * as fs from 'fs';
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Product Catalog ETL with Parent-Child Relationships
|
|
834
|
+
*/
|
|
835
|
+
export async function productCatalogETL(ctx: any) {
|
|
836
|
+
const logger = console;
|
|
837
|
+
const { openKv } = ctx;
|
|
838
|
+
const fluentClient = await createClient({
|
|
839
|
+
config: {
|
|
840
|
+
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
841
|
+
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
842
|
+
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
843
|
+
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
844
|
+
},
|
|
845
|
+
logger,
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// Initialize components
|
|
849
|
+
const s3DataSource = new S3DataSource(
|
|
850
|
+
{
|
|
851
|
+
type: 'S3_CSV',
|
|
852
|
+
s3Config: {
|
|
853
|
+
bucket: 'product-catalog',
|
|
854
|
+
region: process.env.AWS_REGION!,
|
|
855
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
856
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
logger
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
const kvAdapter = new VersoriKVAdapter(openKv(':project:'));
|
|
863
|
+
const stateService = new StateService(logger);
|
|
864
|
+
|
|
865
|
+
const jsonParser = new JSONParserService();
|
|
866
|
+
|
|
867
|
+
// Load mapping configuration
|
|
868
|
+
const mappingConfig = JSON.parse(fs.readFileSync('config/product-mapping.json', 'utf-8'));
|
|
869
|
+
|
|
870
|
+
const mapper = new UniversalMapper(mappingConfig, { logger, fluentClient });
|
|
871
|
+
|
|
872
|
+
logger.info('Starting product catalog ETL');
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
// List JSON files
|
|
876
|
+
const files = await s3DataSource.listFiles({ prefix: 'products/' });
|
|
877
|
+
|
|
878
|
+
for (const file of files) {
|
|
879
|
+
if (!file.name.endsWith('.json')) continue;
|
|
880
|
+
|
|
881
|
+
const fileKey = `product:${file.name}`;
|
|
882
|
+
|
|
883
|
+
// Check if processed
|
|
884
|
+
if (await stateService.isFileProcessed(kvAdapter, fileKey)) {
|
|
885
|
+
logger.info(`Skipping processed file: ${file.name}`);
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
logger.info(`Processing file: ${file.name}`);
|
|
890
|
+
|
|
891
|
+
try {
|
|
892
|
+
// Download and parse JSON
|
|
893
|
+
const fileContent = await s3DataSource.downloadFile(file.path);
|
|
894
|
+
const data = await jsonParser.parse(fileContent as string, {
|
|
895
|
+
dataPath: 'products', // Extract products array from root
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
const products = Array.isArray(data) ? data : [data];
|
|
899
|
+
logger.info(`Parsed ${products.length} products from ${file.name}`);
|
|
900
|
+
|
|
901
|
+
// Transform and load each product
|
|
902
|
+
for (const product of products) {
|
|
903
|
+
const result = await mapper.map(product);
|
|
904
|
+
|
|
905
|
+
if (result.success) {
|
|
906
|
+
// Create parent product
|
|
907
|
+
await createProduct(fluentClient, result.data, logger);
|
|
908
|
+
|
|
909
|
+
// Create variants if present
|
|
910
|
+
if (result.data.variants && result.data.variants.length > 0) {
|
|
911
|
+
for (const variant of result.data.variants) {
|
|
912
|
+
await createVariant(fluentClient, result.data.ref, variant, logger);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
} else {
|
|
916
|
+
logger.error('Product mapping failed:', {
|
|
917
|
+
product,
|
|
918
|
+
errors: result.errors,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Mark as processed
|
|
924
|
+
await stateService.updateSyncState(
|
|
925
|
+
kvAdapter,
|
|
926
|
+
[
|
|
927
|
+
{
|
|
928
|
+
fileName: file.name,
|
|
929
|
+
lastModified: new Date().toISOString(),
|
|
930
|
+
recordCount: products.length,
|
|
931
|
+
},
|
|
932
|
+
],
|
|
933
|
+
'product-catalog-etl'
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
logger.info(`Successfully processed: ${file.name}`);
|
|
937
|
+
} catch (error) {
|
|
938
|
+
logger.error(`Failed to process file: ${file.name}`, error);
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
logger.info('Product catalog ETL completed');
|
|
944
|
+
} catch (error) {
|
|
945
|
+
logger.error('Product catalog ETL failed', error);
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Create product via GraphQL
|
|
952
|
+
*/
|
|
953
|
+
async function createProduct(client: any, productData: any, logger: any) {
|
|
954
|
+
const mutation = `
|
|
955
|
+
mutation CreateProduct($input: CreateProductInput!) {
|
|
956
|
+
createProduct(input: $input) {
|
|
957
|
+
id
|
|
958
|
+
ref
|
|
959
|
+
name
|
|
960
|
+
status
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
`;
|
|
964
|
+
|
|
965
|
+
try {
|
|
966
|
+
const result = await client.graphql({
|
|
967
|
+
query: mutation,
|
|
968
|
+
variables: { input: productData },
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
logger.info(`Created product: ${productData.ref}`, result.data.createProduct);
|
|
972
|
+
} catch (error) {
|
|
973
|
+
logger.error(`Failed to create product: ${productData.ref}`, error);
|
|
974
|
+
throw error;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Create variant via GraphQL
|
|
980
|
+
*/
|
|
981
|
+
async function createVariant(client: any, parentRef: string, variantData: any, logger: any) {
|
|
982
|
+
const mutation = `
|
|
983
|
+
mutation CreateVariant($input: CreateVariantInput!) {
|
|
984
|
+
createVariant(input: $input) {
|
|
985
|
+
id
|
|
986
|
+
ref
|
|
987
|
+
attributes
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
`;
|
|
991
|
+
|
|
992
|
+
try {
|
|
993
|
+
const result = await client.graphql({
|
|
994
|
+
query: mutation,
|
|
995
|
+
variables: {
|
|
996
|
+
input: {
|
|
997
|
+
...variantData,
|
|
998
|
+
parentRef,
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
logger.info(`Created variant: ${variantData.ref}`, result.data.createVariant);
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
logger.error(`Failed to create variant: ${variantData.ref}`, error);
|
|
1006
|
+
// Don't throw - continue with other variants
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Example usage
|
|
1011
|
+
if (require.main === module) {
|
|
1012
|
+
productCatalogETL()
|
|
1013
|
+
.then(() => console.log('ETL completed'))
|
|
1014
|
+
.catch(err => {
|
|
1015
|
+
console.error('ETL failed:', err);
|
|
1016
|
+
process.exit(1);
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
**Key Design Decisions:**
|
|
1022
|
+
|
|
1023
|
+
1. **Parent-Child Processing**: Create parent product first, then variants
|
|
1024
|
+
2. **Array Mapping**: `variants[]` notation handles nested arrays
|
|
1025
|
+
3. **Relative Paths**: `$.sku` resolves relative to current variant item
|
|
1026
|
+
4. **Error Isolation**: Variant creation failures don't stop parent processing
|
|
1027
|
+
|
|
1028
|
+
---
|
|
1029
|
+
|
|
1030
|
+
## Example 3: Control/Config Data
|
|
1031
|
+
|
|
1032
|
+
### Overview
|
|
1033
|
+
|
|
1034
|
+
Load business rules and configuration parameters from JSON files.
|
|
1035
|
+
|
|
1036
|
+
**Business Context:**
|
|
1037
|
+
|
|
1038
|
+
- Controls define business rules, thresholds, flags
|
|
1039
|
+
- Source: Configuration management system exports JSON
|
|
1040
|
+
- Destination: Fluent Control entities
|
|
1041
|
+
- Characteristics: Simple structure, validation logic important
|
|
1042
|
+
|
|
1043
|
+
### Source Data Format
|
|
1044
|
+
|
|
1045
|
+
**JSON Format:**
|
|
1046
|
+
|
|
1047
|
+
```json
|
|
1048
|
+
{
|
|
1049
|
+
"controls": [
|
|
1050
|
+
{
|
|
1051
|
+
"name": "ORDER_TIMEOUT_MINUTES",
|
|
1052
|
+
"value": "30",
|
|
1053
|
+
"type": "INTEGER",
|
|
1054
|
+
"context": "ORDER_MANAGEMENT",
|
|
1055
|
+
"description": "Order allocation timeout in minutes"
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
"name": "ENABLE_AUTO_ALLOCATION",
|
|
1059
|
+
"value": "true",
|
|
1060
|
+
"type": "BOOLEAN",
|
|
1061
|
+
"context": "ORDER_MANAGEMENT",
|
|
1062
|
+
"description": "Enable automatic order allocation"
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
"name": "DEFAULT_CARRIER",
|
|
1066
|
+
"value": "FEDEX",
|
|
1067
|
+
"type": "STRING",
|
|
1068
|
+
"context": "FULFILLMENT",
|
|
1069
|
+
"description": "Default shipping carrier"
|
|
1070
|
+
}
|
|
1071
|
+
]
|
|
1072
|
+
}
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
### Field Mapping Configuration
|
|
1076
|
+
|
|
1077
|
+
**`config/control-mapping.json`:**
|
|
1078
|
+
|
|
1079
|
+
```json
|
|
1080
|
+
{
|
|
1081
|
+
"version": "1.0",
|
|
1082
|
+
"description": "Map configuration controls to Fluent Control schema",
|
|
1083
|
+
"fields": {
|
|
1084
|
+
"name": {
|
|
1085
|
+
"source": "name",
|
|
1086
|
+
"required": true,
|
|
1087
|
+
"resolver": "sdk.uppercase"
|
|
1088
|
+
},
|
|
1089
|
+
"value": {
|
|
1090
|
+
"source": "value",
|
|
1091
|
+
"required": true,
|
|
1092
|
+
"resolver": "custom.validateControlValue"
|
|
1093
|
+
},
|
|
1094
|
+
"type": {
|
|
1095
|
+
"source": "type",
|
|
1096
|
+
"required": true,
|
|
1097
|
+
"resolver": "sdk.uppercase"
|
|
1098
|
+
},
|
|
1099
|
+
"context": {
|
|
1100
|
+
"source": "context",
|
|
1101
|
+
"defaultValue": "GLOBAL"
|
|
1102
|
+
},
|
|
1103
|
+
"description": {
|
|
1104
|
+
"source": "description"
|
|
1105
|
+
},
|
|
1106
|
+
"status": {
|
|
1107
|
+
"value": "ACTIVE"
|
|
1108
|
+
},
|
|
1109
|
+
"retailerId": {
|
|
1110
|
+
"value": "${RETAILER_ID}"
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
### Validation Logic
|
|
1117
|
+
|
|
1118
|
+
**Custom resolver for control value validation:**
|
|
1119
|
+
|
|
1120
|
+
```typescript
|
|
1121
|
+
import { FieldResolverFunction } from '@fluentcommerce/fc-connect-sdk';
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Validate control value based on type
|
|
1125
|
+
*/
|
|
1126
|
+
export const validateControlValue: FieldResolverFunction = (
|
|
1127
|
+
value: any,
|
|
1128
|
+
sourceData: any,
|
|
1129
|
+
config: any,
|
|
1130
|
+
helpers: any
|
|
1131
|
+
) => {
|
|
1132
|
+
const type = sourceData.type;
|
|
1133
|
+
|
|
1134
|
+
switch (type) {
|
|
1135
|
+
case 'INTEGER':
|
|
1136
|
+
const intValue = helpers.parseIntSafe(value, null);
|
|
1137
|
+
if (intValue === null) {
|
|
1138
|
+
throw new Error(`Invalid INTEGER value: ${value}`);
|
|
1139
|
+
}
|
|
1140
|
+
return intValue.toString();
|
|
1141
|
+
|
|
1142
|
+
case 'FLOAT':
|
|
1143
|
+
const floatValue = helpers.parseFloatSafe(value, null);
|
|
1144
|
+
if (floatValue === null) {
|
|
1145
|
+
throw new Error(`Invalid FLOAT value: ${value}`);
|
|
1146
|
+
}
|
|
1147
|
+
return floatValue.toString();
|
|
1148
|
+
|
|
1149
|
+
case 'BOOLEAN':
|
|
1150
|
+
if (!['true', 'false'].includes(String(value).toLowerCase())) {
|
|
1151
|
+
throw new Error(`Invalid BOOLEAN value: ${value}`);
|
|
1152
|
+
}
|
|
1153
|
+
return String(value).toLowerCase();
|
|
1154
|
+
|
|
1155
|
+
case 'STRING':
|
|
1156
|
+
return String(value);
|
|
1157
|
+
|
|
1158
|
+
case 'JSON':
|
|
1159
|
+
try {
|
|
1160
|
+
JSON.parse(value);
|
|
1161
|
+
return value;
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
throw new Error(`Invalid JSON value: ${value}`);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
default:
|
|
1167
|
+
helpers.log.warn(`Unknown control type: ${type}, treating as STRING`);
|
|
1168
|
+
return String(value);
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
### Complete Working Code
|
|
1174
|
+
|
|
1175
|
+
**`control-etl.ts`:**
|
|
1176
|
+
|
|
1177
|
+
```typescript
|
|
1178
|
+
import { createClient } from '@fluentcommerce/fc-connect-sdk';
|
|
1179
|
+
import { S3DataSource, JSONParserService } from '@fluentcommerce/fc-connect-sdk';
|
|
1180
|
+
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
1181
|
+
import { StateService, VersoriKVAdapter } from '@fluentcommerce/fc-connect-sdk';
|
|
1182
|
+
// Access openKv from context: const { openKv } = ctx;
|
|
1183
|
+
import * as fs from 'fs';
|
|
1184
|
+
import { validateControlValue } from './resolvers/control-validators';
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Control/Config Data ETL
|
|
1188
|
+
*/
|
|
1189
|
+
export async function controlDataETL(ctx: any) {
|
|
1190
|
+
const logger = console;
|
|
1191
|
+
const { openKv } = ctx;
|
|
1192
|
+
const fluentClient = await createClient({
|
|
1193
|
+
config: {
|
|
1194
|
+
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
1195
|
+
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
1196
|
+
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
1197
|
+
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
1198
|
+
},
|
|
1199
|
+
logger,
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
// Initialize components
|
|
1203
|
+
const s3DataSource = new S3DataSource(
|
|
1204
|
+
{
|
|
1205
|
+
type: 'S3_CSV',
|
|
1206
|
+
s3Config: {
|
|
1207
|
+
bucket: 'config-data',
|
|
1208
|
+
region: process.env.AWS_REGION!,
|
|
1209
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
1210
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
logger
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1216
|
+
const kvAdapter = new VersoriKVAdapter(openKv(':project:'));
|
|
1217
|
+
const stateService = new StateService(logger);
|
|
1218
|
+
|
|
1219
|
+
const jsonParser = new JSONParserService();
|
|
1220
|
+
|
|
1221
|
+
// Load mapping configuration
|
|
1222
|
+
const mappingConfig = JSON.parse(fs.readFileSync('config/control-mapping.json', 'utf-8'));
|
|
1223
|
+
|
|
1224
|
+
// Initialize mapper with custom resolvers
|
|
1225
|
+
const mapper = new UniversalMapper(mappingConfig, {
|
|
1226
|
+
logger,
|
|
1227
|
+
fluentClient,
|
|
1228
|
+
customResolvers: {
|
|
1229
|
+
'custom.validateControlValue': validateControlValue,
|
|
1230
|
+
},
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
logger.info('Starting control data ETL');
|
|
1234
|
+
|
|
1235
|
+
try {
|
|
1236
|
+
// List JSON files
|
|
1237
|
+
const files = await s3DataSource.listFiles({ prefix: 'controls/' });
|
|
1238
|
+
|
|
1239
|
+
for (const file of files) {
|
|
1240
|
+
if (!file.name.endsWith('.json')) continue;
|
|
1241
|
+
|
|
1242
|
+
const fileKey = `control:${file.name}`;
|
|
1243
|
+
|
|
1244
|
+
// Check if processed
|
|
1245
|
+
if (await stateService.isFileProcessed(kvAdapter, fileKey)) {
|
|
1246
|
+
logger.info(`Skipping processed file: ${file.name}`);
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
logger.info(`Processing file: ${file.name}`);
|
|
1251
|
+
|
|
1252
|
+
try {
|
|
1253
|
+
// Download and parse JSON
|
|
1254
|
+
const fileContent = await s3DataSource.downloadFile(file.path);
|
|
1255
|
+
const data = await jsonParser.parse(fileContent as string, {
|
|
1256
|
+
dataPath: 'controls',
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
const controls = Array.isArray(data) ? data : [data];
|
|
1260
|
+
logger.info(`Parsed ${controls.length} controls from ${file.name}`);
|
|
1261
|
+
|
|
1262
|
+
// Transform and load each control
|
|
1263
|
+
let successCount = 0;
|
|
1264
|
+
let failureCount = 0;
|
|
1265
|
+
|
|
1266
|
+
for (const control of controls) {
|
|
1267
|
+
try {
|
|
1268
|
+
const result = await mapper.map(control);
|
|
1269
|
+
|
|
1270
|
+
if (result.success) {
|
|
1271
|
+
await createControl(fluentClient, result.data, logger);
|
|
1272
|
+
successCount++;
|
|
1273
|
+
} else {
|
|
1274
|
+
logger.error('Control mapping failed:', {
|
|
1275
|
+
control,
|
|
1276
|
+
errors: result.errors,
|
|
1277
|
+
});
|
|
1278
|
+
failureCount++;
|
|
1279
|
+
}
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
logger.error(`Failed to process control: ${control.name}`, error);
|
|
1282
|
+
failureCount++;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
logger.info(`Control processing summary:`, {
|
|
1287
|
+
total: controls.length,
|
|
1288
|
+
success: successCount,
|
|
1289
|
+
failures: failureCount,
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// Mark as processed
|
|
1293
|
+
await stateService.updateSyncState(
|
|
1294
|
+
kvAdapter,
|
|
1295
|
+
[
|
|
1296
|
+
{
|
|
1297
|
+
fileName: file.name,
|
|
1298
|
+
lastModified: new Date().toISOString(),
|
|
1299
|
+
recordCount: controls.length,
|
|
1300
|
+
},
|
|
1301
|
+
],
|
|
1302
|
+
'control-data-etl'
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
logger.info(`Successfully processed: ${file.name}`);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
logger.error(`Failed to process file: ${file.name}`, error);
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
logger.info('Control data ETL completed');
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
logger.error('Control data ETL failed', error);
|
|
1315
|
+
throw error;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Create control via GraphQL
|
|
1321
|
+
*/
|
|
1322
|
+
async function createControl(client: any, controlData: any, logger: any) {
|
|
1323
|
+
const mutation = `
|
|
1324
|
+
mutation CreateControl($input: CreateControlInput!) {
|
|
1325
|
+
createControl(input: $input) {
|
|
1326
|
+
id
|
|
1327
|
+
name
|
|
1328
|
+
value
|
|
1329
|
+
type
|
|
1330
|
+
context
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
`;
|
|
1334
|
+
|
|
1335
|
+
try {
|
|
1336
|
+
const result = await client.graphql({
|
|
1337
|
+
query: mutation,
|
|
1338
|
+
variables: { input: controlData },
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
logger.info(`Created control: ${controlData.name}`, {
|
|
1342
|
+
value: controlData.value,
|
|
1343
|
+
type: controlData.type,
|
|
1344
|
+
});
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
logger.error(`Failed to create control: ${controlData.name}`, error);
|
|
1347
|
+
throw error;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Example usage
|
|
1352
|
+
if (require.main === module) {
|
|
1353
|
+
controlDataETL()
|
|
1354
|
+
.then(() => console.log('ETL completed'))
|
|
1355
|
+
.catch(err => {
|
|
1356
|
+
console.error('ETL failed:', err);
|
|
1357
|
+
process.exit(1);
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
**Key Design Decisions:**
|
|
1363
|
+
|
|
1364
|
+
1. **Type Validation**: Custom resolver validates value based on type field
|
|
1365
|
+
2. **Error Tracking**: Count successes/failures for summary reporting
|
|
1366
|
+
3. **Atomic Processing**: Each control processed independently
|
|
1367
|
+
4. **Detailed Logging**: Track validation failures with context
|
|
1368
|
+
|
|
1369
|
+
---
|
|
1370
|
+
|
|
1371
|
+
## Source Strategies
|
|
1372
|
+
|
|
1373
|
+
### S3 with Event Notifications
|
|
1374
|
+
|
|
1375
|
+
**Use Case**: Process files as soon as they're uploaded to S3
|
|
1376
|
+
|
|
1377
|
+
**Setup:**
|
|
1378
|
+
|
|
1379
|
+
```typescript
|
|
1380
|
+
import { webhook } from '@versori/run/webhooks';
|
|
1381
|
+
import { masterDataETL } from './location-etl';
|
|
1382
|
+
|
|
1383
|
+
export const s3LocationETL = webhook('s3-location-upload', {
|
|
1384
|
+
response: { mode: 'sync' },
|
|
1385
|
+
})
|
|
1386
|
+
.then(async ({ data }) => {
|
|
1387
|
+
// Parse S3 event notification
|
|
1388
|
+
const s3Event = data.Records[0].s3;
|
|
1389
|
+
const bucket = s3Event.bucket.name;
|
|
1390
|
+
const key = decodeURIComponent(s3Event.object.key.replace(/\+/g, ' '));
|
|
1391
|
+
|
|
1392
|
+
console.log(`S3 event received: ${bucket}/${key}`);
|
|
1393
|
+
|
|
1394
|
+
// Run ETL for this specific file
|
|
1395
|
+
await masterDataETL('config/location-etl-config.json');
|
|
1396
|
+
|
|
1397
|
+
return { success: true, message: 'Location ETL completed' };
|
|
1398
|
+
})
|
|
1399
|
+
.catch(({ error }) => {
|
|
1400
|
+
console.error('S3 location ETL failed:', error);
|
|
1401
|
+
return { success: false, error: error.message };
|
|
1402
|
+
});
|
|
1403
|
+
```
|
|
1404
|
+
|
|
1405
|
+
**S3 Bucket Configuration:**
|
|
1406
|
+
|
|
1407
|
+
```json
|
|
1408
|
+
{
|
|
1409
|
+
"LambdaFunctionConfigurations": [
|
|
1410
|
+
{
|
|
1411
|
+
"LambdaFunctionArn": "arn:aws:lambda:...:function:versori-webhook",
|
|
1412
|
+
"Events": ["s3:ObjectCreated:*"],
|
|
1413
|
+
"Filter": {
|
|
1414
|
+
"Key": {
|
|
1415
|
+
"FilterRules": [
|
|
1416
|
+
{
|
|
1417
|
+
"Name": "prefix",
|
|
1418
|
+
"Value": "locations/"
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
"Name": "suffix",
|
|
1422
|
+
"Value": ".csv"
|
|
1423
|
+
}
|
|
1424
|
+
]
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
]
|
|
1429
|
+
}
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
### SFTP with Polling
|
|
1433
|
+
|
|
1434
|
+
**Use Case**: Poll SFTP server periodically for new files
|
|
1435
|
+
|
|
1436
|
+
#### SFTP Credential Access
|
|
1437
|
+
|
|
1438
|
+
**Versori Platform** has three methods for accessing SFTP credentials:
|
|
1439
|
+
|
|
1440
|
+
1. **Connection Variables (Recommended)** - Direct access to connection config:
|
|
1441
|
+
```typescript
|
|
1442
|
+
const { host, port, username, password, privateKey } = ctx.activation.connections.sftp_server;
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
2. **Credentials API** - For base64-encoded credentials:
|
|
1446
|
+
```typescript
|
|
1447
|
+
const creds = await ctx.credentials().getAccessToken('sftp_server');
|
|
1448
|
+
```
|
|
1449
|
+
|
|
1450
|
+
3. **Connection String Parsing** - Decode `connectionVariables.connectionString`:
|
|
1451
|
+
```typescript
|
|
1452
|
+
const connStr = ctx.activation.connections.sftp_server.connectionString;
|
|
1453
|
+
// Parse: sftp://username:password@host:port
|
|
1454
|
+
```
|
|
1455
|
+
|
|
1456
|
+
**Standalone Node.js/Deno**: Use environment variables directly:
|
|
1457
|
+
```typescript
|
|
1458
|
+
const config = {
|
|
1459
|
+
host: process.env.SFTP_HOST!,
|
|
1460
|
+
port: parseInt(process.env.SFTP_PORT || '22'),
|
|
1461
|
+
username: process.env.SFTP_USERNAME!,
|
|
1462
|
+
password: process.env.SFTP_PASSWORD,
|
|
1463
|
+
privateKey: process.env.SFTP_PRIVATE_KEY,
|
|
1464
|
+
};
|
|
1465
|
+
```
|
|
1466
|
+
|
|
1467
|
+
**Security Best Practices:**
|
|
1468
|
+
- Always prefer SSH keys over passwords
|
|
1469
|
+
- Never log credential values
|
|
1470
|
+
- Use scoped credentials (read-only when possible)
|
|
1471
|
+
- Rotate credentials regularly
|
|
1472
|
+
|
|
1473
|
+
**See:** [SFTP Credential Access Security Guide](../../02-CORE-GUIDES/data-sources/data-sources-sftp-credential-access-security.md) for complete details.
|
|
1474
|
+
|
|
1475
|
+
#### Setup Example
|
|
1476
|
+
|
|
1477
|
+
```typescript
|
|
1478
|
+
import { schedule } from '@versori/run/schedule';
|
|
1479
|
+
import { SftpDataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
1480
|
+
import { Buffer } from 'node:buffer'; // Required for Deno/Versori
|
|
1481
|
+
|
|
1482
|
+
export const sftpPolling = schedule('location-sftp-poll', {
|
|
1483
|
+
cron: '0 */6 * * *', // Every 6 hours
|
|
1484
|
+
retry: { attempts: 3 },
|
|
1485
|
+
}).then(async (ctx) => {
|
|
1486
|
+
const { log, activation } = ctx;
|
|
1487
|
+
|
|
1488
|
+
// Access SFTP credentials (Versori)
|
|
1489
|
+
const { host, port, username, password, privateKey } = activation.connections.sftp_server;
|
|
1490
|
+
|
|
1491
|
+
// Initialize SFTP data source
|
|
1492
|
+
const sftpSource = new SftpDataSource(
|
|
1493
|
+
{
|
|
1494
|
+
type: 'SFTP_CSV',
|
|
1495
|
+
settings: {
|
|
1496
|
+
host,
|
|
1497
|
+
port: port || 22,
|
|
1498
|
+
username,
|
|
1499
|
+
password,
|
|
1500
|
+
privateKey,
|
|
1501
|
+
remotePath: '/data/locations',
|
|
1502
|
+
filePattern: '*.csv',
|
|
1503
|
+
},
|
|
1504
|
+
},
|
|
1505
|
+
log
|
|
1506
|
+
);
|
|
1507
|
+
|
|
1508
|
+
// List new files
|
|
1509
|
+
const files = await sftpSource.listFiles();
|
|
1510
|
+
log.info(`Found ${files.length} files on SFTP`);
|
|
1511
|
+
|
|
1512
|
+
for (const file of files) {
|
|
1513
|
+
try {
|
|
1514
|
+
// Download file
|
|
1515
|
+
const content = await sftpSource.downloadFile(file.path);
|
|
1516
|
+
|
|
1517
|
+
// Process file (same ETL logic as S3)
|
|
1518
|
+
// ... (extract, parse, transform, load)
|
|
1519
|
+
|
|
1520
|
+
// Move to processed folder
|
|
1521
|
+
await sftpSource.moveFile(file.path, `/data/locations/processed/${file.name}`);
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
log.error(`Failed to process ${file.name}:`, error);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return { success: true, filesProcessed: files.length };
|
|
1528
|
+
});
|
|
1529
|
+
```
|
|
1530
|
+
|
|
1531
|
+
---
|
|
1532
|
+
|
|
1533
|
+
## Load Strategies
|
|
1534
|
+
|
|
1535
|
+
### GraphQL Mutation Approach
|
|
1536
|
+
|
|
1537
|
+
**When to Use:**
|
|
1538
|
+
|
|
1539
|
+
- Simple entity creation/updates
|
|
1540
|
+
- Direct control over mutations
|
|
1541
|
+
- Schema validation needed
|
|
1542
|
+
- Small to medium datasets (<10K records)
|
|
1543
|
+
|
|
1544
|
+
**Advantages:**
|
|
1545
|
+
|
|
1546
|
+
- ✅ Type-safe with GraphQL schema
|
|
1547
|
+
- ✅ Immediate validation feedback
|
|
1548
|
+
- ✅ Fine-grained control over mutations
|
|
1549
|
+
- ✅ Can return created IDs
|
|
1550
|
+
|
|
1551
|
+
**Example:**
|
|
1552
|
+
|
|
1553
|
+
```typescript
|
|
1554
|
+
async function loadViaGraphQL(records: any[], mutation: string, logger: any) {
|
|
1555
|
+
const client = await createClient({
|
|
1556
|
+
config: {
|
|
1557
|
+
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
1558
|
+
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
1559
|
+
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
1560
|
+
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
1561
|
+
},
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
for (const record of records) {
|
|
1565
|
+
const query = `
|
|
1566
|
+
mutation ${mutation}($input: ${capitalize(mutation)}Input!) {
|
|
1567
|
+
${mutation}(input: $input) {
|
|
1568
|
+
id
|
|
1569
|
+
ref
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
`;
|
|
1573
|
+
|
|
1574
|
+
try {
|
|
1575
|
+
const result = await client.graphql({
|
|
1576
|
+
query,
|
|
1577
|
+
variables: { input: record },
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
logger.info(`Created ${mutation}:`, result.data[mutation]);
|
|
1581
|
+
} catch (error: any) {
|
|
1582
|
+
logger.error(`Failed ${mutation}:`, {
|
|
1583
|
+
record,
|
|
1584
|
+
error: error.message,
|
|
1585
|
+
details: error.response?.errors,
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
```
|
|
1591
|
+
|
|
1592
|
+
### Event API Approach
|
|
1593
|
+
|
|
1594
|
+
**When to Use:**
|
|
1595
|
+
|
|
1596
|
+
- Asynchronous processing acceptable
|
|
1597
|
+
- Triggering workflows/rules needed
|
|
1598
|
+
- Large datasets (>10K records)
|
|
1599
|
+
- Need event-driven architecture
|
|
1600
|
+
|
|
1601
|
+
**Advantages:**
|
|
1602
|
+
|
|
1603
|
+
- ✅ Asynchronous (better for large datasets)
|
|
1604
|
+
- ✅ Triggers workflows and rules
|
|
1605
|
+
- ✅ Decoupled from mutations
|
|
1606
|
+
- ✅ Better performance for bulk loads
|
|
1607
|
+
|
|
1608
|
+
**Example:**
|
|
1609
|
+
|
|
1610
|
+
```typescript
|
|
1611
|
+
async function loadViaEventAPI(records: any[], eventName: string, logger: any) {
|
|
1612
|
+
const client = await createClient({
|
|
1613
|
+
config: {
|
|
1614
|
+
baseUrl: process.env.FLUENT_BASE_URL!,
|
|
1615
|
+
clientId: process.env.FLUENT_CLIENT_ID!,
|
|
1616
|
+
clientSecret: process.env.FLUENT_CLIENT_SECRET!,
|
|
1617
|
+
retailerId: process.env.FLUENT_RETAILER_ID!,
|
|
1618
|
+
},
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
for (const record of records) {
|
|
1622
|
+
try {
|
|
1623
|
+
await client.sendEvent({
|
|
1624
|
+
name: eventName,
|
|
1625
|
+
entityRef: record.ref,
|
|
1626
|
+
entityType: 'LOCATION',
|
|
1627
|
+
retailerId: record.retailerId,
|
|
1628
|
+
attributes: record,
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
logger.info(`Sent event ${eventName} for ${record.ref}`);
|
|
1632
|
+
} catch (error: any) {
|
|
1633
|
+
logger.error(`Failed to send event for ${record.ref}:`, error);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
```
|
|
1638
|
+
|
|
1639
|
+
**Event-Driven Workflow:**
|
|
1640
|
+
|
|
1641
|
+
```
|
|
1642
|
+
ETL Process Fluent Commerce
|
|
1643
|
+
↓
|
|
1644
|
+
Send Event (LOCATION_CREATED)
|
|
1645
|
+
↓ ↓
|
|
1646
|
+
Workflow Triggers
|
|
1647
|
+
↓
|
|
1648
|
+
Process Event
|
|
1649
|
+
↓
|
|
1650
|
+
Create Location
|
|
1651
|
+
↓
|
|
1652
|
+
Apply Business Rules
|
|
1653
|
+
↓
|
|
1654
|
+
Send Notifications
|
|
1655
|
+
```
|
|
1656
|
+
|
|
1657
|
+
---
|
|
1658
|
+
|
|
1659
|
+
## Configuration Schema
|
|
1660
|
+
|
|
1661
|
+
### Generic Configuration Template
|
|
1662
|
+
|
|
1663
|
+
Use this template for ANY entity type:
|
|
1664
|
+
|
|
1665
|
+
```json
|
|
1666
|
+
{
|
|
1667
|
+
"entityType": "<entity-name>",
|
|
1668
|
+
"description": "<what this ETL does>",
|
|
1669
|
+
"sourceConfig": {
|
|
1670
|
+
"type": "S3_CSV | SFTP_CSV | S3_JSON | SFTP_JSON",
|
|
1671
|
+
"bucket": "<s3-bucket-name>",
|
|
1672
|
+
"prefix": "<folder-prefix>",
|
|
1673
|
+
"filePattern": "*.csv | *.json | *.xml",
|
|
1674
|
+
"sftp": {
|
|
1675
|
+
"host": "${SFTP_HOST}",
|
|
1676
|
+
"port": 22,
|
|
1677
|
+
"username": "${SFTP_USERNAME}",
|
|
1678
|
+
"password": "${SFTP_PASSWORD}",
|
|
1679
|
+
"privateKey": "${SFTP_PRIVATE_KEY}", // Recommended over password
|
|
1680
|
+
"remotePath": "/data/<entity>",
|
|
1681
|
+
"filePattern": "*.<format>"
|
|
1682
|
+
}
|
|
1683
|
+
},
|
|
1684
|
+
"parseConfig": {
|
|
1685
|
+
"format": "csv | json | xml",
|
|
1686
|
+
"delimiter": "," | "|" | "\t",
|
|
1687
|
+
"headers": true | false,
|
|
1688
|
+
"encoding": "utf8 | utf16",
|
|
1689
|
+
"json": {
|
|
1690
|
+
"dataPath": "root.path.to.array",
|
|
1691
|
+
"jsonLines": false
|
|
1692
|
+
},
|
|
1693
|
+
"xml": {
|
|
1694
|
+
"itemPath": "//Item",
|
|
1695
|
+
"includeAttributes": true
|
|
1696
|
+
}
|
|
1697
|
+
},
|
|
1698
|
+
"mappingConfig": {
|
|
1699
|
+
"version": "1.0",
|
|
1700
|
+
"description": "Field mappings",
|
|
1701
|
+
"fields": {
|
|
1702
|
+
"targetField": {
|
|
1703
|
+
"source": "sourceField",
|
|
1704
|
+
"required": true | false,
|
|
1705
|
+
"defaultValue": "default",
|
|
1706
|
+
"resolver": "sdk.* | custom.*"
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
},
|
|
1710
|
+
"loadConfig": {
|
|
1711
|
+
"strategy": "graphql | event",
|
|
1712
|
+
"mutation": "createEntity | updateEntity",
|
|
1713
|
+
"eventName": "ENTITY_CREATED",
|
|
1714
|
+
"batchSize": 100,
|
|
1715
|
+
"retryAttempts": 3
|
|
1716
|
+
},
|
|
1717
|
+
"scheduleConfig": {
|
|
1718
|
+
"enabled": true,
|
|
1719
|
+
"cron": "0 0 * * *",
|
|
1720
|
+
"timezone": "UTC"
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
```
|
|
1724
|
+
|
|
1725
|
+
### How to Adapt for New Entity Types
|
|
1726
|
+
|
|
1727
|
+
**Step-by-Step Guide:**
|
|
1728
|
+
|
|
1729
|
+
1. **Copy Template**: Start with generic configuration template
|
|
1730
|
+
2. **Set Entity Type**: Change `entityType` to your entity name
|
|
1731
|
+
3. **Configure Source**: Set bucket/path for your data source
|
|
1732
|
+
4. **Configure Parser**: Set format and parsing options
|
|
1733
|
+
5. **Define Field Mappings**: Map source fields to Fluent schema
|
|
1734
|
+
6. **Configure Load Strategy**: Choose GraphQL or Event API
|
|
1735
|
+
7. **Test**: Run ETL with sample data
|
|
1736
|
+
8. **Deploy**: Schedule or trigger via webhook
|
|
1737
|
+
|
|
1738
|
+
**Example Adaptation (Customer Entity):**
|
|
1739
|
+
|
|
1740
|
+
```json
|
|
1741
|
+
{
|
|
1742
|
+
"entityType": "customer",
|
|
1743
|
+
"description": "Load customer data from CRM system",
|
|
1744
|
+
"sourceConfig": {
|
|
1745
|
+
"type": "S3_CSV",
|
|
1746
|
+
"bucket": "crm-exports",
|
|
1747
|
+
"prefix": "customers/",
|
|
1748
|
+
"filePattern": "customers_*.csv"
|
|
1749
|
+
},
|
|
1750
|
+
"parseConfig": {
|
|
1751
|
+
"format": "csv",
|
|
1752
|
+
"delimiter": ",",
|
|
1753
|
+
"headers": true
|
|
1754
|
+
},
|
|
1755
|
+
"mappingConfig": {
|
|
1756
|
+
"version": "1.0",
|
|
1757
|
+
"fields": {
|
|
1758
|
+
"ref": {
|
|
1759
|
+
"source": "customer_id",
|
|
1760
|
+
"required": true
|
|
1761
|
+
},
|
|
1762
|
+
"firstName": {
|
|
1763
|
+
"source": "first_name",
|
|
1764
|
+
"required": true
|
|
1765
|
+
},
|
|
1766
|
+
"lastName": {
|
|
1767
|
+
"source": "last_name",
|
|
1768
|
+
"required": true
|
|
1769
|
+
},
|
|
1770
|
+
"email": {
|
|
1771
|
+
"source": "email_address",
|
|
1772
|
+
"required": true,
|
|
1773
|
+
"resolver": "sdk.lowercase"
|
|
1774
|
+
},
|
|
1775
|
+
"primaryPhone": {
|
|
1776
|
+
"source": "phone_number",
|
|
1777
|
+
"resolver": "custom.formatPhoneNumber"
|
|
1778
|
+
},
|
|
1779
|
+
"retailerId": {
|
|
1780
|
+
"value": "${RETAILER_ID}"
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
},
|
|
1784
|
+
"loadConfig": {
|
|
1785
|
+
"strategy": "graphql",
|
|
1786
|
+
"mutation": "createCustomer",
|
|
1787
|
+
"batchSize": 50
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
```
|
|
1791
|
+
|
|
1792
|
+
---
|
|
1793
|
+
|
|
1794
|
+
## Extending to Other Entities
|
|
1795
|
+
|
|
1796
|
+
### Step-by-Step Guide
|
|
1797
|
+
|
|
1798
|
+
**1. Identify Entity Schema**
|
|
1799
|
+
|
|
1800
|
+
Understand the Fluent schema for your entity:
|
|
1801
|
+
|
|
1802
|
+
```graphql
|
|
1803
|
+
# Example: Carrier schema
|
|
1804
|
+
type Carrier {
|
|
1805
|
+
id: ID!
|
|
1806
|
+
ref: String!
|
|
1807
|
+
name: String!
|
|
1808
|
+
type: String!
|
|
1809
|
+
status: String
|
|
1810
|
+
services: [CarrierService]
|
|
1811
|
+
retailerId: ID!
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
input CreateCarrierInput {
|
|
1815
|
+
ref: String!
|
|
1816
|
+
name: String!
|
|
1817
|
+
type: String!
|
|
1818
|
+
status: String
|
|
1819
|
+
services: [CarrierServiceInput]
|
|
1820
|
+
retailerId: ID!
|
|
1821
|
+
}
|
|
1822
|
+
```
|
|
1823
|
+
|
|
1824
|
+
**2. Create Field Mapping Configuration**
|
|
1825
|
+
|
|
1826
|
+
Map source data to schema:
|
|
1827
|
+
|
|
1828
|
+
```json
|
|
1829
|
+
{
|
|
1830
|
+
"version": "1.0",
|
|
1831
|
+
"fields": {
|
|
1832
|
+
"ref": {
|
|
1833
|
+
"source": "carrier_code",
|
|
1834
|
+
"required": true,
|
|
1835
|
+
"resolver": "sdk.uppercase"
|
|
1836
|
+
},
|
|
1837
|
+
"name": {
|
|
1838
|
+
"source": "carrier_name",
|
|
1839
|
+
"required": true
|
|
1840
|
+
},
|
|
1841
|
+
"type": {
|
|
1842
|
+
"source": "carrier_type",
|
|
1843
|
+
"required": true
|
|
1844
|
+
},
|
|
1845
|
+
"status": {
|
|
1846
|
+
"value": "ACTIVE"
|
|
1847
|
+
},
|
|
1848
|
+
"services": {
|
|
1849
|
+
"source": "services",
|
|
1850
|
+
"isArray": true,
|
|
1851
|
+
"fields": {
|
|
1852
|
+
"name": { "source": "$.service_name" },
|
|
1853
|
+
"code": { "source": "$.service_code" },
|
|
1854
|
+
"deliveryDays": { "source": "$.delivery_days", "resolver": "sdk.parseInt" }
|
|
1855
|
+
}
|
|
1856
|
+
},
|
|
1857
|
+
"retailerId": {
|
|
1858
|
+
"value": "${RETAILER_ID}"
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
```
|
|
1863
|
+
|
|
1864
|
+
**3. Set Up Data Source**
|
|
1865
|
+
|
|
1866
|
+
Configure where data comes from:
|
|
1867
|
+
|
|
1868
|
+
```typescript
|
|
1869
|
+
const carrierSource = new S3DataSource(
|
|
1870
|
+
{
|
|
1871
|
+
type: 'S3_CSV',
|
|
1872
|
+
s3Config: {
|
|
1873
|
+
bucket: 'carrier-data',
|
|
1874
|
+
region: process.env.AWS_REGION!,
|
|
1875
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
1876
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
1877
|
+
},
|
|
1878
|
+
},
|
|
1879
|
+
logger
|
|
1880
|
+
);
|
|
1881
|
+
```
|
|
1882
|
+
|
|
1883
|
+
**4. Run Generic ETL Pipeline**
|
|
1884
|
+
|
|
1885
|
+
Use the same ETL code:
|
|
1886
|
+
|
|
1887
|
+
```typescript
|
|
1888
|
+
await masterDataETL('config/carrier-etl-config.json');
|
|
1889
|
+
```
|
|
1890
|
+
|
|
1891
|
+
### Customer Example
|
|
1892
|
+
|
|
1893
|
+
**Source CSV:**
|
|
1894
|
+
|
|
1895
|
+
```csv
|
|
1896
|
+
customer_id,first_name,last_name,email,phone,segment,status
|
|
1897
|
+
CUST001,John,Doe,john.doe@email.com,555-1234,VIP,ACTIVE
|
|
1898
|
+
CUST002,Jane,Smith,jane.smith@email.com,555-5678,STANDARD,ACTIVE
|
|
1899
|
+
```
|
|
1900
|
+
|
|
1901
|
+
**Mapping Configuration:**
|
|
1902
|
+
|
|
1903
|
+
```json
|
|
1904
|
+
{
|
|
1905
|
+
"version": "1.0",
|
|
1906
|
+
"fields": {
|
|
1907
|
+
"ref": { "source": "customer_id", "required": true },
|
|
1908
|
+
"firstName": { "source": "first_name", "required": true },
|
|
1909
|
+
"lastName": { "source": "last_name", "required": true },
|
|
1910
|
+
"email": { "source": "email", "required": true, "resolver": "sdk.lowercase" },
|
|
1911
|
+
"primaryPhone": { "source": "phone" },
|
|
1912
|
+
"status": { "source": "status", "defaultValue": "ACTIVE" },
|
|
1913
|
+
"attributes": {
|
|
1914
|
+
"fields": {
|
|
1915
|
+
"segment": { "source": "segment" }
|
|
1916
|
+
}
|
|
1917
|
+
},
|
|
1918
|
+
"retailerId": { "value": "${RETAILER_ID}" }
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
```
|
|
1922
|
+
|
|
1923
|
+
### Carrier Example
|
|
1924
|
+
|
|
1925
|
+
**Source JSON:**
|
|
1926
|
+
|
|
1927
|
+
```json
|
|
1928
|
+
{
|
|
1929
|
+
"carriers": [
|
|
1930
|
+
{
|
|
1931
|
+
"code": "FEDEX",
|
|
1932
|
+
"name": "FedEx",
|
|
1933
|
+
"type": "PARCEL",
|
|
1934
|
+
"services": [
|
|
1935
|
+
{ "service_code": "GROUND", "service_name": "FedEx Ground", "delivery_days": 3 },
|
|
1936
|
+
{ "service_code": "2DAY", "service_name": "FedEx 2 Day", "delivery_days": 2 }
|
|
1937
|
+
]
|
|
1938
|
+
}
|
|
1939
|
+
]
|
|
1940
|
+
}
|
|
1941
|
+
```
|
|
1942
|
+
|
|
1943
|
+
**Mapping Configuration:**
|
|
1944
|
+
|
|
1945
|
+
```json
|
|
1946
|
+
{
|
|
1947
|
+
"version": "1.0",
|
|
1948
|
+
"fields": {
|
|
1949
|
+
"ref": { "source": "code", "required": true },
|
|
1950
|
+
"name": { "source": "name", "required": true },
|
|
1951
|
+
"type": { "source": "type", "required": true },
|
|
1952
|
+
"status": { "value": "ACTIVE" },
|
|
1953
|
+
"services": {
|
|
1954
|
+
"source": "services",
|
|
1955
|
+
"isArray": true,
|
|
1956
|
+
"fields": {
|
|
1957
|
+
"code": { "source": "$.service_code" },
|
|
1958
|
+
"name": { "source": "$.service_name" },
|
|
1959
|
+
"transitDays": { "source": "$.delivery_days", "resolver": "sdk.parseInt" }
|
|
1960
|
+
}
|
|
1961
|
+
},
|
|
1962
|
+
"retailerId": { "value": "${RETAILER_ID}" }
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
```
|
|
1966
|
+
|
|
1967
|
+
### Pricing Example
|
|
1968
|
+
|
|
1969
|
+
**Source CSV:**
|
|
1970
|
+
|
|
1971
|
+
```csv
|
|
1972
|
+
sku,price_list,currency,base_price,sale_price,start_date,end_date
|
|
1973
|
+
PROD001-S-RED,US_RETAIL,USD,29.99,24.99,2024-01-01,2024-12-31
|
|
1974
|
+
PROD001-M-RED,US_RETAIL,USD,29.99,24.99,2024-01-01,2024-12-31
|
|
1975
|
+
```
|
|
1976
|
+
|
|
1977
|
+
**Mapping Configuration:**
|
|
1978
|
+
|
|
1979
|
+
```json
|
|
1980
|
+
{
|
|
1981
|
+
"version": "1.0",
|
|
1982
|
+
"fields": {
|
|
1983
|
+
"ref": {
|
|
1984
|
+
"resolver": "custom.generatePriceRef",
|
|
1985
|
+
"required": true
|
|
1986
|
+
},
|
|
1987
|
+
"sku": {
|
|
1988
|
+
"source": "sku",
|
|
1989
|
+
"required": true
|
|
1990
|
+
},
|
|
1991
|
+
"priceList": {
|
|
1992
|
+
"source": "price_list",
|
|
1993
|
+
"required": true
|
|
1994
|
+
},
|
|
1995
|
+
"currency": {
|
|
1996
|
+
"source": "currency",
|
|
1997
|
+
"required": true,
|
|
1998
|
+
"resolver": "sdk.uppercase"
|
|
1999
|
+
},
|
|
2000
|
+
"value": {
|
|
2001
|
+
"source": "base_price",
|
|
2002
|
+
"required": true,
|
|
2003
|
+
"resolver": "sdk.parseFloat"
|
|
2004
|
+
},
|
|
2005
|
+
"salePrice": {
|
|
2006
|
+
"source": "sale_price",
|
|
2007
|
+
"resolver": "sdk.parseFloat"
|
|
2008
|
+
},
|
|
2009
|
+
"validFrom": {
|
|
2010
|
+
"source": "start_date",
|
|
2011
|
+
"resolver": "sdk.formatDate"
|
|
2012
|
+
},
|
|
2013
|
+
"validTo": {
|
|
2014
|
+
"source": "end_date",
|
|
2015
|
+
"resolver": "sdk.formatDate"
|
|
2016
|
+
},
|
|
2017
|
+
"retailerId": {
|
|
2018
|
+
"value": "${RETAILER_ID}"
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
```
|
|
2023
|
+
|
|
2024
|
+
**Custom Resolver:**
|
|
2025
|
+
|
|
2026
|
+
```typescript
|
|
2027
|
+
export const generatePriceRef: FieldResolverFunction = (
|
|
2028
|
+
value: any,
|
|
2029
|
+
sourceData: any,
|
|
2030
|
+
config: any,
|
|
2031
|
+
helpers: any
|
|
2032
|
+
) => {
|
|
2033
|
+
// Generate unique ref: SKU-PRICELIST
|
|
2034
|
+
return `${sourceData.sku}-${sourceData.price_list}`;
|
|
2035
|
+
};
|
|
2036
|
+
```
|
|
2037
|
+
|
|
2038
|
+
---
|
|
2039
|
+
|
|
2040
|
+
## Testing
|
|
2041
|
+
|
|
2042
|
+
### Unit Testing Field Mappings
|
|
2043
|
+
|
|
2044
|
+
```typescript
|
|
2045
|
+
import { UniversalMapper } from '@fluentcommerce/fc-connect-sdk';
|
|
2046
|
+
import * as fs from 'fs';
|
|
2047
|
+
|
|
2048
|
+
describe('Location Mapping', () => {
|
|
2049
|
+
let mapper: UniversalMapper;
|
|
2050
|
+
|
|
2051
|
+
beforeEach(() => {
|
|
2052
|
+
const config = JSON.parse(fs.readFileSync('config/location-mapping.json', 'utf-8'));
|
|
2053
|
+
mapper = new UniversalMapper(config);
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
it('should map location data correctly', async () => {
|
|
2057
|
+
const sourceData = {
|
|
2058
|
+
location_id: 'LOC001',
|
|
2059
|
+
location_name: 'Downtown Store',
|
|
2060
|
+
type: 'store',
|
|
2061
|
+
status: 'active',
|
|
2062
|
+
address_line1: '123 Main St',
|
|
2063
|
+
city: 'New York',
|
|
2064
|
+
state: 'NY',
|
|
2065
|
+
zip: '10001',
|
|
2066
|
+
country: 'US',
|
|
2067
|
+
latitude: '40.7128',
|
|
2068
|
+
longitude: '-74.0060',
|
|
2069
|
+
};
|
|
2070
|
+
|
|
2071
|
+
const result = await mapper.map(sourceData);
|
|
2072
|
+
|
|
2073
|
+
expect(result.success).toBe(true);
|
|
2074
|
+
expect(result.data).toMatchObject({
|
|
2075
|
+
ref: 'LOC001',
|
|
2076
|
+
name: 'Downtown Store',
|
|
2077
|
+
type: 'STORE',
|
|
2078
|
+
status: 'ACTIVE',
|
|
2079
|
+
primaryAddress: {
|
|
2080
|
+
street: '123 Main St',
|
|
2081
|
+
city: 'New York',
|
|
2082
|
+
state: 'NY',
|
|
2083
|
+
postcode: '10001',
|
|
2084
|
+
country: 'US',
|
|
2085
|
+
},
|
|
2086
|
+
coordinates: {
|
|
2087
|
+
latitude: 40.7128,
|
|
2088
|
+
longitude: -74.006,
|
|
2089
|
+
},
|
|
2090
|
+
});
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
it('should handle required field validation', async () => {
|
|
2094
|
+
const sourceData = {
|
|
2095
|
+
location_name: 'Store Without ID',
|
|
2096
|
+
// Missing location_id (required)
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
const result = await mapper.map(sourceData);
|
|
2100
|
+
|
|
2101
|
+
expect(result.success).toBe(false);
|
|
2102
|
+
expect(result.errors).toContain("Required field 'ref' is missing or empty");
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
it('should apply default values', async () => {
|
|
2106
|
+
const sourceData = {
|
|
2107
|
+
location_id: 'LOC001',
|
|
2108
|
+
location_name: 'Store',
|
|
2109
|
+
type: 'STORE',
|
|
2110
|
+
// Missing status - should default to "ACTIVE"
|
|
2111
|
+
};
|
|
2112
|
+
|
|
2113
|
+
const result = await mapper.map(sourceData);
|
|
2114
|
+
|
|
2115
|
+
expect(result.success).toBe(true);
|
|
2116
|
+
expect(result.data.status).toBe('ACTIVE');
|
|
2117
|
+
});
|
|
2118
|
+
});
|
|
2119
|
+
```
|
|
2120
|
+
|
|
2121
|
+
### Integration Testing ETL Pipeline
|
|
2122
|
+
|
|
2123
|
+
```typescript
|
|
2124
|
+
import { masterDataETL } from './location-etl';
|
|
2125
|
+
import { S3DataSource } from '@fluentcommerce/fc-connect-sdk';
|
|
2126
|
+
import * as fs from 'fs';
|
|
2127
|
+
|
|
2128
|
+
describe('Location ETL Integration', () => {
|
|
2129
|
+
let s3DataSource: S3DataSource;
|
|
2130
|
+
|
|
2131
|
+
beforeEach(() => {
|
|
2132
|
+
s3DataSource = new S3DataSource(
|
|
2133
|
+
{
|
|
2134
|
+
type: 'S3_CSV',
|
|
2135
|
+
s3Config: {
|
|
2136
|
+
bucket: 'test-bucket',
|
|
2137
|
+
region: 'us-east-1',
|
|
2138
|
+
accessKeyId: process.env.TEST_AWS_KEY!,
|
|
2139
|
+
secretAccessKey: process.env.TEST_AWS_SECRET!,
|
|
2140
|
+
},
|
|
2141
|
+
},
|
|
2142
|
+
console
|
|
2143
|
+
);
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
it('should process location CSV file end-to-end', async () => {
|
|
2147
|
+
// Upload test file to S3
|
|
2148
|
+
const testData = `location_id,location_name,type,status
|
|
2149
|
+
LOC001,Test Store,STORE,ACTIVE
|
|
2150
|
+
LOC002,Test Warehouse,WAREHOUSE,ACTIVE`;
|
|
2151
|
+
|
|
2152
|
+
await s3DataSource.uploadFile('locations/test.csv', testData, { contentType: 'text/csv' });
|
|
2153
|
+
|
|
2154
|
+
// Run ETL
|
|
2155
|
+
await masterDataETL('config/location-etl-config.json');
|
|
2156
|
+
|
|
2157
|
+
// Verify locations were created (query Fluent API)
|
|
2158
|
+
const result = await fluentClient.graphql({
|
|
2159
|
+
query: `
|
|
2160
|
+
query {
|
|
2161
|
+
locations(first: 10, filter: { ref: { in: ["LOC001", "LOC002"] } }) {
|
|
2162
|
+
edges {
|
|
2163
|
+
node {
|
|
2164
|
+
ref
|
|
2165
|
+
name
|
|
2166
|
+
type
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
`,
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
expect(result.data.locations.edges).toHaveLength(2);
|
|
2175
|
+
expect(result.data.locations.edges[0].node.ref).toBe('LOC001');
|
|
2176
|
+
}, 30000); // 30s timeout for integration test
|
|
2177
|
+
});
|
|
2178
|
+
```
|
|
2179
|
+
|
|
2180
|
+
### End-to-End Testing
|
|
2181
|
+
|
|
2182
|
+
```typescript
|
|
2183
|
+
import { masterDataETL } from './location-etl';
|
|
2184
|
+
|
|
2185
|
+
describe('Location ETL E2E', () => {
|
|
2186
|
+
it('should handle full ETL lifecycle', async () => {
|
|
2187
|
+
// 1. Upload test data to S3
|
|
2188
|
+
// 2. Trigger ETL process
|
|
2189
|
+
// 3. Verify data in Fluent
|
|
2190
|
+
// 4. Verify state tracking (file marked as processed)
|
|
2191
|
+
// 5. Verify idempotency (re-running doesn't duplicate)
|
|
2192
|
+
// TODO: Implement full E2E test scenario
|
|
2193
|
+
});
|
|
2194
|
+
});
|
|
2195
|
+
```
|
|
2196
|
+
|
|
2197
|
+
---
|
|
2198
|
+
|
|
2199
|
+
## Common Issues
|
|
2200
|
+
|
|
2201
|
+
### Issue: Duplicate Records Created
|
|
2202
|
+
|
|
2203
|
+
**Symptom**: Same entity created multiple times
|
|
2204
|
+
|
|
2205
|
+
**Root Cause**: State management not working or file processed multiple times
|
|
2206
|
+
|
|
2207
|
+
**Solution:**
|
|
2208
|
+
|
|
2209
|
+
```typescript
|
|
2210
|
+
// Ensure state service is initialized
|
|
2211
|
+
const kvAdapter = new VersoriKVAdapter(openKv());
|
|
2212
|
+
const stateService = new StateService(logger);
|
|
2213
|
+
|
|
2214
|
+
// Check BEFORE processing
|
|
2215
|
+
const fileKey = `${entityType}:${file.name}`;
|
|
2216
|
+
if (await stateService.isFileProcessed(kvAdapter, fileKey)) {
|
|
2217
|
+
logger.info(`Skipping already processed file: ${file.name}`);
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// Mark AFTER successful processing
|
|
2222
|
+
await stateService.updateSyncState(
|
|
2223
|
+
kvAdapter,
|
|
2224
|
+
[
|
|
2225
|
+
{
|
|
2226
|
+
fileName: file.name,
|
|
2227
|
+
lastModified: new Date().toISOString(),
|
|
2228
|
+
recordCount: records.length,
|
|
2229
|
+
},
|
|
2230
|
+
],
|
|
2231
|
+
'master-data-etl'
|
|
2232
|
+
);
|
|
2233
|
+
```
|
|
2234
|
+
|
|
2235
|
+
### Issue: Field Mapping Errors
|
|
2236
|
+
|
|
2237
|
+
**Symptom**: "Required field missing" or "Mapping failed"
|
|
2238
|
+
|
|
2239
|
+
**Root Cause**: Source field name doesn't match configuration
|
|
2240
|
+
|
|
2241
|
+
**Solution:**
|
|
2242
|
+
|
|
2243
|
+
```typescript
|
|
2244
|
+
// Debug source data structure
|
|
2245
|
+
logger.debug('Source data:', JSON.stringify(sourceData, null, 2));
|
|
2246
|
+
|
|
2247
|
+
// Check field names match exactly (case-sensitive)
|
|
2248
|
+
{
|
|
2249
|
+
"ref": {
|
|
2250
|
+
"source": "location_id", // Must match CSV header exactly
|
|
2251
|
+
"required": true
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// Use custom resolver for flexible field access
|
|
2256
|
+
{
|
|
2257
|
+
"ref": {
|
|
2258
|
+
"resolver": "custom.extractRef",
|
|
2259
|
+
"required": true
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
```
|
|
2263
|
+
|
|
2264
|
+
### Issue: Large File Memory Issues
|
|
2265
|
+
|
|
2266
|
+
**Symptom**: Out of memory errors with large CSV/JSON files
|
|
2267
|
+
|
|
2268
|
+
**Root Cause**: Loading entire file into memory
|
|
2269
|
+
|
|
2270
|
+
**Solution:**
|
|
2271
|
+
|
|
2272
|
+
```typescript
|
|
2273
|
+
// Use streaming parsers for large files
|
|
2274
|
+
const csvParser = new CSVParserService();
|
|
2275
|
+
|
|
2276
|
+
// Parse with streaming (yields records one-by-one)
|
|
2277
|
+
for await (const record of csvParser.parseStreaming(fileContent)) {
|
|
2278
|
+
const result = await mapper.map(record);
|
|
2279
|
+
if (result.success) {
|
|
2280
|
+
await loadRecord(result.data);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Or batch process
|
|
2285
|
+
for await (const batch of csvParser.parseStreaming(fileContent, {}, 100)) {
|
|
2286
|
+
await loadBatch(batch);
|
|
2287
|
+
}
|
|
2288
|
+
```
|
|
2289
|
+
|
|
2290
|
+
### Issue: GraphQL Mutation Timeouts
|
|
2291
|
+
|
|
2292
|
+
**Symptom**: Mutations timing out for large datasets
|
|
2293
|
+
|
|
2294
|
+
**Root Cause**: Synchronous processing of many records
|
|
2295
|
+
|
|
2296
|
+
**Solution:**
|
|
2297
|
+
|
|
2298
|
+
```typescript
|
|
2299
|
+
// Use batching and concurrency limits
|
|
2300
|
+
import pLimit from 'p-limit';
|
|
2301
|
+
|
|
2302
|
+
const limit = pLimit(5); // Max 5 concurrent mutations
|
|
2303
|
+
|
|
2304
|
+
const promises = records.map(record => limit(() => createViaGraphQL(record)));
|
|
2305
|
+
|
|
2306
|
+
await Promise.all(promises);
|
|
2307
|
+
|
|
2308
|
+
// Or use Event API for async processing
|
|
2309
|
+
await loadViaEventAPI(records, 'LOCATION_CREATED', logger);
|
|
2310
|
+
```
|
|
2311
|
+
|
|
2312
|
+
### Issue: Type Coercion Errors
|
|
2313
|
+
|
|
2314
|
+
**Symptom**: "Expected number, got string" in GraphQL mutations
|
|
2315
|
+
|
|
2316
|
+
**Root Cause**: CSV parsers return all values as strings
|
|
2317
|
+
|
|
2318
|
+
**Solution:**
|
|
2319
|
+
|
|
2320
|
+
```typescript
|
|
2321
|
+
// Use SDK resolvers for type coercion
|
|
2322
|
+
{
|
|
2323
|
+
"latitude": {
|
|
2324
|
+
"source": "latitude",
|
|
2325
|
+
"resolver": "sdk.parseFloat" // String → number
|
|
2326
|
+
},
|
|
2327
|
+
"active": {
|
|
2328
|
+
"source": "is_active",
|
|
2329
|
+
"resolver": "sdk.boolean" // "true" → true
|
|
2330
|
+
},
|
|
2331
|
+
"quantity": {
|
|
2332
|
+
"source": "qty",
|
|
2333
|
+
"resolver": "sdk.parseInt" // "10" → 10
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
```
|
|
2337
|
+
|
|
2338
|
+
---
|
|
2339
|
+
|
|
2340
|
+
## Related Guides
|
|
2341
|
+
|
|
2342
|
+
### SDK Documentation
|
|
2343
|
+
|
|
2344
|
+
- [Universal Mapping Guide](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Complete field mapping reference
|
|
2345
|
+
- [S3 Data Source](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - S3 integration details
|
|
2346
|
+
- [SFTP Data Source](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - SFTP integration details
|
|
2347
|
+
- [SFTP Credential Access Security](../../02-CORE-GUIDES/data-sources/data-sources-sftp-credential-access-security.md) - Secure credential handling for SFTP
|
|
2348
|
+
- [CSV Parser](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - CSV parsing options
|
|
2349
|
+
- [JSON Parser](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - JSON parsing options
|
|
2350
|
+
- [State Management](../../02-CORE-GUIDES/ingestion/modules/02-core-guides-ingestion-07-state-management.md) - Preventing duplicates
|
|
2351
|
+
|
|
2352
|
+
### Use Case Patterns
|
|
2353
|
+
|
|
2354
|
+
- [Inventory Ingestion](../../02-CORE-GUIDES/ingestion/ingestion-readme.md) - Similar pattern for inventory data
|
|
2355
|
+
- [Order Integration](../../03-PATTERN-GUIDES/connector-scenarios/connector-scenarios-readme.md) - Transaction data vs master data
|
|
2356
|
+
- [Catalog Sync](../../01-TEMPLATES/versori/workflows/ingestion/graphql-mutations/template-ingestion-s3-csv-location-graphql.md) - Product catalog patterns
|
|
2357
|
+
|
|
2358
|
+
### Platform Integration
|
|
2359
|
+
|
|
2360
|
+
- [Versori Connector Guide](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) - Deploy ETL as connector
|
|
2361
|
+
- [Webhook Triggers](../../04-REFERENCE/platforms/versori/modules/platforms-versori-04-workflows.md#webhook-functions-receiving-external-requests) - Event-driven ETL
|
|
2362
|
+
- [Scheduled Jobs](../../04-REFERENCE/platforms/versori/modules/platforms-versori-04-workflows.md#scheduled-functions-time-based-recurring-tasks) - Periodic ETL execution
|
|
2363
|
+
|
|
2364
|
+
---
|
|
2365
|
+
|
|
2366
|
+
## Summary
|
|
2367
|
+
|
|
2368
|
+
This **Master Data ETL Pattern** provides a **generic, configuration-driven framework** for loading ANY entity type into Fluent Commerce.
|
|
2369
|
+
|
|
2370
|
+
**Key Takeaways:**
|
|
2371
|
+
|
|
2372
|
+
1. ✅ **One Pattern for All Entities**: Same code works for locations, products, controls, carriers, etc.
|
|
2373
|
+
2. ✅ **Configuration-Driven**: No code changes needed for new entity types
|
|
2374
|
+
3. ✅ **Four-Phase Pipeline**: Extract → Parse → Transform → Load
|
|
2375
|
+
4. ✅ **Multiple Source Formats**: CSV, JSON, XML support
|
|
2376
|
+
5. ✅ **Multiple Load Strategies**: GraphQL mutations or Event API
|
|
2377
|
+
6. ✅ **Production-Ready**: State management, error handling, logging
|
|
2378
|
+
|
|
2379
|
+
**Getting Started:**
|
|
2380
|
+
|
|
2381
|
+
1. Copy the generic ETL code
|
|
2382
|
+
2. Create field mapping configuration for your entity
|
|
2383
|
+
3. Configure data source (S3/SFTP)
|
|
2384
|
+
4. Run ETL pipeline
|
|
2385
|
+
5. Monitor logs and verify data in Fluent
|
|
2386
|
+
|
|
2387
|
+
**Next Steps:**
|
|
2388
|
+
|
|
2389
|
+
- Review the [Universal Mapping Guide](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) for advanced mapping patterns
|
|
2390
|
+
- Explore [S3 Data Source](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) for source configuration options
|
|
2391
|
+
- Check [Versori Connector Guide](../../02-CORE-GUIDES/advanced-services/advanced-services-readme.md) to deploy as a production connector
|
|
2392
|
+
|
|
2393
|
+
---
|
|
2394
|
+
|
|
2395
|
+
**Need Help?**
|
|
2396
|
+
|
|
2397
|
+
- 📖 Documentation: `fc-connect-sdk/docs/`
|
|
2398
|
+
- 💬 Support: Fluent Commerce support team
|
|
2399
|
+
- 🐛 Issues: GitHub repository issues
|