[object Object]

← back to Designerwallcoverings

TK-10895: track the Tranche B go-live + position-fix executors

e29c49a9c30b1e9687d7588084cc05a62f1ac138 · 2026-09-10 10:52:57 -0700 · Steve Abrams

Both were untracked, so a 'git clean -fd' would have deleted them and the
ProductVariantPositionInput fix with them. Flagged by claude-run-11200 (M-02745)
as the same working-tree-only hole dw-inventory-stamp-guard-canary exists for:
a fix is only durable once committed.

golive.mjs  - adds the sellable 'Sold Per Roll' variant (226 shipped 2026-09-10)
fixpos.mjs  - repairs the $4.25 position leak; unlike the go-live it inspects
              transport result, top-level GraphQL errors and userErrors as three
              SEPARATE conditions, then proves the outcome with a read-back.
              The go-live's 'userErrors || []' collapsed a top-level errors
              payload to [] and reported 226 hard failures as reorderWarnings=0.

Both now declare [ProductVariantPositionInput!]! - the wrong type name
(VariantPositionInput) was the root cause of every rejected reorder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B18ECN33SGCpS3ntckQMc2

Files touched

Diff

commit e29c49a9c30b1e9687d7588084cc05a62f1ac138
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 10:52:57 2026 -0700

    TK-10895: track the Tranche B go-live + position-fix executors
    
    Both were untracked, so a 'git clean -fd' would have deleted them and the
    ProductVariantPositionInput fix with them. Flagged by claude-run-11200 (M-02745)
    as the same working-tree-only hole dw-inventory-stamp-guard-canary exists for:
    a fix is only durable once committed.
    
    golive.mjs  - adds the sellable 'Sold Per Roll' variant (226 shipped 2026-09-10)
    fixpos.mjs  - repairs the $4.25 position leak; unlike the go-live it inspects
                  transport result, top-level GraphQL errors and userErrors as three
                  SEPARATE conditions, then proves the outcome with a read-back.
                  The go-live's 'userErrors || []' collapsed a top-level errors
                  payload to [] and reported 226 hard failures as reorderWarnings=0.
    
    Both now declare [ProductVariantPositionInput!]! - the wrong type name
    (VariantPositionInput) was the root cause of every rejected reorder.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01B18ECN33SGCpS3ntckQMc2
---
 scripts/tk10895-fixpos.mjs          | 112 +++++++++++++++++++++++++++
 scripts/tk10895-trancheB-golive.mjs | 147 ++++++++++++++++++++++++++++++++++++
 2 files changed, 259 insertions(+)

