← back to Hollywood Optc

apply-tk10634.mjs

145 lines

#!/usr/bin/env node
// TK-10634 — Restore 859 Hollywood products to their EXISTING real manufacturer codes.
// SELF-COPY ONLY (keep-old-never-mint): restore_to_canonical == the value already on file in the
// custom.manufacturer_sku metafield. This script NEVER mints a code.
//
// For EACH product row {shopify_id, fabricated_dw_sku, restore_to_canonical}:
//   1. Fetch live variants + the current dw_sku metafields.
//   2. HARD SELF-COPY GUARD: refuse the row unless live custom.manufacturer_sku == restore_to_canonical.
//   3. Reversibility FIRST: append {shopify_id, variant_id, from_sku, to_sku} and
//      {ownerId, ns, key, from, to} metafield records to data/tk10634-reversibility-<ts>.jsonl BEFORE any write.
//   4. Remap base variant SKU  fabricated_dw_sku       -> restore_to_canonical
//      Remap sample variant SKU fabricated_dw_sku-Sample -> restore_to_canonical-Sample (case-insensitive match)
//   5. Set global.dw_sku + dwc.dw_sku metafields (only the ones currently holding a fabricated code) -> restore_to_canonical.
//   manufacturer_sku is LEFT UNTOUCHED (already the real code — it is the SOURCE of the self-copy).
//
// Idempotent (skips a variant/metafield already == target). Rate-limit aware. Never touches held/blocked sets.
// ROLLBACK: node rollback-tk10634.mjs data/tk10634-reversibility-<ts>.jsonl
import { readFileSync, appendFileSync } from 'node:fs';

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const VER = '2024-10';
const env = readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN=')) || '')
  .replace('SHOPIFY_ADMIN_TOKEN=', '').replace(/["'\r]/g, '').trim();
if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }

const args = process.argv.slice(2);
const APPLY = args.includes('--apply');
const LIMIT = (() => { const a = args.find(x => x.startsWith('--limit=')); return a ? parseInt(a.split('=')[1], 10) : Infinity; })();
const MAP_PATH = process.env.HOME + '/Projects/dw-add-sellable-variant-tk10902/tk10634-recon/tk10634-recovery-map.json';
const map = JSON.parse(readFileSync(MAP_PATH, 'utf8'));
let applySet = (map.apply_set || []);
if (isFinite(LIMIT)) applySet = applySet.slice(0, LIMIT);
if (!applySet.length) { console.error('empty apply_set'); process.exit(1); }

const TS = new Date().toISOString().replace(/[:.]/g, '-');
const REV_PATH = new URL(`./data/tk10634-reversibility-${TS}.jsonl`, import.meta.url).pathname;
const OUT_PATH = new URL(`./data/tk10634-result-${TS}.json`, import.meta.url).pathname;
const GQL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function graphql(query, variables, tries = 0) {
  const res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
  if (res.status === 429 && tries < 10) { await sleep(2000); return graphql(query, variables, tries + 1); }
  if (res.status === 429) throw new Error('429 retry budget exhausted');
  if ((res.status === 502 || res.status === 503) && tries < 4) { await sleep(1500 * (tries + 1)); return graphql(query, variables, tries + 1); }
  const j = await res.json();
  if (j.errors && j.errors.some(e => /THROTTLED|throttl/i.test(JSON.stringify(e))) && tries < 6) { await sleep(2500 * (tries + 1)); return graphql(query, variables, tries + 1); }
  return j;
}

const Q_PROD = `query p($id: ID!){ product(id:$id){ id status
  mfr: metafield(namespace:"custom", key:"manufacturer_sku"){ value }
  gdw: metafield(namespace:"global", key:"dw_sku"){ value }
  dwc: metafield(namespace:"dwc", key:"dw_sku"){ value }
  variants(first:20){ nodes{ id title inventoryItem{ sku } } } } }`;

const MUT_SKU = `mutation setSku($productId: ID!, $variants: [ProductVariantsBulkInput!]!){
  productVariantsBulkUpdate(productId:$productId, variants:$variants){ productVariants{ id inventoryItem{ sku } } userErrors{ field message code } } }`;

const MUT_MF = `mutation setMf($mf: [MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ id namespace key value } userErrors{ field message code } } }`;

async function setSku(productGid, variantGid, newSku) {
  const j = await graphql(MUT_SKU, { productId: productGid, variants: [{ id: variantGid, inventoryItem: { sku: newSku } }] });
  const ue = j?.data?.productVariantsBulkUpdate?.userErrors || [];
  const confirmed = j?.data?.productVariantsBulkUpdate?.productVariants?.[0]?.inventoryItem?.sku ?? null;
  return { ue, top: j.errors || [], confirmed };
}
async function setMf(ownerGid, ns, key, value) {
  const j = await graphql(MUT_MF, { mf: [{ ownerId: ownerGid, namespace: ns, key, type: 'single_line_text_field', value }] });
  const ue = j?.data?.metafieldsSet?.userErrors || [];
  const confirmed = j?.data?.metafieldsSet?.metafields?.[0]?.value ?? null;
  return { ue, top: j.errors || [], confirmed };
}

async function main() {
  const R = { total: applySet.length, self_copy_ok: 0, sku_applied: 0, mf_applied: 0, skipped_idempotent: 0, self_copy_refused: 0, failed: 0, details: [] };
  let idx = 0;
  for (const row of applySet) {
    idx++;
    const productGid = row.shopify_id;
    const fab = (row.fabricated_dw_sku || '').trim();
    const restore = (row.restore_to_canonical || '').trim();
    if (!fab || !restore) { R.failed++; R.details.push({ productGid, status: 'MAP_INCOMPLETE' }); continue; }

    const pj = await graphql(Q_PROD, { id: productGid });
    const p = pj?.data?.product;
    if (!p) { R.failed++; R.details.push({ productGid, status: 'PRODUCT_NOT_FOUND' }); console.error(`[${idx}] NOT_FOUND ${productGid}`); continue; }

    // HARD SELF-COPY GUARD — restore MUST equal the real code already on file.
    const mfr = (p.mfr?.value || '').trim();
    if (!mfr || mfr.toUpperCase() !== restore.toUpperCase()) {
      R.self_copy_refused++;
      R.details.push({ productGid, status: 'SELF_COPY_REFUSED', manufacturer_sku: mfr, restore });
      console.error(`[${idx}] SELF-COPY REFUSED ${productGid}: mfr=${mfr} != restore=${restore}`);
      continue;
    }
    R.self_copy_ok++;

    // Map variants: base == fabricated_dw_sku, sample == fabricated_dw_sku + (-sample, any case)
    const vs = p.variants.nodes;
    const jobs = [];
    for (const v of vs) {
      const sku = (v.inventoryItem?.sku || '').trim();
      if (!sku) continue;
      const up = sku.toUpperCase();
      if (up === fab.toUpperCase()) jobs.push({ variantGid: v.id, from: sku, to: restore, kind: 'base' });
      else if (up === (fab + '-SAMPLE').toUpperCase() || up === (fab + '-Sample').toUpperCase()) jobs.push({ variantGid: v.id, from: sku, to: restore + '-Sample', kind: 'sample' });
    }

    for (const job of jobs) {
      if (job.from.toUpperCase() === job.to.toUpperCase()) { R.skipped_idempotent++; continue; }
      if (!APPLY) { R.details.push({ productGid, kind: job.kind, would: `${job.from} -> ${job.to}` }); continue; }
      // Record the reversibility row only for a real write we're about to make —
      // never in dry-run (a dry-run ledger could be replayed by rollback as truth).
      appendFileSync(REV_PATH, JSON.stringify({ type: 'variant_sku', productGid, variant_id: job.variantGid.split('/').pop(), from_sku: job.from, to_sku: job.to, kind: job.kind, ts: new Date().toISOString() }) + '\n');
      const r = await setSku(productGid, job.variantGid, job.to);
      if (r.ue.length || r.top.length) { R.failed++; console.error(`[${idx}] FAIL sku ${job.from} -> ${job.to}: ${JSON.stringify(r.ue.length ? r.ue : r.top)}`); }
      else if (r.confirmed && r.confirmed.toUpperCase() === job.to.toUpperCase()) { R.sku_applied++; console.log(`[${idx}] OK sku ${job.from} -> ${r.confirmed}`); }
      else { R.failed++; console.error(`[${idx}] UNCONFIRMED sku ${job.from} -> ${job.to} (got ${r.confirmed})`); }
      await sleep(300);
    }

    // Metafields: fix global.dw_sku + dwc.dw_sku only where they currently hold the fabricated code.
    const mfJobs = [];
    if ((p.gdw?.value || '').trim().toUpperCase() === fab.toUpperCase()) mfJobs.push({ ns: 'global', key: 'dw_sku', from: p.gdw.value });
    if ((p.dwc?.value || '').trim().toUpperCase() === fab.toUpperCase()) mfJobs.push({ ns: 'dwc', key: 'dw_sku', from: p.dwc.value });
    for (const mf of mfJobs) {
      if (!APPLY) { R.details.push({ productGid, mf: `${mf.ns}.${mf.key}`, would: `${mf.from} -> ${restore}` }); continue; }
      appendFileSync(REV_PATH, JSON.stringify({ type: 'metafield', ownerId: productGid, ns: mf.ns, key: mf.key, from: mf.from, to: restore, ts: new Date().toISOString() }) + '\n');
      const r = await setMf(productGid, mf.ns, mf.key, restore);
      if (r.ue.length || r.top.length) { R.failed++; console.error(`[${idx}] FAIL mf ${mf.ns}.${mf.key}: ${JSON.stringify(r.ue.length ? r.ue : r.top)}`); }
      else if (r.confirmed === restore) { R.mf_applied++; console.log(`[${idx}] OK mf ${mf.ns}.${mf.key} ${mf.from} -> ${restore}`); }
      else { R.failed++; console.error(`[${idx}] UNCONFIRMED mf ${mf.ns}.${mf.key} (got ${r.confirmed})`); }
      await sleep(250);
    }
  }
  console.log('\n=== TK-10634 SUMMARY ===');
  console.log(JSON.stringify({ ...R, details: `(${R.details.length} rows -> ${OUT_PATH})` }, null, 2));
  appendFileSync(OUT_PATH, JSON.stringify(R, null, 2));
  console.log(`reversibility: ${APPLY ? REV_PATH : '(dry-run, no writes)'}`);
  if (R.failed) process.exit(2);
}
main().catch(e => { console.error('FATAL', e); process.exit(1); });