← back to Zero Price Draft Disarm
disarm.mjs
142 lines
#!/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); });