← back to Newmor Onboard

scripts/backfill-metafields.mjs

201 lines

#!/usr/bin/env node
// TK-11346 — Newmor metafield spec-backfill executor (FILL-ONLY, NEVER CLOBBER)
// DRY-RUN by default. Live writes ONLY behind explicit --apply.
// Scope: the 116 ACTIVE Newmor colorways in artifacts/LIVE-NEWMOR-2026-09-09.csv
// Source: dw_unified.newmor_catalog (local mirror, host=/tmp)
// Rules (from artifacts/METAFIELD-MAP.md):
//   1. Write a metafield ONLY when LIVE value is empty/absent AND source value is non-empty.
//   2. Never fork keys — lock write type to the existing live type when the key already exists.
//   3. Never touch body_html / price / variant / channel. Online Store only, unchanged.
import {execFileSync} from 'node:child_process';
import fs from 'node:fs';
import {gql, ROOT} from './shopify.mjs';

const APPLY = process.argv.includes('--apply');
const CSV = `${ROOT}/artifacts/LIVE-NEWMOR-2026-09-09.csv`;
const OUT_DIR = `${ROOT}/data`;
const STAMP = new Date().toISOString().replace(/[:.]/g, '-');
const nowIso = () => new Date().toISOString();

// ---- 1. Load the 116 live products (manufacturer_code -> GID) from the manifest ----
const csvRows = fs.readFileSync(CSV, 'utf8').trim().split('\n').slice(1)
  .map(l => l.split(','))
  .map(c => ({ mfr: c[0].trim(), gid: c[1].trim() }));
if (csvRows.length !== 116) throw new Error(`Expected 116 manifest rows, got ${csvRows.length}`);
const gidSet = new Set(csvRows.map(r => r.gid));
if (gidSet.size !== 116) throw new Error(`Duplicate GIDs in manifest: ${csvRows.length} rows / ${gidSet.size} unique`);

// ---- 2. Fresh catalog read from dw_unified for exactly these mfr codes ----
const codes = csvRows.map(r => r.mfr);
const inList = codes.map(c => `'${c.replace(/'/g, "''")}'`).join(',');
const sql = `COPY (
  SELECT json_agg(row_to_json(t)) FROM (
    SELECT mfr_sku,
           NULLIF(width,'')        AS width,
           NULLIF(length,'')       AS length,
           NULLIF(material,'')      AS material,
           NULLIF(color_hex,'')     AS color_hex,
           NULLIF(product_url,'')   AS product_url,
           NULLIF(fire_rating,'')   AS fire_rating,
           NULLIF(application,'')   AS application,
           NULLIF(match_type,'')    AS match_type,
           NULLIF(coverage,'')      AS coverage,
           NULLIF(repeat_v,'')      AS repeat_v
    FROM newmor_catalog WHERE mfr_sku IN (${inList})
  ) t
) TO STDOUT;`;
const catalogJson = execFileSync('psql', ['host=/tmp dbname=dw_unified', '-tA', '-c', sql], {encoding: 'utf8', maxBuffer: 64 * 1024 * 1024});
const catalog = JSON.parse(catalogJson);
const catByMfr = new Map(catalog.map(r => [r.mfr_sku, r]));
for (const r of csvRows) if (!catByMfr.has(r.mfr)) throw new Error(`No newmor_catalog row for ${r.mfr}`);

// ---- 3. Candidate key definitions (fill-only; catalog-derived unless flagged constant) ----
// roll_length / vendor_url are catalog-derived; us_distributor is a Steve-confirmed CONSTANT
// (map row 27 = "LBI Boyd"), still gated by fill-only (write only where live is empty).
// width/material/color_hex are DELIBERATELY listed so the dry-run PROVES they are skipped
// by the clobber-guard (live already populated) — they will produce 0 writes.
// Set NEWMOR_SKIP_DISTRIBUTOR=1 to drop the constant us_distributor fill (strict catalog-only mode).
const SKIP_DISTRIBUTOR = process.env.NEWMOR_SKIP_DISTRIBUTOR === '1';

// Parse a free-text repeat like "11mm" / "13.25cm" into Shopify's dimension JSON shape.
// Returns null (never written) if it can't be parsed cleanly — never guess a unit.
const UNIT_MAP = { mm: 'MILLIMETERS', cm: 'CENTIMETERS', in: 'INCHES' };
function toDimension(v) {
  const m = String(v).trim().match(/^(\d+(?:\.\d+)?)\s*(mm|cm|in)$/i);
  if (!m) return null;
  return JSON.stringify({ value: parseFloat(m[1]), unit: UNIT_MAP[m[2].toLowerCase()] });
}

