Files
mozimo.in/scripts/seed-dev-store.mjs
T
tanshu 1bc1d5f753 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.
2026-10-16 10:11:32 +05:30

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);