[object Object]

← back to Dw Validator Debug TK11314

TK-10046: manual-path applier for the 2 held products (dry-run default, gated)

1953fb75f4558952bfdf2383840a55b7b2dde7bb · 2026-08-11 11:53:33 -0700 · Steve Abrams

Handles the 2 products the main applier held back:
- fionas-1920s-floral: 40 base-less '-25-*' SKUs prefixed with one fresh minted base.
- Hudson Mural: DIG-510098 base; option A (Steve-approved) — keep the exact-dup pair,
  disambiguate 2nd occurrence with '-2' via deterministic per-product dedup-suffix.
Same approval gate (scope bespoke-collision-reassign), PG-first+rollback, atomic mint,
seq starts at 100 (above the main run's …001..038). Dry-run verified: 2 products / 56
moves, 0 dup, 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1953fb75f4558952bfdf2383840a55b7b2dde7bb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 11 11:53:33 2026 -0700

    TK-10046: manual-path applier for the 2 held products (dry-run default, gated)
    
    Handles the 2 products the main applier held back:
    - fionas-1920s-floral: 40 base-less '-25-*' SKUs prefixed with one fresh minted base.
    - Hudson Mural: DIG-510098 base; option A (Steve-approved) — keep the exact-dup pair,
      disambiguate 2nd occurrence with '-2' via deterministic per-product dedup-suffix.
    Same approval gate (scope bespoke-collision-reassign), PG-first+rollback, atomic mint,
    seq starts at 100 (above the main run's …001..038). Dry-run verified: 2 products / 56
    moves, 0 dup, 0 fail.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .../tk10046-bespoke-reassign-manual.js             | 141 +++++++++++++++++++++
 1 file changed, 141 insertions(+)

diff --git a/scripts/tk10002-null-sample/tk10046-bespoke-reassign-manual.js b/scripts/tk10002-null-sample/tk10046-bespoke-reassign-manual.js
new file mode 100644
index 00000000..565ff47a
--- /dev/null
+++ b/scripts/tk10002-null-sample/tk10046-bespoke-reassign-manual.js
@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+/**
+ * TK-10046 — MANUAL path for the 2 products the main applier held back.
+ *
+ * DRY-RUN by default; live write needs APPLY=1 + approval artifact
+ * "APPROVED: TK-10046 bespoke-collision-reassign" (same gate as the main applier).
+ *
+ * Handles (Steve-approved 2026-08-11, option A):
+ *  1. fionas-1920s-floral (pid 7664591568947) — 40 base-less "-25-*" SKUs (no design base).
+ *     Fix: prefix each with ONE fresh minted base → DIGAI-<n>-25-gold-2x27 (all 40 already unique).
+ *  2. Hudson Mural (pid 7664590323763) — base DIG-510098; 14 variants distinct, 2 variants are an
+ *     EXACT pre-existing duplicate (DIG-510098-IS-type-2-vinyl-25-type-2-vinyl ×2). Option A = keep
+ *     BOTH: disambiguate by appending "-2" to the 2nd occurrence (ordered by variant id).
+ *
+ * Unified transform: newSku = <base> + (strip DIG/DIGAI/DIG_/DIG-MOD base if present, else whole).
+ * Then per-product dedup-suffix: 2nd+ occurrence of a new SKU gets "-2","-3",… (deterministic).
+ */
+const { Pool } = require('pg');
+const fs = require('fs');
+const path = require('path');
+const { requireApproval } = require('../../shopify/scripts/lib/approval-gate');
+const { skuTakenOnActiveProduct } = require('../../DW-Programming/vendor-crawlers/lib/sku-registry');
+
+const pool = new Pool({ connectionString: process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified' });
+function readAdminToken() {
+  for (const p of [ path.join(process.env.HOME, 'Projects/secrets-manager/.env'),
+                    path.join(process.env.HOME, 'Projects/Designer-Wallcoverings/shopify/.env') ]) {
+    try { const l = fs.readFileSync(p, 'utf8').split('\n').find(x => x.startsWith('SHOPIFY_ADMIN_TOKEN='));
+          if (l) return l.split('=').slice(1).join('=').replace(/['"]/g, '').trim(); } catch (_) {}
+  }
+  throw new Error('SHOPIFY_ADMIN_TOKEN not found');
+}
+const TOKEN = readAdminToken();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const APPLY = process.env.APPLY === '1';
+const BASE_RE = /^(AIDIG|DIG)[-_](MOD[-_])?[0-9A-Za-z]+/i;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const TARGETS = [
+  { pid: '7664591568947', label: "fionas-1920s-floral (base-less prefix)" },
+  { pid: '7664590323763', label: "Hudson Mural (DIG-510098 + dedup dup pair)" },
+];
+
+async function gql(query, variables) {
+  for (let a = 0; a < 5; a++) {
+    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();
+    if ((j.errors || []).some(e => /throttl/i.test(e.message || ''))) { 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 MUT = `mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){
+  productVariantsBulkUpdate(productId:$productId,variants:$variants){ productVariants{ id inventoryItem{ sku } } userErrors{ field message } } }`;
+
+async function baseLiveOnShopify(base) {
+  const q = `{ a: productVariants(first:1, query:"sku:'${base}'"){ nodes{ id } }
+              b: productVariants(first:1, query:"sku:${base}-*"){ nodes{ id } } }`;
+  try { const d = await gql(q); return (d.a.nodes.length + d.b.nodes.length) > 0; } catch (_) { return true; }
+}
+async function mintBase(seqStart) {
+  if (!APPLY) return { base: `<MINT@apply>`, seq: seqStart };
+  const now = new Date();
+  const mm = String(now.getMonth() + 1).padStart(2, '0'), dd = String(now.getDate()).padStart(2, '0'), yy = String(now.getFullYear()).slice(-2);
+  for (let seq = seqStart; seq < seqStart + 5000; seq++) {
+    const base = `DIGAI-${mm}${dd}${yy}${String(seq).padStart(3, '0')}`;
+    if ((await skuTakenOnActiveProduct(base)).taken) continue;
+    if (await baseLiveOnShopify(base)) continue;
+    const r = await pool.query(`INSERT INTO dw_sku_registry (dw_sku,vendor_prefix,vendor_name,mfr_sku) VALUES ($1,'DIGAI','DW Bespoke Studios',$1) ON CONFLICT (dw_sku) DO NOTHING`, [base]);
+    if (r.rowCount === 1) return { base, seq: seq + 1 };
+  }
+  throw new Error('mintBase: no free base');
+}
+
+// strip base if present, else keep whole (base-less) — then append the minted base.
+function newSkuFor(oldSku, base) {
+  const s = String(oldSku).trim();
+  const m = BASE_RE.exec(s);
+  return base + (m ? s.slice(m[0].length) : s);
+}
+
+async function main() {
+  const gate = requireApproval({ ticket: 'TK-10046', scope: 'bespoke-collision-reassign', apply: APPLY });
+  console.log(`MODE: ${APPLY ? 'APPLY (LIVE WRITE)' : 'DRY-RUN'}  gate=${gate.mode}  token …${TOKEN.slice(-4)}\n`);
+  const results = { applied: [], failed: [], skippedBlank: [] };
+  let seqStart = 100;  // start above the main run's range (…001..038) to avoid same-day churn
+
+  for (const t of TARGETS) {
+    const gid = `gid://shopify/Product/${t.pid}`;
+    let d; try { d = await gql(`{ product(id:"${gid}"){ status variants(first:100){ nodes{ id inventoryItem{ sku } } } } }`); }
+    catch (e) { results.failed.push({ ...t, err: 'fetch ' + e.message }); console.log('✗', t.label, e.message); continue; }
+    const p = d.product;
+    if (!p) { results.failed.push({ ...t, err: 'not found' }); console.log('✗', t.label, 'not found'); continue; }
+    const nodes = p.variants.nodes || [];
+    const blank = nodes.filter(v => !(v.inventoryItem?.sku)).map(v => v.id);
+    if (blank.length) { results.skippedBlank.push({ label: t.label, variantIds: blank }); console.log('⚠', t.label, `${blank.length} blank-SKU variant(s) left untouched`); }
+    // deterministic order by variant id so dedup-suffix is stable across runs
+    const live = nodes.filter(v => v.inventoryItem?.sku).map(v => ({ id: v.id, sku: v.inventoryItem.sku }))
+                      .sort((a, b) => a.id.localeCompare(b.id));
+
+    const mint = await mintBase(seqStart); seqStart = mint.seq;
+    const seen = new Map();
+    const moves = [];
+    for (const v of live) {
+      let neu = newSkuFor(v.sku, mint.base);
+      const n = (seen.get(neu) || 0) + 1; seen.set(neu, n);
+      if (n > 1) neu = `${neu}-${n}`;   // Option A: 2nd+ occurrence disambiguated with -2, -3, …
+      if (neu !== v.sku) moves.push({ id: v.id, old: v.sku, neu });
+    }
+    // final safety: no residual dup after disambiguation
+    const set = new Set(moves.map(m => m.neu));
+    if (set.size !== moves.length) { results.failed.push({ ...t, err: 'residual dup after disambiguation' }); console.log('✗', t.label, 'residual dup — abort product'); continue; }
+
+    if (!APPLY) {
+      console.log(`[dry] ${t.label} [${p.status}] base=${mint.base} · ${moves.length} moves`);
+      moves.forEach(m => console.log(`        '${m.old}' -> '${m.neu}'`));
+      results.applied.push({ label: t.label, base: mint.base, moves: moves.length }); continue;
+    }
+    const rollbackPg = async () => { for (const m of moves) { try { await pool.query(
+        `UPDATE shopify_products SET variant_sku=$1, synced_at=NOW() WHERE split_part(shopify_id,'/',5)=$2 AND variant_sku=$3`, [m.old, t.pid, m.neu]); } catch (_) {} } };
+    try { for (const m of moves) await pool.query(
+        `UPDATE shopify_products SET variant_sku=$1, synced_at=NOW() WHERE split_part(shopify_id,'/',5)=$2 AND variant_sku=$3`, [m.neu, t.pid, m.old]);
+    } catch (e) { await rollbackPg(); results.failed.push({ ...t, err: 'pg ' + e.message }); console.log('✗', t.label, 'pg (rolled back)', e.message); continue; }
+    try {
+      const r = await gql(MUT, { productId: gid, variants: moves.map(m => ({ id: m.id, inventoryItem: { sku: m.neu } })) });
+      const ue = r.productVariantsBulkUpdate.userErrors;
+      if (ue.length) { await rollbackPg(); results.failed.push({ ...t, err: 'ue ' + JSON.stringify(ue) }); console.log('✗', t.label, 'ue (pg rolled back)', JSON.stringify(ue)); continue; }
+      results.applied.push({ label: t.label, base: mint.base, moves: moves.length });
+      console.log('✓', t.label, '->', mint.base, `(${moves.length} variants)`);
+    } catch (e) { await rollbackPg(); results.failed.push({ ...t, err: 'shopify ' + e.message }); console.log('✗', t.label, 'shopify (pg rolled back)', e.message); }
+    await sleep(400);
+  }
+  fs.writeFileSync(path.join(process.env.HOME, 'Projects/ticket-system/tk10002-phase2b/tk10046-bespoke-reassign-manual-results.json'), JSON.stringify(results, null, 2));
+  console.log(`\nDONE: applied/planned=${results.applied.length} failed=${results.failed.length} skippedBlank=${results.skippedBlank.length}`);
+  if (!APPLY) console.log('(DRY-RUN — APPLY=1 + approved artifact to write)');
+  await pool.end();
+}
+main().catch(e => { console.error(e); process.exit(1); });

← ea08e8ae auto-data-snapshot: 2026-08-11T11:45:12 (1 data files) — pac  ·  back to Dw Validator Debug TK11314  ·  activate-gated: add title-based re-introduction guard — neve 0086af1a →