← 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.`);