← back to Kravet Wrongjoin Reprice 2026 07
apply-reprice.js
132 lines
#!/usr/bin/env node
/**
* apply-reprice.js — approved 2026-07-15 ("approve all", full 425)
* Repricing the wrong-join DWKK rows to true MAP per
* 2026-07-15-dwkk-wrongjoin-map-reprice.md.
*
* Guards (all from the approved memo):
* - product must be ACTIVE and its global.Item must equal the CSV true_mfr_sku
* - main (non-Sample) variant live price must equal CSV wrong_map_likely_live (±1¢)
* → drifted rows logged + skipped, never blind-written
* - rows already at correct_price skip (idempotent)
* - Sample variants and product status never touched
* - also refreshes global.MAP metafield to correct_price (contrarian addendum #2)
* - 50 products/batch, ≥90s gap between batches, throttle-aware retry
*
* Log: run-log-<ts>.jsonl in this repo. DRY_RUN=1 for a no-write pass.
*/
const https = require('https');
const fs = require('fs');
const os = require('os');
const path = require('path');
const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
// SAFE-BY-DEFAULT (2026-07-26, post gate-bypass audit): dry unless caller sets APPLY_CONFIRM=YES.
// Prevents any automated/accidental invocation from writing live prices (the 07-15 + 07-26 bypass vector).
const DRY = process.env.APPLY_CONFIRM !== 'YES';
const sleep = ms => new Promise(r => setTimeout(r, ms));
function gql(query, variables = {}) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ query, variables });
const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('bad json: ' + d.slice(0, 200))); } }); });
req.on('error', reject); req.write(body); req.end();
});
}
async function gqlR(q, v, tries = 5) {
for (let i = 0; i < tries; i++) {
let r; try { r = await gql(q, v); } catch (e) { r = { errors: [{ message: String(e).slice(0, 200) }] }; }
if (r && !r.errors) return r;
const throttled = JSON.stringify(r.errors || []).match(/throttl/i);
if (!throttled && i >= 1) return r;
await sleep(2000 * (i + 1));
}
return gql(q, v);
}
function splitCsvLine(line) {
const out = []; let cur = ''; let inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQ) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; } else cur += ch; }
else if (ch === '"') inQ = true;
else if (ch === ',') { out.push(cur); cur = ''; }
else cur += ch;
}
out.push(cur); return out;
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id status
variants(first:30){nodes{id title sku price}}
metafield(namespace:"global",key:"Item"){value}}}`;
const M_PRICE = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkUpdate(productId:$pid,variants:$variants){
productVariants{id price} userErrors{field message}}}`;
const M_MAP_NOTYPE = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}`;
(async () => {
const csv = fs.readFileSync(path.join(__dirname, 'affected-dwkk-wrongjoin.csv'), 'utf8').trim().split('\n');
const rows = csv.slice(1).map(splitCsvLine).map(v => ({
handle: v[0], gid: v[1], dw_sku: v[2], title: v[3],
wrong_sku: v[4], true_sku: v[5],
wrong_price: parseFloat(v[6]), correct_price: parseFloat(v[8]),
}));
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const logPath = path.join(__dirname, `run-log-${ts}.jsonl`);
const log = o => fs.appendFileSync(logPath, JSON.stringify(o) + '\n');
const near = (a, b) => Math.abs(a - b) <= 0.011;
let updated = 0, skipAlready = 0, skipDrift = 0, skipGuard = 0, errors = 0, mfOk = 0, mfErr = 0;
console.log(`${DRY ? 'DRY RUN' : 'LIVE'} — ${rows.length} rows → ${logPath}`);
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (!DRY && i > 0 && i % 50 === 0) { console.log(`batch gap after ${i} (updated ${updated})…`); await sleep(90000); }
const res = await gqlR(Q_PRODUCT, { id: r.gid });
const p = res && res.data && res.data.product;
if (!p) { errors++; log({ ...r, action: 'error', note: 'product fetch failed', raw: JSON.stringify(res && res.errors).slice(0, 200) }); continue; }
if (p.status !== 'ACTIVE') { skipGuard++; log({ dw_sku: r.dw_sku, action: 'skip_guard', note: `status ${p.status}` }); continue; }
const item = p.metafield && p.metafield.value;
if (item !== r.true_sku) { skipGuard++; log({ dw_sku: r.dw_sku, action: 'skip_guard', note: `global.Item "${item}" != CSV true_sku "${r.true_sku}"` }); continue; }
const main = p.variants.nodes.find(v => !/sample/i.test(v.title || '') && !/sample/i.test(v.sku || ''));
if (!main) { skipGuard++; log({ dw_sku: r.dw_sku, action: 'skip_guard', note: 'no main variant' }); continue; }
const live = parseFloat(main.price);
if (near(live, r.correct_price)) { skipAlready++; log({ dw_sku: r.dw_sku, action: 'skip_already', live }); continue; }
if (!near(live, r.wrong_price)) { skipDrift++; log({ dw_sku: r.dw_sku, action: 'skip_drift', live, expected_wrong: r.wrong_price, correct: r.correct_price }); continue; }
if (DRY) { updated++; log({ dw_sku: r.dw_sku, action: 'would_update', from: live, to: r.correct_price }); await sleep(150); continue; }
const w = await gqlR(M_PRICE, { pid: p.id, variants: [{ id: main.id, price: r.correct_price.toFixed(2) }] });
const bup = w && w.data && w.data.productVariantsBulkUpdate;
const ue = bup && bup.userErrors;
const got = bup && bup.productVariants;
if ((ue && ue.length) || !got || !got.length) {
errors++; log({ dw_sku: r.dw_sku, action: 'error', note: 'price write failed', ue: JSON.stringify(ue).slice(0, 200) }); continue;
}
updated++;
log({ dw_sku: r.dw_sku, handle: r.handle, action: 'updated', from: live, to: parseFloat(got[0].price), variant: main.id });
// metafield refresh — existing metafields keep their type when omitted; fall back to explicit type
let m = await gqlR(M_MAP_NOTYPE, { mf: [{ ownerId: p.id, namespace: 'global', key: 'MAP', value: String(r.correct_price) }] });
let mue = m && m.data && m.data.metafieldsSet && m.data.metafieldsSet.userErrors;
if (mue && mue.length) {
m = await gqlR(M_MAP_NOTYPE, { mf: [{ ownerId: p.id, namespace: 'global', key: 'MAP', value: String(r.correct_price), type: 'single_line_text_field' }] });
mue = m && m.data && m.data.metafieldsSet && m.data.metafieldsSet.userErrors;
}
if (mue && mue.length) { mfErr++; log({ dw_sku: r.dw_sku, action: 'map_metafield_error', ue: JSON.stringify(mue).slice(0, 200) }); }
else mfOk++;
await sleep(350);
}
const summary = { total: rows.length, updated, skipAlready, skipDrift, skipGuard, errors, mapMetafieldOk: mfOk, mapMetafieldErr: mfErr, dry: DRY, log: logPath };
log({ action: 'SUMMARY', ...summary });
console.log('SUMMARY ' + JSON.stringify(summary));
})();