← back to Dw Validator Debug TK11314
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
A scripts/tk10002-null-sample/tk10046-bespoke-reassign.js
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 Dw Validator Debug TK11314
·
TK-10046: harden Bespoke reassign applier per contrarian gat dbe8e102 →