← back to Sanderson Onboard
tk10873/reprice_live_runbook.mjs
353 lines
#!/usr/bin/env node
// reprice_live_runbook.mjs — TK-10873 Zoffany Option-A LIVE reprice runbook.
// Memo APPROVED by Steve 2026-08-31 (pending-approval/TK-10873-zoffany-optionA-livepublish-2026-08-30.md).
//
// ─────────────────────────────────────────────────────────────────────────────
// DESIGN CONTRACT — restore-map-FIRST, fail-closed, reversible, ledgered.
// The LIVE reprice (customer-facing Shopify money write) is GATED: it only fires
// under `--apply --i-understand-this-is-live`, which Steve runs as a separate `!`
// step. The DEFAULT run is a zero-network DRY-RUN that resolves what it can from
// the local mirror and PROVES the plan. No mode writes anything to Shopify unless
// BOTH live flags are present.
// ─────────────────────────────────────────────────────────────────────────────
//
// Scope: the 277 action=REPRICE rows of zoffany_optionA_draft_v3.json — 239 feed-
// verified US retail + 38 trade-verified (retail == trade/0.65/0.85). Sample ($4.25)
// is NEVER touched. Only the sellable "Sold Per {unit}" variant's price moves.
//
// Modes
// (default) plan — DRY-RUN. Mirror-only resolution (no network). Writes
// reprice_live_plan.json + reprice_restore_map.preview.json.
// Reports how many have a cached variant_id vs need a live GET.
// --apply LIVE — Phase 1 RESOLVE+SNAPSHOT: for every row, read the live
// product variants, pick the unique sellable variant, capture
// {product_id, variant_id, old_price, new_price}. Persist the
// COMPLETE restore map (+fsync) BEFORE any write. ABORT the
// whole run if ANY row fails to resolve to exactly one sellable
// variant (zero writes happen on a resolution failure).
// Phase 2 WRITE: productVariantsBulkUpdate per product in
// batches, verify-readback each, append to the reversible ledger.
// --revert <map.json> LIVE — read a restore map and set every variant back to
// old_price (the undo). Verify-readback + ledger.
//
// Safety rails
// • --apply requires the second flag --i-understand-this-is-live (footgun guard).
// • ACTIVE-only (mirror status='ACTIVE'); exactly-one-sellable-variant per product.
// • Sample variant is identified and EXCLUDED from every write (price==4.25 / sku
// endsWith '-Sample' / title contains 'Sample').
// • Every new price must pass the sanity classifier (PASS/WARN, never FAIL) or the
// row is dropped from the write set with a loud report.
// • Restore map path is printed BEFORE the first write; the run aborts if it can't
// be written+fsync'd.
// • Batches pace to respect Shopify's GraphQL cost throttle.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { SAMPLE_PRICE, classifyPrice } from './reprice_lib.mjs';
const DIR = new URL('.', import.meta.url).pathname;
// The reprice set defaults to the v3 draft (the 277). A follow-up set can be driven by
// pointing REPRICE_DRAFT at another REPRICE-shaped JSON (e.g. the v4 promotions) — output
// filenames derive from the draft basename so a v4 run NEVER clobbers the v3 live restore map.
const V3_IN = process.env.REPRICE_DRAFT ? `${DIR}${process.env.REPRICE_DRAFT}` : `${DIR}zoffany_optionA_draft_v3.json`;
const BASE = V3_IN.replace(/\.json$/, '').split('/').pop();
const PLAN_OUT = `${DIR}${BASE}.live_plan.json`;
const PREVIEW_OUT = `${DIR}${BASE}.restore_map.preview.json`;
const RESTORE_OUT = `${DIR}${BASE}.restore_map.live.json`; // populated ONLY by --apply, before writes
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const BATCH = Number(process.env.REPRICE_BATCH || 25);
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const LIVE_OK = argv.includes('--i-understand-this-is-live');
const REVERT_IDX = argv.indexOf('--revert');
const REVERT_MAP = REVERT_IDX >= 0 ? argv[REVERT_IDX + 1] : null;
// ── mirror read (read-only, local, $0) ───────────────────────────────────────
function psql(q) {
return execFileSync('psql', ['-h', '/tmp', '-d', 'dw_unified', '-tAF\t', '-c', q], { encoding: 'utf8' });
}
function loadMirror(mfrSkus) {
const inlist = mfrSkus.map(s => `'${String(s).replace(/'/g, "''")}'`).join(',');
const q = `SELECT mfr_sku, vendor, shopify_id, variant_id, sku, variant_sku, variant_count,
has_product_variant, has_sample_variant, status
FROM shopify_products WHERE mfr_sku IN (${inlist})`;
const m = new Map();
for (const line of psql(q).trim().split('\n').filter(Boolean)) {
const [mfr_sku, vendor, shopify_id, variant_id, sku, variant_sku, variant_count,
has_product_variant, has_sample_variant, status] = line.split('\t');
m.set(mfr_sku, {
mfr_sku, vendor, shopify_id, variant_id: variant_id || null, sku, variant_sku,
variant_count: Number(variant_count),
has_product_variant: has_product_variant === 't',
has_sample_variant: has_sample_variant === 't',
status,
});
}
return m;
}
// ── the reprice set: 277 REPRICE rows, sanity-gated ───────────────────────────
function loadRepriceRows() {
const v3 = JSON.parse(fs.readFileSync(V3_IN, 'utf8'));
const reprice = v3.filter(r => r.action === 'REPRICE');
const rows = [], dropped = [];
for (const r of reprice) {
const price = Number(r.proposed_sellable_price);
const floor = Number.isFinite(Number(r.price_trade)) ? Number(r.price_trade) : null;
const { verdict, reasons } = classifyPrice(price, SAMPLE_PRICE, floor);
if (verdict === 'FAIL') { dropped.push({ mfr_sku: r.mfr_sku, price, reasons }); continue; }
rows.push({
mfr_sku: r.mfr_sku,
new_price: Number(price.toFixed(2)),
price_source: r.price_source,
sanity: verdict, // PASS or WARN (WARN = out-of-band outlier, still written; logged)
sanity_reasons: reasons,
});
}
return { rows, dropped };
}
// ── Shopify Admin GraphQL (used ONLY by --apply / --revert) ───────────────────
function token() {
const t = process.env.SHOPIFY_ADMIN_TOKEN;
if (!t) throw new Error('SHOPIFY_ADMIN_TOKEN not set — source ~/Projects/secrets-manager/.env before --apply');
return t;
}
async function gql(query, variables) {
const res = await fetch(`https://${SHOP}/admin/api/${API}/graphql.json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token() },
body: JSON.stringify({ query, variables }),
});
const j = await res.json();
if (j.errors) throw new Error('GraphQL errors: ' + JSON.stringify(j.errors));
// pace against the throttle: if we're low on the cost bucket, breathe.
const throttle = j.extensions?.cost?.throttleStatus;
if (throttle && throttle.currentlyAvailable < 200) await new Promise(r => setTimeout(r, 1200));
return j.data;
}
// Live-read a product's variants and pick the UNIQUE sellable (non-sample) variant.
const PRODUCT_VARIANTS_Q = `query($id: ID!) {
product(id: $id) {
id status
variants(first: 20) { nodes { id sku title price } }
}
}`;
function pickSellable(variants) {
const sellable = variants.filter(v =>
!(String(v.sku || '').endsWith('-Sample')) &&
!/sample/i.test(v.title || '') &&
Number(v.price) !== SAMPLE_PRICE);
return sellable; // caller enforces exactly-one
}
const BULK_UPDATE_M = `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants { id price sku }
userErrors { field message }
}
}`;
function ledger(entry) {
fs.appendFileSync(LEDGER, JSON.stringify(entry) + '\n');
}
function writeFsync(path, data) {
const fd = fs.openSync(path, 'w');
fs.writeSync(fd, data);
fs.fsyncSync(fd);
fs.closeSync(fd);
}
// ── MODE: plan (DRY-RUN, zero network) ────────────────────────────────────────
function runPlan() {
const { rows, dropped } = loadRepriceRows();
const mirror = loadMirror(rows.map(r => r.mfr_sku));
const plan = [], needLiveGet = [], notActive = [], noProduct = [];
for (const r of rows) {
const m = mirror.get(r.mfr_sku);
if (!m) { noProduct.push(r.mfr_sku); continue; }
if (m.status !== 'ACTIVE') { notActive.push(r.mfr_sku); continue; }
if (!m.variant_id) needLiveGet.push(r.mfr_sku); // must resolve variant id via live GET in --apply
plan.push({
mfr_sku: r.mfr_sku,
product_id: m.shopify_id,
sellable_sku: m.sku,
cached_variant_id: m.variant_id, // null for the 38 → live GET required
new_price: r.new_price,
old_price: '<LIVE-READ-REQUIRED before write>',
price_source: r.price_source,
sanity: r.sanity,
});
}
// restore-map PREVIEW: same shape as the live map, old_price/variant_id as markers.
const preview = plan.map(p => ({
mfr_sku: p.mfr_sku, product_id: p.product_id, sellable_sku: p.sellable_sku,
variant_id: p.cached_variant_id || '<LIVE-GET-REQUIRED>',
old_price: '<LIVE-READ-REQUIRED>', new_price: p.new_price,
_note: 'PREVIEW only — the live --apply run materializes old_price + variant_id from a live read BEFORE any write.',
}));
fs.writeFileSync(PLAN_OUT, JSON.stringify(plan, null, 2));
fs.writeFileSync(PREVIEW_OUT, JSON.stringify(preview, null, 2));
console.log('=== Zoffany Option-A LIVE reprice — PLAN (DRY-RUN, zero network) ===');
console.log(`reprice rows (v3, sanity-passed): ${rows.length} dropped by sanity FAIL: ${dropped.length}`);
console.log(`resolved to an ACTIVE product in mirror: ${plan.length}`);
console.log(` ├─ have cached sellable variant_id: ${plan.length - needLiveGet.length}`);
console.log(` └─ need a LIVE GET to resolve variant_id: ${needLiveGet.length}`);
if (notActive.length) console.log(`NOT active (excluded): ${notActive.length} → ${notActive.slice(0, 10).join(', ')}`);
if (noProduct.length) console.log(`NO mirror product (excluded): ${noProduct.length} → ${noProduct.slice(0, 10).join(', ')}`);
if (dropped.length) console.log(`SANITY-FAIL dropped: ${JSON.stringify(dropped.slice(0, 10))}`);
const warns = plan.filter(p => p.sanity === 'WARN');
if (warns.length) console.log(`out-of-band WARN prices (written but flagged): ${warns.length} → ${warns.slice(0, 8).map(w => w.mfr_sku + '=$' + w.new_price).join(', ')}`);
console.log(`\nartifacts:\n ${PLAN_OUT}\n ${PREVIEW_OUT}`);
console.log('\nThis run made NO network calls and NO writes. The live reprice is:');
console.log(' node reprice_live_runbook.mjs --apply --i-understand-this-is-live');
console.log(' (run by Steve via `!`, with SHOPIFY_ADMIN_TOKEN sourced.)');
}
// ── MODE: apply (LIVE — restore-map-first) ────────────────────────────────────
async function runApply() {
if (!LIVE_OK) {
console.error('REFUSING: --apply requires --i-understand-this-is-live (customer-facing money write).');
process.exit(2);
}
const { rows, dropped } = loadRepriceRows();
const mirror = loadMirror(rows.map(r => r.mfr_sku));
console.log(`[apply] ${rows.length} candidate rows (${dropped.length} dropped by sanity). Resolving live variants…`);
// ── PHASE 1: RESOLVE + SNAPSHOT (no writes) ─────────────────────────────────
const restore = [], failures = [];
for (const r of rows) {
const m = mirror.get(r.mfr_sku);
if (!m) { failures.push({ mfr_sku: r.mfr_sku, why: 'no mirror product' }); continue; }
if (m.status !== 'ACTIVE') { failures.push({ mfr_sku: r.mfr_sku, why: `status ${m.status}` }); continue; }
let data;
try { data = await gql(PRODUCT_VARIANTS_Q, { id: m.shopify_id }); }
catch (e) { failures.push({ mfr_sku: r.mfr_sku, why: 'GET failed: ' + e.message }); continue; }
const prod = data.product;
if (!prod) { failures.push({ mfr_sku: r.mfr_sku, why: 'product not found live' }); continue; }
if (prod.status !== 'ACTIVE') { failures.push({ mfr_sku: r.mfr_sku, why: `live status ${prod.status}` }); continue; }
const sellable = pickSellable(prod.variants.nodes);
if (sellable.length !== 1) {
failures.push({ mfr_sku: r.mfr_sku, why: `expected 1 sellable variant, found ${sellable.length}` });
continue;
}
const v = sellable[0];
restore.push({
mfr_sku: r.mfr_sku, product_id: prod.id, variant_id: v.id, sellable_sku: v.sku,
old_price: Number(v.price), new_price: r.new_price, sanity: r.sanity,
});
}
// FAIL-CLOSED: any resolution failure aborts BEFORE a single write.
if (failures.length) {
console.error(`\n[apply] ABORTED — ${failures.length} rows failed to resolve. ZERO writes made.`);
console.error(JSON.stringify(failures.slice(0, 30), null, 2));
process.exit(3);
}
// Persist the COMPLETE restore map (+fsync) BEFORE the first write. This IS the undo.
writeFsync(RESTORE_OUT, JSON.stringify({
ticket: 'TK-10873', generated_at: new Date().toISOString(),
shop: SHOP, count: restore.length, rows: restore,
}, null, 2));
console.log(`\n[apply] restore map persisted BEFORE any write → ${RESTORE_OUT} (${restore.length} rows)`);
console.log(`[apply] undo at any time: node reprice_live_runbook.mjs --revert ${RESTORE_OUT} --i-understand-this-is-live`);
// Cody HOLE 3: a WARN price (out-of-band $20–$2000) is a possible scraper unit error.
// Fine to preview, but HARD-STOP a live money write unless --allow-warn is explicit.
const warnRows = restore.filter(r => r.sanity === 'WARN');
if (warnRows.length && !argv.includes('--allow-warn')) {
console.error(`\n[apply] ABORTED — ${warnRows.length} out-of-band WARN prices in the write set; ZERO writes made.`);
console.error(` ${warnRows.map(r => r.mfr_sku + '=$' + r.new_price).join(', ')}`);
console.error(' Review them, then re-run with --allow-warn to include, or fix v3 to exclude.');
process.exit(4);
}
if (warnRows.length) console.warn(`[apply] --allow-warn set: writing ${warnRows.length} out-of-band prices → ${warnRows.map(r => r.mfr_sku + '=$' + r.new_price).join(', ')}`);
// ── PHASE 2: WRITE (batched, verify-readback, ledger) ───────────────────────
// Cody HOLE 1: EVERY network call is wrapped so a transient 5xx/timeout lands in
// errs[] instead of throwing past the loop and skipping the ledger flush below.
// The loop can never throw → the ledger always fires → we always have a durable
// record of exactly which rows landed. The restore map already covers the undo.
let ok = 0, errs = [];
for (let i = 0; i < restore.length; i += BATCH) {
const batch = restore.slice(i, i + BATCH);
for (const row of batch) {
try {
const data = await gql(BULK_UPDATE_M, {
productId: row.product_id,
variants: [{ id: row.variant_id, price: row.new_price.toFixed(2) }],
});
const ue = data.productVariantsBulkUpdate.userErrors;
if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, userErrors: ue }); continue; }
const wrote = data.productVariantsBulkUpdate.productVariants?.[0];
if (!wrote || Number(wrote.price) !== row.new_price) {
errs.push({ mfr_sku: row.mfr_sku, why: `readback ${wrote?.price} != ${row.new_price}` });
continue;
}
ok++;
} catch (e) {
errs.push({ mfr_sku: row.mfr_sku, why: 'write threw: ' + e.message });
continue;
}
}
console.log(`[apply] batch ${Math.floor(i / BATCH) + 1}: ${ok}/${restore.length} written (errors so far: ${errs.length})`);
}
ledger({
ts: new Date().toISOString(), agent: 'codex-10873', ticket: 'TK-10873',
action: `Zoffany Option-A live reprice — set sellable variant price on ${ok} ACTIVE products (v3, feed+trade verified)`,
blast_radius: ok,
undo_cmd: `node ${DIR}reprice_live_runbook.mjs --revert ${RESTORE_OUT} --i-understand-this-is-live`,
verify: `restore map ${RESTORE_OUT}; readback-verified each write`,
});
console.log(`\n[apply] DONE. written+verified=${ok} errors=${errs.length}`);
if (errs.length) console.log(JSON.stringify(errs.slice(0, 20), null, 2));
}
// ── MODE: revert (LIVE undo) ──────────────────────────────────────────────────
async function runRevert() {
if (!LIVE_OK) { console.error('REFUSING: --revert requires --i-understand-this-is-live.'); process.exit(2); }
const map = JSON.parse(fs.readFileSync(REVERT_MAP, 'utf8'));
const rows = map.rows || map;
console.log(`[revert] restoring ${rows.length} variants to old_price from ${REVERT_MAP}…`);
let ok = 0, errs = [];
for (const row of rows) {
try {
const data = await gql(BULK_UPDATE_M, {
productId: row.product_id,
variants: [{ id: row.variant_id, price: Number(row.old_price).toFixed(2) }],
});
const ue = data.productVariantsBulkUpdate.userErrors;
if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, userErrors: ue }); continue; }
const wrote = data.productVariantsBulkUpdate.productVariants?.[0];
if (!wrote || Number(wrote.price) !== Number(row.old_price)) {
errs.push({ mfr_sku: row.mfr_sku, why: `readback ${wrote?.price} != ${row.old_price}` }); continue;
}
ok++;
} catch (e) {
errs.push({ mfr_sku: row.mfr_sku, why: 'revert threw: ' + e.message }); continue;
}
}
ledger({
ts: new Date().toISOString(), agent: 'codex-10873', ticket: 'TK-10873',
action: `REVERT Zoffany Option-A reprice — restored ${ok} variants to pre-write price`,
blast_radius: ok, undo_cmd: 're-run --apply to redo', verify: `from ${REVERT_MAP}`,
});
console.log(`[revert] DONE. restored=${ok} errors=${errs.length}`);
if (errs.length) console.log(JSON.stringify(errs.slice(0, 20), null, 2));
}
// ── dispatch ──────────────────────────────────────────────────────────────────
(async () => {
if (REVERT_MAP) return runRevert();
if (APPLY) return runApply();
return runPlan();
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });