← back to Dw Contact Us Pages
scripts/restore-map.mjs
101 lines
#!/usr/bin/env node
// restore-map.mjs — freeze + check a cohort's RESTORE MAP. TK-11669. READ ONLY against Shopify.
//
// node scripts/restore-map.mjs --cohort maharam --freeze
// Writes <data dir>/restore-map-<ts>.json from the current targets.json (the full live preimage
// enumerate.mjs just captured): per product templateSuffix, tags, per-channel isPublished, and per
// variant price / inventoryPolicy / tracked / per-location on_hand. Also points restore-map.json at it.
// Run BEFORE any --apply. Writes only local files.
//
// node scripts/restore-map.mjs --cohort maharam --check [--map <file>] [--fields suffix,policy,tracked,qty,channels]
// Re-reads LIVE Shopify for every product in the map and reports every field that differs from the
// map. Pre-flip: expect 0 diffs (proves the checker reads what the map holds). Post-flip: expect
// diffs on exactly the fields the flip changes. Post-ROLLBACK: expect 0 diffs again — that is the
// rollback-completeness proof. Exit 0 = no diffs, 1 = diffs, 2 = NOT-MEASURED (read failures).
//
// --inject-fault (with --check) flips one field of the in-memory map before comparing, so the checker
// MUST report exactly one diff — the negative test proving it can go red. Never written to disk.
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
import { join, basename } from 'node:path';
import { DATA_DIR, COHORT, TICKET, parseArgs, gql, loadTargets, TARGET_PUBLICATIONS, chunk } from './lib.mjs';
const a = parseArgs();
if (!COHORT) { console.error('REFUSED: pass --cohort <name>'); process.exit(2); }
const CUR = join(DATA_DIR, 'restore-map.json');
const CH = new Map(TARGET_PUBLICATIONS.map((p) => [p.id, p.name]));
const onHand = (l) => (l.quantities || []).find((q) => q.name === 'on_hand')?.quantity ?? null;
if (a.freeze) {
const t = JSON.parse(readFileSync(join(DATA_DIR, 'targets.json'), 'utf8'));
const map = {
ticket: TICKET, cohort: COHORT, frozen_at: new Date().toISOString(), source_captured_at: t.captured_at,
channels_in_scope: TARGET_PUBLICATIONS, count: t.products.length,
products: t.products.map((p) => ({
id: p.id, handle: p.handle, vendor: p.vendor, templateSuffix: p.templateSuffix, tags: p.tags,
channels: Object.fromEntries(p.resourcePublicationsV2.filter((r) => CH.has(r.publication.id)).map((r) => [r.publication.id, r.isPublished])),
online_store_published: !!p.resourcePublicationsV2.find((r) => r.publication.name === 'Online Store')?.isPublished,
variants: p.variants.map((v) => ({
id: v.id, sku: v.sku, title: v.title, price: v.price, isSample: v.isSample,
inventoryPolicy: v.inventoryPolicy, tracked: v.inventoryItem.tracked, inventoryItemId: v.inventoryItem.id,
levels: v.inventoryItem.inventoryLevels.map((l) => ({ locationId: l.location.id, on_hand: onHand(l) })),
})),
})),
};
const f = join(DATA_DIR, `restore-map-${map.frozen_at.replace(/[:.]/g, '-')}.json`);
writeFileSync(f, JSON.stringify(map, null, 1));
copyFileSync(f, CUR);
console.log(`froze ${map.count} products -> ${f}\n (restore-map.json -> ${basename(f)})`);
process.exit(0);
}
if (!a.check) { console.error('pass --freeze or --check'); process.exit(2); }
const mapFile = a.map || CUR;
if (!existsSync(mapFile)) { console.error(`NOT-MEASURED: no restore map at ${mapFile} — run --freeze first`); process.exit(2); }
const map = JSON.parse(readFileSync(mapFile, 'utf8'));
const fields = new Set(String(a.fields || 'suffix,policy,tracked,qty,channels,price').split(','));
if (a['inject-fault']) {
const p = map.products[0];
p.templateSuffix = p.templateSuffix === 'INJECTED-FAULT' ? null : 'INJECTED-FAULT';
console.log(`--inject-fault: in-memory map product ${p.handle} templateSuffix -> ${JSON.stringify(p.templateSuffix)} (expect exactly 1 diff)`);
}
const Q = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id handle templateSuffix
resourcePublicationsV2(first:30){ nodes{ isPublished publication{ id } } }
variants(first:100){ nodes{ id price inventoryPolicy inventoryItem{ tracked inventoryLevels(first:5){ nodes{ location{ id } quantities(names:["on_hand"]){ name quantity } } } } } } } } }`;
const diffs = []; let read = 0, unreadable = 0;
for (const batch of chunk(map.products, 25)) {
let d; try { d = await gql(Q, { ids: batch.map((p) => p.id) }); } catch (e) { unreadable += batch.length; continue; }
d.nodes.forEach((live, i) => {
const want = batch[i];
if (!live) { unreadable++; diffs.push({ handle: want.handle, field: 'product', want: 'exists', got: null }); return; }
read++;
const norm = (s) => (s == null ? '' : s); // Shopify's write API cannot set null; '' and null are behaviourally identical
if (fields.has('suffix') && norm(live.templateSuffix) !== norm(want.templateSuffix)) diffs.push({ handle: want.handle, field: 'templateSuffix', want: want.templateSuffix, got: live.templateSuffix });
if (fields.has('channels')) {
const pubs = new Map(live.resourcePublicationsV2.nodes.map((r) => [r.publication.id, r.isPublished]));
for (const [pid, was] of Object.entries(want.channels)) if (!!pubs.get(pid) !== !!was) diffs.push({ handle: want.handle, field: `channel:${CH.get(pid)}`, want: was, got: !!pubs.get(pid) });
}
const lv = new Map(live.variants.nodes.map((v) => [v.id, v]));
for (const wv of want.variants) {
const v = lv.get(wv.id);
if (!v) { diffs.push({ handle: want.handle, field: `variant ${wv.sku}`, want: 'exists', got: null }); continue; }
if (fields.has('price') && Number(v.price) !== Number(wv.price)) diffs.push({ handle: want.handle, field: `price ${wv.sku}`, want: wv.price, got: v.price });
if (fields.has('policy') && v.inventoryPolicy !== wv.inventoryPolicy) diffs.push({ handle: want.handle, field: `policy ${wv.sku}`, want: wv.inventoryPolicy, got: v.inventoryPolicy });
if (fields.has('tracked') && v.inventoryItem.tracked !== wv.tracked) diffs.push({ handle: want.handle, field: `tracked ${wv.sku}`, want: wv.tracked, got: v.inventoryItem.tracked });
if (fields.has('qty')) {
const got = new Map(v.inventoryItem.inventoryLevels.nodes.map((l) => [l.location.id, onHand(l)]));
for (const l of wv.levels) if ((got.get(l.locationId) ?? null) !== l.on_hand) diffs.push({ handle: want.handle, field: `on_hand ${wv.sku}`, want: l.on_hand, got: got.get(l.locationId) ?? null });
}
}
});
}
const byField = {};
for (const d of diffs) { const k = d.field.split(' ')[0]; byField[k] = (byField[k] || 0) + 1; }
const out = { ts: new Date().toISOString(), map: basename(mapFile), population: map.products.length, read, unreadable, diff_count: diffs.length, by_field: byField, sample: diffs.slice(0, 15) };
writeFileSync(join(DATA_DIR, a['inject-fault'] ? 'restore-map-check.negtest.json' : 'restore-map-check-latest.json'), JSON.stringify({ ...out, diffs }, null, 1));
console.log(JSON.stringify(out, null, 2));
if (unreadable || read !== map.products.length) { console.log(`VERDICT: NOT-MEASURED (${read} of ${map.products.length} read)`); process.exit(2); }
console.log(diffs.length ? `VERDICT: ${diffs.length} DIFF(S) vs restore map` : 'VERDICT: live == restore map (0 diffs)');
process.exit(diffs.length ? 1 : 0);