← back to Rebel Walls Push
scripts/execute-TK11248.js
192 lines
'use strict';
/* GATED executor for the TK-11248 batch. Serializes:
* Step 1 = TK-11238 Aries title correction (productUpdate)
* Step 2 = TK-10405 delete 12 Rebel Walls Default-Title variants (productVariantsBulkDelete)
*
* SAFETY:
* - DRY-RUN by default. Prints the exact diff and exits without writing.
* - To write, ALL of these must hold:
* --apply
* --approval=TK-11248 (must match the recorded approval ticket)
* env RW_APPROVAL_TOKEN must equal the token written into the approval
* sentinel file data/TK-11248-APPROVAL.json by the operator AFTER Steve's
* explicit exact approval is logged on TK-11248.
* - Re-revalidates each target live immediately before writing (title still
* matches old; product still has exactly the 1 Default-Title variant + Mural
* + Sample survivors). Refuses the individual write if drift is detected.
* - One product at a time; verifies userErrors empty + post-state after each.
* - Logts every write to ~/.claude/yolo-queue/executed-reversible/ledger.jsonl.
*
* Usage:
* node execute-TK11248.js # dry-run both steps
* node execute-TK11248.js --step=11238 # dry-run only the title
* node execute-TK11248.js --step=10405 # dry-run only the deletes
* node execute-TK11248.js --apply --approval=TK-11248 # LIVE (needs sentinel)
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { gqlRetry, sleep, DOMAIN, psql } = require('./_shop');
const DATA = path.join(__dirname, '..', 'data');
const argv = process.argv.slice(2);
const has = f => argv.includes(f);
const val = k => { const a = argv.find(x => x.startsWith(k + '=')); return a ? a.split('=')[1] : null; };
const APPLY = has('--apply');
const STEP = val('--step'); // '11238' | '10405' | null(both)
const APPROVAL = val('--approval');
const LEDGER = '/Users/macstudio3/.claude/yolo-queue/executed-reversible/ledger.jsonl';
const SENTINEL = path.join(DATA, 'TK-11248-APPROVAL.json');
const ARIES_GID = 'gid://shopify/Product/7800009752627';
const ARIES_OLD = 'Aries - Single Color Commercial Wallcovering | DW Commercial Surfaces';
const ARIES_NEW = 'Aries - Akoya Type II Vinyl Wallcovering | Hollywood Wallcoverings';
const PRODUCT_Q = `query($id: ID!) { product(id:$id){ id legacyResourceId title status vendor
variants(first:20){ nodes{ id legacyResourceId title sku price position } } } }`;
// GUARD (DTD verdict): a Default-Title variant is UNSAFE to delete iff it is the
// position-1 / default-served variant, OR its variant id / inventory_item id is
// referenced anywhere in the local dw_unified mirror. Unsafe -> skip + flag for A'.
// Returns { safe, reasons[] }.
function mirrorReferences(legacyVariantId) {
// Scan the local mirror for any column holding this Shopify variant id.
// Uses information_schema to find text/bigint columns; cheap, read-only.
try {
const sql = `SELECT c.table_name, c.column_name
FROM information_schema.columns c
JOIN information_schema.tables t ON t.table_name=c.table_name AND t.table_schema='public'
WHERE t.table_type='BASE TABLE'
AND (c.column_name ILIKE '%variant%id%' OR c.column_name ILIKE '%variant_id%');`;
const cols = psql(sql).trim().split('\n').filter(Boolean).map(l => l.split('\t'));
for (const [tbl, col] of cols) {
const q = `SELECT 1 FROM "${tbl}" WHERE "${col}"::text LIKE '%${legacyVariantId}%' LIMIT 1;`;
let hit = '';
try { hit = psql(q).trim(); } catch (e) { continue; }
if (hit) return `${tbl}.${col}`;
}
} catch (e) { return `MIRROR-SCAN-ERROR:${e.message.slice(0,60)}`; }
return null;
}
function guardVariant(dtVariant) {
const reasons = [];
if (dtVariant.position === 1) reasons.push('position-1/default-served');
const ref = mirrorReferences(dtVariant.legacyResourceId);
if (ref) reasons.push(ref.startsWith('MIRROR-SCAN-ERROR') ? ref : `mirror-ref:${ref}`);
return { safe: reasons.length === 0, reasons };
}
const PRODUCT_UPDATE = `mutation($input: ProductInput!){ productUpdate(input:$input){
product{ id title } userErrors{ field message } } }`;
const VARIANTS_DELETE = `mutation($productId: ID!, $variantsIds: [ID!]!){
productVariantsBulkDelete(productId:$productId, variantsIds:$variantsIds){
product{ id variants(first:20){ nodes{ id title sku } } } userErrors{ field message } } }`;
function gateOrDie() {
if (!APPLY) return; // dry-run always allowed
if (APPROVAL !== 'TK-11248') { console.error('REFUSED: --apply requires --approval=TK-11248'); process.exit(3); }
if (!fs.existsSync(SENTINEL)) {
console.error(`REFUSED: approval sentinel missing: ${SENTINEL}`);
console.error(' Operator writes this file ONLY after Steve\'s explicit exact approval is logged on TK-11248.');
process.exit(3);
}
const s = JSON.parse(fs.readFileSync(SENTINEL, 'utf8'));
if (!s.token || process.env.RW_APPROVAL_TOKEN !== s.token) {
console.error('REFUSED: env RW_APPROVAL_TOKEN does not match the sentinel token.');
process.exit(3);
}
if (!s.approves || !s.approves.includes(STEP || 'both')) {
// sentinel should list which steps Steve approved, e.g. approves:["11238","10405"]
console.error(`REFUSED: sentinel does not approve step "${STEP || 'both'}". sentinel.approves=${JSON.stringify(s.approves)}`);
process.exit(3);
}
}
function ledger(entry) {
try { fs.appendFileSync(LEDGER, JSON.stringify(entry) + '\n'); } catch (e) { /* best-effort */ }
}
async function step11238() {
console.log('\n=== STEP 1 — TK-11238 Aries title correction ===');
const r = await gqlRetry(PRODUCT_Q, { id: ARIES_GID }, 'aries-pre');
const p = r.json.data.product;
console.log(` product ${p.legacyResourceId} status=${p.status} vendor="${p.vendor}"`);
console.log(` FROM: "${p.title}"`);
console.log(` TO : "${ARIES_NEW}"`);
if (p.title !== ARIES_OLD) { console.error(` DRIFT: live title != expected old title. Refusing.`); return { ok: false, drift: true }; }
if (p.status !== 'ACTIVE') { console.error(` DRIFT: product not ACTIVE. Refusing.`); return { ok: false, drift: true }; }
if (!APPLY) { console.log(' [dry-run] no write.'); return { ok: true, dry: true }; }
const w = await gqlRetry(PRODUCT_UPDATE, { input: { id: ARIES_GID, title: ARIES_NEW } }, 'aries-update');
const ue = w.json.data.productUpdate.userErrors;
if (ue && ue.length) { console.error(' userErrors:', JSON.stringify(ue)); return { ok: false }; }
const after = w.json.data.productUpdate.product.title;
console.log(` WROTE. live title now: "${after}"`);
ledger({ ts: new Date().toISOString(), agent: 'shopify-repairs', ticket: 'TK-11238', action: 'productUpdate title',
target: '7800009752627', blast_radius: 1, old: ARIES_OLD, new: ARIES_NEW,
undo_cmd: `node execute-TK11248.js rollback --step=11238 (restores old title from TK-11238-restore.json)`,
verify: `product 7800009752627 title == "${ARIES_NEW}"` });
return { ok: after === ARIES_NEW };
}
async function step10405() {
console.log('\n=== STEP 2 — TK-10405 delete 12 Default-Title variants ===');
const map = JSON.parse(fs.readFileSync(path.join(DATA, 'TK-10405-restore-map.json'), 'utf8'));
let done = 0;
for (const v of map.variants) {
const pre = await gqlRetry(PRODUCT_Q, { id: v.product_gid }, `rw-pre:${v.pid}`);
const pp = pre.json.data.product;
const vs = pp.variants.nodes;
const dt = vs.filter(x => x.title === 'Default Title');
const mural = vs.find(x => /Mural/.test(x.title));
const sampleSurvivor = vs.find(x => x.title === 'Sample' && (!dt[0] || x.id !== dt[0].id));
const okPre = dt.length === 1 && dt[0].id === v.delete_variant_gid && mural && sampleSurvivor && vs.length === 3;
console.log(` ${v.pid} ${v.label.padEnd(30)} del=${v.sku}@$${v.price} pre-ok=${okPre} (variants=${vs.length} dt=${dt.length})`);
if (!okPre) { console.error(` DRIFT on ${v.pid}: refusing this delete.`); continue; }
// GUARD: skip + flag if unsafe (position-1/default or mirror-referenced).
const g = guardVariant(dt[0]);
if (!g.safe) {
console.error(` GUARD-SKIP ${v.pid}: ${g.reasons.join(', ')} -> flag for A' (SKU-rename/deactivate), NOT deleted.`);
ledger({ ts: new Date().toISOString(), agent: 'shopify-repairs', ticket: 'TK-10405',
action: 'GUARD-SKIP (unsafe to delete)', target: v.pid, deleted_variant: null,
sku: v.sku, reasons: g.reasons, blast_radius: 0, undo_cmd: 'n/a (no write)',
verify: 'product retains 3 variants; route to A prime' });
continue;
}
console.log(` guard: SAFE (position=${dt[0].position}, no mirror refs)`);
if (!APPLY) { console.log(' [dry-run] no write.'); continue; }
const w = await gqlRetry(VARIANTS_DELETE, { productId: v.product_gid, variantsIds: [v.delete_variant_gid] }, `rw-del:${v.pid}`);
const ue = w.json.data.productVariantsBulkDelete.userErrors;
if (ue && ue.length) { console.error(` userErrors:`, JSON.stringify(ue)); continue; }
const post = w.json.data.productVariantsBulkDelete.product.variants.nodes;
const stillDT = post.some(x => x.title === 'Default Title');
const stillMural = post.some(x => /Mural/.test(x.title));
const stillSample = post.some(x => x.title === 'Sample');
const okPost = post.length === 2 && !stillDT && stillMural && stillSample;
console.log(` WROTE. now ${post.length} variants DT-gone=${!stillDT} mural=${stillMural} sample=${stillSample} post-ok=${okPost}`);
ledger({ ts: new Date().toISOString(), agent: 'shopify-repairs', ticket: 'TK-10405',
action: 'productVariantsBulkDelete Default-Title', target: v.pid, deleted_variant: v.delete_variant_gid,
sku: v.sku, price: v.price, blast_radius: 1,
undo_cmd: `productVariantsBulkCreate from TK-10405-restore-map.json entry pid=${v.pid}`,
verify: `product ${v.pid} has 2 variants (Mural + Sample), no Default Title` });
if (okPost) done++;
await sleep(400);
}
console.log(` deleted ${done}/12`);
return { ok: !APPLY || done === 12 };
}
async function main() {
console.log(`\n### execute-TK11248 APPLY=${APPLY} STEP=${STEP || 'both'} store=${DOMAIN}`);
gateOrDie();
if (!APPLY) console.log('MODE: DRY-RUN (no writes). Pass --apply --approval=TK-11248 + sentinel to fire.');
let r1 = { ok: true, skipped: true }, r2 = { ok: true, skipped: true };
if (!STEP || STEP === '11238') r1 = await step11238();
// serialize: only proceed to deletes if title step did not hit drift/error under --apply
if (!STEP || STEP === '10405') {
if (APPLY && !STEP && !r1.ok) { console.error('\nABORT: step 1 failed under --apply; not proceeding to step 2.'); process.exit(4); }
r2 = await step10405();
}
console.log(`\nRESULT: step1_ok=${r1.ok} step2_ok=${r2.ok}`);
console.log(APPLY ? 'LIVE run complete.' : 'DRY-RUN complete. NO WRITES FIRED.');
}
main().catch(e => { console.error('FATAL', e); process.exit(2); });