const CANDIDATES = [
  { key: 'roll_length',    defaultType: 'single_line_text_field', source: c => c.length,      origin: 'catalog:length' },
  { key: 'vendor_url',     defaultType: 'url',                    source: c => c.product_url, origin: 'catalog:product_url' },
  ...(SKIP_DISTRIBUTOR ? [] : [{ key: 'us_distributor', defaultType: 'single_line_text_field', source: () => 'LBI Boyd', origin: 'constant:LBI Boyd (Steve-confirmed, map row 27)' }]),
  // clobber-guard proof keys (expected 0 writes — live already populated):
  { key: 'width',          defaultType: 'single_line_text_field', source: c => c.width,       origin: 'catalog:width' },
  { key: 'material',       defaultType: 'multi_line_text_field',  source: c => c.material,     origin: 'catalog:material' },
  { key: 'color_hex',      defaultType: 'single_line_text_field', source: c => c.color_hex,    origin: 'catalog:color_hex' },
  // TK-11424 — Norman scraper spec-refresh keys (METAFIELD-MAP.md). Steve's ruling 2026-09-13:
  // STRICT fill-only-never-clobber, no placeholder exception (a live value like
  // custom.fire_rating="Inquire for more Information" counts as populated and is skipped).
  { key: 'fire_rating',    defaultType: 'single_line_text_field', source: c => c.fire_rating,  origin: 'catalog:fire_rating (TK-11424 PDF spec-sheet extraction)' },
  { key: 'application',    defaultType: 'single_line_text_field', source: c => c.application,  origin: 'catalog:application (TK-11424 PDF filename/body)' },
  { key: 'pattern_match',  defaultType: 'single_line_text_field', source: c => c.match_type,   origin: 'catalog:match_type (TK-11424 PDF/HTML)' },
  { key: 'coverage',       defaultType: 'single_line_text_field', source: c => c.coverage,     origin: 'catalog:coverage (TK-11424 computed roll sqm/sqyd)' },
  { key: 'pattern_repeat_vertical', defaultType: 'dimension',     source: c => c.repeat_v,     origin: 'catalog:repeat_v (TK-11424 PDF/HTML)', format: toDimension },
];
const NS = 'custom';
const empty = v => v === null || v === undefined || String(v).trim() === '';

// ---- 4. Fetch live metafields for all 116, compute fill candidates ----
const perProduct = [];     // full record per product
const jsonl = [];          // one line per proposed write
const restoreMap = [];     // reversibility record
const keyTotals = {};      // key -> proposed write count
const skipReasons = {};    // key -> {live_populated, source_empty, would_fork_type}
for (const cand of CANDIDATES) skipReasons[cand.key] = { live_populated: 0, source_empty: 0, unparseable_source: 0 };

// First pass: discover existing live type per candidate key across all 116 (to lock type, never fork)
const liveByGid = new Map();
for (const {mfr, gid} of csvRows) {
  const d = await gql(
    `query($id:ID!){product(id:$id){id title vendor status metafields(first:250){nodes{id namespace key type value}}}}`,
    {id: gid});
  const p = d.product;
  if (!p) throw new Error(`Product not found: ${gid} (${mfr})`);
  if (p.vendor !== 'Newmor Wallcoverings') throw new Error(`Vendor mismatch on ${gid}: ${p.vendor}`);
  if (p.status !== 'ACTIVE') console.error(`WARN ${mfr} ${gid} status=${p.status} (manifest = 116 ACTIVE)`);
  liveByGid.set(gid, p);
}
// Lock type per key from existing live usage
const lockedType = {};
for (const cand of CANDIDATES) {
  let found = null;
  for (const p of liveByGid.values()) {
    const m = p.metafields.nodes.find(n => n.namespace === NS && n.key === cand.key);
    if (m) { found = m.type; break; }
  }
  lockedType[cand.key] = found || cand.defaultType;
}

