← back to Tk10630 Sku Suffix Canary
apply.mjs
100 lines
// Apply the Hollywood/Momentum SKU-consolidation plan to the LIVE store.
// DRY-RUN by default. Pass --apply to write. Resumable via done.jsonl.
//
// Per product (SERIAL within product — never parallelize same-handle writes):
// 1) inventoryItemUpdate for each variant SKU change
// 2) one metafieldsSet for both dw_sku metafields
// Fan out ACROSS products with a small worker pool (rate-limit safe).
import { gql } from './shopify.mjs';
import { FABRICATED } from './lib.mjs';
import { readFileSync, appendFileSync, existsSync } from 'node:fs';
const APPLY = process.argv.includes('--apply');
const LIMIT = Number((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || Infinity);
const CONC = Number((process.argv.find(a => a.startsWith('--conc=')) || '').split('=')[1] || 5);
const ONLY = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1] || null;
const BASEPFX = (process.argv.find(a => a.startsWith('--basePrefix=')) || '').split('=')[1] || null;
const MF_ONLY = process.argv.includes('--mf-only'); // write metafields only (needs write_products)
const VAR_ONLY = process.argv.includes('--var-only'); // write variant SKUs only (needs write_inventory)
const PHASE = MF_ONLY ? 'mf' : VAR_ONLY ? 'var' : 'all';
const DONE = `done-${PHASE}.jsonl`;
const { plan } = JSON.parse(readFileSync('plan.json', 'utf8'));
// --- Guards ---------------------------------------------------------------
// 1) Refuse any target base that is itself a fabricated code (should never happen).
// 2) Refuse to write a base claimed by >1 product (cross-product SKU collision).
const baseOwners = new Map();
for (const p of plan) (baseOwners.get(p.base) || baseOwners.set(p.base, []).get(p.base)).push(p.handle);
const collisions = [...baseOwners].filter(([, hs]) => hs.length > 1);
const badBase = plan.filter(p => FABRICATED.test(p.base));
// Resume: skip products already done.
const done = new Set();
if (existsSync(DONE)) for (const l of readFileSync(DONE, 'utf8').split('\n')) { if (l.trim()) done.add(JSON.parse(l).id); }
let work = plan.filter(p => !done.has(p.id) && !FABRICATED.test(p.base) && !collisions.find(([b]) => b === p.base));
if (ONLY) work = work.filter(p => p.handle.includes(ONLY));
if (BASEPFX) work = work.filter(p => p.base.startsWith(BASEPFX));
if (work.length > LIMIT) work = work.slice(0, LIMIT);
console.log(`[apply] mode=${APPLY ? 'LIVE-WRITE' : 'DRY-RUN'} plan=${plan.length} todo=${work.length} done=${done.size} conc=${CONC}`);
if (collisions.length) console.log(`[apply] ⚠ ${collisions.length} base-collisions SKIPPED (safety): ${collisions.slice(0,5).map(([b,h])=>b+'×'+h.length).join(', ')}`);
if (badBase.length) console.log(`[apply] ⚠ ${badBase.length} products have a fabricated base — SKIPPED`);
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function mut(query, variables) {
for (let attempt = 0; ; attempt++) {
try {
const { data } = await gql(query, variables);
return data;
} catch (e) {
if (/THROTTLED|throttle/i.test(e.message) && attempt < 8) { await sleep(1500 * (attempt + 1)); continue; }
throw e;
}
}
}
const INV = `mutation($id:ID!,$sku:String!){ inventoryItemUpdate(id:$id, input:{sku:$sku}){ inventoryItem{ id sku } userErrors{ field message } } }`;
const MFS = `mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ metafields{ id } userErrors{ field message } } }`;
async function applyOne(p) {
const errs = [];
if (!MF_ONLY) {
for (const c of p.varChanges) {
if (!APPLY) continue;
try {
const d = await mut(INV, { id: c.invItemId, sku: c.to });
const ue = d.inventoryItemUpdate.userErrors;
if (ue.length) errs.push(`var ${c.to}: ${JSON.stringify(ue)}`);
} catch (e) { errs.push(`var ${c.to}: ${e.message.slice(0, 120)}`); }
}
}
if (!VAR_ONLY && p.mfChanges.length) {
const m = p.mfChanges.map(c => ({ ownerId: c.ownerId, namespace: c.namespace, key: c.key, type: 'single_line_text_field', value: c.to }));
if (APPLY) {
try {
const d = await mut(MFS, { m });
const ue = d.metafieldsSet.userErrors;
if (ue.length) errs.push(`mf: ${JSON.stringify(ue)}`);
} catch (e) { errs.push(`mf: ${e.message.slice(0, 120)}`); }
}
}
const rec = { id: p.id, handle: p.handle, base: p.base, vars: p.varChanges.length, mfs: p.mfChanges.length, errs, at: process.hrtime.bigint().toString() };
if (APPLY && errs.length === 0) appendFileSync(DONE, JSON.stringify(rec) + '\n'); // only checkpoint clean successes
return rec;
}
// Worker pool over products.
let idx = 0, ok = 0, err = 0;
async function worker(wid) {
while (idx < work.length) {
const p = work[idx++];
try { const r = await applyOne(p); if (r.errs.length) { err++; console.log(` ✗ ${p.handle} → ${p.base}: ${r.errs.join('; ')}`); } else { ok++; } }
catch (e) { err++; console.log(` ✗ ${p.handle}: ${e.message}`); }
if ((ok + err) % 50 === 0) process.stderr.write(` progress ${ok + err}/${work.length} (ok=${ok} err=${err})\n`);
}
}
await Promise.all(Array.from({ length: Math.min(CONC, work.length) }, (_, i) => worker(i)));
console.log(`[apply] DONE ok=${ok} err=${err} ${APPLY ? '(written)' : '(dry-run — no writes)'}`);