← 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
A .gitignoreA apply.mjsA enumerate.mjsA lib.mjsA shopify.mjs
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 →