← back to Dw Contact Us Pages

scripts/harden-variants.mjs

177 lines

#!/usr/bin/env node
// harden-variants.mjs — hide + HARDEN the sellable variants of the contact-us cohort. TK-11925.
//   node scripts/harden-variants.mjs            # dry-run
//   node scripts/harden-variants.mjs --apply            (--limit 1 = one-variant canary)
//   node scripts/harden-variants.mjs --rollback --apply
//
// For every NON-SAMPLE variant (the $4.25 Sample swatch is NEVER touched):
//   1. inventoryItemUpdate tracked:true   — REQUIRED first: 598 of the 737 sellable
//      variants have tracked=false, and inventorySetQuantities fails on an untracked
//      item. An untracked variant is also ALWAYS available regardless of policy, so
//      DENY+0 only bites once tracking is on.
//   2. inventorySetQuantities on_hand = 0 at every location the item stocks (measured:
//      exactly one location, gid://shopify/Location/5795643504).
//   3. productVariantsBulkUpdate inventoryPolicy = DENY.
//
// PREIMAGE per variant -> data/ledger-variants.jsonl: {inventoryPolicy, tracked,
// levels:[{locationId, on_hand, available}]}. --rollback replays it in reverse order
// (policy -> quantities -> tracked:false last) so tracked=false is restored for the 598.
import { join } from 'node:path';
import { DATA_DIR, TICKET, COHORT_FLAG, parseArgs, banner, gql, loadTargets, appendJsonl, readJsonl, logReversible, chunk, isSampleVariant, payloadErrors, assertFreshTargets } from './lib.mjs';

const a = parseArgs();
const LEDGER = join(DATA_DIR, 'ledger-variants.jsonl');
banner(a.rollback ? 'harden-variants --rollback' : 'harden-variants', a.apply);

const M_TRACK = `mutation T($id: ID!, $input: InventoryItemInput!) {
  inventoryItemUpdate(id: $id, input: $input) { inventoryItem { id tracked } userErrors { field message } } }`;
const M_QTY = `mutation Q($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) { inventoryAdjustmentGroup { createdAt } userErrors { field message } } }`;
const M_POLICY = `mutation P($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
    productVariants { id inventoryPolicy } userErrors { field message } } }`;

function qty(level, name) {
  return (level.quantities || []).find((q) => q.name === name)?.quantity ?? 0;
}

// ---------------------------------------------------------------- build plan
let plan;
if (a.rollback) {
  const rows = readJsonl(LEDGER).filter((r) => r.applied && r.variantId);
  const latest = new Map();
  for (const r of rows) if (!latest.has(r.variantId)) latest.set(r.variantId, r); // FIRST write = true preimage
  plan = [...latest.values()].map((r) => ({
    productId: r.productId, handle: r.handle, variantId: r.variantId, sku: r.sku, title: r.title,
    inventoryItemId: r.inventoryItemId,
    targetPolicy: r.before.inventoryPolicy,
    targetTracked: r.before.tracked,
    targetLevels: r.before.levels,
    before: null,
  }));
} else {
  plan = [];
  for (const p of loadTargets()) {
    for (const v of p.variants) {
      if (v.isSample ?? isSampleVariant(v)) continue;           // never touch the swatch
      plan.push({
        productId: p.id, handle: p.handle, variantId: v.id, sku: v.sku, title: v.title, price: v.price,
        inventoryItemId: v.inventoryItem.id,
        targetPolicy: 'DENY', targetTracked: true,
        targetLevels: (v.inventoryItem.inventoryLevels || []).map((l) => ({ locationId: l.location.id, on_hand: 0 })),
        before: {
          inventoryPolicy: v.inventoryPolicy,
          tracked: v.inventoryItem.tracked,
          levels: (v.inventoryItem.inventoryLevels || []).map((l) => ({
            locationId: l.location.id, on_hand: qty(l, 'on_hand'), available: qty(l, 'available'),
          })),
        },
      });
    }
  }
}

if (a.limit) { plan = plan.slice(0, Number(a.limit)); console.log(`  --limit ${a.limit}: canary run on the first ${plan.length} variant(s) only`); }
const needTrack = plan.filter((x) => a.rollback ? x.targetTracked === false : true).length;
const trackFlips = a.rollback
  ? plan.filter((x) => x.targetTracked === false).length
  : plan.filter((x) => x.before.tracked === false).length;
const policyFlips = a.rollback
  ? plan.filter((x) => x.targetPolicy !== 'DENY').length
  : plan.filter((x) => x.before.inventoryPolicy !== 'DENY').length;
const qtyOps = plan.reduce((n, x) => n + x.targetLevels.length, 0);
const sampleSkipped = a.rollback ? 'n/a' : loadTargets().reduce((n, p) => n + p.variants.filter((v) => v.isSample ?? isSampleVariant(v)).length, 0);
const productsTouched = new Set(plan.map((x) => x.productId)).size;

