← back to Designer Wallcoverings
active-gate-add-samples: additive sample-variant pass for the missing-sample residue (idempotent, resumable, $4.25 + inv 2026)
1c17d27cfcb30e10930ef2b6dbdf9706e28203d7 · 2026-06-21 06:59:05 -0700 · Steve
Files touched
A shopify/scripts/active-gate-add-samples.js
Diff
commit 1c17d27cfcb30e10930ef2b6dbdf9706e28203d7
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jun 21 06:59:05 2026 -0700
active-gate-add-samples: additive sample-variant pass for the missing-sample residue (idempotent, resumable, $4.25 + inv 2026)
---
shopify/scripts/active-gate-add-samples.js | 102 +++++++++++++++++++++++++++++
1 file changed, 102 insertions(+)
diff --git a/shopify/scripts/active-gate-add-samples.js b/shopify/scripts/active-gate-add-samples.js
new file mode 100644
index 00000000..68b721ff
--- /dev/null
+++ b/shopify/scripts/active-gate-add-samples.js
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+/**
+ * active-gate-add-samples.js — ADDITIVE sample-variant pass for the active-gate
+ * residue (products the backfill flagged "missing sample variant").
+ *
+ * Standing rule: EVERY product must have a "Sample" variant @ $4.25 (CLAUDE.md).
+ * This adds the MISSING one — additive only. It never changes status, never
+ * touches the existing variant, and is idempotent (skips products that already
+ * have a Sample). Resumable via a jsonl ledger.
+ *
+ * Proven mechanic (from create-missing-sample-variants.js): REST
+ * POST /products/<id>/variants.json {variant:{option1:'Sample',price:'4.25',sku:<base>-Sample,...}}
+ *
+ * USAGE:
+ * node shopify/scripts/active-gate-add-samples.js --dry-run --limit 10
+ * node shopify/scripts/active-gate-add-samples.js --limit 200
+ * node shopify/scripts/active-gate-add-samples.js # all missing-sample residue
+ */
+'use strict';
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const https = require('https');
+
+const args = process.argv.slice(2);
+const DRY = args.includes('--dry-run');
+const LIMIT = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1], 10) : Infinity;
+
+function loadToken() {
+ if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN.replace(/['"]/g, '').trim();
+ for (const p of ['Projects/secrets-manager/.env', 'Projects/Designer-Wallcoverings/.env', 'Projects/Designer-Wallcoverings/shopify/.env']) {
+ try { const m = fs.readFileSync(path.join(os.homedir(), p), 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m); if (m && m[1].trim()) return m[1].replace(/['"]/g, '').trim(); } catch {}
+ }
+ return null;
+}
+const TOKEN = loadToken();
+if (!TOKEN) { console.error('FATAL: no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const LOCATION_GID = 'gid://shopify/Location/5795643504';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function rest(method, p, body) {
+ return new Promise(res => {
+ const payload = body ? JSON.stringify(body) : null;
+ const req = https.request({ hostname: STORE, path: `/admin/api/${API}${p}`, method,
+ headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}) } },
+ r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { res({ status: r.statusCode, body: JSON.parse(d) }); } catch { res({ status: r.statusCode, body: {} }); } }); });
+ req.on('error', e => res({ status: 0, err: e.message })); if (payload) req.write(payload); req.end();
+ });
+}
+async function restR(method, p, body) { // throttle/retry
+ for (let a = 0; a < 6; a++) { const r = await rest(method, p, body); if (r.status === 429 || r.status === 0) { await sleep(2000 * (a + 1)); continue; } return r; }
+ return { status: 429, body: {} };
+}
+
+(async () => {
+ const RES = path.join(__dirname, 'data', 'active-gate', 'residue-all.jsonl');
+ const rows = fs.readFileSync(RES, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+ // target rows whose remaining gate reasons include a missing sample variant
+ const targets = rows.filter(x => (x.remainingReasons || []).some(s => /sample variant/i.test(s)));
+ // dedupe by product id (gid)
+ const seen = new Set(); const queue0 = [];
+ for (const t of targets) { const id = t.id; if (id && !seen.has(id)) { seen.add(id); queue0.push(t); } }
+
+ const DATADIR = path.join(__dirname, 'data', 'active-gate');
+ const LEDGER = path.join(DATADIR, 'samples-ledger.jsonl');
+ const done = new Set();
+ if (fs.existsSync(LEDGER)) for (const l of fs.readFileSync(LEDGER, 'utf8').split('\n')) { if (!l.trim()) continue; try { done.add(JSON.parse(l).id); } catch {} }
+ const queue = queue0.filter(t => !done.has(t.id)).slice(0, LIMIT === Infinity ? undefined : LIMIT);
+ console.log(`add-samples ${DRY ? '[DRY-RUN] ' : ''}missing-sample residue=${queue0.length} done=${done.size} thisRun=${queue.length}`);
+ if (!queue.length) { console.log('Nothing to do.'); return; }
+
+ const stats = { created: 0, already: 0, skipNoSku: 0, failed: 0 };
+ for (let i = 0; i < queue.length; i++) {
+ const t = queue[i];
+ const numId = String(t.id).replace(/^.*\/Product\//, '');
+ // fetch product variants to get base SKU + check for an existing Sample
+ const pr = await restR('GET', `/products/${numId}.json?fields=id,title,variants`);
+ const prod = pr.body && pr.body.product;
+ if (!prod) { stats.failed++; continue; }
+ const variants = prod.variants || [];
+ if (variants.some(v => /sample/i.test(v.title || '') || /-sample$/i.test(v.sku || ''))) {
+ stats.already++; fs.appendFileSync(LEDGER, JSON.stringify({ id: t.id, result: 'already-has-sample', ts: new Date().toISOString() }) + '\n'); continue;
+ }
+ const base = (variants[0] && variants[0].sku || '').trim();
+ const sampleSku = base ? `${base}-Sample` : `SAMPLE-${numId}`;
+ if (DRY) { console.log(` • ${(prod.title || '').slice(0, 50)} → ${sampleSku}`); stats.created++; continue; }
+ const r = await restR('POST', `/products/${numId}/variants.json`, { variant: { option1: 'Sample', price: '4.25', sku: sampleSku, weight: 0, weight_unit: 'lb', inventory_management: 'shopify', requires_shipping: true } });
+ if (r.status === 201 || r.status === 200) {
+ stats.created++;
+ const inv = r.body.variant && r.body.variant.inventory_item_id;
+ if (inv) await restR('POST', `/inventory_levels/set.json`, { location_id: LOCATION_GID.replace(/^.*\/Location\//, ''), inventory_item_id: inv, available: 2026 });
+ fs.appendFileSync(LEDGER, JSON.stringify({ id: t.id, sampleSku, result: 'created', ts: new Date().toISOString() }) + '\n');
+ if (stats.created % 25 === 0) console.log(` [${i + 1}/${queue.length}] ✓ created ${stats.created} (last ${sampleSku})`);
+ } else if (r.status === 422 && JSON.stringify(r.body).toLowerCase().includes('already')) {
+ stats.already++; fs.appendFileSync(LEDGER, JSON.stringify({ id: t.id, result: 'already', ts: new Date().toISOString() }) + '\n');
+ } else { stats.failed++; if (stats.failed <= 5) console.log(` ! ${numId}: ${r.status} ${JSON.stringify(r.body).slice(0, 100)}`); }
+ }
+ console.log(`\n=== ${DRY ? 'DRY-RUN ' : ''}SUMMARY ===`); console.log(JSON.stringify(stats, null, 2));
+ if (!DRY) console.log(`ledger → ${LEDGER}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
← 63dcdeee Newwall width re-crawl: public-HTML spec-table scraper (DTD-
·
back to Designer Wallcoverings
·
validate-before-activate: fix sample-variant false-positive e69ef2ba →