[object Object]

← back to Dw Sku Integrity

TK-11002: Harlequin DWHQ-->DWHF- prefix re-stamp (Option A) — dry-run-verified, reversible, CAS-guarded; live --apply is Steve-gated

289793bdd91ff5455d9be597eb06976eced6a2a1 · 2026-08-31 09:48:50 -0700 · steve

Files touched

Diff

commit 289793bdd91ff5455d9be597eb06976eced6a2a1
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 31 09:48:50 2026 -0700

    TK-11002: Harlequin DWHQ-->DWHF- prefix re-stamp (Option A) — dry-run-verified, reversible, CAS-guarded; live --apply is Steve-gated
---
 tk11002-harlequin-prefix-fix.mjs | 128 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 128 insertions(+)

diff --git a/tk11002-harlequin-prefix-fix.mjs b/tk11002-harlequin-prefix-fix.mjs
new file mode 100644
index 0000000..647701b
--- /dev/null
+++ b/tk11002-harlequin-prefix-fix.mjs
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+// TK-11002 (parent TK-10979) — Harlequin DWHQ- -> DWHF- prefix re-stamp (Steve decision: Option A, 2026-08-31)
+// Fixes dw-prefix-integrity-canary FAIL: 39 ACTIVE Harlequin products wear DWHQ- (reserved to Alpha Work Shops,
+// 0 products). Re-stamp keeps the numeric tail (keep-old-never-mint) and the -Sample suffix; only the vendor prefix
+// changes DWHQ- -> DWHF- (Harlequin's own registered prefix).
+//
+// SAFETY / R4:
+//   * DRY-RUN by default. Live writes ONLY with --apply (customer-facing LIVE store => Steve-gated `!` run).
+//   * Fetches LIVE variants per product (mirror variant_id is stale) and CAS-verifies current sku starts DWHQ- before writing.
+//   * Writes a restore-map JSON BEFORE any live write; undo = re-run with --rollback <map.json>.
+//   * Order: Shopify (authoritative, customer-facing) THEN local mirror (follows). Never mirror-first (split-brain).
+//   * After apply: re-runs the prefix-integrity canary; expects Harlequin/DWHQ- to clear.
+// Usage:
+//   node tk11002-harlequin-prefix-fix.mjs            # dry-run: show 78 variant diffs, write restore-map, no writes
+//   node tk11002-harlequin-prefix-fix.mjs --apply    # LIVE (Steve `!` only)
+//   node tk11002-harlequin-prefix-fix.mjs --rollback data/tk11002-restore-<ts>.json   # undo
+
+import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const APPLY = process.argv.includes('--apply');
+const ROLLBACK = (() => { const i = process.argv.indexOf('--rollback'); return i > -1 ? process.argv[i + 1] : null; })();
+const DATADIR = path.join(process.cwd(), 'data');
+fs.mkdirSync(DATADIR, { recursive: true });
+
+function token() {
+  const env = fs.readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`, 'utf8');
+  const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+  if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN not found');
+  return m[1].trim();
+}
+const TOKEN = token();
+
+async function gql(query, variables) {
+  const r = await fetch(`https://${SHOP}/admin/api/${API}/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  const j = await r.json();
+  if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors));
+  return j.data;
+}
+
+function psql(sql) {
+  return execSync(`psql -h /tmp -d dw_unified -tAc ${JSON.stringify(sql)}`, { encoding: 'utf8' }).trim();
+}
+
+function targetSet() {
+  // Frozen from the mirror: 39 ACTIVE Harlequin products whose main sku wears DWHQ-.
+  const rows = psql(
+    "select id||chr(9)||shopify_id||chr(9)||coalesce(sku,'')||chr(9)||coalesce(variant_sku,'')||chr(9)||coalesce(dw_sku,'') " +
+    "from shopify_products where vendor='Harlequin' and sku like 'DWHQ-%' and status='ACTIVE' order by sku"
+  );
+  return rows.split('\n').filter(Boolean).map(l => {
+    const [id, shopify_id, sku, variant_sku, dw_sku] = l.split('\t');
+    return { id, shopify_id, mirror_sku: sku, mirror_variant_sku: variant_sku, mirror_dw_sku: dw_sku };
+  });
+}
+
+const restamp = s => (s && s.startsWith('DWHQ-')) ? s.replace(/^DWHQ-/, 'DWHF-') : s;
+
+async function runForward() {
+  const targets = targetSet();
+  console.log(`Target: ${targets.length} ACTIVE Harlequin products (DWHQ- -> DWHF-), fetching LIVE variants...`);
+  const plan = [];
+  for (const t of targets) {
+    const d = await gql(`query($id:ID!){ product(id:$id){ id title variants(first:20){ nodes{ id sku } } } }`, { id: t.shopify_id });
+    if (!d.product) { console.log(`  !! ${t.shopify_id} not found live — SKIP`); continue; }
+    for (const v of d.product.variants.nodes) {
+      if (v.sku && v.sku.startsWith('DWHQ-')) {
+        plan.push({ product: t.shopify_id, title: d.product.title, variant: v.id, old_sku: v.sku, new_sku: restamp(v.sku),
+                    mirror_id: t.id, mirror_sku: t.mirror_sku, mirror_variant_sku: t.mirror_variant_sku, mirror_dw_sku: t.mirror_dw_sku });
+      }
+    }
+  }
+  const ts = psql("select to_char(now(),'YYYYMMDD-HH24MISS')");
+  const mapPath = path.join(DATADIR, `tk11002-restore-${ts}.json`);
+  fs.writeFileSync(mapPath, JSON.stringify(plan, null, 2));
+  console.log(`\n${plan.length} variant writes across ${new Set(plan.map(p => p.product)).size} products. Restore-map: ${mapPath}`);
+  for (const p of plan.slice(0, 6)) console.log(`  ${p.old_sku} -> ${p.new_sku}  (${p.title})`);
+  if (plan.length > 6) console.log(`  … +${plan.length - 6} more`);
+
+  if (!APPLY) { console.log('\nDRY-RUN — no writes. Re-run with --apply (Steve `!`) to execute.'); return; }
+
+  console.log('\n--apply: writing LIVE (Shopify first, then mirror, CAS-guarded)...');
+  let ok = 0, skip = 0;
+  for (const p of plan) {
+    // CAS: re-read this variant live; abort this variant if it drifted off DWHQ-
+    const chk = await gql(`query($id:ID!){ productVariant(id:$id){ id sku } }`, { id: p.variant });
+    if (!chk.productVariant || chk.productVariant.sku !== p.old_sku) { console.log(`  CAS-drift SKIP ${p.old_sku}`); skip++; continue; }
+    const res = await gql(
+      `mutation($input:ProductVariantInput!){ productVariantUpdate(input:$input){ productVariant{ id sku } userErrors{ field message } } }`,
+      { input: { id: p.variant, sku: p.new_sku } }
+    );
+    const ue = res.productVariantUpdate.userErrors;
+    if (ue.length) { console.log(`  ERROR ${p.old_sku}: ${JSON.stringify(ue)}`); skip++; continue; }
+    // mirror follows (CAS on old value)
+    psql(`update shopify_products set sku=case when sku=${q(p.mirror_sku)} then ${q(restamp(p.mirror_sku))} else sku end, ` +
+         `variant_sku=case when variant_sku like 'DWHQ-%' then replace(variant_sku,'DWHQ-','DWHF-') else variant_sku end, ` +
+         `dw_sku=case when dw_sku like 'DWHQ-%' then replace(dw_sku,'DWHQ-','DWHF-') else dw_sku end ` +
+         `where id=${p.mirror_id}`);
+    ok++;
+  }
+  console.log(`\nDone: ${ok} written, ${skip} skipped.`);
+  console.log('\nPost-check — re-running prefix-integrity canary:');
+  try { console.log(execSync('python3 ~/.claude/skills/dw-prefix-integrity-canary/check.py', { encoding: 'utf8' })); }
+  catch (e) { console.log('(canary run:', e.message, ')'); }
+}
+
+function q(s) { return `'${String(s).replace(/'/g, "''")}'`; }
+
+async function runRollback(mapPath) {
+  const plan = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
+  console.log(`ROLLBACK ${plan.length} variants from ${mapPath}`);
+  if (!APPLY) { console.log('DRY-RUN rollback — re-run with --apply to revert.'); return; }
+  for (const p of plan) {
+    await gql(`mutation($input:ProductVariantInput!){ productVariantUpdate(input:$input){ userErrors{ message } } }`,
+              { input: { id: p.variant, sku: p.old_sku } });
+    psql(`update shopify_products set sku=${q(p.mirror_sku)}, variant_sku=${q(p.mirror_variant_sku)}, dw_sku=${q(p.mirror_dw_sku)} where id=${p.mirror_id}`);
+  }
+  console.log('Rollback complete.');
+}
+
+(ROLLBACK ? runRollback(ROLLBACK) : runForward()).catch(e => { console.error('FATAL', e); process.exit(1); });

← 0d133ca TK-10900: contrarian-2 fix — guard title-mode cross-class ho  ·  back to Dw Sku Integrity  ·  TK-11002: fix live write — productVariantUpdate removed in A 233fb7f →