← back to Zero Price Draft Disarm
TK-12276: read-only measure + dry-run-default disarm/rollback for $0 armed DRAFT variants
f498fce64a2ce5dccb73c3f325fca88ae260b2b1 · 2026-09-25 13:53:24 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7LB6rCwpA7ubJTqYzqXEX
Files touched
A .gitignoreA data-measure.outA disarm.mjsA measure.mjsA supplement.mjs
Diff
commit f498fce64a2ce5dccb73c3f325fca88ae260b2b1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 25 13:53:24 2026 -0700
TK-12276: read-only measure + dry-run-default disarm/rollback for $0 armed DRAFT variants
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7LB6rCwpA7ubJTqYzqXEX
---
.gitignore | 9 ++++
data-measure.out | 31 ++++++++++++
disarm.mjs | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
measure.mjs | 91 +++++++++++++++++++++++++++++++++++
supplement.mjs | 24 ++++++++++
5 files changed, 296 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8955433
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/
diff --git a/data-measure.out b/data-measure.out
new file mode 100644
index 0000000..fa19fb7
--- /dev/null
+++ b/data-measure.out
@@ -0,0 +1,31 @@
+{
+ "measured_at": "2026-09-25T20:52:48.679Z",
+ "draft_products_scanned": 21878,
+ "draft_variants_scanned": 30686,
+ "armed_variants": 1395,
+ "armed_products": 335,
+ "armed_by_vendor": {
+ "Christian Fischbacher": 1262,
+ "Phillipe Romano": 79,
+ "Hollywood Acoustical": 14,
+ "Hollywood Wallcoverings": 11,
+ "Fentucci": 7,
+ "Coordonné Europe": 6,
+ "Arte International": 5,
+ "Designer Wallcoverings": 3,
+ "British Walls": 2,
+ "LA Walls": 1,
+ "Versace": 1,
+ "DW Bespoke Studio": 1,
+ "Romo": 1,
+ "Los Angeles Fabrics": 1,
+ "Designer Wallcoverings and Fabrics": 1
+ },
+ "by_reason": {
+ "qty_gt_0": 1146,
+ "policy_continue": 16,
+ "untracked": 249,
+ "qty_2026": 1140
+ },
+ "variants_truncated_note": "variants(first:25) per product"
+}
diff --git a/disarm.mjs b/disarm.mjs
new file mode 100644
index 0000000..a34ce63
--- /dev/null
+++ b/disarm.mjs
@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+// TK-12276 — DISARM $0 NON-sample sellable variants on DRAFT products (option a).
+//
+// node disarm.mjs # DRY-RUN (default): live re-read + plan, NO writes
+// node disarm.mjs --apply --approved TK-12276 [--limit N] [--vendor "Christian Fischbacher"]
+// # LIVE: policy->DENY, tracked->true, available->0
+// node disarm.mjs --rollback data/restore-map-<ts>.jsonl --approved TK-12276
+// # UNDO: restore exact prior policy/tracked/qty per location
+//
+// Input: data/armed.json (from measure.mjs). Every target is RE-READ LIVE before acting and is
+// skipped unless it is STILL: product DRAFT, variant price == 0, non-sample, and orderable.
+// Restore-map rows are appended+fsync'd BEFORE the first write for that variant.
+// Never touches price, status, product, or sample variants. Batches of 50 with a 90s gap.
+import fs from 'node:fs';
+import path from 'node:path';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const DIR = path.dirname(new URL(import.meta.url).pathname);
+const DATA = path.join(DIR, 'data');
+const argv = process.argv.slice(2);
+const flag = f => argv.includes(f);
+const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : d; };
+const APPLY = flag('--apply');
+const ROLLBACK = val('--rollback', null);
+const LIMIT = Number(val('--limit', '1000000'));
+const VENDOR = val('--vendor', null);
+const BATCH = 50, GAP_MS = 90_000;
+const CAP = 500; // reversible-tier blast-radius cap per invocation
+
+if ((APPLY || ROLLBACK) && val('--approved', '') !== 'TK-12276') {
+ console.error('REFUSED: live mode requires --approved TK-12276 (Steve approval of the pending-approval memo).');
+ process.exit(2);
+}
+
+function token() {
+ if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+ const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+ const m = env.match(/^SHOPIFY_ADMIN_TOKEN=["']?([^"'\n]+)/m);
+ if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN missing');
+ return m[1];
+}
+const TOK = token();
+async function gql(query, variables) {
+ for (let a = 0; a < 6; a++) {
+ const r = await fetch(`https://${SHOP}/admin/api/${API}/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(2000 * (a + 1)); continue; }
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
+ const j = await r.json();
+ if (j.errors?.some(e => e.extensions?.code === 'THROTTLED')) { await sleep(3000); continue; }
+ if (j.errors) throw new Error(JSON.stringify(j.errors));
+ return j.data;
+ }
+ throw new Error('gql retries exhausted');
+}
+const sleep = ms => new Promise(s => setTimeout(s, ms));
+const isSample = v => /sample|memo/i.test(v.title || '') || /-sample$/i.test(v.sku || '');
+
+const READ = `query($id:ID!){ productVariant(id:$id){ id sku title price inventoryPolicy inventoryQuantity
+ product{ id status vendor }
+ inventoryItem{ id tracked inventoryLevels(first:20){ nodes{ location{ id name }
+ quantities(names:["available","on_hand"]){ name quantity } } } } } }`;
+const M_POLICY = `mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$pid, variants:$v){ userErrors{ field message } } }`;
+const M_TRACK = `mutation($id:ID!,$in:InventoryItemInput!){ inventoryItemUpdate(id:$id, input:$in){ userErrors{ field message } } }`;
+const M_QTY = `mutation($in:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$in){ userErrors{ field message } } }`;
+
+function snapshot(v) {
+ return { variant_id: v.id, product_id: v.product.id, vendor: v.product.vendor, sku: v.sku,
+ price: v.price, policy: v.inventoryPolicy, tracked: v.inventoryItem.tracked,
+ inventory_item_id: v.inventoryItem.id,
+ levels: v.inventoryItem.inventoryLevels.nodes.map(l => ({ location_id: l.location.id, location: l.location.name,
+ available: l.quantities.find(q => q.name === 'available')?.quantity ?? null })) };
+}
+function stillArmed(v) {
+ if (!v) return 'variant-gone';
+ if (v.product.status !== 'DRAFT') return `product-${v.product.status}`;
+ if (Number(v.price) > 0) return 'price-now-gt-0';
+ if (isSample(v)) return 'is-sample';
+ const orderable = v.inventoryQuantity > 0 || v.inventoryPolicy === 'CONTINUE' || v.inventoryItem.tracked === false;
+ return orderable ? null : 'already-disarmed';
+}
+async function ue(data, key) {
+ const errs = data[key]?.userErrors || [];
+ if (errs.length) throw new Error(`${key}: ${JSON.stringify(errs)}`);
+}
+
+async function disarmOne(s) {
+ await ue(await gql(M_POLICY, { pid: s.product_id, v: [{ id: s.variant_id, inventoryPolicy: 'DENY' }] }), 'productVariantsBulkUpdate');
+ if (s.tracked === false) await ue(await gql(M_TRACK, { id: s.inventory_item_id, in: { tracked: true } }), 'inventoryItemUpdate');
+ const q = s.levels.filter(l => (l.available ?? 0) !== 0).map(l => ({ inventoryItemId: s.inventory_item_id, locationId: l.location_id, quantity: 0 }));
+ if (q.length) await ue(await gql(M_QTY, { in: { name: 'available', reason: 'correction', ignoreCompareQuantity: true,
+ referenceDocumentUri: 'logistics://tk-12276/disarm-zero-price-drafts', quantities: q } }), 'inventorySetQuantities');
+}
+async function restoreOne(s) {
+ const q = s.levels.filter(l => l.available !== null).map(l => ({ inventoryItemId: s.inventory_item_id, locationId: l.location_id, quantity: l.available }));
+ if (q.length) await ue(await gql(M_QTY, { in: { name: 'available', reason: 'correction', ignoreCompareQuantity: true,
+ referenceDocumentUri: 'logistics://tk-12276/rollback', quantities: q } }), 'inventorySetQuantities');
+ if (s.tracked === false) await ue(await gql(M_TRACK, { id: s.inventory_item_id, in: { tracked: false } }), 'inventoryItemUpdate');
+ await ue(await gql(M_POLICY, { pid: s.product_id, v: [{ id: s.variant_id, inventoryPolicy: s.policy }] }), 'productVariantsBulkUpdate');
+}
+
+async function main() {
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
+ if (ROLLBACK) {
+ const rows = fs.readFileSync(ROLLBACK, 'utf8').trim().split('\n').map(l => JSON.parse(l)).filter(r => r.phase === 'before');
+ let ok = 0, fail = 0;
+ for (const [i, r] of rows.entries()) {
+ try { await restoreOne(r); ok++; } catch (e) { fail++; console.error('restore-fail', r.variant_id, e.message); }
+ if ((i + 1) % BATCH === 0 && i + 1 < rows.length) await sleep(GAP_MS);
+ }
+ console.log(JSON.stringify({ mode: 'rollback', restored: ok, failed: fail }));
+ return;
+ }
+ const { armed } = JSON.parse(fs.readFileSync(path.join(DATA, 'armed.json'), 'utf8'));
+ let targets = armed.filter(a => !VENDOR || a.vendor === VENDOR).slice(0, LIMIT);
+ if (APPLY && targets.length > CAP) {
+ console.error(`NOTE: ${targets.length} targets > reversible-tier cap ${CAP}; this invocation will process the first ${CAP}. Re-run for the rest.`);
+ targets = targets.slice(0, CAP);
+ }
+ const mapPath = path.join(DATA, `restore-map-${ts}.jsonl`);
+ const fd = APPLY ? fs.openSync(mapPath, 'a') : null;
+ const plan = { mode: APPLY ? 'APPLY' : 'DRY-RUN', considered: targets.length, would_disarm: 0, skipped: {}, done: 0, failed: 0, by_vendor: {} };
+ for (const [i, t] of targets.entries()) {
+ const v = (await gql(READ, { id: t.variant_id })).productVariant;
+ const why = stillArmed(v);
+ if (why) { plan.skipped[why] = (plan.skipped[why] || 0) + 1; continue; }
+ const s = snapshot(v);
+ plan.would_disarm++; plan.by_vendor[s.vendor] = (plan.by_vendor[s.vendor] || 0) + 1;
+ if (!APPLY) continue;
+ fs.writeSync(fd, JSON.stringify({ phase: 'before', ...s }) + '\n'); fs.fsyncSync(fd);
+ try { await disarmOne(s); plan.done++; fs.writeSync(fd, JSON.stringify({ phase: 'done', variant_id: s.variant_id }) + '\n'); }
+ catch (e) { plan.failed++; fs.writeSync(fd, JSON.stringify({ phase: 'error', variant_id: s.variant_id, error: e.message }) + '\n'); }
+ if (plan.done && plan.done % BATCH === 0) await sleep(GAP_MS);
+ }
+ if (fd !== null) fs.closeSync(fd);
+ if (APPLY) plan.restore_map = mapPath, plan.undo = `node ${path.join(DIR, 'disarm.mjs')} --rollback ${mapPath} --approved TK-12276`;
+ console.log(JSON.stringify(plan, null, 2));
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/measure.mjs b/measure.mjs
new file mode 100644
index 0000000..dddc4d4
--- /dev/null
+++ b/measure.mjs
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+// TK-12276 — READ-ONLY re-measure of $0 NON-sample sellable variants on DRAFT products
+// that are "armed" (qty>0, or inventoryPolicy CONTINUE, or untracked) — i.e. would be
+// instantly orderable at $0 the moment the product is set ACTIVE.
+// Paged GraphQL QUERIES only (no mutations, no bulk op). Token: SHOPIFY_ADMIN_TOKEN.
+// Output: data/draft-variants.jsonl (every draft variant) + data/armed.json (the armed set).
+import fs from 'node:fs';
+import path from 'node:path';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const DIR = path.dirname(new URL(import.meta.url).pathname);
+const DATA = path.join(DIR, 'data');
+fs.mkdirSync(DATA, { recursive: true });
+
+function token() {
+ if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+ const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+ const m = env.match(/^SHOPIFY_ADMIN_TOKEN=["']?([^"'\n]+)/m);
+ if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN missing');
+ return m[1];
+}
+const TOK = token();
+
+async function gql(query, variables) {
+ for (let attempt = 0; attempt < 6; attempt++) {
+ const r = await fetch(`https://${SHOP}/admin/api/${API}/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 new Promise(s => setTimeout(s, 2000 * (attempt + 1))); continue; }
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
+ const j = await r.json();
+ if (j.errors?.some(e => e.extensions?.code === 'THROTTLED')) { await new Promise(s => setTimeout(s, 3000)); continue; }
+ if (j.errors) throw new Error(JSON.stringify(j.errors));
+ const t = j.extensions?.cost?.throttleStatus;
+ if (t && t.currentlyAvailable < 1500) await new Promise(s => setTimeout(s, 4000));
+ return j.data;
+ }
+ throw new Error('gql retries exhausted');
+}
+
+const Q = `query($after:String){ products(first:40, after:$after, query:"status:draft"){
+ pageInfo{ hasNextPage endCursor }
+ nodes{ id title vendor handle status createdAt tags
+ variants(first:25){ nodes{ id sku title price inventoryPolicy inventoryQuantity
+ inventoryItem{ id tracked } } } } } }`;
+
+const isSample = v => /sample|memo/i.test(v.title || '') || /-sample$/i.test(v.sku || '');
+
+const out = fs.createWriteStream(path.join(DATA, 'draft-variants.jsonl'));
+let after = null, products = 0, variants = 0, page = 0;
+const armed = [];
+const t0 = Date.now();
+do {
+ const d = await gql(Q, { after });
+ const p = d.products;
+ for (const prod of p.nodes) {
+ products++;
+ for (const v of prod.variants.nodes) {
+ variants++;
+ const row = { product_id: prod.id, vendor: prod.vendor, handle: prod.handle, title: prod.title,
+ status: prod.status, created_at: prod.createdAt, variant_id: v.id, sku: v.sku, vtitle: v.title,
+ price: Number(v.price), policy: v.inventoryPolicy, qty: v.inventoryQuantity,
+ inventory_item_id: v.inventoryItem?.id, tracked: v.inventoryItem?.tracked, sample: isSample(v) };
+ out.write(JSON.stringify(row) + '\n');
+ const zero = !(row.price > 0);
+ const orderable = (row.qty > 0) || row.policy === 'CONTINUE' || row.tracked === false;
+ if (zero && !row.sample && orderable) armed.push(row);
+ }
+ }
+ after = p.pageInfo.hasNextPage ? p.pageInfo.endCursor : null;
+ if (++page % 25 === 0) console.error(`page ${page} products ${products} variants ${variants} armed ${armed.length} ${(Date.now()-t0)/1000|0}s`);
+} while (after);
+out.end();
+
+const byVendor = {};
+for (const a of armed) byVendor[a.vendor] = (byVendor[a.vendor] || 0) + 1;
+const armedProducts = new Set(armed.map(a => a.product_id));
+const summary = { measured_at: new Date().toISOString(), draft_products_scanned: products,
+ draft_variants_scanned: variants, armed_variants: armed.length, armed_products: armedProducts.size,
+ armed_by_vendor: Object.fromEntries(Object.entries(byVendor).sort((a, b) => b[1] - a[1])),
+ by_reason: {
+ qty_gt_0: armed.filter(a => a.qty > 0).length,
+ policy_continue: armed.filter(a => a.policy === 'CONTINUE').length,
+ untracked: armed.filter(a => a.tracked === false).length,
+ qty_2026: armed.filter(a => a.qty === 2026).length },
+ variants_truncated_note: 'variants(first:25) per product' };
+fs.writeFileSync(path.join(DATA, 'armed.json'), JSON.stringify({ summary, armed }, null, 1));
+console.log(JSON.stringify(summary, null, 2));
diff --git a/supplement.mjs b/supplement.mjs
new file mode 100644
index 0000000..7cf7c60
--- /dev/null
+++ b/supplement.mjs
@@ -0,0 +1,24 @@
+// TK-12276: re-read products that hit the variants(first:25) cap with first:250 (read-only).
+import fs from 'node:fs';
+const env=fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8');
+const TOK=process.env.SHOPIFY_ADMIN_TOKEN||env.match(/^SHOPIFY_ADMIN_TOKEN=["']?([^"'\n]+)/m)[1];
+const rows=fs.readFileSync('data/draft-variants.jsonl','utf8').trim().split('\n').map(JSON.parse);
+const cnt={};for(const r of rows)cnt[r.product_id]=(cnt[r.product_id]||0)+1;
+const ids=Object.keys(cnt).filter(k=>cnt[k]>=25);
+const isSample=v=>/sample|memo/i.test(v.title||'')||/-sample$/i.test(v.sku||'');
+const A=JSON.parse(fs.readFileSync('data/armed.json','utf8'));
+const have=new Set(A.armed.map(a=>a.variant_id));let added=0;const info=[];
+for(const id of ids){
+ const r=await fetch('https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json',{method:'POST',headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},body:JSON.stringify({query:`{product(id:"${id}"){id vendor handle title status createdAt variants(first:250){nodes{id sku title price inventoryPolicy inventoryQuantity inventoryItem{id tracked}}}}}`})});
+ const p=(await r.json()).data.product; info.push([p.vendor,p.variants.nodes.length]);
+ for(const v of p.variants.nodes){ if(have.has(v.id))continue;
+ const row={product_id:p.id,vendor:p.vendor,handle:p.handle,title:p.title,status:p.status,created_at:p.createdAt,variant_id:v.id,sku:v.sku,vtitle:v.title,price:Number(v.price),policy:v.inventoryPolicy,qty:v.inventoryQuantity,inventory_item_id:v.inventoryItem?.id,tracked:v.inventoryItem?.tracked,sample:isSample(v)};
+ if(!(row.price>0)&&!row.sample&&(row.qty>0||row.policy==='CONTINUE'||row.tracked===false)){A.armed.push(row);added++;}}
+}
+const bv={};for(const a of A.armed)bv[a.vendor]=(bv[a.vendor]||0)+1;
+A.summary.armed_variants=A.armed.length;A.summary.armed_products=new Set(A.armed.map(a=>a.product_id)).size;
+A.summary.armed_by_vendor=Object.fromEntries(Object.entries(bv).sort((a,b)=>b[1]-a[1]));
+A.summary.by_reason={qty_gt_0:A.armed.filter(a=>a.qty>0).length,policy_continue:A.armed.filter(a=>a.policy==='CONTINUE').length,untracked:A.armed.filter(a=>a.tracked===false).length,qty_2026:A.armed.filter(a=>a.qty===2026).length};
+A.summary.variants_truncated_note=`variants(first:25) per product; ${ids.length} capped products re-read at first:250 (+${added} armed)`;
+fs.writeFileSync('data/armed.json',JSON.stringify(A,null,1));
+console.log(JSON.stringify({capped:ids.length,info,added,summary:A.summary},null,1));
(oldest)
·
back to Zero Price Draft Disarm
·
(newest)