← 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
A scripts/tk10002-null-sample/tk10046-bespoke-reassign-manual.js
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 →