diff --git a/scripts/tk10895-fixpos.mjs b/scripts/tk10895-fixpos.mjs
new file mode 100644
index 0000000..91729ec
--- /dev/null
+++ b/scripts/tk10895-fixpos.mjs
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+// TK-10895 — FIX the $4.25 position leak on the Tranche B products.
+//
+// WHY THIS EXISTS: the go-live's inline reorder silently did nothing on all 226 products, and the
+// go-live reported reorderWarnings=0 anyway, because it read
+//     res?.data?.productVariantsBulkReorder?.userErrors || []
+// which collapses to [] when the GraphQL call returns null OR returns a TOP-LEVEL `errors` payload
+// with data:null. A hard failure was indistinguishable from success. This script does not repeat
+// that mistake: it inspects the transport result, the top-level errors, AND userErrors separately,
+// and then RE-READS the product to prove position 1 actually changed. Nothing is reported as fixed
+// unless the read-back confirms it.
+//
+// Idempotent + safe: only ever reorders. Never creates, never deletes, never edits a price.
+// DRY-RUN by default; --apply performs live writes.
+
+import fs from 'fs';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
+const TOK = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+  .match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)[1].trim().replace(/^["']|["']$/g, '');
+const APPLY = process.argv.includes('--apply');
+const WL = process.argv[process.argv.indexOf('--worklist') + 1];
+const work = JSON.parse(fs.readFileSync(WL, 'utf8'));
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function rest(path, opts = {}, tries = 5) {
+  for (let a = 1; a <= tries; a++) {
+    try {
+      const r = await fetch(`https://${SHOP}/admin/api/${VER}${path}`, {
+        ...opts, headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json', ...(opts.headers || {}) },
+      });
+      if (r.status === 429 || r.status >= 500) { await sleep(1500 * a); continue; }
+      const t = await r.text();
+      try { return { status: r.status, body: t ? JSON.parse(t) : null }; } catch { return { status: r.status, body: null }; }
+    } catch { await sleep(1200 * a); }
+  }
+  return { status: 0, body: null };
+}
+
+// Returns {ok, why, raw} — never conflates "no userErrors" with "it worked".
+async function reorder(pid, orderedIds, tries = 5) {
+  const q = `mutation r($productId: ID!, $positions: [ProductVariantPositionInput!]!) {
+    productVariantsBulkReorder(productId: $productId, positions: $positions) {
+      userErrors { field message }
+    }
+  }`;
+  const variables = {
+    productId: `gid://shopify/Product/${pid}`,
+    positions: orderedIds.map((id, i) => ({ id: `gid://shopify/ProductVariant/${id}`, position: i + 1 })),
+  };
+  for (let a = 1; a <= tries; a++) {
+    let r, j;
+    try {
+      r = await fetch(`https://${SHOP}/admin/api/${VER}/graphql.json`, {
+        method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query: q, variables }),
+      });
+    } catch (e) { await sleep(1500 * a); continue; }
+    if (r.status === 429 || r.status >= 500) { await sleep(1500 * a); continue; }
+    try { j = await r.json(); } catch (e) { await sleep(1200 * a); continue; }
+    if (j?.errors?.length) {                      // TOP-LEVEL GraphQL errors — the swallowed case
+      const msg = JSON.stringify(j.errors).slice(0, 200);
+      if (/throttle/i.test(msg)) { await sleep(2500 * a); continue; }
+      return { ok: false, why: `graphql errors: ${msg}`, raw: j };
+    }
+    const ue = j?.data?.productVariantsBulkReorder?.userErrors;
+    if (ue === undefined) return { ok: false, why: `no data returned: ${JSON.stringify(j).slice(0, 200)}`, raw: j };
+    if (ue.length) return { ok: false, why: `userErrors: ${JSON.stringify(ue).slice(0, 200)}`, raw: j };
+    return { ok: true, why: 'mutation accepted', raw: j };
+  }
+  return { ok: false, why: 'transport failed after retries', raw: null };
+}
+
+console.log(`${APPLY ? '*** APPLY ***' : 'DRY-RUN'} — checking ${work.length} products\n`);
+let already = 0, fixed = 0, stillBad = 0, failed = 0, skipped = 0;
+const bad = [];
+
+for (const [i, w] of work.entries()) {
+  const tag = `[${i + 1}/${work.length}] ${w.dw_sku}`;
+  const g = await rest(`/products/${w.pid}.json`);
+  if (g.status !== 200 || !g.body?.product) { console.log(`${tag} FAIL GET ${g.status}`); failed++; continue; }
+  const vs = g.body.product.variants || [];
+  if (vs.length < 2) { console.log(`${tag} skip — only ${vs.length} variant`); skipped++; continue; }
+
+  const byPos = [...vs].sort((a, b) => (a.position || 99) - (b.position || 99));
+  const target = vs.find(v => String(v.price) === String(w.price)) ||
+                 vs.find(v => String(v.price) !== '4.25');
+  if (!target) { console.log(`${tag} FAIL — no non-$4.25 variant found`); failed++; continue; }
+
+  if (String(byPos[0].id) === String(target.id)) { console.log(`${tag} already correct ($${byPos[0].price} first)`); already++; continue; }
+  if (!APPLY) { console.log(`${tag} would REORDER -> $${target.price} to position 1 (currently $${byPos[0].price})`); fixed++; continue; }
+
+  const ordered = [target.id, ...vs.filter(v => String(v.id) !== String(target.id)).map(v => v.id)];
+  const res = await reorder(w.pid, ordered);
+  if (!res.ok) { console.log(`${tag} REORDER FAILED — ${res.why}`); failed++; bad.push(w.dw_sku); await sleep(400); continue; }
+
+  await sleep(700);                                   // let it settle before proving it
+  const v2 = await rest(`/products/${w.pid}.json`);   // READ-BACK: the only proof that counts
+  const after = [...(v2.body?.product?.variants || [])].sort((a, b) => (a.position || 99) - (b.position || 99));
+  if (after[0] && String(after[0].id) === String(target.id)) {
+    fixed++; console.log(`${tag} ✓ fixed — $${after[0].price} now position 1`);
+  } else {
+    stillBad++; bad.push(w.dw_sku);
+    console.log(`${tag} ⚠ MUTATION ACCEPTED BUT READ-BACK STILL WRONG — first is $${after[0]?.price}`);
+  }
+  await sleep(450);
+}
+
+console.log(`\nDONE  alreadyCorrect=${already} fixed=${fixed} stillBad=${stillBad} failed=${failed} skipped=${skipped}`);
+if (bad.length) console.log(`NOT FIXED (${bad.length}): ${bad.slice(0, 40).join(' ')}`);
diff --git a/scripts/tk10895-trancheB-golive.mjs b/scripts/tk10895-trancheB-golive.mjs
new file mode 100644
index 0000000..2bf13f3
--- /dev/null
+++ b/scripts/tk10895-trancheB-golive.mjs
@@ -0,0 +1,147 @@
+#!/usr/bin/env node
+// TK-10895 Tranche B go-live — add a sellable "Sold Per Roll" variant to the 234
+// VERIFIED China Seas hand-print wallpaper products (DTD verdict D1, 2026-09-10).
+//
+// The seven rules, all enforced here (learned from this ticket's own 2026-09-04 incident):
+//  1. New variant MUST end at position 1 -> inline GraphQL productVariantsBulkReorder
+//     immediately after each POST, passing an explicit position for EVERY variant.
+//     (Appending leaves it behind the $4.25 Sample and the PDP JSON-LD then advertises
+//      $4.25 to Google Merchant Center. That hit ~1,463 live products on 2026-09-04.)
+//  2. NEVER PUT the product with a `variants` array (deletes omitted variants) and never
+//     use per-variant REST position PUT (does not renumber siblings).
+//  3. shopify_product_id gid:// prefix already stripped in the worklist.
+//  4. Additive POST /variants only; inventory_management=null, inventory_policy=continue.
+//  5. Idempotent: skip any product already carrying >= 2 variants.
+//  6. 5x retry on 429/5xx/non-JSON; run backgrounded (foreground SIGTERMs at 2 min).
+//  7. Verification is done from the RENDERED STOREFRONT, not the Admin API (separate step).
+//
+// Rollback map is appended BEFORE the next product starts.
+// DRY-RUN by default. Live writes require --apply.
+
+import fs from 'fs';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
+// FULL token: the narrow SHOPIFY_ADMIN_TOKEN lacks write_inventory (dw-golive-token-guard-canary).
+const TOK = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+  .match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)[1].trim().replace(/^["']|["']$/g, '');
+
+const APPLY = process.argv.includes('--apply');
+const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? +process.argv[i + 1] : 0; })();
+const WORKLIST = process.argv[process.argv.indexOf('--worklist') + 1];
+const MAP = process.env.HOME + '/.claude/yolo-queue/executed-reversible/TK-10895-trancheB-rollback-map.jsonl';
+
+let work = JSON.parse(fs.readFileSync(WORKLIST, 'utf8'));
+if (LIMIT) work = work.slice(0, LIMIT);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function api(path, opts = {}, tries = 5) {
+  for (let a = 1; a <= tries; a++) {
+    try {
+      const r = await fetch(`https://${SHOP}/admin/api/${VER}${path}`, {
+        ...opts,
+        headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json', ...(opts.headers || {}) },
+      });
+      if (r.status === 429 || r.status >= 500) { await sleep(1500 * a); continue; }
+      const txt = await r.text();
+      try { return { status: r.status, body: JSON.parse(txt) }; }
+      catch { await sleep(1200 * a); continue; }
+    } catch { await sleep(1200 * a); }
+  }
+  return { status: 0, body: null };
+}
+
+async function gql(query, variables, tries = 5) {
+  for (let a = 1; a <= tries; a++) {
+    try {
+      const r = await fetch(`https://${SHOP}/admin/api/${VER}/graphql.json`, {
+        method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables }),
+      });
+      if (r.status === 429 || r.status >= 500) { await sleep(1500 * a); continue; }
+      return await r.json();
+    } catch { await sleep(1200 * a); }
+  }
+  return null;
+}
+
+const REORDER = `mutation r($productId: ID!, $positions: [ProductVariantPositionInput!]!) {
+  productVariantsBulkReorder(productId: $productId, positions: $positions) {
+    userErrors { field message }
+  }
+}`;
+
+console.log(`${APPLY ? '*** APPLY (LIVE SHOPIFY WRITES) ***' : 'DRY-RUN'} — ${work.length} products`);
+let created = 0, skipped = 0, failed = 0, reorderWarn = 0;
+
+for (const [i, w] of work.entries()) {
+  const tag = `[${i + 1}/${work.length}] ${w.dw_sku} pid=${w.pid}`;
+
+  const got = await api(`/products/${w.pid}.json`);
+  if (got.status !== 200 || !got.body?.product) {
+    console.log(`${tag} FAIL GET status=${got.status} — skipping (fails closed, wrote nothing)`);
+    failed++; continue;
+  }
+  const p = got.body.product;
+
+  if ((p.variants || []).length >= 2) {
+    console.log(`${tag} skip — already has ${p.variants.length} variants (idempotent)`);
+    skipped++; continue;
+  }
+
+  if (!APPLY) {
+    console.log(`${tag} would ADD "${w.label}" @ $${w.price} then reorder to position 1`);
+    created++; continue;
+  }
+
+  // 4. additive POST — never rewrites the variants array, so the $4.25 Sample cannot be clobbered
+  const mk = await api(`/products/${w.pid}/variants.json`, {
+    method: 'POST',
+    body: JSON.stringify({
+      variant: {
+        option1: w.label, price: w.price, sku: w.dw_sku,
+        inventory_management: null, inventory_policy: 'continue',
+        taxable: true, requires_shipping: true,
+      },
+    }),
+  });
+  if (mk.status !== 201 || !mk.body?.variant) {
+    console.log(`${tag} FAIL POST status=${mk.status} ${JSON.stringify(mk.body).slice(0, 180)}`);
+    failed++; continue;
+  }
+  const nv = mk.body.variant;
+
+  // rollback map appended BEFORE the next product starts
+  fs.appendFileSync(MAP, JSON.stringify({
+    ts: new Date().toISOString(), ticket: 'TK-10895', tranche: 'B',
+    product_id: w.pid, dw_sku: w.dw_sku, mfr: w.mfr,
+    created_variant_id: nv.id, price: w.price, label: w.label,
+    prior_option_name: p.options?.[0]?.name ?? null,
+  }) + '\n');
+
+  // 1. inline reorder — explicit position for EVERY variant, new one first
+  const fresh = await api(`/products/${w.pid}.json`);
+  const vars = fresh.body?.product?.variants || [];
+  const ordered = [nv.id, ...vars.filter(v => v.id !== nv.id).map(v => v.id)];
+  const res = await gql(REORDER, {
+    productId: `gid://shopify/Product/${w.pid}`,
+    positions: ordered.map((id, idx) => ({ id: `gid://shopify/ProductVariant/${id}`, position: idx + 1 })),
+  });
+  const ue = res?.data?.productVariantsBulkReorder?.userErrors || [];
+  if (ue.length) { console.log(`${tag} ⚠ reorder userErrors ${JSON.stringify(ue)}`); reorderWarn++; }
+
+  // option name -> "Size" to match live DW convention (Brunschwig/Zoffany)
+  if (p.options?.[0]?.name && p.options[0].name !== 'Size') {
+    await api(`/products/${w.pid}.json`, {
+      method: 'PUT',
+      body: JSON.stringify({ product: { id: +w.pid, options: [{ id: p.options[0].id, name: 'Size' }] } }),
+    });
+  }
+
+  created++;
+  console.log(`${tag} ✓ added ${nv.id} "${w.label}" $${w.price} -> position 1`);
+  await sleep(600);
+}
+
+console.log(`\nDONE  created=${created} skipped=${skipped} failed=${failed} reorderWarnings=${reorderWarn}`);

← f5e1da7 TK-10895: reprice/revert tool for the 5 products priced off  ·  back to Designerwallcoverings  ·  auto-data-snapshot: 2026-09-10T11:08:25 (5 data files) — scr 24c73c5 →