← 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
A scripts/tk10002-null-sample/apply.jsA scripts/tk10002-null-sample/plan.js
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 →