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