← back to Designerwallcoverings
TK-11357: reversible qty->0 stopgap for NEW canary-flagged 1255 $0-orderable Phillipe Romano cohort (enumerate/apply/rollback via canary predicate)
250a2bfe5d4b93f5e3259f7adad1f037559e9951 · 2026-09-10 06:25:46 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2VeWGsSQXCTMR7WtJ5Ga9
Files touched
A scripts/tk11357-zero-price-stopgap/.gitignoreA scripts/tk11357-zero-price-stopgap/apply.mjsA scripts/tk11357-zero-price-stopgap/enumerate.mjsA scripts/tk11357-zero-price-stopgap/lib.mjsA scripts/tk11357-zero-price-stopgap/rollback.mjs
Diff
commit 250a2bfe5d4b93f5e3259f7adad1f037559e9951
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 10 06:25:46 2026 -0700
TK-11357: reversible qty->0 stopgap for NEW canary-flagged 1255 $0-orderable Phillipe Romano cohort (enumerate/apply/rollback via canary predicate)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2VeWGsSQXCTMR7WtJ5Ga9
---
scripts/tk11357-zero-price-stopgap/.gitignore | 3 +
scripts/tk11357-zero-price-stopgap/apply.mjs | 134 ++++++++++++
scripts/tk11357-zero-price-stopgap/enumerate.mjs | 248 +++++++++++++++++++++++
scripts/tk11357-zero-price-stopgap/lib.mjs | 70 +++++++
scripts/tk11357-zero-price-stopgap/rollback.mjs | 53 +++++
5 files changed, 508 insertions(+)
diff --git a/scripts/tk11357-zero-price-stopgap/.gitignore b/scripts/tk11357-zero-price-stopgap/.gitignore
new file mode 100644
index 0000000..8b1934f
--- /dev/null
+++ b/scripts/tk11357-zero-price-stopgap/.gitignore
@@ -0,0 +1,3 @@
+targets.json
+restore-map.json
+*.out
diff --git a/scripts/tk11357-zero-price-stopgap/apply.mjs b/scripts/tk11357-zero-price-stopgap/apply.mjs
new file mode 100644
index 0000000..71ac522
--- /dev/null
+++ b/scripts/tk11357-zero-price-stopgap/apply.mjs
@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+/**
+ * TK-11357 — apply.mjs (DRY-RUN by default; mutates ONLY with --apply)
+ *
+ * Reversible qty→0 stopgap for the NEW ~1,255-product cohort the zero-price-orderable-canary
+ * flags (status:active, quote-tag family OR Fentucci vendor, non-sample $0 variant that is
+ * availableForSale=true). For every NONZERO inventory level on each flagged variant it sets
+ * available→0 (REST /inventory_levels/set.json) using the FULL token (…2ea5, write_inventory).
+ * Zeroing stock flips availableForSale=false (inventory_policy=DENY), so the $0 variant stops
+ * being orderable — WITHOUT touching price, status, publication, or the sample variant.
+ *
+ * · reads targets.json (produced by enumerate.mjs — canary-derived set)
+ * · BEFORE each set, records {inventory_item_id, location_id, prev_available} to
+ * restore-map.json → the concrete reversible undo (rollback.mjs reads it)
+ * · idempotent: skips a level already at 0
+ * · rate-limit-safe: shared rest() throttles + retries on 429/5xx
+ * · ledgers the run to ~/.claude/yolo-queue/executed-reversible/ledger.jsonl ONLY on --apply
+ *
+ * HARD GATE: --apply is a customer-facing write across ~1,255 live products. Steve runs it.
+ *
+ * DRY-RUN: node apply.mjs
+ * LIVE: node apply.mjs --apply
+ */
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { rest, SHOP, TOKEN_LAST4 } from './lib.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const TARGETS = path.join(HERE, 'targets.json');
+const RESTORE = path.join(HERE, 'restore-map.json');
+const LEDGER = path.join(os.homedir(), '.claude/yolo-queue/executed-reversible/ledger.jsonl');
+
+const APPLY = process.argv.includes('--apply');
+
+function loadTargets() {
+ if (!fs.existsSync(TARGETS)) { console.error(`missing ${TARGETS} — run enumerate.mjs first`); process.exit(1); }
+ return JSON.parse(fs.readFileSync(TARGETS, 'utf8'));
+}
+
+// restore-map is a keyed map so re-runs never lose the earliest prev value we recorded.
+function loadRestore() {
+ if (fs.existsSync(RESTORE)) return JSON.parse(fs.readFileSync(RESTORE, 'utf8'));
+ return { ts_started: new Date().toISOString(), ticket: 'TK-11357', store: SHOP, entries: {} };
+}
+function saveRestore(m) { fs.writeFileSync(RESTORE, JSON.stringify(m, null, 2)); }
+
+async function main() {
+ const data = loadTargets();
+ const targets = data.targets || [];
+ // Safety: the search predicate is status:active, but assert it here too — never zero a non-ACTIVE product.
+ const active = targets.filter(t => t.product_status === 'ACTIVE');
+ const droppedNonActive = targets.length - active.length;
+
+ console.log(`[apply] mode=${APPLY ? 'LIVE --apply' : 'DRY-RUN'} store=${SHOP} token=…${TOKEN_LAST4} (FULL)`);
+ console.log(`[apply] targets=${active.length} ACTIVE flagged variants (of ${targets.length} in targets.json; ${droppedNonActive} non-ACTIVE excluded) — enumerated ${data.ts}`);
+ console.log(`[apply] canary reconcile: products_with_canary_hit=${data.products_with_canary_hit} (expected ~${data.canary_target_count})`);
+
+ const restoreMap = APPLY ? loadRestore() : null;
+ let nonzeroLevels = 0, wouldSet = 0, didSet = 0, skippedZero = 0, errors = 0;
+
+ for (const t of active) {
+ for (const l of (t.levels || [])) {
+ if (!(l.available > 0)) { skippedZero++; continue; } // idempotent: already 0/null
+ nonzeroLevels++;
+ const key = `${t.inventory_item_id}:${l.location_id}`;
+
+ if (!APPLY) {
+ wouldSet++;
+ if (wouldSet <= 12)
+ console.log(` would set 0 ← ${l.available} ${t.sku} inv_item=${t.inventory_item_id} loc=${l.location_id} (${l.location_name})`);
+ continue;
+ }
+
+ // --- LIVE: record prev BEFORE the set (only if we haven't already recorded it) ---
+ if (!restoreMap.entries[key]) {
+ restoreMap.entries[key] = {
+ inventory_item_id: t.inventory_item_id,
+ location_id: l.location_id,
+ location_name: l.location_name,
+ sku: t.sku,
+ variant_id: t.variant_id,
+ product_id: t.product_id,
+ prev_available: l.available,
+ };
+ saveRestore(restoreMap); // persist incrementally so a crash never loses the undo
+ }
+
+ const r = await rest('/inventory_levels/set.json', {
+ method: 'POST',
+ body: { location_id: Number(l.location_id), inventory_item_id: Number(t.inventory_item_id), available: 0 },
+ });
+ if (r.ok) {
+ didSet++;
+ restoreMap.entries[key].set_at = new Date().toISOString();
+ if (didSet % 100 === 0) { saveRestore(restoreMap); process.stdout.write(`\r[apply] set ${didSet} levels → 0 `); }
+ } else {
+ errors++;
+ const txt = await r.text().catch(() => '');
+ console.error(`\n ERROR set ${t.sku} inv_item=${t.inventory_item_id} loc=${l.location_id}: ${r.status} ${txt.slice(0, 160)}`);
+ }
+ }
+ }
+ if (APPLY) { saveRestore(restoreMap); process.stdout.write('\n'); }
+
+ console.log('\n========== TK-11357 APPLY SUMMARY ==========');
+ console.log(`mode : ${APPLY ? 'LIVE --apply' : 'DRY-RUN'}`);
+ console.log(`ACTIVE flagged variants : ${active.length}`);
+ console.log(`nonzero levels to zero : ${nonzeroLevels}`);
+ console.log(`already-0 levels skipped: ${skippedZero}`);
+ if (APPLY) {
+ console.log(`levels SET → 0 : ${didSet}`);
+ console.log(`errors : ${errors}`);
+ console.log(`restore-map : ${RESTORE} (${Object.keys(restoreMap.entries).length} entries)`);
+ const line = {
+ ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-11357',
+ action: 'zeroed inventory on the NEW canary-flagged $0 orderable non-sample variants (quote-tag/Fentucci cohort) — stopgap',
+ blast_radius: didSet, store: SHOP,
+ undo_cmd: `node ${path.join(HERE, 'rollback.mjs')} --apply`,
+ verify: `node ${path.join(process.env.HOME, '.claude/skills/zero-price-orderable-canary/check.mjs')} → zero_price_orderable should drop to ~0`,
+ errors,
+ };
+ fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
+ fs.appendFileSync(LEDGER, JSON.stringify(line) + '\n');
+ console.log(`ledgered : ${LEDGER}`);
+ } else {
+ console.log(`levels that WOULD be set: ${wouldSet}`);
+ console.log('\nDRY-RUN only — nothing changed. Re-run with --apply to execute.');
+ }
+ console.log('============================================');
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/tk11357-zero-price-stopgap/enumerate.mjs b/scripts/tk11357-zero-price-stopgap/enumerate.mjs
new file mode 100644
index 0000000..8f48100
--- /dev/null
+++ b/scripts/tk11357-zero-price-stopgap/enumerate.mjs
@@ -0,0 +1,248 @@
+#!/usr/bin/env node
+/**
+ * TK-11357 — enumerate.mjs (READ-ONLY)
+ *
+ * The NEW cohort (distinct from TK-11301's variant-title "Per Yard" set): products that
+ * the zero-price-orderable-canary flags — status:active AND (quote-tag family OR
+ * vendor:'Fentucci Naturals') AND a NON-sample variant priced $0 with availableForSale=true.
+ * Latest canary run: zero_price_orderable = 1255 (verdict FAIL, ts 2026-09-10T13:02:52Z).
+ *
+ * This script REUSES the canary's exact predicate — it imports SEARCHES / inScope /
+ * badVariant straight from ~/.claude/skills/zero-price-orderable-canary/check.mjs — so the
+ * target set is identical to what the canary counts. It runs its OWN paging query (the
+ * canary's does not fetch variant id / sku / inventoryItem.id, which the inventory writes
+ * need) and captures, per flagged non-sample variant:
+ * product_id, product_title, vendor, product_status, variant_id, variant_title, sku,
+ * price, availableForSale, inventoryPolicy, tracked, inventoryQuantity,
+ * inventoryItem.id, and per-location inventory levels (id + available).
+ *
+ * Reconciliation: the canary counts PRODUCTS with >=1 flagged variant (via badVariant,
+ * which returns the FIRST match). We capture ALL flagged non-sample variants per product
+ * (usually exactly 1) so the fix zeroes every orderable $0 variant, and we report the
+ * PRODUCT count for the ~1,255 reconcile plus the VARIANT count actually captured.
+ *
+ * EXCLUDES: sample variants (title ~sample) and any variant with availableForSale=false —
+ * both are inherent in the canary's badVariant predicate, so neither can enter the set.
+ *
+ * Purely read-only — it never mutates anything.
+ *
+ * Run: node enumerate.mjs
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { gql, rest, getLocations, SHOP, TOKEN_LAST4 } from './lib.mjs';
+
+// Import the canary's authoritative predicate so the scope is provably identical.
+const CANARY = pathToFileURL(path.join(process.env.HOME, '.claude/skills/zero-price-orderable-canary/check.mjs')).href;
+const { SEARCHES, inScope, badVariant } = await import(CANARY);
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'targets.json');
+
+const isSample = t => /sample/i.test(t || '');
+const norm = gid => (gid || '').split('/').pop();
+
+// The canary's per-variant defect test (mirror of badVariant's inner predicate), applied
+// to ALL variants so a product with >1 orderable $0 non-sample variant gets every one zeroed.
+const isBadVariant = v =>
+ !isSample(v.title) && Number(v.price) === 0 && v.availableForSale === true;
+
+// Our paging query — superset of the canary's fields (adds variant id, sku, inventoryItem.id).
+const Q = `
+ query($q:String!,$after:String){
+ products(first:100, query:$q, after:$after){
+ pageInfo{ hasNextPage endCursor }
+ nodes{
+ id title vendor status tags
+ variants(first:100){
+ pageInfo{ hasNextPage endCursor }
+ nodes{
+ id title price sku availableForSale inventoryPolicy inventoryQuantity
+ inventoryItem{ id tracked }
+ }
+ }
+ }
+ }
+ }`;
+
+// Follow-up page fetch for products with >100 variants (keeps all fields we need).
+async function hydrateVariantPages(products) {
+ const VQ = `query($id:ID!,$after:String!){product(id:$id){variants(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{ id title price sku availableForSale inventoryPolicy inventoryQuantity inventoryItem{ id tracked } }}}}`;
+ for (const p of products) {
+ let pi = p.variants?.pageInfo;
+ let after = pi?.hasNextPage ? pi.endCursor : null;
+ while (after) {
+ const d = await gql(VQ, { id: p.id, after });
+ if (d?.__err) throw new Error('variant page error: ' + JSON.stringify(d.__err).slice(0, 300));
+ const page = d.product?.variants;
+ if (!page) throw new Error(`missing variant page for ${p.id}`);
+ p.variants.nodes.push(...page.nodes);
+ pi = page.pageInfo;
+ after = pi.hasNextPage ? pi.endCursor : null;
+ }
+ }
+ return products;
+}
+
+async function main() {
+ console.log(`[enumerate] store=${SHOP} token=…${TOKEN_LAST4} (FULL, read-only use)`);
+ console.log(`[enumerate] canary predicate imported from check.mjs — ${SEARCHES.length} searches`);
+ const locNames = await getLocations();
+ console.log(`[enumerate] ${Object.keys(locNames).length} locations resolved`);
+
+ // ---- Phase 1: page products via the canary's EXACT searches ----
+ const raw = [];
+ for (const q of SEARCHES) {
+ let after = null, page = 0;
+ do {
+ const d = await gql(Q, { q, after });
+ if (d?.__err) { console.error('GraphQL error:', JSON.stringify(d.__err)); process.exit(1); }
+ raw.push(...d.products.nodes);
+ after = d.products.pageInfo.hasNextPage ? d.products.pageInfo.endCursor : null;
+ page++;
+ process.stdout.write(`\r[enumerate] "${q.slice(0, 40)}…" page ${page} · raw ${raw.length} `);
+ } while (after);
+ }
+ process.stdout.write('\n');
+
+ // De-dupe by product GID (searches overlap), then hydrate any >100-variant products.
+ const uniqueAll = [...new Map(raw.map(p => [p.id, p])).values()];
+ await hydrateVariantPages(uniqueAll);
+ console.log(`[enumerate] raw rows ${raw.length} → unique products ${uniqueAll.length}`);
+
+ // Enforce the canary's scope boundary locally (Shopify OR-grammar can over-return).
+ const scoped = uniqueAll.filter(inScope);
+ console.log(`[enumerate] in-scope (quote-tag family OR Fentucci): ${scoped.length}`);
+
+ // ---- Identify flagged non-sample $0 orderable variants ----
+ const targets = [];
+ const flaggedProductIds = new Set();
+ let productsWithCanaryHit = 0; // matches canary count basis (badVariant finds >=1)
+ const multiMatch = [];
+ const nonActive = []; // safety: search is status:active, but assert nothing else slips in
+
+ for (const p of scoped) {
+ // reconcile basis: does the canary's badVariant find a hit on this product?
+ if (badVariant(p)) productsWithCanaryHit++;
+
+ const cands = p.variants.nodes.filter(isBadVariant);
+ if (cands.length === 0) continue;
+ if (p.status !== 'ACTIVE') nonActive.push({ product_id: norm(p.id), status: p.status, title: p.title });
+ if (cands.length > 1) multiMatch.push({ product_id: norm(p.id), title: p.title, count: cands.length });
+ flaggedProductIds.add(p.id);
+ for (const v of cands) {
+ targets.push({
+ product_id: norm(p.id),
+ product_title: p.title,
+ vendor: p.vendor,
+ product_status: p.status,
+ variant_id: norm(v.id),
+ variant_title: v.title,
+ sku: v.sku,
+ price: v.price,
+ available_for_sale: v.availableForSale,
+ inventory_policy: v.inventoryPolicy, // DENY needed for qty→0 to flip afs=false
+ tracked: v.inventoryItem?.tracked, // must be true for qty→0 to matter
+ inventory_quantity: v.inventoryQuantity, // current qty snapshot (often placeholder 2026)
+ inventory_item_id: norm(v.inventoryItem?.id),
+ levels: [], // filled in phase 2
+ });
+ }
+ }
+ console.log(`[enumerate] products with canary hit (reconcile basis): ${productsWithCanaryHit}`);
+ console.log(`[enumerate] flagged products: ${flaggedProductIds.size} · flagged variants: ${targets.length}`);
+
+ // ---- Phase 2: per-location inventory levels via REST (batches of 50 inv_item_ids) ----
+ const byInvItem = new Map(targets.map(t => [t.inventory_item_id, t]));
+ const ids = [...byInvItem.keys()].filter(Boolean);
+ for (let i = 0; i < ids.length; i += 50) {
+ const batch = ids.slice(i, i + 50);
+ const r = await rest(`/inventory_levels.json?inventory_item_ids=${batch.join(',')}&limit=250`);
+ const j = await r.json();
+ for (const lvl of (j.inventory_levels || [])) {
+ const t = byInvItem.get(String(lvl.inventory_item_id));
+ if (!t) continue;
+ t.levels.push({
+ location_id: lvl.location_id,
+ location_name: locNames[String(lvl.location_id)] || `(unknown ${lvl.location_id})`,
+ available: lvl.available,
+ });
+ }
+ process.stdout.write(`\r[enumerate] inventory levels ${Math.min(i + 50, ids.length)}/${ids.length} `);
+ }
+ process.stdout.write('\n');
+
+ fs.writeFileSync(OUT, JSON.stringify({
+ ts: new Date().toISOString(),
+ ticket: 'TK-11357',
+ store: SHOP,
+ predicate: "status:active AND (quote-tag family OR vendor:'Fentucci Naturals') AND non-sample variant price==0 && availableForSale==true",
+ predicate_source: '~/.claude/skills/zero-price-orderable-canary/check.mjs (SEARCHES/inScope/badVariant)',
+ stopgap: 'zero every nonzero inventory level on each flagged variant so availableForSale=false (policy=DENY)',
+ canary_target_count: 1255,
+ products_with_canary_hit: productsWithCanaryHit,
+ flagged_products: flaggedProductIds.size,
+ total_targets: targets.length,
+ targets,
+ }, null, 2));
+
+ // ---- Report ----
+ const withStock = targets.filter(t => t.levels.some(l => l.available > 0));
+ const noLevels = targets.filter(t => t.levels.length === 0);
+ const notTracked = targets.filter(t => t.tracked !== true);
+ const notDeny = targets.filter(t => String(t.inventory_policy).toUpperCase() !== 'DENY');
+
+ // per-location nonzero qty distribution
+ const dist = {};
+ for (const t of targets) for (const l of t.levels) {
+ if (l.available > 0) {
+ const k = `${l.location_id} · ${l.location_name}`;
+ dist[k] = dist[k] || { variants: 0, total_qty: 0 };
+ dist[k].variants++;
+ dist[k].total_qty += l.available;
+ }
+ }
+ // by vendor
+ const byVendor = {};
+ for (const t of targets) { const v = t.vendor || 'UNKNOWN'; byVendor[v] = (byVendor[v] || 0) + 1; }
+
+ console.log('\n========== TK-11357 ENUMERATION SUMMARY ==========');
+ console.log(`in-scope products : ${scoped.length}`);
+ console.log(`products w/ canary hit (~1255) : ${productsWithCanaryHit}`);
+ console.log(`flagged products : ${flaggedProductIds.size}`);
+ console.log(`flagged VARIANTS (targets) : ${targets.length}`);
+ console.log(` · with nonzero stock at >=1 loc: ${withStock.length}`);
+ console.log(` · with NO inventory levels : ${noLevels.length}`);
+ console.log('\nby vendor:');
+ for (const [v, n] of Object.entries(byVendor).sort((a, b) => b[1] - a[1])) console.log(` ${v}: ${n}`);
+ console.log('\nper-location NONZERO qty distribution:');
+ for (const [k, v] of Object.entries(dist).sort((a, b) => b[1].total_qty - a[1].total_qty))
+ console.log(` ${k}: ${v.variants} variants, ${v.total_qty} total units`);
+
+ // Red-team signals: qty→0 only flips availableForSale=false when tracked=true AND policy=DENY.
+ if (notTracked.length)
+ console.log(`\n⚠ ${notTracked.length} target(s) have tracked!=true — qty→0 alone will NOT flip availableForSale (needs a policy/track fix instead).`);
+ if (notDeny.length)
+ console.log(`⚠ ${notDeny.length} target(s) have inventory_policy!=DENY — qty→0 alone will NOT flip availableForSale.`);
+ if (nonActive.length)
+ console.log(`⚠ ${nonActive.length} flagged product(s) are NOT status=ACTIVE (unexpected — search is status:active). First: ${JSON.stringify(nonActive.slice(0, 3))}`);
+ if (multiMatch.length) {
+ console.log(`\nℹ ${multiMatch.length} product(s) had >1 flagged $0 non-sample variant (all captured):`);
+ for (const m of multiMatch.slice(0, 10)) console.log(` product ${m.product_id} "${m.title}" — ${m.count}`);
+ }
+
+ // Spot-check the memo's cited products.
+ const CITED = ['7896457248819', '7896457347123'];
+ console.log('\nmemo spot-check:');
+ for (const pid of CITED) {
+ const hit = targets.filter(t => t.product_id === pid);
+ if (hit.length) console.log(` ✓ ${pid} "${hit[0].product_title}" IN SET — ${hit.length} variant(s); price=${hit[0].price} afs=${hit[0].available_for_sale} policy=${hit[0].inventory_policy} tracked=${hit[0].tracked} qty=${hit[0].inventory_quantity}`);
+ else console.log(` ✗ ${pid} NOT in target set`);
+ }
+
+ console.log(`\nwrote ${OUT}`);
+ console.log('==================================================');
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/tk11357-zero-price-stopgap/lib.mjs b/scripts/tk11357-zero-price-stopgap/lib.mjs
new file mode 100644
index 0000000..8d97edc
--- /dev/null
+++ b/scripts/tk11357-zero-price-stopgap/lib.mjs
@@ -0,0 +1,70 @@
+/**
+ * TK-11357 zero-price-stopgap — self-contained Shopify Admin seam.
+ * (Copied verbatim-in-behavior from the proven TK-11301 lib.mjs.)
+ *
+ * Uses SHOPIFY_FULL_ACCESS_TOKEN EXPLICITLY (…2ea5, 139 scopes incl. write_inventory
+ * + read_locations). The narrow SHOPIFY_ADMIN_TOKEN (…7d19) LACKS write_inventory, so
+ * the apply/rollback inventory writes would 403 with it — this seam refuses to fall back.
+ *
+ * Store: designer-laboratory-sandbox.myshopify.com (the LIVE DW store). API 2024-10.
+ */
+import fs from 'node:fs';
+
+export const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+export const VER = '2024-10';
+export const ENDPOINT = `https://${SHOP}/admin/api/${VER}`;
+const GQL_URL = `${ENDPOINT}/graphql.json`;
+
+const _env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
+export const TOKEN = (_env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m) || [])[1]?.trim();
+if (!TOKEN) {
+ console.error('FATAL: no SHOPIFY_FULL_ACCESS_TOKEN in ~/Projects/secrets-manager/.env — required for write_inventory + read_locations');
+ process.exit(1);
+}
+export const TOKEN_LAST4 = TOKEN.slice(-4);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+export async function gql(query, vars) {
+ for (let a = 0; a < 8; a++) {
+ let j;
+ try {
+ const r = await fetch(GQL_URL, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables: vars }),
+ });
+ j = await r.json();
+ } catch (e) { await sleep(1500 * (a + 1)); continue; }
+ if (j.errors) {
+ if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
+ return { __err: j.errors };
+ }
+ const t = j.extensions?.cost?.throttleStatus;
+ if (t && t.currentlyAvailable < 400) await sleep(1200);
+ return j.data;
+ }
+ throw new Error('gql retries exhausted');
+}
+
+export async function rest(path, { method = 'GET', body } = {}, tries = 6) {
+ for (let i = 0; i < tries; i++) {
+ const r = await fetch(`${ENDPOINT}${path}`, {
+ method,
+ headers: { 'X-Shopify-Access-Token': TOKEN, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ });
+ if (r.status === 429 || r.status >= 500) { await sleep(1800 * (i + 1)); continue; }
+ await sleep(120); // Shopify REST 2-call/s leaky-bucket friendliness
+ return r;
+ }
+ throw new Error('rest fail ' + path);
+}
+
+export async function getLocations() {
+ const d = await gql(`{locations(first:50){nodes{id name}}}`);
+ if (d?.__err) throw new Error('cannot read locations: ' + JSON.stringify(d.__err).slice(0, 300));
+ const out = {};
+ for (const n of d.locations.nodes) out[n.id.split('/').pop()] = n.name;
+ return out;
+}
diff --git a/scripts/tk11357-zero-price-stopgap/rollback.mjs b/scripts/tk11357-zero-price-stopgap/rollback.mjs
new file mode 100644
index 0000000..c21fb63
--- /dev/null
+++ b/scripts/tk11357-zero-price-stopgap/rollback.mjs
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+/**
+ * TK-11357 — rollback.mjs (DRY-RUN by default; restores ONLY with --apply)
+ *
+ * Reverses apply.mjs: for every entry in restore-map.json it sets the inventory level
+ * back to prev_available (REST /inventory_levels/set.json), using the FULL token.
+ * This is the concrete, recorded undo for the reversible qty→0 stopgap.
+ *
+ * DRY-RUN: node rollback.mjs
+ * LIVE: node rollback.mjs --apply
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { rest, SHOP, TOKEN_LAST4 } from './lib.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const RESTORE = path.join(HERE, 'restore-map.json');
+const APPLY = process.argv.includes('--apply');
+
+async function main() {
+ if (!fs.existsSync(RESTORE)) { console.error(`missing ${RESTORE} — nothing to roll back (apply.mjs --apply never ran)`); process.exit(1); }
+ const map = JSON.parse(fs.readFileSync(RESTORE, 'utf8'));
+ const entries = Object.values(map.entries || {});
+ console.log(`[rollback] mode=${APPLY ? 'LIVE --apply' : 'DRY-RUN'} store=${SHOP} token=…${TOKEN_LAST4} (FULL)`);
+ console.log(`[rollback] ${entries.length} levels to restore (from ${RESTORE})`);
+
+ let restored = 0, errors = 0, preview = 0;
+ for (const e of entries) {
+ if (!APPLY) {
+ preview++;
+ if (preview <= 12)
+ console.log(` would restore ${e.sku} inv_item=${e.inventory_item_id} loc=${e.location_id} → ${e.prev_available}`);
+ continue;
+ }
+ const r = await rest('/inventory_levels/set.json', {
+ method: 'POST',
+ body: { location_id: Number(e.location_id), inventory_item_id: Number(e.inventory_item_id), available: e.prev_available },
+ });
+ if (r.ok) { restored++; if (restored % 100 === 0) process.stdout.write(`\r[rollback] restored ${restored} `); }
+ else { errors++; const txt = await r.text().catch(() => ''); console.error(`\n ERROR ${e.sku} loc=${e.location_id}: ${r.status} ${txt.slice(0, 160)}`); }
+ }
+ if (APPLY) process.stdout.write('\n');
+
+ console.log('\n========== TK-11357 ROLLBACK SUMMARY ==========');
+ console.log(`mode : ${APPLY ? 'LIVE --apply' : 'DRY-RUN'}`);
+ console.log(`entries : ${entries.length}`);
+ if (APPLY) { console.log(`restored : ${restored}`); console.log(`errors : ${errors}`); }
+ else console.log(`\nDRY-RUN only — nothing changed. Re-run with --apply to restore.`);
+ console.log('===============================================');
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
← b90d16f TK-11238: staged reversible Matka stray-orphan fixes (archiv
·
back to Designerwallcoverings
·
auto-data-snapshot: 2026-09-10T06:27:49 (2 data files) — scr 1076d4a →