← back to Letsbegin
Write C3 per-m2 cost ($44.50) metafield on 1000 Rebel Walls murals (portal-confirmed Net)
62605f82f723a9867418ac5f4f4d94bf3300c5c7 · 2026-06-11 14:44:56 -0700 · Steve Abrams
Files touched
Diff
commit 62605f82f723a9867418ac5f4f4d94bf3300c5c7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jun 11 14:44:56 2026 -0700
Write C3 per-m2 cost ($44.50) metafield on 1000 Rebel Walls murals (portal-confirmed Net)
---
rw-cost-write.js | 238 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 238 insertions(+)
diff --git a/rw-cost-write.js b/rw-cost-write.js
new file mode 100644
index 0000000..0ef488b
--- /dev/null
+++ b/rw-cost-write.js
@@ -0,0 +1,238 @@
+#!/usr/bin/env node
+/**
+ * rw-cost-write.js — Write the portal-confirmed C3 per-m² COST metafield onto
+ * every Rebel Walls custom mural in the sandbox store.
+ *
+ * Steve approved ("go"). Cost confirmed live from partner.gimmersta.com trade
+ * portal (Rebel Walls shares Sandberg/Gimmersta group price list):
+ * C3 standard non-woven: RRP $76.40/m² → Net (our cost, excl tax) = $44.50/m².
+ *
+ * Writes (namespace `custom`) on each product:
+ * - custom.cost_per_m2 = 44.50 (number_decimal — no existing def, safe)
+ * - custom.cost_currency = USD (single_line_text_field)
+ * - custom.price_group = C3 (single_line_text_field)
+ * - custom.cost_basis = <provenance string> (multi_line_text_field)
+ *
+ * Does NOT touch: color/title/tag metafields, Shopify variant price/compare-at,
+ * or any non-RW product. Cohort is locked by the dw_unified query below.
+ *
+ * Idempotent + resumable: re-fetches each product's existing custom.* metafields
+ * first; if all 4 already match the target values it SKIPs the write. So a
+ * re-run only writes the rows that aren't already correct.
+ *
+ * Rate-limited (~2 req/s) with the same Throttled-backoff as enrich-write.js.
+ *
+ * Usage:
+ * node rw-cost-write.js --validate # write+verify first 3 only, print before/after
+ * node rw-cost-write.js # run all 1000
+ * node rw-cost-write.js --limit N # run first N
+ * node rw-cost-write.js --reqa # re-fetch ~15 and print cost_per_m2/price_group, no writes
+ *
+ * Env: SHOPIFY_ADMIN_TOKEN (loaded from secrets-manager/.env). PG defaults
+ * user stevestudio2, db dw_unified, local socket.
+ */
+
+const https = require('https');
+const fs = require('fs');
+const { Client } = require('pg');
+
+// ---------- env (parse secrets-manager/.env; zsh-source-unsafe, so do it here) ----------
+function loadEnv(path) {
+ try {
+ for (const line of fs.readFileSync(path, 'utf8').split('\n')) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
+ if (!m) continue;
+ let v = m[2].trim();
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+ if (process.env[m[1]] == null) process.env[m[1]] = v;
+ }
+ } catch { /* file optional if env already set */ }
+}
+loadEnv('/Users/stevestudio2/Projects/secrets-manager/.env');
+
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_PRODUCT_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN env var required'); process.exit(1); }
+
+// HARD GUARD: sandbox store only.
+if (STORE !== 'designer-laboratory-sandbox.myshopify.com') {
+ console.error(`FATAL: refusing to run against non-sandbox store "${STORE}"`); process.exit(1);
+}
+
+// ---------- target values (portal-confirmed) ----------
+const COST_PER_M2 = '44.50';
+const COST_CURRENCY = 'USD';
+const PRICE_GROUP = 'C3';
+const COST_BASIS = 'C3 standard non-woven; Net excl tax from partner.gimmersta.com 2026-06; RRP $76.40/m2; ~41.8% off RRP. Provisional — C2/C4/P&S material variants pending portal Net.';
+
+// ---------- cli ----------
+const args = process.argv.slice(2);
+const VALIDATE = args.includes('--validate');
+const REQA = args.includes('--reqa');
+const limIdx = args.indexOf('--limit');
+const LIMIT = limIdx >= 0 ? parseInt(args[limIdx + 1], 10) : null;
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// ---------- Shopify GraphQL (gqlRaw/gql backoff lifted from enrich-write.js) ----------
+function gqlRaw(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+ }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } }); });
+ req.on('error', reject);
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data); req.end();
+ });
+}
+async function gql(body, retries = 5) {
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
+ const result = await gqlRaw(body);
+ const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+ const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable || 9999) < 200;
+ if (throttled) { await sleep(3000); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+
+// Fetch only the 4 cost-related custom metafields for a product (cheap, targeted).
+async function fetchCost(pid) {
+ const r = await gql({ query: `{ product(id:"${pid}") {
+ id title vendor
+ mfA: metafield(namespace:"custom", key:"cost_per_m2"){ value }
+ mfB: metafield(namespace:"custom", key:"cost_currency"){ value }
+ mfC: metafield(namespace:"custom", key:"price_group"){ value }
+ mfD: metafield(namespace:"custom", key:"cost_basis"){ value }
+ } }` });
+ const p = r?.data?.product;
+ if (!p) return null;
+ return {
+ id: p.id, title: p.title, vendor: p.vendor,
+ cost_per_m2: p.mfA?.value ?? null,
+ cost_currency: p.mfB?.value ?? null,
+ price_group: p.mfC?.value ?? null,
+ cost_basis: p.mfD?.value ?? null,
+ };
+}
+
+function alreadyCorrect(c) {
+ // number_decimal normalizes "44.50" -> "44.5"; accept either spelling.
+ const numOk = c.cost_per_m2 === COST_PER_M2 || c.cost_per_m2 === '44.5';
+ return numOk && c.cost_currency === COST_CURRENCY && c.price_group === PRICE_GROUP && c.cost_basis === COST_BASIS;
+}
+
+async function writeCost(pid) {
+ const mf = [
+ { ownerId: pid, namespace: 'custom', key: 'cost_per_m2', value: COST_PER_M2, type: 'number_decimal' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_currency', value: COST_CURRENCY, type: 'single_line_text_field' },
+ { ownerId: pid, namespace: 'custom', key: 'price_group', value: PRICE_GROUP, type: 'single_line_text_field' },
+ { ownerId: pid, namespace: 'custom', key: 'cost_basis', value: COST_BASIS, type: 'multi_line_text_field' },
+ ];
+ const r = await gql({ query: MF_MUTATION, variables: { m: mf } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ return errs.map(e => `${(e.field || []).join('.')}: ${e.message}`);
+}
+
+// ---------- cohort from dw_unified (RW murals, last 20 days) ----------
+async function loadCohort() {
+ const pg = new Client({ user: process.env.PGUSER || 'stevestudio2', database: process.env.PGDATABASE || 'dw_unified', host: process.env.PGHOST || '/tmp' });
+ await pg.connect();
+ try {
+ const res = await pg.query(
+ `SELECT shopify_id FROM shopify_products
+ WHERE created_at_shopify >= now() - interval '20 days'
+ AND vendor = 'Rebel Walls'
+ ORDER BY shopify_id`
+ );
+ return res.rows.map(r => r.shopify_id).filter(Boolean);
+ } finally { await pg.end(); }
+}
+
+async function main() {
+ let ids = await loadCohort();
+ console.log(`[rw-cost-write] cohort = ${ids.length} Rebel Walls murals (sandbox)`);
+ if (ids.length === 0) { console.error('FATAL: cohort empty'); process.exit(1); }
+
+ // ---- --reqa: re-fetch a 15-item sample, print, no writes ----
+ if (REQA) {
+ const step = Math.max(1, Math.floor(ids.length / 15));
+ const sample = ids.filter((_, i) => i % step === 0).slice(0, 15);
+ console.log(`[re-QA] sampling ${sample.length} products...`);
+ let ok = 0, bad = 0;
+ for (const pid of sample) {
+ const c = await fetchCost(pid);
+ const good = c && (c.cost_per_m2 === '44.5' || c.cost_per_m2 === '44.50') && c.price_group === 'C3';
+ if (good) ok++; else bad++;
+ console.log(` ${good ? 'OK ' : 'BAD'} ${pid} cost_per_m2=${c?.cost_per_m2} price_group=${c?.price_group} cur=${c?.cost_currency} vendor=${c?.vendor}`);
+ await sleep(450);
+ }
+ console.log(`[re-QA] ${ok}/${sample.length} confirmed cost_per_m2=44.50 & price_group=C3, ${bad} bad`);
+ return;
+ }
+
+ if (VALIDATE) ids = ids.slice(0, 3);
+ else if (LIMIT) ids = ids.slice(0, LIMIT);
+
+ let written = 0, skipped = 0, failed = 0;
+ const failures = [];
+
+ for (let i = 0; i < ids.length; i++) {
+ const pid = ids[i];
+ const before = await fetchCost(pid);
+ if (!before) { failed++; failures.push(`${pid}: product not found`); console.log(` [${i + 1}/${ids.length}] ${pid} NOT FOUND`); continue; }
+
+ // Guardrail: never touch a non-RW product even if the cohort query drifted.
+ if (before.vendor !== 'Rebel Walls') {
+ failed++; failures.push(`${pid}: vendor="${before.vendor}" not Rebel Walls — skipped`);
+ console.log(` [${i + 1}/${ids.length}] ${pid} GUARD: vendor="${before.vendor}" not RW, skipped`);
+ continue;
+ }
+
+ if (alreadyCorrect(before)) {
+ skipped++;
+ if (VALIDATE || i % 100 === 0) console.log(` [${i + 1}/${ids.length}] ${pid} already correct, skip`);
+ continue;
+ }
+
+ const errs = await writeCost(pid);
+ await sleep(350); // ~2 req/s (fetch + write per item)
+
+ if (errs.length) {
+ failed++; failures.push(`${pid}: ${errs.join('; ')}`);
+ console.log(` [${i + 1}/${ids.length}] ${pid} ERROR: ${errs.join('; ')}`);
+ continue;
+ }
+
+ if (VALIDATE) {
+ const after = await fetchCost(pid);
+ console.log(` VALIDATE ${pid}`);
+ console.log(` BEFORE: cost_per_m2=${before.cost_per_m2} price_group=${before.price_group} cur=${before.cost_currency}`);
+ console.log(` AFTER : cost_per_m2=${after.cost_per_m2} price_group=${after.price_group} cur=${after.cost_currency} basis=${after.cost_basis ? 'set' : 'MISSING'}`);
+ const ok = (after.cost_per_m2 === '44.5' || after.cost_per_m2 === '44.50') && after.price_group === 'C3' && after.cost_currency === 'USD' && !!after.cost_basis;
+ if (!ok) { failed++; failures.push(`${pid}: post-write verify failed`); }
+ else written++;
+ await sleep(350);
+ } else {
+ written++;
+ if (i % 50 === 0 || i === ids.length - 1) console.log(` [${i + 1}/${ids.length}] progress: written=${written} skipped=${skipped} failed=${failed}`);
+ }
+ }
+
+ console.log(`\n=== DONE (${VALIDATE ? 'VALIDATE' : 'FULL'}) ===`);
+ console.log(`cohort=${ids.length} written=${written} skipped(already-correct)=${skipped} failed=${failed}`);
+ if (failures.length) { console.log('FAILURES:'); failures.slice(0, 30).forEach(f => console.log(' - ' + f)); }
+ if (failed > 0) process.exit(1);
+}
+
+main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });
← 857b11f Class-wide tag/entity cleanup on 1003-product cohort (decode
·
back to Letsbegin
·
Backfill SKUs (mfr_sku/dw_sku/variant sku + rw url metafield 3c0c7fa →