← back to Hollywood Optc
TK-10634 rollback: compare-and-swap, dry-run ledger gate, per-record receipts + read-back
00618f1c3de945db8667fe4918289661654e2af9 · 2026-09-24 15:26:44 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTb6zN3GEtWz4ML6e7S4ox
Files touched
R100 data/tk10634-reversibility-2026-08-28T07-39-18-244Z.jsonl data/tk10634-reversibility-2026-08-28T07-39-18-244Z.DRYRUN.jsonlA rollback-tk10634.mjs
Diff
commit 00618f1c3de945db8667fe4918289661654e2af9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 15:26:44 2026 -0700
TK-10634 rollback: compare-and-swap, dry-run ledger gate, per-record receipts + read-back
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTb6zN3GEtWz4ML6e7S4ox
---
...sibility-2026-08-28T07-39-18-244Z.DRYRUN.jsonl} | 0
rollback-tk10634.mjs | 97 ++++++++++++++++++++++
2 files changed, 97 insertions(+)
diff --git a/data/tk10634-reversibility-2026-08-28T07-39-18-244Z.jsonl b/data/tk10634-reversibility-2026-08-28T07-39-18-244Z.DRYRUN.jsonl
similarity index 100%
rename from data/tk10634-reversibility-2026-08-28T07-39-18-244Z.jsonl
rename to data/tk10634-reversibility-2026-08-28T07-39-18-244Z.DRYRUN.jsonl
diff --git a/rollback-tk10634.mjs b/rollback-tk10634.mjs
new file mode 100644
index 0000000..97e4928
--- /dev/null
+++ b/rollback-tk10634.mjs
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+// TK-10634 ROLLBACK — restore every variant SKU + metafield from a reversibility jsonl.
+// Reads each {type,...,from_sku/from,to_sku/to} record and writes the value BACK to `from`.
+// Usage: node rollback-tk10634.mjs data/tk10634-reversibility-<ts>.jsonl [--apply] [--no-result-ok]
+//
+// Safety:
+// - Ledger gate: refuses a ledger whose sibling tk10634-result-<ts>.json shows 0 applied writes
+// (pre-dbe09cd dry runs still wrote ledgers). A ledger with NO result file (run died mid-way)
+// needs --no-result-ok; the compare-and-swap below still protects every record.
+// - Compare-and-swap: each record is reverted ONLY if the live value still equals `to`
+// (what the run wrote). Anything else is drift (edited since, or never written) and is skipped.
+// - Read-back: the mutation selects the written value; a mismatch counts as a failure.
+// - Receipt: every outcome is appended to data/tk10634-rollback-<ts>.jsonl as it happens.
+import { readFileSync, existsSync, appendFileSync } from 'node:fs';
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const VER = '2024-10';
+const env = readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (env.split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN=')) || '').replace('SHOPIFY_ADMIN_TOKEN=', '').replace(/["'\r]/g, '').trim();
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const path = process.argv[2];
+const APPLY = process.argv.includes('--apply');
+const NO_RESULT_OK = process.argv.includes('--no-result-ok');
+if (!path || path.startsWith('--')) { console.error('usage: rollback-tk10634.mjs <jsonl> [--apply] [--no-result-ok]'); process.exit(1); }
+
+// Ledger gate — tie the ledger to its run's result counts.
+if (/DRYRUN/i.test(path)) { console.error('refusing: ledger is marked DRYRUN'); process.exit(2); }
+const resultPath = path.replace('tk10634-reversibility-', 'tk10634-result-').replace(/\.jsonl$/, '.json');
+if (existsSync(resultPath)) {
+ const res = JSON.parse(readFileSync(resultPath, 'utf8'));
+ const applied = (res.sku_applied ?? res.summary?.sku_applied ?? 0) + (res.mf_applied ?? res.summary?.mf_applied ?? 0);
+ if (!applied) { console.error(`refusing: ${resultPath} shows 0 applied writes — this ledger is from a dry run`); process.exit(2); }
+} else if (!NO_RESULT_OK) {
+ console.error(`refusing: no result file ${resultPath} (run may have died mid-way). Re-run with --no-result-ok; compare-and-swap still guards each record.`);
+ process.exit(2);
+}
+
+const GQL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function gql(query, variables, t = 0) {
+ let res, j;
+ try {
+ res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
+ } catch (e) {
+ if (t < 6) { await sleep(2000 * (t + 1)); return gql(query, variables, t + 1); }
+ throw e;
+ }
+ if ((res.status === 429 || res.status >= 500) && t < 8) { await sleep(2000 * (t + 1)); return gql(query, variables, t + 1); }
+ const text = await res.text();
+ try { j = JSON.parse(text); } catch { throw new Error(`HTTP ${res.status} non-JSON: ${text.slice(0, 120)}`); }
+ if (j.errors && /THROTTL/i.test(JSON.stringify(j.errors)) && t < 6) { await sleep(2500 * (t + 1)); return gql(query, variables, t + 1); }
+ return j;
+}
+const Q_SKU = `query q($id:ID!){productVariant(id:$id){sku}}`;
+const Q_MF = `query q($id:ID!,$ns:String!,$k:String!){node(id:$id){...on HasMetafields{metafield(namespace:$ns,key:$k){value}}}}`;
+const MUT_SKU = `mutation s($p:ID!,$v:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$p,variants:$v){productVariants{id sku} userErrors{message}}}`;
+const MUT_MF = `mutation m($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){metafields{value} userErrors{message}}}`;
+
+const recs = readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+const TS = new Date().toISOString().replace(/[:.]/g, '-');
+const RECEIPT = new URL(`./data/tk10634-rollback-${TS}.jsonl`, import.meta.url).pathname;
+const receipt = o => { if (APPLY) appendFileSync(RECEIPT, JSON.stringify({ ...o, ts: new Date().toISOString() }) + '\n'); };
+
+let ok = 0, fail = 0, drift = 0, would = 0;
+for (const r of recs) {
+ const isSku = r.type === 'variant_sku';
+ const from = isSku ? r.from_sku : r.from;
+ const to = isSku ? r.to_sku : r.to;
+ const id = isSku ? `gid://shopify/ProductVariant/${r.variant_id}` : r.ownerId;
+ const tag = isSku ? `variant ${r.variant_id}` : `${r.ownerId} ${r.ns}.${r.key}`;
+ if (!isSku && r.type !== 'metafield') { fail++; console.error('FAIL unknown type', r.type); receipt({ rec: r, outcome: 'unknown_type' }); continue; }
+ try {
+ const cur = isSku
+ ? (await gql(Q_SKU, { id }))?.data?.productVariant?.sku
+ : (await gql(Q_MF, { id, ns: r.ns, k: r.key }))?.data?.node?.metafield?.value;
+ if (cur === from) { console.log(`[skip] ${tag} already ${from}`); receipt({ rec: r, outcome: 'already_reverted', live: cur }); continue; }
+ if (cur !== to) { drift++; console.warn(`[drift] ${tag} live=${JSON.stringify(cur)} expected ${to} — skipped`); receipt({ rec: r, outcome: 'drift_skipped', live: cur ?? null }); continue; }
+ if (!APPLY) { would++; console.log(`[dry] revert ${tag} ${to} -> ${from}`); continue; }
+
+ const j = isSku
+ ? await gql(MUT_SKU, { p: r.productGid, v: [{ id, inventoryItem: { sku: from } }] })
+ : await gql(MUT_MF, { mf: [{ ownerId: r.ownerId, namespace: r.ns, key: r.key, type: 'single_line_text_field', value: from }] });
+ const payload = j?.data?.productVariantsBulkUpdate || j?.data?.metafieldsSet;
+ const ue = payload?.userErrors || [];
+ const written = isSku ? payload?.productVariants?.find(v => v.id === id)?.sku : payload?.metafields?.[0]?.value;
+ if (ue.length || j.errors || written !== from) {
+ fail++; console.error('FAIL', tag, JSON.stringify(ue.length ? ue : j.errors || { written }));
+ receipt({ rec: r, outcome: 'failed', errors: ue.length ? ue : j.errors || null, written: written ?? null });
+ } else { ok++; receipt({ rec: r, outcome: 'reverted', written }); }
+ } catch (e) {
+ fail++; console.error('FAIL', tag, e.message); receipt({ rec: r, outcome: 'error', error: e.message });
+ }
+ await sleep(300);
+}
+console.log(APPLY
+ ? `rollback done: ${ok} reverted, ${drift} drift-skipped, ${fail} failed — receipt ${RECEIPT}`
+ : `dry-run: ${would} would revert, ${drift} drift-skipped, ${fail} errors of ${recs.length} records`);
+process.exit(fail ? 1 : 0);
← dbe09cd apply-tk10634: cap the 429 retry + stop writing the reversib
·
back to Hollywood Optc
·
(newest)