[object Object]

← back to Designer Wallcoverings

TK-10002 Phase-2a: null-Sample + low-risk collision remediation (41 renames + 4 drafts)

7ccd2e42212493fd18a7db12d5776fb0c202ba47 · 2026-07-28 11:55:27 -0700 · vp-dw-commerce

Files touched

Diff

commit 7ccd2e42212493fd18a7db12d5776fb0c202ba47
Author: vp-dw-commerce <steve@designerwallcoverings.com>
Date:   Tue Jul 28 11:55:27 2026 -0700

    TK-10002 Phase-2a: null-Sample + low-risk collision remediation (41 renames + 4 drafts)
---
 scripts/tk10002-null-sample/apply.js | 127 +++++++++++++++++++++++++++++++++++
 scripts/tk10002-null-sample/plan.js  | 100 +++++++++++++++++++++++++++
 2 files changed, 227 insertions(+)

diff --git a/scripts/tk10002-null-sample/apply.js b/scripts/tk10002-null-sample/apply.js
new file mode 100644
index 00000000..78616cc4
--- /dev/null
+++ b/scripts/tk10002-null-sample/apply.js
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+/**
+ * TK-10002 Phase-2a APPLIER — DRY-RUN by default; APPLY=1 to write.
+ * Applies:
+ *   (1) 31 safe null-Sample renames  (/tmp/tk10002_safe.json)
+ *   (2) 10 low-risk variant renames + 4 product->DRAFT (/tmp/tk10002_lowrisk_plan.json)
+ * Order per item: PostgreSQL mirror FIRST, then Shopify (authoritative).
+ * SKU write uses productVariantsBulkUpdate { inventoryItem: { sku } } (GraphQL 2024-10).
+ * Rate-limit aware: ~120ms between GraphQL calls; retries on 429/throttle.
+ */
+const { Pool } = require('pg');
+const fs = require('fs');
+require('dotenv').config({ path: require('path').join(process.env.HOME,'Projects/Designer-Wallcoverings/shopify/.env') });
+const pool = new Pool({ host:'/tmp', database:'dw_unified', user: require('os').userInfo().username });
+const TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || process.env.SHOPIFY_ADMIN_TOKEN;
+const STORE = process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com';
+const API = process.env.SHOPIFY_ADMIN_API_VERSION || '2024-10';
+const APPLY = process.env.APPLY === '1';
+const sleep = ms => new Promise(r=>setTimeout(r,ms));
+
+async function gql(query, variables){
+  for(let attempt=0; attempt<5; attempt++){
+    const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{
+      method:'POST',
+      headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},
+      body: JSON.stringify({query,variables})
+    });
+    if(r.status===429){ await sleep(2000); continue; }
+    const j = await r.json();
+    const throttled = (j.errors||[]).some(e=>/throttl/i.test(e.message||''));
+    if(throttled){ await sleep(2000); continue; }
+    if(j.errors) throw new Error('GQL '+JSON.stringify(j.errors));
+    return j.data;
+  }
+  throw new Error('gql retries exhausted');
+}
+
+const VARIANT_SKU_MUT = `
+mutation($productId: ID!, $variants:[ProductVariantsBulkInput!]!){
+  productVariantsBulkUpdate(productId:$productId, variants:$variants){
+    productVariants { id inventoryItem { sku } }
+    userErrors { field message }
+  }
+}`;
+const PRODUCT_STATUS_MUT = `
+mutation($input: ProductInput!){
+  productUpdate(input:$input){ product{ id status } userErrors{ field message } }
+}`;
+
+async function renameVariant(pid, vid, oldSku, newSku){
+  // PG mirror FIRST
+  await pool.query(
+    `UPDATE shopify_products SET variant_sku=$1 WHERE split_part(shopify_id,'/',5)=$2 AND variant_sku=$3`,
+    [newSku, String(pid), oldSku]);
+  // Shopify (authoritative)
+  const d = await gql(VARIANT_SKU_MUT, {
+    productId:`gid://shopify/Product/${pid}`,
+    variants:[{ id:`gid://shopify/ProductVariant/${vid}`, inventoryItem:{ sku:newSku } }]
+  });
+  const errs = d.productVariantsBulkUpdate.userErrors;
+  if(errs.length) throw new Error('userErrors '+JSON.stringify(errs));
+  return d.productVariantsBulkUpdate.productVariants[0].inventoryItem.sku;
+}
+async function draftProduct(pid){
+  await pool.query(`UPDATE shopify_products SET status='DRAFT' WHERE split_part(shopify_id,'/',5)=$1`, [String(pid)]);
+  const d = await gql(PRODUCT_STATUS_MUT, { input:{ id:`gid://shopify/Product/${pid}`, status:'DRAFT' } });
+  const errs = d.productUpdate.userErrors;
+  if(errs.length) throw new Error('userErrors '+JSON.stringify(errs));
+  return d.productUpdate.product.status;
+}
+
+async function main(){
+  const safe = JSON.parse(fs.readFileSync('/tmp/tk10002_safe.json'));
+  const low = JSON.parse(fs.readFileSync('/tmp/tk10002_lowrisk_plan.json'));
+  const results = { renamed:[], drafted:[], registry:[], failed:[] };
+
+  console.log(`MODE: ${APPLY?'APPLY (LIVE WRITE)':'DRY-RUN'}`);
+  console.log(`Batch 1: ${safe.length} null-Sample renames | Batch 2: ${low.writes.length} low-risk renames + ${low.drafts.length} drafts\n`);
+
+  // ---- Batch 1: 31 safe null-Sample ----
+  for(const s of safe){
+    const vids = String(s.sampleVariantId).split(',').filter(Boolean);
+    for(const vid of vids){
+      if(!APPLY){ console.log('  [dry] rename', s.pid, s.handle, "'null-Sample' ->", s.newSampleSku); continue; }
+      try{
+        const got = await renameVariant(s.pid, vid, 'null-Sample', s.newSampleSku);
+        results.renamed.push({pid:s.pid, handle:s.handle, new:got}); console.log('  ✓', s.pid, '->', got);
+      }catch(e){ results.failed.push({pid:s.pid, new:s.newSampleSku, err:e.message}); console.log('  ✗', s.pid, e.message); }
+      await sleep(130);
+    }
+  }
+  // ---- Batch 2a: low-risk renames ----
+  for(const w of low.writes){
+    if(!APPLY){ console.log('  [dry] rename', w.pid, w.handle, `'${w.old}' ->`, w.new); continue; }
+    try{
+      const got = await renameVariant(w.pid, w.vid, w.old ?? '', w.new);
+      results.renamed.push({pid:w.pid, handle:w.handle, new:got});
+      // mint registry row where flagged
+      if(w.mint_dwsku){
+        await pool.query(
+          `INSERT INTO dw_sku_registry(dw_sku,vendor_prefix,vendor_name,mfr_sku,shopify_product_id,shopify_handle,status,created_at,updated_at)
+           VALUES($1,'DWPF','Pierre Frey',NULL,$2,$3,'active',NOW(),NOW())
+           ON CONFLICT DO NOTHING`,
+          [w.mint_dwsku, `gid://shopify/Product/${w.pid}`, w.handle]);
+        results.registry.push(w.mint_dwsku);
+      }
+      console.log('  ✓', w.pid, '->', got, w.mint_dwsku?`(registry+${w.mint_dwsku})`:'');
+    }catch(e){ results.failed.push({pid:w.pid, new:w.new, err:e.message}); console.log('  ✗', w.pid, e.message); }
+    await sleep(130);
+  }
+  // ---- Batch 2b: drafts ----
+  for(const d of low.drafts){
+    if(!APPLY){ console.log('  [dry] DRAFT', d.pid, d.handle, '|', d.reason); continue; }
+    try{ const st = await draftProduct(d.pid); results.drafted.push({pid:d.pid, handle:d.handle, status:st}); console.log('  ✓ DRAFT', d.pid, st); }
+    catch(e){ results.failed.push({pid:d.pid, action:'draft', err:e.message}); console.log('  ✗', d.pid, e.message); }
+    await sleep(130);
+  }
+
+  if(APPLY){
+    fs.writeFileSync('/tmp/tk10002_apply_results.json', JSON.stringify(results,null,2));
+    console.log(`\nDONE: renamed=${results.renamed.length} drafted=${results.drafted.length} registry+=${results.registry.length} failed=${results.failed.length}`);
+  } else {
+    console.log('\n(DRY-RUN. Set APPLY=1 to write.)');
+  }
+  await pool.end();
+}
+main().catch(e=>{console.error(e);process.exit(1);});
diff --git a/scripts/tk10002-null-sample/plan.js b/scripts/tk10002-null-sample/plan.js
new file mode 100644
index 00000000..35722cd3
--- /dev/null
+++ b/scripts/tk10002-null-sample/plan.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+/**
+ * TK-10002 Phase-2a — null-Sample remediation planner + applier.
+ * DRY-RUN by default. Set APPLY=1 to write (PG mirror FIRST, then Shopify).
+ *
+ * For each ACTIVE product whose SAMPLE variant sku === 'null-Sample':
+ *   - Fetch all live variants.
+ *   - Derive the correct sample SKU:
+ *       (a) If a sibling NON-sample variant carries a real base SKU B (strip any
+ *           -Yard/-Roll/-DR/-SR suffix), new sample sku = `${B}-Sample`.
+ *       (b) Else (single-variant sample-only, e.g. China Seas dwcw-<num>):
+ *           reconstruct base from handle `dwcw-<num>` -> `DWCW-<num>`,
+ *           else from mfr_sku, new sample sku = `${base}-Sample`.
+ *   - Guard: skip + flag if the proposed sku already exists on a NON-archived
+ *     product other than this one (case-insensitive), or duplicates within-batch.
+ */
+const { Pool } = require('pg');
+require('dotenv').config({ path: require('path').join(process.env.HOME,'Projects/Designer-Wallcoverings/shopify/.env') });
+// Local dw_unified mirror over the /tmp unix socket (host=/tmp), per Steve's rule.
+const pool = new Pool({ host: '/tmp', database: 'dw_unified', user: process.env.PGUSER || require('os').userInfo().username });
+const TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || process.env.SHOPIFY_ADMIN_TOKEN;
+const STORE = process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com';
+const API = process.env.SHOPIFY_ADMIN_API_VERSION || '2024-10';
+const APPLY = process.env.APPLY === '1';
+const sleep = ms => new Promise(r=>setTimeout(r,ms));
+
+// strip a known unit suffix to get the base DW SKU
+function baseOf(sku){
+  if(!sku) return null;
+  return sku.replace(/-(Sample|Yard|Roll|DR|SR|Double\-?Roll|Single\-?Roll)$/i,'');
+}
+
+async function shopifyGet(path){
+  const r = await fetch(`https://${STORE}/admin/api/${API}/${path}`,{headers:{'X-Shopify-Access-Token':TOKEN}});
+  if(r.status===429){ await sleep(2000); return shopifyGet(path); }
+  if(!r.ok) throw new Error(`GET ${path} -> ${r.status} ${await r.text()}`);
+  return r.json();
+}
+
+async function main(){
+  const { rows } = await pool.query(`
+    select split_part(shopify_id,'/',5) as pid, shopify_id, handle, mfr_sku, vendor, variant_id
+    from shopify_products
+    where upper(status)='ACTIVE' and variant_sku='null-Sample'
+    order by vendor, handle`);
+  console.log(`Loaded ${rows.length} null-Sample products from mirror`);
+
+  const plan = [];
+  const proposedSeen = new Map(); // upper -> pid (within-batch dedupe)
+  for(const row of rows){
+    const p = (await shopifyGet(`products/${row.pid}.json?fields=id,handle,title,status,variants`)).product;
+    const variants = p.variants;
+    const sampleVars = variants.filter(v => (v.sku||'').toLowerCase()==='null-sample');
+    const nonSampleReal = variants.find(v => v.sku && v.sku.toLowerCase()!=='null-sample' && !/^null$/i.test(v.sku));
+    let base=null, source=null;
+    if(nonSampleReal){ base = baseOf(nonSampleReal.sku); source=`sibling-variant(${nonSampleReal.sku})`; }
+    else if(/^dwcw-\d+$/i.test(p.handle)){ base = 'DWCW-'+p.handle.split('-')[1]; source='handle'; }
+    else if(row.mfr_sku){ base = row.mfr_sku; source='mfr_sku'; }
+    const newSampleSku = base ? `${base}-Sample` : null;
+    plan.push({
+      pid: row.pid, handle: p.handle, vendor: row.vendor, title: p.title,
+      sampleVariantId: sampleVars.map(v=>v.id).join(','),
+      nVariants: variants.length, oldSku: 'null-Sample', base, source, newSampleSku,
+      allSkus: variants.map(v=>`${v.title}:${v.sku}`).join(' | ')
+    });
+    await sleep(120); // ~8 req/s
+  }
+
+  // collision + within-batch dedupe check
+  for(const item of plan){
+    item.flags = [];
+    if(!item.newSampleSku){ item.flags.push('NO_BASE_DERIVED'); continue; }
+    const up = item.newSampleSku.toUpperCase();
+    if(proposedSeen.has(up)) item.flags.push(`BATCH_DUP_with_${proposedSeen.get(up)}`);
+    else proposedSeen.set(up, item.pid);
+    // external collision: same sku on a different non-archived product
+    const { rows:ex } = await pool.query(`
+      select split_part(shopify_id,'/',5) as pid, vendor, status
+      from shopify_products
+      where upper(variant_sku)=upper($1)
+        and split_part(shopify_id,'/',5) <> $2
+        and variant_sku <> 'null-Sample'
+        and upper(coalesce(status,'')) <> 'ARCHIVED'`, [item.newSampleSku, item.pid]);
+    if(ex.length) item.flags.push('EXT_COLLISION:'+ex.map(e=>`${e.pid}/${e.vendor}/${e.status}`).join(','));
+  }
+
+  const clean = plan.filter(i=>i.flags.length===0);
+  const flagged = plan.filter(i=>i.flags.length>0);
+  require('fs').writeFileSync('/tmp/tk10002_null_sample_plan.json', JSON.stringify(plan,null,2));
+  console.log(`\nDRY-RUN PLAN: ${clean.length} clean, ${flagged.length} flagged`);
+  console.log('vendor breakdown:', Object.entries(clean.reduce((a,i)=>{a[i.vendor]=(a[i.vendor]||0)+1;return a;},{})));
+  if(flagged.length){ console.log('\nFLAGGED:'); flagged.forEach(f=>console.log(' ',f.pid,f.handle,'->',f.newSampleSku,'|',f.flags.join(';'),'|',f.allSkus)); }
+  console.log('\nSample of clean plan (first 6):');
+  clean.slice(0,6).forEach(c=>console.log(`  ${c.pid} ${c.handle} [${c.vendor}] ${c.oldSku} -> ${c.newSampleSku}  (via ${c.source})`));
+
+  if(!APPLY){ console.log('\n(DRY-RUN only. Set APPLY=1 to write.)'); await pool.end(); return; }
+  // APPLY handled by apply.js
+  await pool.end();
+}
+main().catch(e=>{console.error(e);process.exit(1);});

← aec137bb kravet worklist: coalesce FTP new_map + GDrive master map_pr  ·  back to Designer Wallcoverings  ·  TK-10002: harden skuTakenOnActiveProduct — fail-closed on er de4cd0e3 →