console.log(`plan (${a.rollback ? 'ROLLBACK' : 'HARDEN'}):`);
console.log(`  non-sample variants        : ${plan.length}  across ${productsTouched} products`);
console.log(`  inventoryItemUpdate tracked: ${trackFlips} flips  -> ${a.rollback ? 'false (restore)' : 'true'}`);
console.log(`  inventorySetQuantities     : ${qtyOps} location rows -> ${a.rollback ? 'restored on_hand' : 'on_hand 0'}`);
console.log(`  inventoryPolicy            : ${policyFlips} flips  -> ${a.rollback ? 'restored' : 'DENY'}`);
console.log(`  SAMPLE variants untouched  : ${sampleSkipped}`);
for (const x of plan.slice(0, 3)) {
  console.log(`  e.g. ${x.handle} / "${x.title}" ${x.sku || '(no sku)'} — tracked ${a.rollback ? '->' + x.targetTracked : (x.before.tracked + ' -> true')}, on_hand ${a.rollback ? JSON.stringify(x.targetLevels) : JSON.stringify(x.before.levels.map((l) => l.on_hand)) + ' -> 0'}`);
}
if (!a.apply) { console.log('\nDRY-RUN: nothing was written.'); process.exit(0); }
assertFreshTargets(a);

// ---------------------------------------------------------------- apply
// PREIMAGE FIRST (TK-11925 review fix): every variant's before-state is appended to the
// ledger BEFORE the first Shopify write, so a crash / kill mid-run still leaves a complete
// undo record. Restoring a preimage for a variant that was never changed is a no-op.
if (!a.rollback) {
  const runId = new Date().toISOString();
  for (const x of plan) {
    appendJsonl(LEDGER, {
      ts: runId, runId, productId: x.productId, handle: x.handle, variantId: x.variantId,
      sku: x.sku, title: x.title, inventoryItemId: x.inventoryItemId, before: x.before,
      after: { inventoryPolicy: 'DENY', tracked: true, levels: x.targetLevels }, applied: true, phase: 'preimage',
    });
  }
  console.log(`  preimage: ${plan.length} rows appended to ${LEDGER} BEFORE any write`);
}
let ok = 0, fail = 0;
const failed = [];
function note(msg) { fail++; failed.push(msg); console.error('  FAIL ' + msg); }
// One failed call must not abort the run (the ledger is already written; every op is idempotent).
async function safe(label, fn) { try { return await fn(); } catch (e) { note(`${label}: ${String(e.message || e).slice(0, 300)}`); return null; } }

// Order matters. Forward: tracked -> qty -> policy. Rollback: policy -> qty -> tracked(false last).
const steps = a.rollback ? ['policy', 'qty', 'tracked'] : ['tracked', 'qty', 'policy'];

for (const step of steps) {
  if (step === 'tracked') {
    const items = plan.filter((x) => a.rollback ? x.targetTracked === false : x.before.tracked !== true);
    for (const x of items) {
      const d = await safe(`tracked ${x.sku}`, () => gql(M_TRACK, { id: x.inventoryItemId, input: { tracked: a.rollback ? x.targetTracked : true } }));
      if (d === null) continue;
      const e = payloadErrors(d.inventoryItemUpdate, 'inventoryItemUpdate');
      e.length ? note(`tracked ${x.sku}: ${JSON.stringify(e)}`) : ok++;
    }
    console.log(`  tracked step: ${items.length} calls`);
  }
  if (step === 'qty') {
    const rows = [];
    for (const x of plan) {
      for (const l of x.targetLevels) {
        rows.push({ inventoryItemId: x.inventoryItemId, locationId: l.locationId, quantity: a.rollback ? l.on_hand : 0 });
      }
    }
    for (const b of chunk(rows, 100)) {   // Shopify caps inventorySetQuantities at 100 quantities/call
      const d = await safe(`qty batch(${b.length})`, () => gql(M_QTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: b } }));
      if (d === null) continue;
      const e = payloadErrors(d.inventorySetQuantities, 'inventorySetQuantities');
      e.length ? note(`qty batch(${b.length}): ${JSON.stringify(e).slice(0, 300)}`) : (ok += b.length);
    }
    console.log(`  qty step: ${rows.length} rows in ${Math.ceil(rows.length / 100)} batches`);
  }
  if (step === 'policy') {
    const byProduct = new Map();
    for (const x of plan) {
      if (!byProduct.has(x.productId)) byProduct.set(x.productId, []);
      byProduct.get(x.productId).push({ id: x.variantId, inventoryPolicy: a.rollback ? x.targetPolicy : 'DENY' });
    }
    for (const [pid, variants] of byProduct) {
      const d = await safe(`policy ${pid}`, () => gql(M_POLICY, { productId: pid, variants }));
      if (d === null) continue;
      const e = payloadErrors(d.productVariantsBulkUpdate, 'productVariantsBulkUpdate');
      e.length ? note(`policy ${pid}: ${JSON.stringify(e).slice(0, 200)}`) : (ok += variants.length);
    }
    console.log(`  policy step: ${byProduct.size} productVariantsBulkUpdate calls`);
  }
}

if (!a.rollback) {
  // run summary (not a preimage row; --rollback ignores rows without a variantId)
  appendJsonl(LEDGER, { ts: new Date().toISOString(), phase: 'summary', ok, fail, failed: failed.slice(0, 200), applied: false });
}

console.log(`\ndone: ${ok} successful ops, ${fail} failures`);
if (ok && !a.rollback) logReversible({
  action: `${TICKET} harden ${plan.length} non-sample variants (tracked:true + on_hand 0 + policy DENY) across ${productsTouched} products`,
  blast: plan.length,
  undo: `cd ~/Projects/dw-contact-us-pages && node scripts/harden-variants.mjs${COHORT_FLAG} --rollback --apply`,
  verify: `cd ~/Projects/dw-contact-us-pages && node scripts/verify.mjs${COHORT_FLAG}`,
});
process.exit(fail ? 1 : 0);