[object Object]

← back to Designer Wallcoverings

TK-10046: Bespoke variant-level reassign applier (dry-run default, gated)

1c086e8f2e8416b0d395ac5a67c4d5f72ef3a082 · 2026-08-11 07:15:01 -0700 · Steve Abrams

Reads bespoke-257-reassign-PLAN.json; reassigns ALL variants of each fully-DIG-based
colliding product to ONE freshly-minted DIGAI base (atomic dw_sku_registry reservation
w/ skuTakenOnActiveProduct guard + ON CONFLICT rowCount check). PG-first then Shopify.
Behind approval-gate.js scope 'bespoke-collision-reassign' (APPLY=1 alone insufficient).
Defers the 1 base-less product (fionas-1920s-floral, 40 vars) to manual review to avoid
within-product SKU collisions. Canonical 7d19 token. Full dry-run verified: 38 products /
548 moves, 0 skipped/failed, 0 malformed SKUs.

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

Files touched

Diff

commit 1c086e8f2e8416b0d395ac5a67c4d5f72ef3a082
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 11 07:15:01 2026 -0700

    TK-10046: Bespoke variant-level reassign applier (dry-run default, gated)
    
    Reads bespoke-257-reassign-PLAN.json; reassigns ALL variants of each fully-DIG-based
    colliding product to ONE freshly-minted DIGAI base (atomic dw_sku_registry reservation
    w/ skuTakenOnActiveProduct guard + ON CONFLICT rowCount check). PG-first then Shopify.
    Behind approval-gate.js scope 'bespoke-collision-reassign' (APPLY=1 alone insufficient).
    Defers the 1 base-less product (fionas-1920s-floral, 40 vars) to manual review to avoid
    within-product SKU collisions. Canonical 7d19 token. Full dry-run verified: 38 products /
    548 moves, 0 skipped/failed, 0 malformed SKUs.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .../tk10046-bespoke-reassign.js                    | 164 +++++++++++++++++++++
 1 file changed, 164 insertions(+)

