← 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); });