← back to Rebel Walls Push
Add read-only roll/bolt audit (TK-10029): group the 280 held products by unit label
d710728a097ec6585558364a9067bea198e131bd · 2026-09-03 13:47:31 -0700 · Steve Abrams
Files touched
A scripts/list-rollbolt.js
Diff
commit d710728a097ec6585558364a9067bea198e131bd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 3 13:47:31 2026 -0700
Add read-only roll/bolt audit (TK-10029): group the 280 held products by unit label
---
scripts/list-rollbolt.js | 106 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
diff --git a/scripts/list-rollbolt.js b/scripts/list-rollbolt.js
new file mode 100644
index 0000000..b5a1706
--- /dev/null
+++ b/scripts/list-rollbolt.js
@@ -0,0 +1,106 @@
+#!/usr/bin/env node
+/**
+ * TK-10029 (280 roll/bolt review): READ-ONLY audit.
+ * Pulls every Rebel Walls product whose variant carries a roll/bolt unit label
+ * (the cohort HELD out of the Scope-B per-m² relabel) and groups them by exact
+ * label, with title + SKU + product id. No writes. $0.
+ *
+ * Usage: node scripts/list-rollbolt.js
+ * node scripts/list-rollbolt.js --json # machine-readable
+ */
+const https = require('https');
+const fs = require('fs');
+
+const SECRETS_ENV = '/Users/macstudio3/Projects/secrets-manager/.env';
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const API_VERSION = '2024-10';
+const TOKEN = (() => {
+ const env = fs.readFileSync(SECRETS_ENV, 'utf8');
+ const m = env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m);
+ if (!m) throw new Error('SHOPIFY_FULL_ACCESS_TOKEN not found');
+ return m[1].trim();
+})();
+const JSON_OUT = process.argv.includes('--json');
+
+// The held roll/bolt unit-of-sale cohort (mirrors relabel-units.js HELD_ROLLBOLT_LABELS,
+// plus the interim canonical-adjacent ones we DON'T count here — we want only the
+// genuine roll/bolt unit labels that need Steve's per-m²-or-not ruling).
+const ROLLBOLT_LABELS = new Set([
+ 'roll', 'single roll', 'sold per roll', 'sold per bolt (20.5in x 33ft)',
+]);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function gqlOnce(query, vars) {
+ return new Promise((resolve, reject) => {
+ const body = JSON.stringify({ query, variables: vars || {} });
+ const req = https.request({
+ hostname: DOMAIN, path: `/admin/api/${API_VERSION}/graphql.json`, method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
+ }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error(`non-JSON (${res.statusCode}): ${String(d).slice(0, 80)}`)); } }); });
+ req.on('error', reject); req.write(body); req.end();
+ });
+}
+// Throttle-aware: a Shopify cost throttle is a 200 with top-level `errors` (THROTTLED),
+// NOT a parse failure — so treat data-less/THROTTLED responses as retryable too.
+async function gql(q, v) {
+ let last;
+ for (let a = 1; a <= 8; a++) {
+ try {
+ const r = await gqlOnce(q, v);
+ if (r && r.data) return r;
+ const throttled = (r.errors || []).some(e => /throttl/i.test(e.message || '') || e.extensions?.code === 'THROTTLED');
+ last = new Error(`no data${throttled ? ' (THROTTLED)' : ''}: ${JSON.stringify(r.errors || r).slice(0, 120)}`);
+ } catch (e) { last = e; }
+ if (a < 8) await sleep(Math.min(10000, 500 * 2 ** (a - 1)));
+ }
+ throw last;
+}
+
+async function fetchAll() {
+ const products = []; let cursor = null;
+ while (true) {
+ const r = await gql(`query($after: String) {
+ products(first: 100, query: "vendor:Rebel Walls", after: $after) {
+ pageInfo { hasNextPage endCursor }
+ edges { node { id title
+ variants(first: 10) { edges { node { id sku title selectedOptions { name value } } } } } }
+ } }`, { after: cursor });
+ const page = r.data.products;
+ for (const e of page.edges) products.push(e.node);
+ if (!page.pageInfo.hasNextPage) break;
+ cursor = page.pageInfo.endCursor; await sleep(300);
+ }
+ return products;
+}
+
+(async () => {
+ const all = await fetchAll();
+ const groups = {}; // exact-label -> [{title, id, sku}]
+ for (const p of all) {
+ const titleLower = p.title.toLowerCase();
+ if (titleLower.includes(' sample') || titleLower.endsWith('sample')) continue; // skip memo samples
+ // find the roll/bolt-labeled variant (record ONCE per product, by the matched value)
+ let matched = null, sku = null;
+ for (const v of p.variants.edges.map(e => e.node)) {
+ for (const o of v.selectedOptions) {
+ if (ROLLBOLT_LABELS.has(o.value.toLowerCase())) { matched = o.value; sku = v.sku; break; }
+ }
+ if (matched) break;
+ }
+ if (!matched) continue;
+ (groups[matched] = groups[matched] || []).push({ title: p.title, id: p.id, sku });
+ }
+
+ if (JSON_OUT) { console.log(JSON.stringify(groups, null, 2)); return; }
+
+ const labels = Object.keys(groups).sort((a, b) => groups[b].length - groups[a].length);
+ let total = 0;
+ for (const label of labels) {
+ const rows = groups[label]; total += rows.length;
+ console.log(`\n=== "${label}" (${rows.length}) ===`);
+ for (const r of rows.sort((a, b) => a.title.localeCompare(b.title))) {
+ console.log(` ${(r.sku || '(no sku)').padEnd(22)} ${r.title}`);
+ }
+ }
+ console.log(`\n[list-rollbolt] TOTAL held roll/bolt products: ${total} (across ${labels.length} label(s))`);
+})().catch(e => { console.error('[list-rollbolt] FATAL', e.message); process.exit(1); });
← 2b3cea0 auto-data-snapshot: 2026-09-03T12:32:21 (1 data files) — dat
·
back to Rebel Walls Push
·
Add read-only confirm for soft roll/bolt cohort (TK-10029 DT 3fda4bb →