diff --git a/scripts/tk10002-null-sample/tk10046-bespoke-reassign.js b/scripts/tk10002-null-sample/tk10046-bespoke-reassign.js
new file mode 100644
index 00000000..fda999ee
--- /dev/null
+++ b/scripts/tk10002-null-sample/tk10046-bespoke-reassign.js
@@ -0,0 +1,164 @@
+#!/usr/bin/env node
+/**
+ * TK-10046 — DW Bespoke variant-level SKU-collision REASSIGN applier.
+ *
+ * DRY-RUN by default. A live write requires BOTH:
+ *   (a) APPLY=1, and
+ *   (b) a signed approval artifact "APPROVED: TK-10046 bespoke-collision-reassign"
+ *       in ~/.claude/yolo-queue/approved/ (approval-gate.js; APPLY alone is NOT enough).
+ *
+ * WHAT: For each colliding Bespoke product it mints ONE fresh unique base and moves
+ * ALL of that product's variants to <newBase>-<suffix> (Missoni half-fix lesson —
+ * never leave a product with mixed bases). The SURVIVOR side of each pair keeps its
+ * base (only reassign products are touched). PG mirror first, then Shopify (authoritative).
+ *
+ * SCOPE (derived from bespoke-257-reassign-PLAN.json, 39 reassign products / 588 moves):
+ *   - 38 products are FULLY DIG-based  → reassigned here (548 variant-moves).
+ *   - 1 product (fionas-1920s-floral) contains 40 BASE-LESS variants (e.g. "-25-gold-2x27")
+ *     that would collide with their base-stripped siblings under one minted base → DEFERRED
+ *     to manual review (never silently mixed/corrupted).
+ *
+ * Transform: newSku = <newBase> + everything after the leading DIG/DIGAI/DIG_/DIG-MOD base
+ *   token (preserves colorway + variant type + -Sample). A variant whose SKU has NO base
+ *   token makes its whole product ineligible (deferred), so every variant reaching a write
+ *   here is guaranteed base-prefixed.
+ *
+ * Base mint: a fresh DIGAI-MMDDYY### reserved atomically in dw_sku_registry
+ *   (skuTakenOnActiveProduct guard + INSERT ... ON CONFLICT (dw_sku) DO NOTHING with a
+ *   rowCount===1 check), so two concurrent mints can never claim the same base.
+ */
+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' });
+
+// Canonical token first (secrets-manager 7d19 has read/write_products), shopify/.env fallback.
+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 LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT) : Infinity; // stagger: process first N products
+const PLAN_PATH = process.env.PLAN_PATH ||
+  path.join(process.env.HOME, 'Projects/ticket-system/tk10002-phase2b/bespoke-257-reassign-PLAN.json');
+const BASE_RE = /^(AIDIG|DIG)[-_](MOD[-_])?[0-9A-Za-z]+/i;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function transform(oldSku, newBase) {
+  const s = String(oldSku).trim();
+  const m = BASE_RE.exec(s);
+  if (!m) return null;                 // base-less → product is deferred, never reached at write
+  return newBase + s.slice(m[0].length);
+}
+
+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 } } }`;
+
+// Reserve a fresh, unique DIGAI base atomically. DRY-RUN returns a deterministic placeholder.
+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');
+  const dd = String(now.getDate()).padStart(2, '0');
+  const 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')}`;
+    const taken = await skuTakenOnActiveProduct(base);   // fails CLOSED on DB error
+    if (taken.taken) 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 };  // truly reserved (not a concurrent dup)
+  }
+  throw new Error('mintBase: no free DIGAI base after 5000 tries');
+}
+
+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 plan = JSON.parse(fs.readFileSync(PLAN_PATH, 'utf8'));
+  const eligible = [], deferred = [];
+  for (const p of plan.products) {
+    const baseless = p.variants.filter(v => !BASE_RE.test(String(v.old_sku).trim()));
+    (baseless.length ? deferred : eligible).push({ ...p, baseless });
+  }
+  console.log(`plan: ${plan.products.length} products · eligible(fully-DIG)=${eligible.length} · deferred(base-less)=${deferred.length}`);
+  for (const d of deferred) console.log(`  DEFER ${d.handle} (${d.variant_count} vars, ${d.baseless.length} base-less) → manual review`);
+  console.log('');
+
+  const results = { applied: [], skipped: [], failed: [], deferred: deferred.map(d => d.handle) };
+  let seqStart = 1, count = 0;
+
+  for (const p of eligible) {
+    if (count++ >= LIMIT) { console.log(`(LIMIT ${LIMIT} reached — stopping)`); break; }
+    const gid = `gid://shopify/Product/${p.product_id}`;
+    let d; try { d = await gql(`{ product(id:"${gid}"){ status variants(first:100){ nodes{ id inventoryItem{ sku } } } } }`); }
+    catch (e) { results.failed.push({ handle: p.handle, err: 'fetch ' + e.message }); console.log('✗', p.handle, 'fetch', e.message); continue; }
+    const prod = d.product;
+    if (!prod) { results.skipped.push({ handle: p.handle, reason: 'product not found live' }); console.log('⚠', p.handle, 'not found live — skip'); continue; }
+
+    const live = (prod.variants.nodes || []).map(v => ({ id: v.id, sku: v.inventoryItem?.sku || '' })).filter(v => v.sku);
+    // Only move variants that still carry a DIG-family base (already-reassigned ones are skipped).
+    const movable = live.filter(v => BASE_RE.test(v.sku.trim()));
+    if (!movable.length) { results.skipped.push({ handle: p.handle, reason: 'no DIG-base variants live (already reassigned?)' }); console.log('=', p.handle, 'already clean — skip'); continue; }
+
+    const mint = await mintBase(seqStart); seqStart = mint.seq;
+    const moves = movable.map(v => ({ id: v.id, old: v.sku, neu: transform(v.sku, mint.base) }))
+                         .filter(m => m.neu && m.neu !== m.old);
+    if (!moves.length) { results.skipped.push({ handle: p.handle, reason: 'transform produced no change' }); continue; }
+
+    if (!APPLY) {
+      console.log(`[dry] ${p.handle} [${prod.status}] base=${mint.base} · ${moves.length} moves` +
+        (moves[0] ? `  e.g. '${moves[0].old}' -> '${moves[0].neu}'` : ''));
+      results.applied.push({ handle: p.handle, base: mint.base, moves: moves.length, sample: moves[0] });
+      continue;
+    }
+
+    // PG mirror first, then Shopify (authoritative), per variant.
+    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, p.product_id, m.old]);
+      }
+    } catch (e) { results.failed.push({ handle: p.handle, err: 'pg ' + e.message }); console.log('✗', p.handle, 'pg', 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) { results.failed.push({ handle: p.handle, err: 'userErrors ' + JSON.stringify(ue) }); console.log('✗', p.handle, 'ue', JSON.stringify(ue)); continue; }
+      results.applied.push({ handle: p.handle, base: mint.base, moves: moves.length });
+      console.log('✓', p.handle, '->', mint.base, `(${moves.length} variants)`);
+    } catch (e) { results.failed.push({ handle: p.handle, err: 'shopify ' + e.message }); console.log('✗', p.handle, 'shopify', e.message); }
+    await sleep(400);
+  }
+
+  const outDir = path.join(process.env.HOME, 'Projects/ticket-system/tk10002-phase2b');
+  fs.writeFileSync(path.join(outDir, 'tk10046-bespoke-reassign-results.json'), JSON.stringify(results, null, 2));
+  console.log(`\nDONE: applied/planned=${results.applied.length} skipped=${results.skipped.length} failed=${results.failed.length} deferred=${results.deferred.length}`);
+  if (!APPLY) console.log('(DRY-RUN — set APPLY=1 with an approved artifact for scope "bespoke-collision-reassign" to write)');
+  await pool.end();
+}
+main().catch(e => { console.error(e); process.exit(1); });

← 612bbcfc auto-data-snapshot: 2026-08-11T06:33:56 (1 data files) — sho  ·  back to Designer Wallcoverings  ·  TK-10046: harden Bespoke reassign applier per contrarian gat dbe8e102 →