[object Object]

← back to Tk10630 Sku Suffix Canary

TK-10630: Hollywood SKU consolidation tool (enumerate/apply/lib) + shared canary lib

6072853b8718e053849796d3dd29c0805dfc5a8b · 2026-08-17 11:27:45 -0700 · steve

Files touched

Diff

commit 6072853b8718e053849796d3dd29c0805dfc5a8b
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 17 11:27:45 2026 -0700

    TK-10630: Hollywood SKU consolidation tool (enumerate/apply/lib) + shared canary lib
---
 .gitignore    |  6 ++++
 apply.mjs     | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 enumerate.mjs | 63 ++++++++++++++++++++++++++++++++++++++
 lib.mjs       | 59 ++++++++++++++++++++++++++++++++++++
 shopify.mjs   | 53 ++++++++++++++++++++++++++++++++
 5 files changed, 278 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5a7c0b0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+*.log
+done-*.jsonl
+plan.json
+plan-summary.json
+.DS_Store
diff --git a/apply.mjs b/apply.mjs
new file mode 100644
index 0000000..ab44e18
--- /dev/null
+++ b/apply.mjs
@@ -0,0 +1,97 @@
+// Apply the Hollywood/Momentum SKU-consolidation plan to the LIVE store.
+// DRY-RUN by default. Pass --apply to write. Resumable via done.jsonl.
+//
+// Per product (SERIAL within product — never parallelize same-handle writes):
+//   1) inventoryItemUpdate for each variant SKU change
+//   2) one metafieldsSet for both dw_sku metafields
+// Fan out ACROSS products with a small worker pool (rate-limit safe).
+import { gql } from './shopify.mjs';
+import { FABRICATED } from './lib.mjs';
+import { readFileSync, appendFileSync, existsSync } from 'node:fs';
+
+const APPLY = process.argv.includes('--apply');
+const LIMIT = Number((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || Infinity);
+const CONC = Number((process.argv.find(a => a.startsWith('--conc=')) || '').split('=')[1] || 5);
+const ONLY = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1] || null;
+const MF_ONLY = process.argv.includes('--mf-only');   // write metafields only (needs write_products)
+const VAR_ONLY = process.argv.includes('--var-only'); // write variant SKUs only (needs write_inventory)
+const PHASE = MF_ONLY ? 'mf' : VAR_ONLY ? 'var' : 'all';
+const DONE = `done-${PHASE}.jsonl`;
+
+const { plan } = JSON.parse(readFileSync('plan.json', 'utf8'));
+
+// --- Guards ---------------------------------------------------------------
+// 1) Refuse any target base that is itself a fabricated code (should never happen).
+// 2) Refuse to write a base claimed by >1 product (cross-product SKU collision).
+const baseOwners = new Map();
+for (const p of plan) (baseOwners.get(p.base) || baseOwners.set(p.base, []).get(p.base)).push(p.handle);
+const collisions = [...baseOwners].filter(([, hs]) => hs.length > 1);
+const badBase = plan.filter(p => FABRICATED.test(p.base));
+
+// Resume: skip products already done.
+const done = new Set();
+if (existsSync(DONE)) for (const l of readFileSync(DONE, 'utf8').split('\n')) { if (l.trim()) done.add(JSON.parse(l).id); }
+
+let work = plan.filter(p => !done.has(p.id) && !FABRICATED.test(p.base) && !collisions.find(([b]) => b === p.base));
+if (ONLY) work = work.filter(p => p.handle.includes(ONLY));
+if (work.length > LIMIT) work = work.slice(0, LIMIT);
+
+console.log(`[apply] mode=${APPLY ? 'LIVE-WRITE' : 'DRY-RUN'} plan=${plan.length} todo=${work.length} done=${done.size} conc=${CONC}`);
+if (collisions.length) console.log(`[apply] ⚠ ${collisions.length} base-collisions SKIPPED (safety): ${collisions.slice(0,5).map(([b,h])=>b+'×'+h.length).join(', ')}`);
+if (badBase.length) console.log(`[apply] ⚠ ${badBase.length} products have a fabricated base — SKIPPED`);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function mut(query, variables) {
+  for (let attempt = 0; ; attempt++) {
+    try {
+      const { data } = await gql(query, variables);
+      return data;
+    } catch (e) {
+      if (/THROTTLED|throttle/i.test(e.message) && attempt < 8) { await sleep(1500 * (attempt + 1)); continue; }
+      throw e;
+    }
+  }
+}
+
+const INV = `mutation($id:ID!,$sku:String!){ inventoryItemUpdate(id:$id, input:{sku:$sku}){ inventoryItem{ id sku } userErrors{ field message } } }`;
+const MFS = `mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ metafields{ id } userErrors{ field message } } }`;
+
+async function applyOne(p) {
+  const errs = [];
+  if (!MF_ONLY) {
+    for (const c of p.varChanges) {
+      if (!APPLY) continue;
+      try {
+        const d = await mut(INV, { id: c.invItemId, sku: c.to });
+        const ue = d.inventoryItemUpdate.userErrors;
+        if (ue.length) errs.push(`var ${c.to}: ${JSON.stringify(ue)}`);
+      } catch (e) { errs.push(`var ${c.to}: ${e.message.slice(0, 120)}`); }
+    }
+  }
+  if (!VAR_ONLY && p.mfChanges.length) {
+    const m = p.mfChanges.map(c => ({ ownerId: c.ownerId, namespace: c.namespace, key: c.key, type: 'single_line_text_field', value: c.to }));
+    if (APPLY) {
+      try {
+        const d = await mut(MFS, { m });
+        const ue = d.metafieldsSet.userErrors;
+        if (ue.length) errs.push(`mf: ${JSON.stringify(ue)}`);
+      } catch (e) { errs.push(`mf: ${e.message.slice(0, 120)}`); }
+    }
+  }
+  const rec = { id: p.id, handle: p.handle, base: p.base, vars: p.varChanges.length, mfs: p.mfChanges.length, errs, at: process.hrtime.bigint().toString() };
+  if (APPLY && errs.length === 0) appendFileSync(DONE, JSON.stringify(rec) + '\n'); // only checkpoint clean successes
+  return rec;
+}
+
+// Worker pool over products.
+let idx = 0, ok = 0, err = 0;
+async function worker(wid) {
+  while (idx < work.length) {
+    const p = work[idx++];
+    try { const r = await applyOne(p); if (r.errs.length) { err++; console.log(`  ✗ ${p.handle} → ${p.base}: ${r.errs.join('; ')}`); } else { ok++; } }
+    catch (e) { err++; console.log(`  ✗ ${p.handle}: ${e.message}`); }
+    if ((ok + err) % 50 === 0) process.stderr.write(`  progress ${ok + err}/${work.length} (ok=${ok} err=${err})\n`);
+  }
+}
+await Promise.all(Array.from({ length: Math.min(CONC, work.length) }, (_, i) => worker(i)));
+console.log(`[apply] DONE ok=${ok} err=${err} ${APPLY ? '(written)' : '(dry-run — no writes)'}`);
diff --git a/enumerate.mjs b/enumerate.mjs
new file mode 100644
index 0000000..03aa86f
--- /dev/null
+++ b/enumerate.mjs
@@ -0,0 +1,63 @@
+// READ-ONLY enumerator + planner for the Hollywood/Momentum SKU consolidation.
+// Pages every live Hollywood Wallcoverings product, derives its ORIGINAL line code,
+// and emits a per-product change plan (plan.json). Writes NOTHING to Shopify.
+import { gql } from './shopify.mjs';
+import { deriveBase, planProduct } from './lib.mjs';
+import { writeFileSync } from 'node:fs';
+
+const VENDOR = 'Hollywood Wallcoverings';
+const PAGE = `query($cursor:String){
+  products(first:40, query:"vendor:\\"${VENDOR}\\"", after:$cursor){
+    pageInfo{ hasNextPage endCursor }
+    nodes{
+      id handle title status
+      pn:  metafield(namespace:"global", key:"product_number"){ id value }
+      mfG: metafield(namespace:"global", key:"dw_sku"){ id value }
+      mfD: metafield(namespace:"dwc",    key:"dw_sku"){ id value }
+      variants(first:10){ nodes{ id title sku selectedOptions{ name value } inventoryItem{ id } } }
+    }
+  }
+}`;
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const plan = [], skipped = [];
+let cursor = null, pages = 0, total = 0, clean = 0, fatal = null;
+
+const summarize = () => ({
+  scanned: total, need_change: plan.length, already_clean: clean,
+  skipped: skipped.length,
+  skip_no_code: skipped.filter(s => s.reason === 'no-original-code').length,
+  skip_conflict: skipped.filter(s => s.reason === 'variant-base-conflict').length,
+  variant_writes: plan.reduce((a, p) => a + p.varChanges.length, 0),
+  metafield_writes: plan.reduce((a, p) => a + p.mfChanges.length, 0),
+  sample_suffix_fixes: plan.reduce((a, p) => a + p.varChanges.filter(c => c.option === 'sample' && /-yard$/.test(c.from || '')).length, 0),
+  dwhd_bases_removed: plan.reduce((a, p) => a + p.varChanges.filter(c => /^DWHD-/i.test(c.from || '')).length, 0),
+  dwhw_metafields_removed: plan.reduce((a, p) => a + p.mfChanges.filter(c => /^DWHW/i.test(c.from || '')).length, 0),
+});
+const flush = () => writeFileSync('plan.json', JSON.stringify({ summary: summarize(), plan, skipped, fatal, cursor }, null, 2));
+
+while (true) {
+  let res;
+  try {
+    for (let attempt = 0; ; attempt++) {
+      try { res = await gql(PAGE, { cursor }); break; }
+      catch (e) { if (/THROTTLED|throttle/i.test(e.message) && attempt < 8) { await sleep(2000 * (attempt + 1)); continue; } throw e; }
+    }
+  } catch (e) { fatal = { atPage: pages, atTotal: total, cursor, message: e.message }; process.stderr.write(`FATAL p${pages}: ${e.message}\n`); break; }
+
+  const { nodes, pageInfo } = res.data.products;
+  for (const p of nodes) {
+    total++;
+    const base = deriveBase(p);
+    if (!base) { skipped.push({ id: p.id, handle: p.handle, reason: 'no-original-code' }); continue; }
+    if (typeof base === 'object') { skipped.push({ id: p.id, handle: p.handle, reason: 'variant-base-conflict', bases: base.conflict }); continue; }
+    const { varChanges, mfChanges } = planProduct(p, base);
+    if (varChanges.length || mfChanges.length) plan.push({ id: p.id, handle: p.handle, status: p.status, base, varChanges, mfChanges });
+    else clean++;
+  }
+  pages++; cursor = pageInfo.endCursor;
+  if (pages % 5 === 0) { flush(); process.stderr.write(`  ...${total} scanned | ${plan.length} change, ${clean} clean, ${skipped.length} skip\n`); }
+  if (!pageInfo.hasNextPage) break;
+}
+flush();
+console.log(JSON.stringify({ ...summarize(), fatal }, null, 2));
diff --git a/lib.mjs b/lib.mjs
new file mode 100644
index 0000000..56bc947
--- /dev/null
+++ b/lib.mjs
@@ -0,0 +1,59 @@
+// Pure transform + detection logic shared by enumerate / apply / canary.
+// No I/O, no side effects on import.
+
+// Fabricated DW codes to purge. Hollywood Wallcoverings & Phillipe Romano are DW
+// BRANDS, not external vendors — a scraper wrongly minted DW-vendor-style SKUs:
+//   DWHD-* stamped onto sample variants, DWHW*-* written into dw_sku metafields.
+export const FABRICATED = /^DWH[DW]\d?-/i;
+
+// A legit ORIGINAL private-label code: alpha prefix + digits, e.g. XWH-52359, NOC-105.
+const ORIG = /^([A-Z]{2,5})-?(\d{2,})$/i;
+
+function normCode(s) {
+  const m = s.match(ORIG);
+  return m ? `${m[1].toUpperCase()}-${m[2]}` : s.toUpperCase();
+}
+
+// Derive a product's ORIGINAL line code (prefix-agnostic), ignoring fabricated codes.
+// Returns a string base, null (no code), or {conflict:[...]} (variants disagree).
+export function deriveBase(p) {
+  const cand = new Set();
+  for (const v of p.variants.nodes) {
+    const m = (v.sku || '').match(/^(.+?)-(sample|yard|roll)$/i);
+    const raw = m ? m[1] : (v.sku || '');
+    if (raw && !FABRICATED.test(raw) && ORIG.test(raw)) cand.add(normCode(raw));
+  }
+  if (cand.size === 1) return [...cand][0];
+  if (cand.size > 1) return { conflict: [...cand] };
+  const hm = p.handle.match(/-([a-z]{2,5})-(\d{2,})$/i);
+  if (hm) return `${hm[1].toUpperCase()}-${hm[2]}`;
+  if (p.pn?.value) { const m = p.pn.value.match(/^([A-Z]{2,5})-?(\d{2,})$/i); if (m) return `${m[1].toUpperCase()}-${m[2]}`; }
+  return null;
+}
+
+// Which purchase-option suffix a variant should carry.
+export function suffixFor(v) {
+  const opt = (v.selectedOptions?.find(o => /purchase option/i.test(o.name))?.value || v.title || '').toLowerCase();
+  const cur = (v.sku || '').toLowerCase();
+  if (/sample/.test(opt) || /-sample$/.test(cur)) return 'sample';
+  if (/roll/.test(opt)   || /-roll$/.test(cur))   return 'roll';
+  if (/yard/.test(opt)   || /per yard/.test(opt) || /-yard$/.test(cur)) return 'yard';
+  return 'yard';
+}
+
+// Compute the change set for one product given its canonical base.
+export function planProduct(p, base) {
+  const varChanges = [];
+  for (const v of p.variants.nodes) {
+    const suf = suffixFor(v);
+    const target = `${base}-${suf}`;
+    if ((v.sku || '') !== target)
+      varChanges.push({ variantId: v.id, invItemId: v.inventoryItem.id, from: v.sku, to: target, option: suf });
+  }
+  const mfChanges = [];
+  for (const [ns, mf] of [['global', p.mfG], ['dwc', p.mfD]]) {
+    const cur = mf?.value || null;
+    if (cur !== base) mfChanges.push({ namespace: ns, key: 'dw_sku', ownerId: p.id, from: cur, to: base });
+  }
+  return { varChanges, mfChanges };
+}
diff --git a/shopify.mjs b/shopify.mjs
new file mode 100644
index 0000000..80a99dd
--- /dev/null
+++ b/shopify.mjs
@@ -0,0 +1,53 @@
+// Read-only Shopify Admin GraphQL helper (shared by query + canary).
+import { readFileSync } from 'node:fs';
+
+function loadEnv() {
+  const txt = readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`, 'utf8');
+  const env = {};
+  for (const line of txt.split('\n')) {
+    const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+    if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+  }
+  return env;
+}
+
+const env = loadEnv();
+export const STORE = env.SHOPIFY_STORE_DOMAIN;
+// Token selection: default SHOPIFY_ADMIN_TOKEN; override via SHOPIFY_TOKEN_VAR
+// (e.g. SHOPIFY_TOKEN_VAR=SHOPIFY_FULL_ACCESS_TOKEN for the write_inventory variant phase).
+const TOKEN_VAR = process.env.SHOPIFY_TOKEN_VAR || 'SHOPIFY_ADMIN_TOKEN';
+const TOKEN = env[TOKEN_VAR];
+export const TOKEN_NAME = TOKEN_VAR;
+const API = '2024-10';
+
+export async function gql(query, variables = {}) {
+  const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'X-Shopify-Access-Token': TOKEN,
+    },
+    body: JSON.stringify({ query, variables }),
+  });
+  const json = await res.json();
+  if (json.errors) throw new Error('GraphQL errors: ' + JSON.stringify(json.errors));
+  const throttle = json.extensions?.cost?.throttleStatus;
+  return { data: json.data, throttle };
+}
+
+export async function getProductByHandle(handle) {
+  const q = `query($h:String!){
+    productByHandle(handle:$h){
+      id title handle status vendor productType
+      variants(first:50){
+        nodes{
+          id title sku price
+          selectedOptions{ name value }
+          inventoryItem{ id sku }
+        }
+      }
+    }
+  }`;
+  const { data } = await gql(q, { h: handle });
+  return data.productByHandle;
+}

(oldest)  ·  back to Tk10630 Sku Suffix Canary  ·  TK-10630: XWH metafield backfill (25) + base-prefix filter; 722d3ce →