// Second pass: compute fills
for (const {mfr, gid} of csvRows) {
  const p = liveByGid.get(gid);
  const cat = catByMfr.get(mfr);
  const liveMf = new Map(p.metafields.nodes.filter(n => n.namespace === NS).map(n => [n.key, n]));
  const rec = { mfr, gid, title: p.title, writes: [], skips: [] };
  for (const cand of CANDIDATES) {
    const src = cand.source(cat);
    const liveM = liveMf.get(cand.key);
    const liveVal = liveM ? liveM.value : null;
    if (empty(src)) { rec.skips.push({key: cand.key, reason: 'source_empty'}); skipReasons[cand.key].source_empty++; continue; }
    if (!empty(liveVal)) { rec.skips.push({key: cand.key, reason: 'live_populated', liveVal, liveType: liveM.type}); skipReasons[cand.key].live_populated++; continue; }
    // FILL: live empty/absent AND source non-empty
    const type = lockedType[cand.key];
    const value = cand.format ? cand.format(src) : String(src).trim();
    if (value === null) { rec.skips.push({key: cand.key, reason: 'unparseable_source', src}); skipReasons[cand.key].unparseable_source++; continue; }
    const write = { key: `${NS}.${cand.key}`, type, value, origin: cand.origin, existingMetafieldId: liveM ? liveM.id : null, action: liveM ? 'update-empty' : 'create' };
    rec.writes.push(write);
    keyTotals[cand.key] = (keyTotals[cand.key] || 0) + 1;
    jsonl.push(JSON.stringify({mfr, gid, title: p.title, ...write}));
    restoreMap.push({
      gid, mfr, namespace: NS, key: cand.key,
      restore: liveM ? { action: 'set-old-value', oldValue: liveM.value, metafieldId: liveM.id, oldType: liveM.type }
                     : { action: 'delete-if-created' },
      newValue: value, newType: type, origin: cand.origin, applied: false, appliedMetafieldId: null, appliedAt: null,
    });
  }
  perProduct.push(rec);
}

const grandTotal = jsonl.length;
const productsAffected = perProduct.filter(r => r.writes.length).length;

// ---- 5. Emit artifacts ----
fs.mkdirSync(`${OUT_DIR}/tk11346`, {recursive: true});
const jsonlPath = `${OUT_DIR}/tk11346/dryrun-writes-${STAMP}.jsonl`;
const perProdPath = `${OUT_DIR}/tk11346/dryrun-per-product-${STAMP}.json`;
const restorePath = `${OUT_DIR}/tk11346/restore-map-${STAMP}.json`;
fs.writeFileSync(jsonlPath, jsonl.join('\n') + (jsonl.length ? '\n' : ''));
fs.writeFileSync(perProdPath, JSON.stringify({generated: nowIso(), mode: APPLY ? 'apply' : 'dry-run', perProduct}, null, 2) + '\n');

const summary = {
  ticket: 'TK-11346', generated: nowIso(), mode: APPLY ? 'apply' : 'dry-run',
  scope: { live_products: 116, source: 'dw_unified.newmor_catalog', rule: 'fill-only (live-empty AND source-non-empty), never clobber, never fork type' },
  locked_types: lockedType,
  key_totals: keyTotals,
  grand_total_writes: grandTotal,
  products_affected: productsAffected,
  skip_reasons: skipReasons,
};

// ---- 6. Live writes (ONLY with --apply) ----
if (APPLY) {
  console.error('!!! --apply: firing LIVE metafieldsSet writes !!!');
  const applied = [];
  for (const r of perProduct) {
    for (const w of r.writes) {
      const [ns, key] = w.key.split('.');
      const d = await gql(
        `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){metafields{id namespace key type value}userErrors{field message}}}`,
        {mf: [{ownerId: r.gid, namespace: ns, key, type: w.type, value: w.value}]});
      const ue = d.metafieldsSet.userErrors;
      if (ue.length) throw new Error(`metafieldsSet ${r.gid} ${w.key}: ${JSON.stringify(ue)}`);
      const setId = d.metafieldsSet.metafields[0].id;
      const rm = restoreMap.find(x => x.gid === r.gid && x.key === key);
      rm.applied = true; rm.appliedMetafieldId = setId; rm.appliedAt = nowIso();
      applied.push({gid: r.gid, key: w.key, id: setId});
      await new Promise(res => setTimeout(res, 250)); // gentle pacing
    }
  }
  summary.applied_count = applied.length;
  console.error(`Applied ${applied.length} live writes.`);
}

fs.writeFileSync(restorePath, JSON.stringify({ticket: 'TK-11346', generated: nowIso(), mode: APPLY ? 'apply' : 'dry-run', restore_map: restoreMap}, null, 2) + '\n');

// ---- 7. Report ----
console.log(JSON.stringify(summary, null, 2));
console.log(`\nArtifacts:\n  writes JSONL : ${jsonlPath}\n  per-product  : ${perProdPath}\n  restore map  : ${restorePath}`);
console.log(`\nGRAND TOTAL proposed writes: ${grandTotal}  |  products affected: ${productsAffected}/116`);
if (!APPLY) console.log('\nMODE = DRY-RUN. Zero live writes fired. Re-run with --apply (GATED) to write.');