Seed the dev store's collections from a production snapshot
The AFK half of the wayfinder ticket "Seed the dev store from the live catalogue". Products, metafields and images still come from the CSV export — the CSV re-hosts images for free — but collection membership cannot: the Collection column is import-only, takes one collection per product, and this catalogue runs 1.6 memberships per product across 16 collections. - scripts/seed-dev-store.mjs (`npm run seed:dev-store`): creates each missing collection with its members in one call and publishes it. Uses CollectionCreateInput.sources because collectionAddProducts is deprecated at 2026-10, and publishes because collectionCreate leaves a collection unpublished — invisible to the Storefront API the dev store exists to serve. Idempotent, never deletes, reports handles it cannot find (which is how a partial CSV import shows up), and is a dry run unless given --apply. - assets/dev-store-seed.json: the baseline the seed is checked against and the membership map it consumes — 61 products with option values, weights and product types, 16 collections with 100 memberships, and the 10 metafield definitions that must exist before the import. - The ticket now records what the probes settled: the option shape is readable from the Storefront API (all 61 use Option1 name=Title; 22 are `Default Title`, 39 carry real values, so the import must not normalise), and the repo's production credentials are Storefront-scoped only — `products`, `collections` and `productsCount` are Access denied without read_products — so the ticket's "Admin API script using the credentials the repo already mints" was never available. A dev-store custom app is required instead.
This commit is contained in:
1 parent
de0553d0d4
commit
1bc1d5f753
4 files changed
+1354
-2
No files matched your search
@@ -0,0 +1,928 @@
|
||||
{
|
||||
"generatedAt": "2026-10-14T03:46:20.941Z",
|
||||
"source": "e21i4d-0q.myshopify.com (production) via Storefront API 2026-10",
|
||||
"productCount": 61,
|
||||
"collectionCount": 16,
|
||||
"membershipTotal": 100,
|
||||
"productsWithoutWeight": 41,
|
||||
"productsWithoutProductType": 61,
|
||||
"metafieldDefinitions": [
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "ingredients",
|
||||
"type": "multi_line_text_field",
|
||||
"populated": 20
|
||||
},
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "energy_kcal",
|
||||
"type": "number_decimal",
|
||||
"populated": 20
|
||||
},
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "protein_g",
|
||||
"type": "number_decimal",
|
||||
"populated": 20
|
||||
},
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "total_fat_g",
|
||||
"type": "number_decimal",
|
||||
"populated": 20
|
||||
},
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "saturated_fat_g",
|
||||
"type": "number_decimal",
|
||||
"populated": 20
|
||||
},
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "carbohydrate_g",
|
||||
"type": "number_decimal",
|
||||
"populated": 20
|
||||
},
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "sodium_mg",
|
||||
"type": "number_decimal",
|
||||
"populated": 20
|
||||
},
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "sugar_g",
|
||||
"type": "number_decimal",
|
||||
"populated": 18
|
||||
},
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "added_sugar_g",
|
||||
"type": "number_decimal",
|
||||
"populated": 17
|
||||
},
|
||||
{
|
||||
"namespace": "custom",
|
||||
"key": "tagline",
|
||||
"type": "single_line_text_field",
|
||||
"populated": 1
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"handle": "pistachio-dragees",
|
||||
"title": "Pistachio Dragees",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/47801725092086",
|
||||
"weight": 200,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-fruits-nuts-45",
|
||||
"title": "Mozimo Fruits & Nuts 45%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946279670",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-hazelnut-dragees-60",
|
||||
"title": "Mozimo Hazelnut Dragees 60%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946312438",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-pistachio-dragees",
|
||||
"title": "Mozimo Pistachio Dragees",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946345206",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-whole-hazelnuts-bark-60",
|
||||
"title": "Mozimo Whole Hazelnuts Bark 60%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946377974",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-whole-hazelnuts-bark-45",
|
||||
"title": "Mozimo Whole Hazelnuts Bark 45%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946410742",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-hazelnut-dragees-45",
|
||||
"title": "Mozimo Hazelnut Dragees 45%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946476278",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-gems-bark",
|
||||
"title": "Mozimo Gems Bark",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946509046",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-crunchy-granola-bark-45",
|
||||
"title": "Mozimo Crunchy Granola Bark 45%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946541814",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-almond-bark-60",
|
||||
"title": "Mozimo Almond Bark 60%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946574582",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-almond-bark-45",
|
||||
"title": "Mozimo Almond Bark 45%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946607350",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-almond-dragees-45",
|
||||
"title": "Mozimo Almond Dragees 45%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946640118",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-almond-slivers-rocks",
|
||||
"title": "Mozimo Almond Slivers Rocks",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946672886",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-raspberry-bark",
|
||||
"title": "Mozimo Raspberry Bark",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946705654",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-nutty-bar",
|
||||
"title": "Mozimo Nutty Bar",
|
||||
"optionName": "Title",
|
||||
"optionValue": "1",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946738422",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-hazelnuts-spread",
|
||||
"title": "Mozimo Single Origin 45% Hazelnut Spread",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946771190",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-hot-chocolate-premix",
|
||||
"title": "Mozimo Hot Chocolate Premix",
|
||||
"optionName": "Title",
|
||||
"optionValue": "250gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946803958",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-almond-dragees-60",
|
||||
"title": "Mozimo Almond Dragees 60%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946869494",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-white-chocolate-tuilles",
|
||||
"title": "Mozimo White Chocolate Tuilles",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946902262",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-milk-45-tuilles",
|
||||
"title": "Mozimo Milk 45% Tuilles",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806946935030",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-hazelnut-praline-bark",
|
||||
"title": "Mozimo Hazelnut Praline Bark",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947000566",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-pecan-bark",
|
||||
"title": "Mozimo Pecan Bark",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947033334",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-rice-crispy-bark",
|
||||
"title": "Mozimo Rice crispy Bark",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947066102",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-dark-chocolate-bar-72",
|
||||
"title": "Mozimo Dark Chocolate Bar 72%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "60gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947098870",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-praline-panetela",
|
||||
"title": "Mozimo Praline Panetela",
|
||||
"optionName": "Title",
|
||||
"optionValue": "12pc",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947131638",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-honey-comb",
|
||||
"title": "Mozimo Honey Comb",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947164406",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-almond-praline-bark",
|
||||
"title": "Mozimo Almond Praline Bark",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947197174",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-dark-chocolate-bar-60",
|
||||
"title": "Mozimo Dark Chocolate Bar 60%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "60gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947295478",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-dark-chocolate-bar-85",
|
||||
"title": "Mozimo Dark Chocolate Bar 85%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "60gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947328246",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-milk-chocolate-bar-45",
|
||||
"title": "Mozimo Milk Chocolate Bar 45%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "60gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947361014",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-orangettes",
|
||||
"title": "Mozimo Orangettes",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947393782",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "single-origin-coco-nibs",
|
||||
"title": "Single Origin Coco Nibs",
|
||||
"optionName": "Title",
|
||||
"optionValue": "150gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947426550",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "cocoa-bean-shell",
|
||||
"title": "Cocoa Bean Shell",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947459318",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "4-pralines-box",
|
||||
"title": "4 Pralines Box",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947557622",
|
||||
"weight": 80,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "9-pralines-box",
|
||||
"title": "9 Pralines Box",
|
||||
"optionName": "Title",
|
||||
"optionValue": "1",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947590390",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-assorted-bark-box",
|
||||
"title": "Mozimo Assorted Bark Box",
|
||||
"optionName": "Title",
|
||||
"optionValue": "1",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947623158",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-sugar-free-dark-72-chocolate-bar",
|
||||
"title": "Mozimo Sugar Free Dark 72% Chocolate Bar",
|
||||
"optionName": "Title",
|
||||
"optionValue": "60gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947655926",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-hazelnut-bark-sugar-free-45",
|
||||
"title": "Mozimo Hazelnut Bark Sugar Free 45%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947721462",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "pistachio-spread-60",
|
||||
"title": "Pistachio Spread 60%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "200gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947754230",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-salted-butter-crunch-bark-45",
|
||||
"title": "Mozimo Salted Butter Crunch Bark 45%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947786998",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-fruits-and-nuts-bark-60",
|
||||
"title": "Mozimo Fruits and Nuts Bark 60%",
|
||||
"optionName": "Title",
|
||||
"optionValue": "100gm",
|
||||
"variantGid": "gid://shopify/ProductVariant/47806947852534",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "bars-box",
|
||||
"title": "Bars Box",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49078921101558",
|
||||
"weight": 450,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "spreads-box",
|
||||
"title": "Spreads Box",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079091003638",
|
||||
"weight": 1210,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-carnival-celebration-s",
|
||||
"title": "Mozimo Carnival Celebration (S)",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079145595126",
|
||||
"weight": 1680,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-carnival-celebration-m",
|
||||
"title": "Mozimo Carnival Celebration (M)",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079147725046",
|
||||
"weight": 1800,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-carnival-celebration-l",
|
||||
"title": "Mozimo Carnival Celebration (L)",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079154082038",
|
||||
"weight": 2330,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-celestial-s",
|
||||
"title": "Mozimo Celestial (S)",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079192748278",
|
||||
"weight": 2100,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-celestial-l",
|
||||
"title": "Mozimo Celestial Hamper",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079213293814",
|
||||
"weight": 5500,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "200-gas-bark-box",
|
||||
"title": "200 gms Bark Box",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079244456182",
|
||||
"weight": 380,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "kunafa-chocolate-bar",
|
||||
"title": "Kunafa Chocolate Bar",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079263101174",
|
||||
"weight": 370,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "1-kg-bark-box",
|
||||
"title": "1 kg Bark Box",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079272046838",
|
||||
"weight": 2100,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-praline-panetela-1",
|
||||
"title": "Mozimo Praline Panetela (Box of 12)",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079278239990",
|
||||
"weight": 375,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-praline-box-of-25",
|
||||
"title": "Mozimo Praline (Box of 25)",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079282696438",
|
||||
"weight": 550,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-praline-box-of-36",
|
||||
"title": "Mozimo Praline (Box of 36)",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49079289741558",
|
||||
"weight": 770,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-sugar-free-milk-45-chocolate-bar",
|
||||
"title": "Mozimo Sugar Free Milk 45% Chocolate Bar",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49080727372022",
|
||||
"weight": 70,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-matcha-collection-box",
|
||||
"title": "Mozimo Matcha Collection Box",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49947063976182",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "matcha-strawberry-praline",
|
||||
"title": "Matcha Strawberry Praline (Box of 4)",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/49956310417654",
|
||||
"weight": 0,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-matcha-bark",
|
||||
"title": "Mozimo Matcha Bark Box 200gm",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/50112483590390",
|
||||
"weight": 200,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "matcha-hazelnut-dragees-100gm",
|
||||
"title": "Matcha Hazelnut Dragees 100gm",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/50112861831414",
|
||||
"weight": 100,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-chocolate-muesli-200gm",
|
||||
"title": "Mozimo Chocolate Muesli 200gm",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/50137431736566",
|
||||
"weight": 200,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-chocolate-muesli-400gm",
|
||||
"title": "Mozimo Chocolate Muesli 400gm",
|
||||
"optionName": "Title",
|
||||
"optionValue": "Default Title",
|
||||
"variantGid": "gid://shopify/ProductVariant/50137463685366",
|
||||
"weight": 400,
|
||||
"weightUnit": "GRAMS",
|
||||
"productType": null,
|
||||
"tags": []
|
||||
}
|
||||
],
|
||||
"collections": [
|
||||
{
|
||||
"handle": "frontpage",
|
||||
"title": "Home page",
|
||||
"members": [
|
||||
"mozimo-chocolate-muesli-400gm",
|
||||
"mozimo-chocolate-muesli-200gm",
|
||||
"mozimo-matcha-bark",
|
||||
"mozimo-matcha-collection-box",
|
||||
"mozimo-celestial-l"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "dragees",
|
||||
"title": "Dragees",
|
||||
"members": [
|
||||
"mozimo-almond-dragees-60",
|
||||
"mozimo-almond-dragees-45",
|
||||
"mozimo-hazelnut-dragees-60",
|
||||
"mozimo-hazelnut-dragees-45",
|
||||
"pistachio-dragees",
|
||||
"matcha-hazelnut-dragees-100gm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "bars",
|
||||
"title": "Bars",
|
||||
"members": [
|
||||
"mozimo-dark-chocolate-bar-85",
|
||||
"mozimo-dark-chocolate-bar-72",
|
||||
"mozimo-dark-chocolate-bar-60",
|
||||
"mozimo-milk-chocolate-bar-45",
|
||||
"mozimo-sugar-free-dark-72-chocolate-bar",
|
||||
"mozimo-sugar-free-milk-45-chocolate-bar",
|
||||
"mozimo-nutty-bar"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "spreads",
|
||||
"title": "Spreads",
|
||||
"members": ["mozimo-hazelnuts-spread", "pistachio-spread-60"]
|
||||
},
|
||||
{
|
||||
"handle": "best-sellers",
|
||||
"title": "Best Sellers",
|
||||
"members": [
|
||||
"mozimo-hot-chocolate-premix",
|
||||
"200-gas-bark-box",
|
||||
"kunafa-chocolate-bar",
|
||||
"mozimo-celestial-l",
|
||||
"mozimo-carnival-celebration-m",
|
||||
"mozimo-dark-chocolate-bar-60",
|
||||
"mozimo-sugar-free-dark-72-chocolate-bar",
|
||||
"mozimo-hazelnut-dragees-45",
|
||||
"mozimo-orangettes",
|
||||
"mozimo-praline-box-of-36",
|
||||
"mozimo-praline-panetela-1",
|
||||
"mozimo-hazelnuts-spread",
|
||||
"pistachio-spread-60",
|
||||
"mozimo-matcha-collection-box",
|
||||
"mozimo-matcha-bark",
|
||||
"matcha-hazelnut-dragees-100gm",
|
||||
"mozimo-chocolate-muesli-400gm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "new-arrivals",
|
||||
"title": "New Arrivals",
|
||||
"members": [
|
||||
"mozimo-matcha-collection-box",
|
||||
"mozimo-chocolate-muesli-200gm",
|
||||
"mozimo-chocolate-muesli-400gm",
|
||||
"mozimo-celestial-l"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "gift-collection",
|
||||
"title": "Gift Collection",
|
||||
"members": [
|
||||
"mozimo-celestial-l",
|
||||
"1-kg-bark-box",
|
||||
"mozimo-celestial-s",
|
||||
"mozimo-carnival-celebration-l",
|
||||
"mozimo-matcha-collection-box",
|
||||
"mozimo-carnival-celebration-m",
|
||||
"mozimo-carnival-celebration-s",
|
||||
"200-gas-bark-box",
|
||||
"bars-box",
|
||||
"9-pralines-box"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "bark",
|
||||
"title": "Barks",
|
||||
"members": [
|
||||
"mozimo-almond-bark-45",
|
||||
"mozimo-almond-bark-60",
|
||||
"mozimo-whole-hazelnuts-bark-45",
|
||||
"mozimo-whole-hazelnuts-bark-60",
|
||||
"mozimo-fruits-nuts-45",
|
||||
"mozimo-fruits-and-nuts-bark-60",
|
||||
"mozimo-gems-bark",
|
||||
"mozimo-rice-crispy-bark",
|
||||
"mozimo-raspberry-bark",
|
||||
"mozimo-pecan-bark",
|
||||
"mozimo-crunchy-granola-bark-45",
|
||||
"mozimo-salted-butter-crunch-bark-45",
|
||||
"mozimo-hazelnut-bark-sugar-free-45",
|
||||
"mozimo-assorted-bark-box",
|
||||
"mozimo-almond-praline-bark"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "sugar-free-chocolate-bar",
|
||||
"title": "Sugar Free Chocolate Bar",
|
||||
"members": ["mozimo-sugar-free-dark-72-chocolate-bar"]
|
||||
},
|
||||
{
|
||||
"handle": "rocks-orangettes",
|
||||
"title": "Rocks / Orangettes",
|
||||
"members": ["mozimo-orangettes", "mozimo-almond-slivers-rocks"]
|
||||
},
|
||||
{
|
||||
"handle": "tuilles",
|
||||
"title": "Tuilles",
|
||||
"members": ["mozimo-milk-45-tuilles", "mozimo-white-chocolate-tuilles"]
|
||||
},
|
||||
{
|
||||
"handle": "pralines-bark",
|
||||
"title": "Pralines Bark",
|
||||
"members": ["mozimo-almond-praline-bark", "mozimo-hazelnut-praline-bark"]
|
||||
},
|
||||
{
|
||||
"handle": "pralines",
|
||||
"title": "Pralines",
|
||||
"members": [
|
||||
"9-pralines-box",
|
||||
"matcha-strawberry-praline",
|
||||
"4-pralines-box",
|
||||
"mozimo-praline-panetela"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "mozimo-delights",
|
||||
"title": "Mozimo Delights",
|
||||
"members": [
|
||||
"mozimo-honey-comb",
|
||||
"single-origin-coco-nibs",
|
||||
"mozimo-nutty-bar",
|
||||
"cocoa-bean-shell",
|
||||
"mozimo-hot-chocolate-premix"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "diwali",
|
||||
"title": "Diwali",
|
||||
"members": [
|
||||
"bars-box",
|
||||
"spreads-box",
|
||||
"mozimo-carnival-celebration-s",
|
||||
"mozimo-carnival-celebration-m",
|
||||
"mozimo-carnival-celebration-l",
|
||||
"mozimo-celestial-s",
|
||||
"200-gas-bark-box",
|
||||
"kunafa-chocolate-bar",
|
||||
"1-kg-bark-box",
|
||||
"mozimo-praline-panetela-1",
|
||||
"mozimo-praline-box-of-25",
|
||||
"mozimo-praline-box-of-36"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": "hampers-boxes",
|
||||
"title": "Hampers & Boxes",
|
||||
"members": [
|
||||
"mozimo-celestial-s",
|
||||
"mozimo-carnival-celebration-s",
|
||||
"1-kg-bark-box",
|
||||
"mozimo-celestial-l",
|
||||
"mozimo-carnival-celebration-l",
|
||||
"mozimo-carnival-celebration-m"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Seed the dev store from the live catalogue
|
||||
|
||||
`Type:` task (HITL)
|
||||
`Status:` open
|
||||
`Status:` claimed
|
||||
|
||||
Part of [the map](../map.md). Empty the frontier from the top.
|
||||
|
||||
@@ -42,3 +42,146 @@ Facts later tickets depend on: which products exist in the dev store, whether th
|
||||
## Answer
|
||||
|
||||
<!-- filled on resolution -->
|
||||
|
||||
## Comments
|
||||
|
||||
### AFK half worked — and the seeding decision re-opens further
|
||||
|
||||
Progress against **To resolve**: #1 is settled without an export; #4's stated option is
|
||||
dead and needs a decision the agent cannot make alone.
|
||||
|
||||
#### 1. Variant option shape — settled (To resolve #1) ✅
|
||||
|
||||
The Storefront API exposes `Product.options`, so the export is not needed to read the
|
||||
shape. All **61** products carry one option named `Title`. The *values* are not uniform:
|
||||
|
||||
| `Option1 value` (= Variant title) | products |
|
||||
| --- | --- |
|
||||
| `Default Title` | 22 |
|
||||
| `100gm` | 17 |
|
||||
| `200gm` | 11 |
|
||||
| `60gm` | 5 |
|
||||
| `1` | 3 |
|
||||
| `250gm`, `150gm`, `12pc` | 1 each |
|
||||
|
||||
So the sample CSV's shape (name `Title`, a real value) covers 39 of 61 and the docs'
|
||||
`Default Title` covers 22. The import must preserve each product's own value: changing
|
||||
an option value deletes and recreates the Variant, and the resulting Variant GIDs are
|
||||
what Phase 1 carts. **Do not normalise.** (`ADR-0002`'s claim that weight lives in the
|
||||
Variant title holds for 39 products, not all 61; 22 are `Default Title`.)
|
||||
|
||||
#### 2. The repo's credentials cannot drive an Admin API seed (To resolve #4) ❌
|
||||
|
||||
The production app's granted scopes are **Storefront-only**:
|
||||
|
||||
```
|
||||
unauthenticated_read_product_listings unauthenticated_read_product_inventory
|
||||
unauthenticated_read_product_tags unauthenticated_read_checkouts
|
||||
unauthenticated_write_checkouts
|
||||
```
|
||||
|
||||
`products`, `productsCount` and `collections` all return
|
||||
`Access denied … Required access: 'read_products' access scope`. So
|
||||
*"a small Admin API script using the credentials the repo already mints"* is **not
|
||||
available as written**. Any Admin API mechanism needs a custom app installed in the
|
||||
destination store with `write_products`.
|
||||
|
||||
Two consequences beyond this ticket:
|
||||
|
||||
- Production's collection *structure* (manual vs smart, and any smart rules) is also
|
||||
unreadable with today's scopes, so a collection rebuild can only reproduce **today's
|
||||
membership as manual collections** — a real fidelity gap, not a bug.
|
||||
- The Phase 0 admin data fixes (61 × `productType`, 41 × weight) could be scripted
|
||||
rather than hand-edited *if* an Admin-scoped app is installed on production.
|
||||
|
||||
#### 3. Metafield definitions to create first (To resolve #2) — 10 keys
|
||||
|
||||
The Storefront API cannot enumerate metafields (`metafields` demands explicit
|
||||
`identifiers`), so this comes from `docs/shopify-catalog-inventory.md` §6, measured with
|
||||
an explicit identifier list. These are the populated keys, i.e. the columns the CSV will
|
||||
actually carry:
|
||||
|
||||
| definition | type | populated |
|
||||
| --- | --- | --- |
|
||||
| `custom.ingredients` | `multi_line_text_field` | 20 |
|
||||
| `custom.energy_kcal`, `custom.protein_g`, `custom.total_fat_g`, `custom.saturated_fat_g`, `custom.carbohydrate_g`, `custom.sodium_mg` | `number_decimal` | 20 each |
|
||||
| `custom.sugar_g` | `number_decimal` | 18 |
|
||||
| `custom.added_sugar_g` | `number_decimal` | 17 |
|
||||
| `custom.tagline` | `single_line_text_field` | 1 |
|
||||
|
||||
The `facts.*` namespace is empty catalogue-wide, so no `facts.*` definitions are needed
|
||||
for a faithful seed (ticket 09 decides whether the compat branch survives at all).
|
||||
|
||||
#### 4. Collection membership is 16 collections / 100 memberships
|
||||
|
||||
Recorded with the full product list, option values, weights and product types in
|
||||
[`assets/dev-store-seed.json`](../assets/dev-store-seed.json) — the baseline the seed is
|
||||
checked against, and the membership map whichever mechanism is chosen will consume.
|
||||
|
||||
Nothing in the CSV path can carry this. The docs confirm the `Collection` column takes
|
||||
**one collection per product**, is never exported, and — per the overwrite rules — a
|
||||
*blank* non-required column overwrites the existing value as blank. Repeated imports with
|
||||
different `Collection` values would have to *accumulate* membership, which is
|
||||
undocumented and whose failure mode is destroying membership. Not a sound mechanism.
|
||||
|
||||
#### 5. The second mechanism, at 2026-10
|
||||
|
||||
- `collectionAddProducts` is **deprecated**; the current path is `collectionUpdate` with
|
||||
`inclusion.selectionsToAdd`, or `collectionCreate` with `sources[].inclusion.selections`.
|
||||
Both need `write_products`.
|
||||
- **Trap:** `collectionCreate` leaves the collection **unpublished**, so without a
|
||||
`publishablePublish` the membership work is invisible to the Storefront API — the
|
||||
thing the dev store exists to serve.
|
||||
|
||||
### Decision: CSV for products, a dev-store app for collections ✅
|
||||
|
||||
Products, metafields and images stay on CSV — it re-hosts images for free, whereas an API
|
||||
seed would need staged uploads for 61 products. Collections go through a custom app
|
||||
installed in the dev store, driven by
|
||||
[`scripts/seed-dev-store.mjs`](../../../scripts/seed-dev-store.mjs), which creates each
|
||||
collection with its members in one call and publishes it. Chosen over hand-building
|
||||
because the membership map is a snapshot that will drift, and over an all-API seed because
|
||||
images would then need staging. It also makes the collection step re-runnable whenever
|
||||
production changes.
|
||||
|
||||
**Fidelity gap, recorded rather than hidden:** production's collection *structure* is
|
||||
unreadable with today's scopes, so the rebuild reproduces today's membership as **manual**
|
||||
collections. If any of the 16 is a smart collection in production, the dev store will hold
|
||||
a manual copy of it.
|
||||
|
||||
### Runbook (HITL — the parts that need admin access)
|
||||
|
||||
In the dev store `staging-iw0gviik.myshopify.com`:
|
||||
|
||||
1. **Create the custom app.** Settings → Apps and sales channels → Develop apps → Create
|
||||
an app → Configure Admin API scopes: `read_products`, `write_products`,
|
||||
`read_publications`, `write_publications` → Install app → copy the **Admin API access
|
||||
token** (`shpat_…`). `write_products` covers `collectionCreate`; `write_publications`
|
||||
and `read_publications` cover `publishablePublish` and the `publications` query.
|
||||
2. **Create the metafield definitions** (Settings → Custom data → Products) *before*
|
||||
importing — the 10 keys and types in §3 above. Undefined metafields on import are
|
||||
undocumented, which is why this comes first.
|
||||
3. **Export from production, then import.** Products → Export → *Plain CSV* → Export
|
||||
products (an *All products* export is emailed, not downloaded). Then Products → Import
|
||||
→ *Overwrite products with matching handles*, small batch first. Leave `Option1
|
||||
name`/`Option1 value` exactly as exported (§1). Images are URLs only: production's
|
||||
products must stay publicly reachable until the import finishes.
|
||||
4. **Add the dev-store credentials to `.env`** (gitignored): `SHOPIFY_DEV_STORE_DOMAIN`,
|
||||
`SHOPIFY_DEV_ADMIN_TOKEN`.
|
||||
5. **Run the collection seed** — dry run, then apply:
|
||||
`npm run seed:dev-store`, then `npm run seed:dev-store -- --apply`. It creates the 16
|
||||
collections, adds the 100 memberships and publishes each one; existing handles are
|
||||
skipped and any product handle it cannot find is reported, which is how a partial CSV
|
||||
import shows up.
|
||||
6. **Record what landed** (To resolve #5): product count, collection count and
|
||||
membership, metafield coverage, anything rebuilt by hand — and whether the Variant GIDs
|
||||
differ from [`assets/dev-store-seed.json`](../assets/dev-store-seed.json) (they will).
|
||||
|
||||
**Unverified until step 5 runs:** the script's mutation shapes come from the 2026-10 Admin
|
||||
reference (it uses the `CollectionCreateInput.sources` form because `collectionAddProducts`
|
||||
is deprecated). Its auth, error and snapshot paths have been exercised against production;
|
||||
the writes have not, because no dev-store app exists yet. That is why the dry run is the
|
||||
default.
|
||||
|
||||
Facts later tickets depend on once this is done: which products exist in the dev store,
|
||||
the dev store's own Variant GIDs, and whether the metafield values survived the import.
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"pb": "bash -c 'if [ ! -f .pb/pocketbase ]; then mkdir -p .pb && curl -sL -o .pb/pb.zip https://github.com/pocketbase/pocketbase/releases/download/v0.40.2/pocketbase_0.40.2_linux_amd64.zip && (cd .pb && unzip -o -q pb.zip pocketbase && rm pb.zip); fi; cd .pb && ./pocketbase serve --http=127.0.0.1:8090 --dir=./pb_data'",
|
||||
"seed": "node --env-file-if-exists=.env scripts/pb-seed.mjs"
|
||||
"seed": "node --env-file-if-exists=.env scripts/pb-seed.mjs",
|
||||
"seed:dev-store": "node --env-file-if-exists=.env scripts/seed-dev-store.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/tanstackstart-react": "^10.73.0",
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Seed a dev store's collections from the production membership snapshot.
|
||||
*
|
||||
* node --env-file=.env scripts/seed-dev-store.mjs # dry run
|
||||
* node --env-file=.env scripts/seed-dev-store.mjs --apply # write
|
||||
*
|
||||
* Why a script at all: the product CSV round-trips products and product
|
||||
* metafields, but it cannot carry collection membership — the `Collection`
|
||||
* column is import-only, takes one collection per product, and this catalogue
|
||||
* runs 1.6 memberships per product across 16 collections.
|
||||
*
|
||||
* Per collection in the snapshot it:
|
||||
* 1. skips the collection when the handle already exists in the destination
|
||||
* (and reports the destination's member count, so drift is visible);
|
||||
* 2. otherwise creates it with its members in one call, using the current
|
||||
* 2026-10 shape — `CollectionCreateInput.sources[].source.inclusion
|
||||
* .selections` on the `collection` argument. The `input`/`ruleSet` form is
|
||||
* deprecated, and so is `collectionAddProducts`;
|
||||
* 3. publishes it, because `collectionCreate` leaves a collection
|
||||
* unpublished and an unpublished collection is invisible to the
|
||||
* Storefront API the dev store exists to serve.
|
||||
*
|
||||
* It never deletes anything, never touches products, and re-runs are safe.
|
||||
* Products must already exist in the destination: run the CSV import first.
|
||||
*
|
||||
* Env:
|
||||
* SHOPIFY_DEV_STORE_DOMAIN e.g. staging-iw0gviik.myshopify.com
|
||||
* SHOPIFY_DEV_ADMIN_TOKEN Admin API access token (shpat_…) from a custom
|
||||
* app installed in the dev store. Scopes:
|
||||
* read_products, write_products,
|
||||
* read_publications, write_publications.
|
||||
* Production's app cannot be used — it is
|
||||
* Storefront-scoped only, with no read_products.
|
||||
* SHOPIFY_ADMIN_API_VERSION default 2026-10
|
||||
*
|
||||
* Flags:
|
||||
* --apply write. Without it the run is a dry run.
|
||||
* --only=handle,handle limit to these collections
|
||||
* --publication=<name|id> publish target (default: Online Store)
|
||||
* --snapshot=<path> membership snapshot to seed from
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const DOMAIN = process.env.SHOPIFY_DEV_STORE_DOMAIN ?? "";
|
||||
const TOKEN = process.env.SHOPIFY_DEV_ADMIN_TOKEN ?? "";
|
||||
const API_VERSION = process.env.SHOPIFY_ADMIN_API_VERSION ?? "2026-10";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, fallback) => {
|
||||
const hit = args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
||||
if (!hit) return fallback;
|
||||
return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : true;
|
||||
};
|
||||
|
||||
const APPLY = Boolean(flag("apply", false));
|
||||
const ONLY = String(flag("only", "") ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const PUBLICATION = String(flag("publication", "Online Store"));
|
||||
const SNAPSHOT = String(
|
||||
flag(
|
||||
"snapshot",
|
||||
join(root, ".scratch/shopify-headless-commerce/assets/dev-store-seed.json"),
|
||||
),
|
||||
);
|
||||
|
||||
if (!DOMAIN || !TOKEN) {
|
||||
console.error(
|
||||
"Set SHOPIFY_DEV_STORE_DOMAIN and SHOPIFY_DEV_ADMIN_TOKEN — see the header of this file.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* ── Admin API ─────────────────────────────────────────────── */
|
||||
|
||||
const gql = async (query, variables) => {
|
||||
const res = await fetch(
|
||||
`https://${DOMAIN}/admin/api/${API_VERSION}/graphql.json`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Shopify-Access-Token": TOKEN,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
},
|
||||
);
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!res.ok || !json) {
|
||||
throw new Error(`Admin API HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
if (json.errors?.length) {
|
||||
throw new Error(json.errors.map((e) => e.message).join("; "));
|
||||
}
|
||||
return json.data;
|
||||
};
|
||||
|
||||
const CREATE_COLLECTION = /* GraphQL */ `
|
||||
mutation SeedCollection($collection: CollectionCreateInput!) {
|
||||
collectionCreate(collection: $collection) {
|
||||
collection {
|
||||
id
|
||||
handle
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PUBLISH = /* GraphQL */ `
|
||||
mutation Publish($id: ID!, $input: [PublicationInput!]!) {
|
||||
publishablePublish(id: $id, input: $input) {
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/* ── the plan ──────────────────────────────────────────────── */
|
||||
|
||||
const snapshot = JSON.parse(readFileSync(SNAPSHOT, "utf8"));
|
||||
const plan = snapshot.collections.filter(
|
||||
(c) => !ONLY.length || ONLY.includes(c.handle),
|
||||
);
|
||||
if (!plan.length) {
|
||||
console.error(`Nothing to seed: ${SNAPSHOT} has no matching collections.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${APPLY ? "Seeding" : "Dry run against"} ${DOMAIN} (Admin API ${API_VERSION})`,
|
||||
);
|
||||
console.log(
|
||||
`snapshot: ${SNAPSHOT}\n ${snapshot.productCount} products, ` +
|
||||
`${snapshot.collectionCount} collections, ${snapshot.membershipTotal} memberships ` +
|
||||
`(taken ${snapshot.generatedAt})\n`,
|
||||
);
|
||||
|
||||
let products;
|
||||
let collections;
|
||||
let publications;
|
||||
try {
|
||||
products = await gql(
|
||||
`query { products(first: 250) { nodes { id handle } } }`,
|
||||
);
|
||||
collections = await gql(
|
||||
`query { collections(first: 250) { nodes { id handle productsCount { count } } } }`,
|
||||
);
|
||||
publications = await gql(
|
||||
`query { publications(first: 20) { nodes { id name } } }`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Could not read the destination store: ${error.message}`);
|
||||
console.error(
|
||||
"The custom app needs read_products and read_publications, and must be installed.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const productId = new Map(products.products.nodes.map((p) => [p.handle, p.id]));
|
||||
const existing = new Map(
|
||||
collections.collections.nodes.map((c) => [c.handle, c]),
|
||||
);
|
||||
|
||||
const pubs = publications.publications.nodes ?? [];
|
||||
const chosen = pubs.find(
|
||||
(p) =>
|
||||
p.name.toLowerCase() === PUBLICATION.toLowerCase() || p.id === PUBLICATION,
|
||||
);
|
||||
if (!chosen) {
|
||||
console.error(
|
||||
`No publication matches "${PUBLICATION}". Available: ${pubs.map((p) => p.name).join(", ") || "(none)"}`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
console.log(`publish target: ${chosen.name} (${chosen.id})\n`);
|
||||
|
||||
/* ── report and apply ──────────────────────────────────────── */
|
||||
|
||||
const tally = { created: 0, published: 0, existing: 0, empty: 0, errors: 0 };
|
||||
|
||||
for (const c of plan) {
|
||||
const missing = c.members.filter((h) => !productId.has(h));
|
||||
const ids = c.members
|
||||
.filter((h) => productId.has(h))
|
||||
.map((h) => productId.get(h));
|
||||
const label = c.handle.padEnd(24);
|
||||
const note = `${String(c.members.length).padStart(3)} members`;
|
||||
|
||||
if (missing.length) {
|
||||
console.log(
|
||||
`${label}${note} ${missing.length} not in the destination: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const here = existing.get(c.handle);
|
||||
if (here) {
|
||||
tally.existing += 1;
|
||||
const count = here.productsCount?.count ?? "?";
|
||||
const drift =
|
||||
count === c.members.length ? "" : " ← differs from the snapshot";
|
||||
console.log(`${label}${note} exists with ${count} products${drift}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ids.length) {
|
||||
tally.empty += 1;
|
||||
console.log(
|
||||
`${label}${note} skipped: none of its products exist here yet`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${label}${note} ${APPLY ? "creating" : "would create"} and publish to ${chosen.name}`,
|
||||
);
|
||||
if (!APPLY) continue;
|
||||
|
||||
const created = await gql(CREATE_COLLECTION, {
|
||||
collection: {
|
||||
title: c.title,
|
||||
handle: c.handle,
|
||||
sources: [
|
||||
{
|
||||
source: {
|
||||
title: c.title,
|
||||
inclusion: { selections: ids.map((productId) => ({ productId })) },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const userErrors = created.collectionCreate?.userErrors ?? [];
|
||||
if (userErrors.length || !created.collectionCreate?.collection) {
|
||||
tally.errors += 1;
|
||||
console.error(
|
||||
`${label} create failed: ${userErrors.map((e) => `${e.field?.join(".")} ${e.message}`).join("; ") || "no collection returned"}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tally.created += 1;
|
||||
const gid = created.collectionCreate.collection.id;
|
||||
|
||||
const published = await gql(PUBLISH, {
|
||||
id: gid,
|
||||
input: [{ publicationId: chosen.id }],
|
||||
});
|
||||
const pubErrors = published.publishablePublish?.userErrors ?? [];
|
||||
if (pubErrors.length) {
|
||||
tally.errors += 1;
|
||||
console.error(
|
||||
`${label} created but not published: ${pubErrors.map((e) => e.message).join("; ")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tally.published += 1;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${APPLY ? "Done" : "Dry run complete"}: ` +
|
||||
`${tally.created} created, ${tally.published} published, ` +
|
||||
`${tally.existing} already present, ${tally.empty} skipped, ${tally.errors} errors.`,
|
||||
);
|
||||
if (!APPLY) {
|
||||
console.log("Nothing was written. Re-run with --apply.");
|
||||
}
|
||||
process.exit(tally.errors ? 1 : 0);
|
||||
Reference in new issue
Block a user