← back to Sanderson Onboard
tk10873/apply_labeling.mjs
408 lines
#!/usr/bin/env node
// apply_labeling.mjs — TK-10873 Zoffany wide-width UNIT-LABELING pass (the pair to the v7 reprice).
//
// ─────────────────────────────────────────────────────────────────────────────
// DESIGN CONTRACT — restore-map-FIRST, fail-closed, reversible, ledgered.
// Mirrors reprice_live_runbook.mjs exactly: the LIVE write (customer-facing COPY:
// variant title + PDP description) is GATED behind BOTH `--apply` and
// `--i-understand-this-is-live`, run by Steve as a separate `!` step. The DEFAULT
// run is a DRY-RUN that resolves each product LIVE (read-only), prints the planned
// label changes, and writes NOTHING to Shopify.
// ─────────────────────────────────────────────────────────────────────────────
//
// Spec: zoffany_widewidth_labeling_spec.json (45 rows / 20 patterns). Per SKU:
// 1) Rename the SELLABLE variant's option value "Default Title" -> "Per Yard".
// (These are single-option "Title" products with 2 variants: the sellable
// one carries option-value "Default Title", the memo carries "Sample". We
// rename ONLY the sellable option value via productOptionUpdate; the Sample
// value is never touched, so the memo variant + its $4.25 price are safe.)
// 2) Append the spec's pdp_line to the product descriptionHtml (idempotent: if
// the exact line is already present, the description is left unchanged).
//
// Modes
// (default) plan — DRY-RUN. Resolve each row LIVE (read-only GET), verify the
// expected pre-state (ACTIVE, single "Title" option, a
// "Default Title" sellable value, a "Sample" value present),
// and PRINT the planned changes. Writes a PREVIEW restore
// map. ZERO Shopify writes. ABORT-report (not exit) on any
// row that fails to resolve so the whole set is visible.
// --apply LIVE — Phase 1 RESOLVE+SNAPSHOT: live-read every row, capture the
// COMPLETE restore map {product_id, option_id,
// sellable_value_id, old_value_name, new_value_name,
// old_descriptionHtml, new_descriptionHtml}. Persist (+fsync)
// BEFORE any write. FAIL-CLOSED: if ANY row fails to resolve
// or its live state != expected, ABORT with zero writes.
// Phase 2 WRITE: per product, productOptionUpdate (rename the
// sellable value) then productUpdate (descriptionHtml), each
// readback-verified, appended to the reversible ledger.
// --revert <map.json> LIVE — read a restore map and set every option value name +
// descriptionHtml back to old_* (the undo). Readback + ledger.
//
// Safety rails (identical philosophy to the reprice runbook):
// • --apply requires --i-understand-this-is-live (footgun guard).
// • ACTIVE-only (live status must be ACTIVE); exactly-one sellable option value.
// • The Sample variant / its value / its $4.25 price are NEVER in any write set.
// • Restore map path is printed BEFORE the first write; 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';
const DIR = new URL('.', import.meta.url).pathname;
const SPEC_IN = process.env.LABELING_SPEC ? `${DIR}${process.env.LABELING_SPEC}` : `${DIR}zoffany_widewidth_labeling_spec.json`;
const PLAN_OUT = `${DIR}apply_labeling.live_plan.json`;
const PREVIEW_OUT = `${DIR}apply_labeling.restore_map.preview.json`;
const RESTORE_OUT = `${DIR}apply_labeling.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.LABELING_BATCH || 25);
const SAMPLE_PRICE = 4.25;
const NEW_VALUE = 'Per Yard';
const OLD_VALUE = 'Default Title';
// GraphQL query width limits (must cover all real products; bump if Shopify returns truncation warnings)
const OPTIONS_FIRST = 5;
const VARIANTS_FIRST = 20;
// Throttle pacing: pause when cost bucket is low
const THROTTLE_MIN_AVAILABLE = 200;
const THROTTLE_PAUSE_MS = 1200;
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) — resolves mfr_sku -> shopify product id ─
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, sku, 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, sku, status] = line.split('\t');
m.set(mfr_sku, { mfr_sku, vendor, shopify_id, sku, status });
}
return m;
}
// ── the labeling set: 45 rows from the spec ───────────────────────────────────
function loadSpecRows() {
const spec = JSON.parse(fs.readFileSync(SPEC_IN, 'utf8'));
const rows = spec.rows || spec;
return rows.map(r => ({
mfr_sku: r.mfr_sku,
pattern: r.pattern,
variant_label: r.variant_label || NEW_VALUE,
pdp_line: r.pdp_line,
}));
}
// ── 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 a live run');
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 < THROTTLE_MIN_AVAILABLE) await new Promise(r => setTimeout(r, THROTTLE_PAUSE_MS));
return j.data;
}
// Live-read a product's options, variants, and description.
const PRODUCT_Q = `query($id: ID!) {
product(id: $id) {
id status title descriptionHtml
options(first: ${OPTIONS_FIRST}) { id name position optionValues { id name } }
variants(first: ${VARIANTS_FIRST}) { nodes { id sku title price selectedOptions { name value } } }
}
}`;
// Resolve the single sellable option value to rename. Enforces the expected shape:
// • one option named "Title"
// • that option has a "Sample" value (the memo variant we must NEVER touch)
// • exactly one non-sample sellable variant; its "Title" option value is the one we
// rename to NEW_VALUE ("Per Yard"), whatever it currently is ("Default Title",
// "Sold Per Roll", etc.) — EXCEPT that if it is ALREADY NEW_VALUE the row is a
// no-op (already labeled).
// This is deliberately tolerant of the ACTUAL live pre-state: an earlier pass labeled
// most of these "Sold Per Roll" (the wrong unit for per-yard goods) rather than leaving
// them "Default Title", so we key off the sellable VARIANT's own option value, not a
// hard-coded literal. Returns { option_id, sellable_value_id, old_value_name } or throws.
function resolveSellableValue(prod) {
if (!prod) throw new Error('product not found live');
if (prod.status !== 'ACTIVE') throw new Error(`live status ${prod.status} (expected ACTIVE)`);
const titleOpts = prod.options.filter(o => o.name === 'Title');
if (titleOpts.length !== 1) throw new Error(`expected 1 "Title" option, found ${titleOpts.length}`);
const opt = titleOpts[0];
const sampleVal = opt.optionValues.find(v => v.name === 'Sample');
if (!sampleVal) throw new Error('no "Sample" option value present (unexpected structure)');
// the sellable variant: exactly one non-sample, priced != sample.
const sellableVars = prod.variants.nodes.filter(v =>
!String(v.sku || '').endsWith('-Sample') &&
!/sample/i.test(v.title || '') &&
Number(v.price) !== SAMPLE_PRICE);
if (sellableVars.length !== 1) throw new Error(`expected 1 sellable variant, found ${sellableVars.length}`);
const sv = sellableVars[0];
const svOptVal = (sv.selectedOptions || []).find(so => so.name === 'Title')?.value;
if (svOptVal == null) throw new Error('sellable variant has no "Title" option value');
if (svOptVal === 'Sample') throw new Error('sellable variant resolved to the Sample value (guard tripped)');
// find the option-value record backing that sellable value.
const sellableVal = opt.optionValues.find(v => v.name === svOptVal);
if (!sellableVal) throw new Error(`sellable option value "${svOptVal}" not found in the option's values`);
if (sellableVal.id === sampleVal.id) throw new Error('sellable value id equals the Sample value id (guard tripped)');
return { option_id: opt.id, sellable_value_id: sellableVal.id, old_value_name: sellableVal.name };
}
// Compute the new descriptionHtml: append the pdp_line as its own <p> unless already present.
function appendPdpLine(oldHtml, pdpLine) {
const html = oldHtml || '';
if (html.includes(pdpLine)) return { changed: false, newHtml: html };
const sep = html.trim().length ? '\n' : '';
return { changed: true, newHtml: `${html}${sep}<p>${pdpLine}</p>` };
}
const OPTION_UPDATE_M = `mutation($productId: ID!, $option: OptionUpdateInput!, $optionValuesToUpdate: [OptionValueUpdateInput!]) {
productOptionUpdate(productId: $productId, option: $option, optionValuesToUpdate: $optionValuesToUpdate) {
product { id options(first: ${OPTIONS_FIRST}) { id name optionValues { id name } } }
userErrors { field message }
}
}`;
const PRODUCT_UPDATE_M = `mutation($input: ProductInput!) {
productUpdate(input: $input) { product { id descriptionHtml } 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);
}
// Resolve every row to its live pre-state. Used by BOTH plan and apply (single code path
// = the dry-run proves exactly what apply will do). Returns { resolved, failures }.
async function resolveAll(rows, mirror) {
const resolved = [], 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: `mirror status ${m.status}` }); continue; }
let data;
try { data = await gql(PRODUCT_Q, { id: m.shopify_id }); }
catch (e) { failures.push({ mfr_sku: r.mfr_sku, why: 'GET failed: ' + e.message }); continue; }
const prod = data.product;
let sel;
try { sel = resolveSellableValue(prod); }
catch (e) { failures.push({ mfr_sku: r.mfr_sku, why: e.message }); continue; }
const { changed, newHtml } = appendPdpLine(prod.descriptionHtml, r.pdp_line);
resolved.push({
mfr_sku: r.mfr_sku, pattern: r.pattern, product_id: prod.id, product_title: prod.title,
option_id: sel.option_id, sellable_value_id: sel.sellable_value_id,
old_value_name: sel.old_value_name, new_value_name: r.variant_label,
old_descriptionHtml: prod.descriptionHtml || '',
new_descriptionHtml: newHtml,
desc_changed: changed,
value_changed: sel.old_value_name !== r.variant_label,
});
}
return { resolved, failures };
}
// ── MODE: plan (DRY-RUN — resolves live read-only, writes NOTHING to Shopify) ──
async function runPlan() {
const rows = loadSpecRows();
const mirror = loadMirror(rows.map(r => r.mfr_sku));
console.log('=== Zoffany wide-width UNIT-LABELING — PLAN (DRY-RUN, read-only GETs, ZERO writes) ===');
console.log(`spec rows: ${rows.length} (spec: ${SPEC_IN.split('/').pop()})`);
const { resolved, failures } = await resolveAll(rows, mirror);
// PREVIEW restore map — same shape as the live map materializes.
const preview = resolved.map(p => ({
mfr_sku: p.mfr_sku, product_id: p.product_id, option_id: p.option_id,
sellable_value_id: p.sellable_value_id,
old_value_name: p.old_value_name, new_value_name: p.new_value_name,
old_descriptionHtml: p.old_descriptionHtml, new_descriptionHtml: p.new_descriptionHtml,
_note: 'PREVIEW only — the live --apply run re-reads + captures this map from a live read BEFORE any write.',
}));
fs.writeFileSync(PREVIEW_OUT, JSON.stringify({ ticket: 'TK-10873', mode: 'preview', count: preview.length, rows: preview }, null, 2));
fs.writeFileSync(PLAN_OUT, JSON.stringify(resolved.map(p => ({
mfr_sku: p.mfr_sku, pattern: p.pattern, product_id: p.product_id, product_title: p.product_title,
rename: `"${p.old_value_name}" -> "${p.new_value_name}"`, value_changed: p.value_changed,
desc_changed: p.desc_changed,
})), null, 2));
console.log(`resolved LIVE to an ACTIVE product w/ a single sellable option value: ${resolved.length}/${rows.length}`);
const renameCount = resolved.filter(p => p.value_changed).length;
const descCount = resolved.filter(p => p.desc_changed).length;
// show the distinct current sellable-value names we'd rename FROM (real pre-state varies).
const fromNames = [...new Set(resolved.filter(p => p.value_changed).map(p => p.old_value_name))];
console.log(` ├─ would rename sellable option value -> "${NEW_VALUE}": ${renameCount} (from: ${fromNames.map(n => `"${n}"`).join(', ') || 'n/a'})`);
console.log(` └─ would append pdp_line to descriptionHtml: ${descCount} (already-present, skipped: ${resolved.length - descCount})`);
console.log('\nsample of planned changes (first 6):');
for (const p of resolved.slice(0, 6)) {
console.log(` ${p.mfr_sku} "${p.product_title}" value "${p.old_value_name}"->"${p.new_value_name}" desc+${p.desc_changed ? 'yes' : 'no'}`);
}
if (failures.length) {
console.log(`\n!! ${failures.length} row(s) FAILED to resolve — a live --apply would ABORT with zero writes:`);
console.log(JSON.stringify(failures, null, 2));
} else {
console.log(`\nALL ${resolved.length} rows resolved clean — restore-map-first apply is wired and ready.`);
}
console.log(`\nartifacts:\n ${PLAN_OUT}\n ${PREVIEW_OUT}`);
console.log('\nThis run made NO Shopify writes. The live labeling is:');
console.log(' node apply_labeling.mjs --apply --i-understand-this-is-live');
console.log(' (run by Steve via `!`, with SHOPIFY_ADMIN_TOKEN sourced.)');
process.exit(failures.length ? 5 : 0);
}
// ── MODE: apply (LIVE — restore-map-first) ────────────────────────────────────
async function runApply() {
if (!LIVE_OK) {
console.error('REFUSING: --apply requires --i-understand-this-is-live (customer-facing copy write).');
process.exit(2);
}
const rows = loadSpecRows();
const mirror = loadMirror(rows.map(r => r.mfr_sku));
console.log(`[apply] ${rows.length} spec rows. Resolving live pre-state…`);
// ── PHASE 1: RESOLVE + SNAPSHOT (no writes) ─────────────────────────────────
const { resolved, failures } = await resolveAll(rows, mirror);
// 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: resolved.length,
rows: resolved.map(p => ({
mfr_sku: p.mfr_sku, product_id: p.product_id, option_id: p.option_id,
sellable_value_id: p.sellable_value_id,
old_value_name: p.old_value_name, new_value_name: p.new_value_name,
old_descriptionHtml: p.old_descriptionHtml, new_descriptionHtml: p.new_descriptionHtml,
})),
}, null, 2));
console.log(`\n[apply] restore map persisted BEFORE any write → ${RESTORE_OUT} (${resolved.length} rows)`);
console.log(`[apply] undo at any time: node apply_labeling.mjs --revert ${RESTORE_OUT} --i-understand-this-is-live`);
// ── PHASE 2: WRITE (batched, verify-readback, ledger) ───────────────────────
// Every network call is wrapped so a transient error lands in errs[] instead of
// throwing past the loop; the ledger always fires. The restore map covers the undo.
let ok = 0, errs = [];
for (let i = 0; i < resolved.length; i += BATCH) {
const batch = resolved.slice(i, i + BATCH);
for (const row of batch) {
try {
// (a) rename the sellable option value: "Default Title" -> "Per Yard"
if (row.value_changed) {
const d = await gql(OPTION_UPDATE_M, {
productId: row.product_id,
option: { id: row.option_id },
optionValuesToUpdate: [{ id: row.sellable_value_id, name: row.new_value_name }],
});
const ue = d.productOptionUpdate.userErrors;
if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, step: 'optionUpdate', userErrors: ue }); continue; }
const vals = d.productOptionUpdate.product?.options?.find(o => o.id === row.option_id)?.optionValues || [];
if (!vals.some(v => v.id === row.sellable_value_id && v.name === row.new_value_name)) {
errs.push({ mfr_sku: row.mfr_sku, step: 'optionUpdate', why: 'readback value name mismatch' }); continue;
}
}
// (b) append the pdp_line to descriptionHtml
if (row.desc_changed) {
const d = await gql(PRODUCT_UPDATE_M, { input: { id: row.product_id, descriptionHtml: row.new_descriptionHtml } });
const ue = d.productUpdate.userErrors;
if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, step: 'productUpdate', userErrors: ue }); continue; }
const wrote = d.productUpdate.product?.descriptionHtml;
if (wrote !== row.new_descriptionHtml) {
errs.push({ mfr_sku: row.mfr_sku, step: 'productUpdate', why: 'readback descriptionHtml mismatch' }); 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}/${resolved.length} labeled (errors so far: ${errs.length})`);
}
ledger({
ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10873',
action: `Zoffany wide-width unit-labeling — rename sellable option value "${OLD_VALUE}"->"${NEW_VALUE}" + append per-yard PDP line on ${ok} ACTIVE products`,
blast_radius: ok,
undo_cmd: `node ${DIR}apply_labeling.mjs --revert ${RESTORE_OUT} --i-understand-this-is-live`,
verify: `restore map ${RESTORE_OUT}; readback-verified each write`,
});
console.log(`\n[apply] DONE. labeled+verified=${ok} errors=${errs.length}`);
if (errs.length) console.log(JSON.stringify(errs.slice(0, 20), null, 2));
process.exit(errs.length ? 1 : 0);
}
// ── 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} products' option value + description from ${REVERT_MAP}…`);
let ok = 0, errs = [];
for (const row of rows) {
try {
// (a) restore the option value name
if (row.old_value_name !== row.new_value_name) {
const d = await gql(OPTION_UPDATE_M, {
productId: row.product_id,
option: { id: row.option_id },
optionValuesToUpdate: [{ id: row.sellable_value_id, name: row.old_value_name }],
});
const ue = d.productOptionUpdate.userErrors;
if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, step: 'optionUpdate', userErrors: ue }); continue; }
}
// (b) restore the description
if (row.old_descriptionHtml !== row.new_descriptionHtml) {
const d = await gql(PRODUCT_UPDATE_M, { input: { id: row.product_id, descriptionHtml: row.old_descriptionHtml } });
const ue = d.productUpdate.userErrors;
if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, step: 'productUpdate', userErrors: ue }); continue; }
}
ok++;
} catch (e) { errs.push({ mfr_sku: row.mfr_sku, why: 'revert threw: ' + e.message }); continue; }
}
ledger({
ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10873',
action: `REVERT Zoffany wide-width unit-labeling — restored ${ok} products' option value + description`,
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));
process.exit(errs.length ? 1 : 0);
}
// ── dispatch ──────────────────────────────────────────────────────────────────
(async () => {
if (REVERT_IDX >= 0 && !REVERT_MAP) throw new Error('--revert requires a restore-map path argument');
if (REVERT_MAP) return runRevert();
if (APPLY) return runApply();
return runPlan();
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });