← back to Dw Pairs Well

scripts/pipe-title-backfill/apply-clean-23.mjs

210 lines

#!/usr/bin/env node
// Apply the 23 CLEAN pipe-title-backfill rows. PostgreSQL-FIRST, then Shopify.
// Steve APPROVED in-session 2026-07-13 (canonical write on the 23 CLEAN rows ONLY).
// - Reads payload apply[] (the 23). Ignores draft[] (the 31 stay staged/untouched).
// - PG: UPDATE title (+ mfr_sku when payload changes it) per row, verify each before moving on.
// - Shopify: product title update + custom.manufacturer_sku / dwc.manufacturer_sku metafieldsSet
//   ONLY when mfr_sku changes (mfr_sku is NOT the variant SKU). No pricing/status/tag/channel/image writes.
// - Batches >= 95s apart per standing bulk cadence. Rate-limit backoff + retry, never abort mid-run.
// Author: steve@designerwallcoverings.com
import pg from 'pg';
import fs from 'node:fs';

const DB = process.env.DATABASE_URL || 'postgresql://stevestudio2@/dw_unified?host=/tmp';
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const GQL = `https://${SHOP}/admin/api/${API}/graphql.json`;

// --- token (avoid sourcing the whole .env; it has a parse-error line) ---
function readToken() {
  const envPath = `${process.env.HOME}/Projects/secrets-manager/.env`;
  const line = fs.readFileSync(envPath, 'utf8').split('\n').find(l => /^SHOPIFY_ADMIN_TOKEN=/.test(l));
  if (!line) throw new Error('SHOPIFY_ADMIN_TOKEN not found');
  return line.replace(/^SHOPIFY_ADMIN_TOKEN=/, '').replace(/["']/g, '').trim();
}
const TOKEN = readToken();

const PAYLOAD = new URL('../../../../.claude/yolo-queue/pending-approval/pipe-title-backfill-payload.json', import.meta.url).pathname;
const APPLY_LOG = new URL('./apply-clean-23.result.json', import.meta.url).pathname;

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

async function gql(query, variables, attempt = 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 || res.status === 502 || res.status === 503) {
    if (attempt >= 6) throw new Error(`Shopify ${res.status} after ${attempt} retries`);
    const wait = Math.min(30000, 2000 * 2 ** attempt);
    console.log(`   [rate/transient ${res.status}] backing off ${wait}ms (attempt ${attempt + 1})`);
    await sleep(wait);
    return gql(query, variables, attempt + 1);
  }
  const json = await res.json();
  // GraphQL-level THROTTLED
  if (json.errors && json.errors.some(e => (e.extensions?.code || '') === 'THROTTLED')) {
    if (attempt >= 6) throw new Error('THROTTLED after retries');
    const wait = Math.min(30000, 2000 * 2 ** attempt);
    console.log(`   [THROTTLED] backing off ${wait}ms (attempt ${attempt + 1})`);
    await sleep(wait);
    return gql(query, variables, attempt + 1);
  }
  return json;
}

const M_PRODUCT_UPDATE = `
mutation($input: ProductInput!) {
  productUpdate(input: $input) {
    product { id title }
    userErrors { field message }
  }
}`;

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

const Q_VERIFY = `
query($id: ID!) {
  product(id: $id) {
    id title
    cm: metafield(namespace: "custom", key: "manufacturer_sku") { value }
    dm: metafield(namespace: "dwc", key: "manufacturer_sku") { value }
  }
}`;

async function main() {
  const dry = process.argv.includes('--dry');
  const payload = JSON.parse(fs.readFileSync(PAYLOAD, 'utf8'));
  const rows = payload.apply;
  if (rows.length !== 23) throw new Error(`expected 23 apply rows, got ${rows.length}`);

  // Safety: private-label leak guard — corrected titles must not expose an upstream true-vendor name.
  const LEAK = /\b(command\s*54|wallquest|chesapeake|nextwall|seabrook|brewster|greenland|momentum|versa|desima|carlsten|nicolette\s*mayer)\b/i;
  const leaks = rows.filter(r => LEAK.test(r.after.title));
  if (leaks.length) {
    console.error('LEAK GUARD tripped — STOPPING these rows:', leaks.map(r => r.dw_sku));
    // Continue with the non-leaking rows only.
  }

  const client = new pg.Client({ connectionString: DB });
  await client.connect();

  const results = [];
  const BATCH = 6;
  let batchIdx = 0;

  for (let i = 0; i < rows.length; i++) {
    const r = rows[i];
    if (LEAK.test(r.after.title)) {
      results.push({ id: r.id, dw_sku: r.dw_sku, status: 'SKIPPED_LEAK' });
      continue;
    }

    const gid = r.shopify_id;
    const newTitle = r.after.title;
    const newMfr = r.after.mfr_sku;
    const oldMfr = r.before.mfr_sku;
    const mfrChanges = newMfr !== oldMfr;

    console.log(`\n[${i + 1}/23] id=${r.id} ${r.dw_sku}`);
    console.log(`   title: ${JSON.stringify(r.before.title)} -> ${JSON.stringify(newTitle)}`);
    console.log(`   mfr  : ${JSON.stringify(oldMfr)} -> ${JSON.stringify(newMfr)} ${mfrChanges ? '(WRITE)' : '(unchanged)'}`);

    const rec = { id: r.id, dw_sku: r.dw_sku, shopify_id: gid,
      before: r.before, after: { title: newTitle, mfr_sku: newMfr },
      pg: null, shopify_title: null, shopify_mfr: null };

    // ---------- 1) PostgreSQL FIRST (source of truth) ----------
    // Idempotent guard: only write rows still showing the malformed title.
    const cur = await client.query(
      'SELECT title, mfr_sku, status FROM shopify_products WHERE id=$1', [r.id]);
    if (!cur.rows[0]) { rec.pg = 'MISSING'; results.push(rec); console.log('   PG: MISSING, skip'); continue; }
    const curr = cur.rows[0];
    if (curr.title === newTitle && curr.mfr_sku === newMfr) {
      rec.pg = 'ALREADY_APPLIED';
    } else if (!/^\| /.test(curr.title) && curr.title !== r.before.title) {
      rec.pg = `DRIFT_SKIP(current=${JSON.stringify(curr.title)})`;
      console.log(`   PG: DRIFT — current title ${JSON.stringify(curr.title)} not the malformed 'before'; SKIP`);
      results.push(rec); continue;
    } else {
      if (!dry) {
        await client.query(
          'UPDATE shopify_products SET title=$1, mfr_sku=$2 WHERE id=$3',
          [newTitle, newMfr, r.id]);
      }
      // verify
      const chk = await client.query('SELECT title, mfr_sku FROM shopify_products WHERE id=$1', [r.id]);
      const v = chk.rows[0];
      rec.pg = (dry) ? 'DRY' : ((v.title === newTitle && v.mfr_sku === newMfr) ? 'OK' : `FAIL(${JSON.stringify(v)})`);
      console.log(`   PG: ${rec.pg}`);
      if (!dry && rec.pg !== 'OK') { results.push(rec); continue; } // do not push to Shopify on a failed PG write
    }

    // ---------- 2) Shopify (title + mfr metafields only) ----------
    if (dry) { rec.shopify_title = 'DRY'; rec.shopify_mfr = 'DRY'; results.push(rec); continue; }

    // 2a) title
    const up = await gql(M_PRODUCT_UPDATE, { input: { id: gid, title: newTitle } });
    const ue = up?.data?.productUpdate?.userErrors || [];
    if (ue.length) { rec.shopify_title = `ERR:${JSON.stringify(ue)}`; }
    else { rec.shopify_title = up?.data?.productUpdate?.product?.title === newTitle ? 'OK' : `MISMATCH:${up?.data?.productUpdate?.product?.title}`; }
    console.log(`   Shopify title: ${rec.shopify_title}`);
    await sleep(600);

    // 2b) mfr metafields (only when it changes)
    if (mfrChanges) {
      const mf = [
        { ownerId: gid, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: newMfr },
        { ownerId: gid, namespace: 'dwc', key: 'manufacturer_sku', type: 'single_line_text_field', value: newMfr },
      ];
      const ms = await gql(M_METAFIELDS_SET, { mf });
      const me = ms?.data?.metafieldsSet?.userErrors || [];
      rec.shopify_mfr = me.length ? `ERR:${JSON.stringify(me)}` : 'OK';
      console.log(`   Shopify mfr metafields: ${rec.shopify_mfr}`);
      await sleep(600);
    } else {
      rec.shopify_mfr = 'UNCHANGED';
    }

    // 2c) live verify
    const vr = await gql(Q_VERIFY, { id: gid });
    const vp = vr?.data?.product;
    rec.shopify_verify = { title: vp?.title, custom_mfr: vp?.cm?.value, dwc_mfr: vp?.dm?.value };
    console.log(`   Shopify verify: title=${JSON.stringify(vp?.title)} custom=${vp?.cm?.value} dwc=${vp?.dm?.value}`);

    results.push(rec);

    // ---------- batch cadence: >=95s gap between batches of 6 ----------
    const done = i + 1;
    if (done % BATCH === 0 && done < rows.length) {
      batchIdx++;
      console.log(`\n=== batch ${batchIdx} done (${done}/23). Cadence gap 95s before next batch ===`);
      await sleep(95000);
    }
  }

  await client.end();

  const out = { applied_at: new Date().toISOString(), dry, count: results.length, results };
  fs.writeFileSync(APPLY_LOG, JSON.stringify(out, null, 2));
  console.log(`\nwrote ${APPLY_LOG}`);

  // summary
  const pgOk = results.filter(x => x.pg === 'OK' || x.pg === 'ALREADY_APPLIED').length;
  const shTitleOk = results.filter(x => x.shopify_title === 'OK').length;
  const shMfrOk = results.filter(x => x.shopify_mfr === 'OK' || x.shopify_mfr === 'UNCHANGED').length;
  console.log(`SUMMARY: PG ok/already=${pgOk}/23  ShopifyTitle ok=${shTitleOk}  ShopifyMfr ok/unchanged=${shMfrOk}`);
  const bad = results.filter(x => !['OK','ALREADY_APPLIED','DRY'].includes(x.pg) ||
    (!dry && x.shopify_title && x.shopify_title !== 'OK') ||
    (!dry && x.shopify_mfr && !['OK','UNCHANGED'].includes(x.shopify_mfr)));
  if (bad.length) console.log('NEEDS ATTENTION:', bad.map(b => `${b.dw_sku}[pg=${b.pg},t=${b.shopify_title},m=${b.shopify_mfr}]`).join('  '));
}
main().catch(e => { console.error('FATAL', e); process.exit(1); });