← back to Dw Sku Integrity
apply-plans/phillip-jeffries-tk11131/apply.graphql.mjs
144 lines
// GATED — customer-facing Shopify write. Do NOT run without Steve's explicit go.
// TK-11131. Backfills custom.manufacturer_sku + global.manufacturer_sku on 897
// Phillip Jeffries products that went ACTIVE with no real mfr code. Idempotent —
// evaluates and writes each of the two metafields (custom + global) INDEPENDENTLY,
// so a row with only ONE field populated still gets the other backfilled, and a
// field that is already set is never overwritten. Writes an undo-map derived from
// the SET-batch SUCCESS results (only rows actually written) so it is a reliable
// rollback source (clear the written metafields back to null).
// Batched: checks 250 products/call via nodes(ids:), applies up to 24 metafields/call.
import fs from 'fs';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const DRY = !process.argv.includes('--apply');
const rows = JSON.parse(fs.readFileSync(new URL('./backfill-mapping-897.json', import.meta.url)));
async function gql(query, variables) {
const r = await fetch(`https://${SHOP}/admin/api/2024-10/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables })
});
return r.json();
}
function chunk(arr, n) {
const out = [];
for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
return out;
}
const CHECK_Q = `query($ids: [ID!]!) {
nodes(ids: $ids) {
... on Product {
id
m1: metafield(namespace: "custom", key: "manufacturer_sku") { value }
m2: metafield(namespace: "global", key: "manufacturer_sku") { value }
}
}
}`;
const SET_M = `mutation($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id namespace key value }
userErrors { field message }
}
}`;
// 1. Batch-check existing state, 250 ids/call
const existing = new Map(); // gid -> {m1,m2}
for (const batch of chunk(rows.map(r => r.shopify_gid), 250)) {
const res = await gql(CHECK_Q, { ids: batch });
if (res.errors) { console.error('CHECK ERR', JSON.stringify(res.errors)); process.exit(1); }
for (const n of res.data.nodes) {
if (n) existing.set(n.id, { m1: n.m1?.value, m2: n.m2?.value });
}
process.stderr.write(`checked ${existing.size}/${rows.length}\n`);
}
// 2. Decide per-FIELD (custom + global evaluated independently) what to write.
// A field is written only when currently absent/empty; a row is skipped entirely
// only when BOTH fields are already set. A partial row (one field set) still gets
// the other field backfilled — matching the README's "only writes where the field
// is currently empty" contract.
const toWrite = []; // { row, needCustom, needGlobal }
let skipped = 0; // rows where both fields already set
let partialBackfills = 0; // rows where exactly one field was already set
for (const row of rows) {
const e = existing.get(row.shopify_gid) || { m1: null, m2: null };
const needCustom = !e.m1;
const needGlobal = !e.m2;
if (!needCustom && !needGlobal) {
skipped++;
console.log(`SKIP (both already set): ${row.sku} -> custom=${e.m1} global=${e.m2}`);
continue;
}
if (!needCustom || !needGlobal) {
partialBackfills++;
const have = needCustom ? `global=${e.m2}` : `custom=${e.m1}`;
const fill = needCustom ? 'custom' : 'global';
console.log(`PARTIAL (backfilling ${fill}, already had ${have}): ${row.sku}`);
}
toWrite.push({ row, needCustom, needGlobal });
}
console.log(`\n${toWrite.length} rows to write (${partialBackfills} partial backfills), ${skipped} already-set (skipped), out of ${rows.length} total.`);
if (DRY) {
console.log('DRY-RUN — no writes. Sample of first 10 to be written:');
for (const { row, needCustom, needGlobal } of toWrite.slice(0, 10)) {
const fields = [needCustom && 'custom', needGlobal && 'global'].filter(Boolean).join('+');
console.log(` ${row.sku} (${row.shopify_gid}) -> ${row.real_mfr_sku} [${fields}]`);
}
process.exit(0);
}
// 3. Apply in batches of 12 products (<=24 metafields/call), writing only the
// needed fields per row. The undo-map is built from the SET-batch SUCCESS
// results — only rows actually written land in it — and flushed after each
// successful batch so a mid-run crash still leaves a reliable rollback source.
const undoPath = new URL('./undo-map.json', import.meta.url);
const undoMap = []; // populated from confirmed writes only
let applied = 0, errored = 0;
const productBatches = chunk(toWrite, 12);
for (const batch of productBatches) {
const metafields = batch.flatMap(({ row, needCustom, needGlobal }) => {
const mf = [];
if (needCustom) mf.push({ ownerId: row.shopify_gid, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: row.real_mfr_sku });
if (needGlobal) mf.push({ ownerId: row.shopify_gid, namespace: 'global', key: 'manufacturer_sku', type: 'single_line_text_field', value: row.real_mfr_sku });
return mf;
});
if (!metafields.length) continue;
const res = await gql(SET_M, { metafields });
if (res.errors) {
errored += batch.length;
console.error('BATCH ERR', JSON.stringify(res.errors));
continue;
}
const errs = res.data?.metafieldsSet?.userErrors || [];
if (errs.length) {
errored += batch.length;
console.error(`ERR batch [${batch.map(({ row }) => row.sku).join(',')}]:`, JSON.stringify(errs));
continue;
}
applied += batch.length;
// Record undo entries ONLY for rows in this confirmed-successful batch, and
// only for the fields actually written (before = null, since we only wrote
// fields that were empty). Flush immediately so the undo-map stays reliable.
for (const { row, needCustom, needGlobal } of batch) {
undoMap.push({
gid: row.shopify_gid,
sku: row.sku,
wrote: { custom: needCustom, global: needGlobal },
before: { custom: null, global: null },
});
}
fs.writeFileSync(undoPath, JSON.stringify(undoMap, null, 1));
console.log(`SET batch: ${batch.map(({ row }) => row.sku + '->' + row.real_mfr_sku).join(', ')}`);
}
console.log(`\nAPPLIED: ${applied} rows written (undo-map: ${undoMap.length} entries), ${errored} errored, ${skipped} already-set (skipped), out of ${rows.length} total.`);