← back to Dw Unbuyable Recovery Pilot

tk11041-innovations-reconcile/rescrape/backfill-width.mjs

77 lines

#!/usr/bin/env node
/**
 * TK-11041 — backfill the width metafield for the 18 Innovations products that lack it
 * (Enchase, Flanders, Tatami), from AUTHORITATIVE dw_unified vendor width (in the authoritative
 * table). Writes global.width / dwc.width / custom.width = "<N> Inches" (matching the 21 ready
 * products). Reversible: prestate (metafield absent) snapshotted; undo = delete the metafields.
 * DEFAULT dry-run; --execute --i-am-steve to write.
 *   node backfill-width.mjs                          # dry-run
 *   node backfill-width.mjs --execute --i-am-steve --pid=<id>
 */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v === undefined ? true : v]; }));
const EXECUTE = args.execute === true && args['i-am-steve'] === true;
const ONLY_PID = args.pid ? String(args.pid) : null;
const TABLE = JSON.parse(fs.readFileSync(path.join(HERE, 'data/tk11041-authoritative-table.json'), 'utf8'));
const TOKEN = (fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
const SHOP_URL = 'https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const ROLLBACK = path.join(HERE, 'undo', 'tk11041-width-backfill-rollback.jsonl');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const NEED = new Set(['ENS','FLN','TTM']);  // only the 18 width-less patterns

async function gql(q, v) {
  for (let i = 0; i < 6; i++) {
    let r; try { r = await fetch(SHOP_URL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); }
    catch (e) { await sleep(1500 * (i + 1)); continue; }
    if (r.status === 429 || r.status >= 500) { await sleep(2000 * (i + 1)); continue; }
    const j = await r.json();
    if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (i + 1)); continue; }
    if (j.errors) return { __err: j.errors };
    return j.data;
  }
  throw new Error('exhausted retries');
}
const READ = `query($id:ID!){ node(id:$id){ ... on Product { id title
  gw:metafield(namespace:"global",key:"width"){value}
  dw:metafield(namespace:"dwc",key:"width"){value}
  cw:metafield(namespace:"custom",key:"width"){value} } } }`;
const SET = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ namespace key value } userErrors{ field message } } }`;

(async () => {
  const rows = TABLE.rows
    .filter(r => NEED.has((r.mfr_sku||'').split('-')[0]))
    .map(r => ({ ...r, pidNum: String(r.shopify_product_id).replace('gid://shopify/Product/', ''), inches: (String(r.width).match(/(\d+)/) || [])[1] }))
    .filter(r => !ONLY_PID || r.pidNum === ONLY_PID);
  console.log(`TK-11041 width-backfill — ${EXECUTE ? 'LIVE' : 'DRY-RUN'} — ${rows.length} product(s)`);
  const rb = EXECUTE ? fs.createWriteStream(ROLLBACK, { flags: 'a' }) : null;
  let ok = 0, skip = 0, err = 0;
  for (const r of rows) {
    if (!r.inches) { err++; console.log(`ERR ${r.mfr_sku}: cannot parse width "${r.width}"`); continue; }
    const gid = `gid://shopify/Product/${r.pidNum}`;
    const lk = await gql(READ, { id: gid });
    const p = lk && !lk.__err && lk.node;
    if (!p) { err++; console.log(`ERR ${r.mfr_sku}: not found`); continue; }
    const pre = { gw: p.gw?.value ?? null, dw: p.dw?.value ?? null, cw: p.cw?.value ?? null };
    const val = `${r.inches} Inches`;
    if (pre.cw && pre.gw && pre.dw) { skip++; console.log(`SKIP ${r.mfr_sku}: width already present (${pre.cw})`); continue; }
    console.log(`${EXECUTE?'DO  ':'PLAN'} ${r.mfr_sku} "${p.title.slice(0,32)}" width -> "${val}" (pre: g=${pre.gw} d=${pre.dw} c=${pre.cw})`);
    if (!EXECUTE) { ok++; continue; }
    const mf = [
      { ownerId: gid, namespace: 'global', key: 'width', type: 'single_line_text_field', value: val },
      { ownerId: gid, namespace: 'dwc',    key: 'width', type: 'single_line_text_field', value: val },
      { ownerId: gid, namespace: 'custom', key: 'width', type: 'single_line_text_field', value: val },
    ];
    const res = await gql(SET, { mf });
    const ue = res?.__err || res?.metafieldsSet?.userErrors;
    if (res?.__err || (res?.metafieldsSet?.userErrors?.length)) { err++; console.log(`  ERR ${r.mfr_sku}: ${JSON.stringify(ue).slice(0,160)}`); continue; }
    rb.write(JSON.stringify({ mfr_sku: r.mfr_sku, pid: r.pidNum, prestate: pre, wrote: val }) + '\n');
    ok++; console.log(`  ✓ ${r.mfr_sku} width set`);
    await sleep(150);
  }
  if (rb) rb.end();
  console.log(`\nDONE — ${ok} ${EXECUTE?'set':'planned'}, ${skip} skipped, ${err} errors`);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });