← 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
A scripts/tk10895-fixpos.mjsA scripts/tk10895-trancheB-golive.mjs
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 →