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.
281 lines
9.1 KiB
JavaScript
281 lines
9.1 KiB
JavaScript
#!/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);
|