← back to Dw Kravet Hires
scripts/cleanup-dupe-media.mjs
207 lines
#!/usr/bin/env node
/**
* cleanup-dupe-media.mjs — TK-12097
*
* Removes the REDUNDANT duplicate hi-res media created when two apply-hires
* runs processed Batch A concurrently (both read the same pre-swap state and
* each uploaded its own MediaImage from the identical source URL).
*
* For each product in the manifest it does a LIVE media read and deletes only
* the one uploaded copy that is NOT currently featured. Ledger order is never
* trusted to decide which id to delete — misreading a ledger is what produced
* this cleanup in the first place.
*
* Dry-run by default. --live is required to delete anything.
*
* node scripts/cleanup-dupe-media.mjs --manifest data/tk12097/tk12097-batchA-duplicates.json
* node scripts/cleanup-dupe-media.mjs --manifest ... --live
* node scripts/cleanup-dupe-media.mjs --rollback --ledger data/tk12097/dupe-cleanup-ledger.jsonl --live
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
const SHOP = 'designer-laboratory-sandbox';
const API = '2024-10';
const argv = process.argv.slice(2);
const has = (f) => argv.includes(f);
const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
const LIVE = has('--live');
const ROLLBACK = has('--rollback');
const TICKET = val('--ticket', 'TK-12097');
const AGENT = process.env.TK_AGENT || 'yoloforever-master';
const LIMIT = parseInt(val('--limit', '0'), 10);
const GAP_MS = parseInt(val('--gap-ms', '700'), 10);
const HERE = path.dirname(new URL(import.meta.url).pathname);
const PROJ = path.resolve(HERE, '..');
const MANIFEST = path.resolve(PROJ, val('--manifest', 'data/tk12097/tk12097-batchA-duplicates.json'));
const LEDGER = path.resolve(PROJ, val('--ledger', 'data/tk12097/dupe-cleanup-ledger.jsonl'));
const EXEC_LEDGER = path.join(os.homedir(), '.claude/yolo-queue/executed-reversible/ledger.jsonl');
function shopTok() {
const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
const admin = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
const full = (env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m) || [])[1];
const tok = admin || full;
if (!tok) { console.error('FATAL: no SHOPIFY_ADMIN_TOKEN / SHOPIFY_FULL_ACCESS_TOKEN in secrets .env'); process.exit(2); }
return tok.trim();
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const MAX_RETRIES = parseInt(val('--max-retries', '6'), 10);
async function shopify(query, variables, attempt = 0) {
const r = await fetch(`https://${SHOP}.myshopify.com/admin/api/${API}/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': shopTok(), 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(60000),
});
if (r.status === 429) return backoff('HTTP 429', query, variables, attempt, r.headers.get('retry-after'), null);
const j = await r.json();
if (j.errors) {
const throttled = Array.isArray(j.errors) &&
j.errors.some((e) => e?.extensions?.code === 'THROTTLED' || /throttl/i.test(e?.message || ''));
if (throttled) return backoff('THROTTLED', query, variables, attempt, null, j.extensions?.cost?.throttleStatus);
throw new Error('shopify gql: ' + JSON.stringify(j.errors));
}
return j.data;
}
async function backoff(reason, query, variables, attempt, retryAfter, throttleStatus) {
if (attempt >= MAX_RETRIES) throw new Error(`shopify gql: ${reason} — exhausted ${MAX_RETRIES} retries`);
let waitMs = 0;
if (throttleStatus?.currentlyAvailable != null && throttleStatus.restoreRate) {
waitMs = Math.ceil((Math.max(0, 50 - throttleStatus.currentlyAvailable) / throttleStatus.restoreRate) * 1000);
} else if (retryAfter) waitMs = parseFloat(retryAfter) * 1000 || 0;
waitMs = Math.max(waitMs, Math.min(30000, 1000 * 2 ** attempt)) + Math.floor(Math.random() * 500);
console.log(` … ${reason}, backing off ${Math.round(waitMs)}ms (retry ${attempt + 1}/${MAX_RETRIES})`);
await sleep(waitMs);
return shopify(query, variables, attempt + 1);
}
const Q_MEDIA = `query($id: ID!) {
product(id: $id) {
id title
media(first: 50) { edges { node { id ... on MediaImage { image { url width height } } } } }
}
}`;
const M_DELETE = `mutation($productId: ID!, $mediaIds: [ID!]!) {
productDeleteMedia(productId: $productId, mediaIds: $mediaIds) {
deletedMediaIds
mediaUserErrors { field message }
}
}`;
function appendLedger(file, obj) {
const fd = fs.openSync(file, 'a');
try { fs.writeSync(fd, JSON.stringify(obj) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
}
/**
* Decide what to delete for one product, from LIVE state only.
* Returns {action:'delete', id} | {action:'skip', why} — never guesses.
*/
export function decide(liveMediaIds, featuredId, candidateIds) {
const present = candidateIds.filter((id) => liveMediaIds.includes(id));
if (present.length === 0) return { action: 'skip', why: 'neither uploaded copy present (already cleaned)' };
if (present.length === 1) return { action: 'skip', why: 'only one copy present — nothing redundant' };
if (!present.includes(featuredId)) {
// Refuse: featured is something we did not upload. Deleting here could
// remove the only hi-res, or touch media this run never created.
return { action: 'skip', why: `featured ${featuredId} is not one of the uploaded copies — REFUSING` };
}
const redundant = present.filter((id) => id !== featuredId);
if (redundant.length !== 1) return { action: 'skip', why: `expected exactly 1 redundant, got ${redundant.length}` };
return { action: 'delete', id: redundant[0] };
}
async function main() {
if (ROLLBACK) return rollback();
const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
const rows = LIMIT > 0 ? manifest.slice(0, LIMIT) : manifest;
console.log(`${LIVE ? 'LIVE' : 'DRY-RUN'} — ${rows.length} products from ${path.basename(MANIFEST)}`);
if (!LIVE) console.log('(no deletes will be made; pass --live to execute)\n');
let del = 0, skip = 0, err = 0;
for (const [i, row] of rows.entries()) {
try {
const d = await shopify(Q_MEDIA, { id: row.shopify_id });
if (!d?.product) { console.log(` ?? ${row.mfr_sku} — product not found`); err++; continue; }
const edges = d.product.media.edges;
const liveIds = edges.map((e) => e.node.id);
const featuredId = edges[0]?.node?.id;
const verdict = decide(liveIds, featuredId, row.media_ids);
if (verdict.action === 'skip') { console.log(` -- ${row.mfr_sku} skip: ${verdict.why}`); skip++; continue; }
// Safety: the retained low-res anchor must still be on the product.
if (row.old_media_id && !liveIds.includes(row.old_media_id)) {
console.log(` -- ${row.mfr_sku} skip: rollback anchor ${row.old_media_id} missing — REFUSING`); skip++; continue;
}
if (!LIVE) { console.log(` ~~ ${row.mfr_sku} would delete ${verdict.id} (featured ${featuredId} kept)`); del++; continue; }
const res = await shopify(M_DELETE, { productId: row.shopify_id, mediaIds: [verdict.id] });
const errs = res?.productDeleteMedia?.mediaUserErrors || [];
if (errs.length) { console.log(` !! ${row.mfr_sku} — ${JSON.stringify(errs)}`); err++; continue; }
const deleted = res?.productDeleteMedia?.deletedMediaIds || [];
if (!deleted.includes(verdict.id)) { console.log(` !! ${row.mfr_sku} — API did not confirm deletion`); err++; continue; }
appendLedger(LEDGER, {
ts: new Date().toISOString(), ticket: TICKET, agent: AGENT,
shopify_id: row.shopify_id, mfr_sku: row.mfr_sku,
deleted_media_id: verdict.id, kept_featured_id: featuredId,
old_media_id: row.old_media_id, src: row.src,
undo: `re-upload ${row.src} via stagedUploadsCreate + productCreateMedia on ${row.shopify_id}`,
});
console.log(` ✓ ${row.mfr_sku} deleted ${verdict.id} (featured ${featuredId} kept)`);
del++;
} catch (e) {
console.log(` !! ${row.mfr_sku} — ${e.message}`); err++;
}
if ((i + 1) % 25 === 0) console.log(` [${i + 1}/${rows.length}]`);
await sleep(GAP_MS);
}
console.log(`\n${LIVE ? 'deleted' : 'would delete'}: ${del} | skipped: ${skip} | errors: ${err}`);
if (LIVE && del > 0) {
appendLedger(EXEC_LEDGER, {
ts: new Date().toISOString(), agent: AGENT, ticket: TICKET,
action: `Removed ${del} redundant duplicate hi-res media created by concurrent Batch-A apply runs`,
blast_radius: del,
undo_cmd: `node scripts/cleanup-dupe-media.mjs --rollback --ledger ${path.relative(PROJ, LEDGER)} --live`,
verify: `re-run without --live; every row should report "only one copy present"`,
});
}
}
async function rollback() {
if (!fs.existsSync(LEDGER)) { console.error(`no ledger at ${LEDGER}`); process.exit(2); }
const rows = fs.readFileSync(LEDGER, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse)
.filter((r) => r.ticket === TICKET);
console.log(`${LIVE ? 'LIVE' : 'DRY-RUN'} rollback — ${rows.length} deleted media to re-upload`);
for (const r of rows) {
console.log(` ${LIVE ? 're-upload' : 'would re-upload'} ${r.src} -> ${r.mfr_sku} (${r.shopify_id})`);
if (!LIVE) continue;
const d = await shopify(`mutation($productId: ID!, $media: [CreateMediaInput!]!) {
productCreateMedia(productId: $productId, media: $media) {
media { ... on MediaImage { id } } mediaUserErrors { field message } } }`,
{ productId: r.shopify_id, media: [{ originalSource: r.src, mediaContentType: 'IMAGE', alt: r.mfr_sku }] });
const errs = d?.productCreateMedia?.mediaUserErrors || [];
console.log(errs.length ? ` !! ${JSON.stringify(errs)}` : ` ✓ restored`);
await sleep(GAP_MS);
}
}
// Only run when invoked directly. Importing this module (e.g. from the test
// file) must never execute a delete-capable main — an import is not a request
// to act on the live store.
const invokedDirectly = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(new URL(import.meta.url).pathname);
if (invokedDirectly) main().catch((e) => { console.error(e); process.exit(1); });