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