← back to Rebel Walls Push
scripts/gen-rollback-ledger.js
121 lines
#!/usr/bin/env node
/**
* TK-10029: Generate a fresh, COMPLETE pre-apply rollback ledger from current live
* Shopify state, mirroring relabel-units.js's exact rename decision (canonical
* "Mural (per m²)"). Captures old_value -> new_value + undo for EVERY product that
* --apply will rename, so 100% of the apply is reversible. Read-only (no writes).
* Output: data/relabel-rollback-map-<ISO>.json (same schema as prior durable ledger)
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const SECRETS_ENV = '/Users/macstudio3/Projects/secrets-manager/.env';
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API_VERSION = '2024-10';
const CORRECT_LABEL = 'Mural (per m²)';
function getToken() {
const env = fs.readFileSync(SECRETS_ENV, 'utf8');
const m = env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m);
if (!m) throw new Error('SHOPIFY_FULL_ACCESS_TOKEN not found');
return m[1].trim();
}
const TOKEN = getToken();
const WRONG_MURAL_LABELS = new Set([
'roll', 'single roll', 'sold per roll', 'complete mural',
'sold per bolt (20.5in x 33ft)', 'default title',
'sold per square meter', 'sold per square metre',
]);
function isWrongMuralLabel(value) {
const lower = value.toLowerCase();
if (WRONG_MURAL_LABELS.has(lower)) return true;
if (lower.startsWith('mural (per m') && value !== CORRECT_LABEL) return true;
return false;
}
function gql(query, vars) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ query, variables: vars || {} });
const req = https.request({
hostname: DOMAIN, path: `/admin/api/${API_VERSION}/graphql.json`, method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>{try{resolve(JSON.parse(d))}catch(e){reject(e)}}); });
req.on('error', reject); req.write(body); req.end();
});
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
function isSampleProduct(product) {
const t = product.title.toLowerCase();
return t.includes('memo sample') || t.includes(' sample') || t.endsWith('sample');
}
async function fetchAll() {
const products = []; let cursor = null;
while (true) {
const q = `query($after: String) {
products(first: 250, query: "vendor:Rebel Walls", after: $after) {
pageInfo { hasNextPage endCursor }
edges { node { id title options { id name values }
variants(first: 10) { edges { node { id sku title selectedOptions { name value } } } } } }
}
}`;
const r = await gql(q, { after: cursor });
const page = r.data.products;
for (const e of page.edges) products.push(e.node);
if (!page.pageInfo.hasNextPage) break;
cursor = page.pageInfo.endCursor; await sleep(300);
}
return products;
}
(async () => {
const all = await fetchAll();
const renames = [];
const review_orphans = [];
for (const product of all) {
if (isSampleProduct(product)) continue;
// does any variant carry a wrong mural label?
const hasWrong = product.variants.edges.map(e=>e.node)
.some(v => v.selectedOptions.some(o => isWrongMuralLabel(o.value)));
if (!hasWrong) continue;
const optionToFix = product.options.find(o => o.values.some(v => isWrongMuralLabel(v)))
|| product.options.find(o => o.name === 'Size' || o.name === 'Title');
if (!optionToFix) continue;
const wrongValues = optionToFix.values.filter(v => isWrongMuralLabel(v));
if (!wrongValues.length) continue;
const canonicalAlreadyPresent = optionToFix.values.includes(CORRECT_LABEL);
const nonDefault = wrongValues.filter(v => v.toLowerCase() !== 'default title');
const primary = nonDefault[0] || wrongValues[0];
const toRename = canonicalAlreadyPresent ? [] : [primary];
const toReview = canonicalAlreadyPresent ? wrongValues : wrongValues.filter(v => v !== primary);
if (!toRename.length) {
review_orphans.push({ productId: product.id, title: product.title,
optionId: optionToFix.id, optionName: optionToFix.name, redundant_values: toReview });
continue;
}
renames.push({
productId: product.id, title: product.title,
optionId: optionToFix.id, optionName: optionToFix.name,
old_value: primary, new_value: CORRECT_LABEL,
review_leftover: toReview,
undo: `rename option value "${CORRECT_LABEL}" back to "${primary}" on option ${optionToFix.id}`,
});
}
const out = {
ticket: 'TK-10029',
generated: new Date().toISOString(),
canonical: CORRECT_LABEL,
rename_count: renames.length,
review_count: review_orphans.length,
renames, review_orphans,
};
const fn = path.join(__dirname, '..', 'data', `relabel-rollback-map-${out.generated.replace(/[:.]/g,'-')}.json`);
fs.writeFileSync(fn, JSON.stringify(out, null, 2));
console.log(`[gen-rollback] wrote ${fn}`);
console.log(`[gen-rollback] renames=${renames.length} review_orphans=${review_orphans.length}`);
})().catch(e => { console.error('[gen-rollback] FATAL', e.message); process.exit(1); });