[object Object]

← back to Rebel Walls Push

rebel walls -> shopify pusher (productSet, draft, sample variant, resumable)

3dcaf207ed575a0297b74cd36b43dfbf2b65b61a · 2026-06-04 00:11:47 -0700 · Steve

Files touched

Diff

commit 3dcaf207ed575a0297b74cd36b43dfbf2b65b61a
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 4 00:11:47 2026 -0700

    rebel walls -> shopify pusher (productSet, draft, sample variant, resumable)
---
 .gitignore      |  10 ++
 scripts/push.js | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 298 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1c7862e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/push-progress.jsonl
+data/failures.json
diff --git a/scripts/push.js b/scripts/push.js
new file mode 100644
index 0000000..1efa9eb
--- /dev/null
+++ b/scripts/push.js
@@ -0,0 +1,288 @@
+#!/usr/bin/env node
+/*
+ * Rebel Walls -> DW Shopify pusher.
+ *
+ * Source of truth: PostgreSQL dw_unified.rebelwalls_catalog (local Mac2).
+ * Target:          designer-laboratory-sandbox.myshopify.com (Admin GraphQL 2024-10).
+ *
+ * One product per row (each colorway is already its own DWRW SKU row).
+ * Each product gets TWO variants via productSet:
+ *    - main mural variant  : DWRW-xxxxxx, price = price_retail (per m2), tracked=false
+ *    - sample variant      : DWRW-xxxxxx-Sample, $4.25, tracked=false
+ *
+ * Standing rules honored:
+ *  - PostgreSQL before Shopify (PG already populated).
+ *  - Title: "Pattern Name, Color | Rebel Walls" (skip color seg when missing -> never "Unknown").
+ *  - Word "Wallpaper" banned -> productType "Mural", body uses "Wallcovering"/"Mural".
+ *  - NEVER ACTIVE without image AND width -> status=DRAFT always on create.
+ *    (every row HAS image + width, so we tag nothing as Needs-Image/Needs-Width;
+ *     activation is a separate, later, Steve-gated step.)
+ *  - Sample variant required.
+ *  - Discontinued => ARCHIVED (none in this batch, handled anyway).
+ *  - Checkpoint: shopify_product_id written back to PG per row -> resumable.
+ *  - Rate limit: GraphQL cost-aware throttle + >=90s gap is N/A for single-product
+ *    creates (that rule is for BULK pushes); we self-throttle per-call instead.
+ *
+ * Usage:
+ *   node scripts/push.js --canary 5          # push first 5 unpushed rows
+ *   node scripts/push.js --all               # push all remaining unpushed rows
+ *   node scripts/push.js --all --limit 100   # cap this run
+ *   node scripts/push.js --ids 1,2,3         # specific PG ids
+ *   node scripts/push.js --dry-run --canary 5
+ */
+const https = require('https');
+const { execFileSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+// ---- config -------------------------------------------------------------
+const SECRETS_ENV = '/Users/stevestudio2/Projects/secrets-manager/.env';
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const API_VERSION = '2024-10';
+const PG = { host: '127.0.0.1', user: 'dw_admin', db: 'dw_unified', pass: 'DW2024SecurePass' };
+const VENDOR = 'Rebel Walls';
+const SAMPLE_PRICE = '4.25';
+const LOG_DIR = path.join(__dirname, '..', 'data');
+const PROGRESS_LOG = path.join(LOG_DIR, 'push-progress.jsonl');
+
+function getToken() {
+  const env = fs.readFileSync(SECRETS_ENV, 'utf8');
+  const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+  if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN not found in secrets .env');
+  return m[1].trim();
+}
+const TOKEN = getToken();
+
+// ---- args ---------------------------------------------------------------
+const args = process.argv.slice(2);
+function flag(name) { return args.includes(name); }
+function val(name, def) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; }
+const DRY = flag('--dry-run');
+const CANARY = val('--canary', null);
+const DO_ALL = flag('--all');
+const LIMIT = val('--limit', null);
+const IDS = val('--ids', null);
+
+// ---- pg helpers (psql, no external deps) --------------------------------
+function psql(sql) {
+  const out = execFileSync('psql', [
+    '-h', PG.host, '-U', PG.user, '-d', PG.db,
+    '-At', '-F', '', '-c', sql,
+  ], { env: { ...process.env, PGPASSWORD: PG.pass }, maxBuffer: 1 << 28 });
+  return out.toString();
+}
+function fetchRows() {
+  let where = "(shopify_product_id IS NULL OR shopify_product_id='')";
+  if (IDS) where = `id IN (${IDS.split(',').map(n => parseInt(n, 10)).filter(Number.isFinite).join(',')})`;
+  let limitClause = '';
+  if (CANARY) limitClause = `LIMIT ${parseInt(CANARY, 10)}`;
+  else if (LIMIT) limitClause = `LIMIT ${parseInt(LIMIT, 10)}`;
+  const cols = ['id','mfr_sku','dw_sku','pattern_name','color_name','collection',
+    'product_type','material','roll_width','grammage','fire_rating','light_fastness',
+    'cleanability','sustainability','paper_quality','repeat_h','repeat_v','match_type',
+    'price_retail','price_unit','display_dimension','image_url','product_url',
+    'in_stock','discontinued'];
+  const sql = `SELECT ${cols.join(',')} FROM rebelwalls_catalog WHERE ${where} ORDER BY id ${limitClause};`;
+  const raw = psql(sql).trim();
+  if (!raw) return [];
+  return raw.split('\n').map(line => {
+    const f = line.split('');
+    const o = {}; cols.forEach((c, i) => { o[c] = f[i] === '' ? null : f[i]; });
+    return o;
+  });
+}
+function markPushed(pgId, shopifyGid) {
+  const numeric = String(shopifyGid).replace(/^gid:\/\/shopify\/Product\//, '');
+  psql(`UPDATE rebelwalls_catalog SET shopify_product_id='${numeric}', updated_at=NOW() WHERE id=${pgId};`);
+}
+
+// ---- shopify graphql ----------------------------------------------------
+function gql(query, variables) {
+  const body = JSON.stringify({ query, variables });
+  return new Promise((resolve, reject) => {
+    const req = https.request({
+      hostname: DOMAIN, path: `/admin/api/${API_VERSION}/graphql.json`, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN,
+        'Content-Length': Buffer.byteLength(body) },
+    }, res => {
+      let data = '';
+      res.on('data', c => data += c);
+      res.on('end', () => {
+        try {
+          const j = JSON.parse(data);
+          resolve({ status: res.statusCode, headers: res.headers, json: j });
+        } catch (e) { reject(new Error(`bad JSON (${res.statusCode}): ${data.slice(0,300)}`)); }
+      });
+    });
+    req.on('error', reject);
+    req.write(body); req.end();
+  });
+}
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// ---- build helpers ------------------------------------------------------
+function titleCase(s) {
+  if (!s) return s;
+  return s.replace(/\w[^\s-]*/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
+    .replace(/\b(Of|And|The|In|On|For|A|An|To|With)\b/g, m => m.toLowerCase())
+    .replace(/^(\w)/, c => c.toUpperCase());
+}
+function buildTitle(row) {
+  // Format: "Pattern Name, Color | Rebel Walls". No color -> skip color segment. Never "Unknown", never "Wallpaper".
+  let pattern = (row.pattern_name || '').trim();
+  let color = (row.color_name || '').trim();
+  if (pattern) pattern = titleCase(pattern);
+  if (color && !/unknown/i.test(color)) color = titleCase(color); else color = '';
+  let core = color ? `${pattern}, ${color}` : pattern;
+  if (!core) core = row.mfr_sku || row.dw_sku;       // ultimate fallback, never blank
+  return `${core} | ${VENDOR}`.replace(/wallpaper/gi, 'Wallcovering');
+}
+function buildHandle(row) {
+  const base = (buildTitle(row).replace(/\s*\|\s*Rebel Walls$/i, '') + '-' + row.dw_sku)
+    .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+  return base.slice(0, 100);
+}
+function buildBodyHtml(row) {
+  const pattern = titleCase(row.pattern_name || '');
+  const color = row.color_name ? titleCase(row.color_name) : '';
+  const collection = row.collection || '';
+  const specs = [];
+  if (row.material) specs.push(`<li><strong>Material:</strong> ${esc(row.material)}</li>`);
+  if (row.roll_width) specs.push(`<li><strong>Roll Width:</strong> ${esc(row.roll_width)}</li>`);
+  if (row.grammage) specs.push(`<li><strong>Grammage:</strong> ${esc(row.grammage)}</li>`);
+  if (row.fire_rating) specs.push(`<li><strong>Fire Rating:</strong> ${esc(row.fire_rating)}</li>`);
+  if (row.light_fastness) specs.push(`<li><strong>Light Fastness:</strong> ${esc(row.light_fastness)}</li>`);
+  if (row.cleanability) specs.push(`<li><strong>Cleanability:</strong> ${esc(row.cleanability)}</li>`);
+  if (row.display_dimension) specs.push(`<li><strong>Sold By:</strong> ${esc(row.price_unit || 'Square Meter')} — ${esc(row.display_dimension)}</li>`);
+  const intro = `${pattern}${color ? ' in ' + color : ''} is a designer wall mural from the ${collection ? esc(collection) + ' collection by ' : ''}${VENDOR} line. Printed to order on premium non-woven substrate, this wallcovering is priced per square meter so it scales to any wall.`;
+  return `<p>${esc(intro)}</p><ul>${specs.join('')}</ul>`;
+}
+function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
+
+function buildTags(row) {
+  const tags = ['Rebel Walls', 'Mural', 'Wall Mural', 'Non-Woven', 'Priced Per Square Meter'];
+  if (row.collection) tags.push(`Collection: ${row.collection}`);
+  if (row.color_name) tags.push(row.color_name);
+  return tags;
+}
+
+function buildMetafields(row) {
+  const mf = [];
+  const push = (ns, key, value, type = 'single_line_text_field') => {
+    if (value == null || value === '') return;
+    mf.push({ namespace: ns, key, type, value: String(value) });
+  };
+  push('custom', 'manufacturer_sku', row.mfr_sku);
+  push('dwc', 'manufacturer_sku', row.mfr_sku);
+  push('global', 'Brand', VENDOR, 'string');
+  push('global', 'width', row.roll_width);
+  push('global', 'unit_of_measure', row.price_unit || 'Priced Per Square Meter', 'string');
+  if (row.color_name) { push('custom', 'color', row.color_name); push('custom', 'real_color_name', row.color_name); push('global','color',row.color_name); }
+  push('custom', 'fire_rating', row.fire_rating);
+  push('custom', 'grammage', row.grammage);
+  push('custom', 'material', row.material);
+  push('custom', 'display_dimension', row.display_dimension);
+  if (row.product_url) push('custom', 'source_url', row.product_url, 'url');
+  return mf;
+}
+
+function buildInput(row) {
+  const status = row.discontinued === 't' || row.discontinued === true ? 'ARCHIVED' : 'DRAFT';
+  const price = String(row.price_retail);
+  const input = {
+    title: buildTitle(row),
+    handle: buildHandle(row),
+    vendor: VENDOR,
+    productType: row.product_type || 'Mural',
+    status,
+    tags: buildTags(row),
+    descriptionHtml: buildBodyHtml(row),
+    metafields: buildMetafields(row),
+    productOptions: [{ name: 'Title', position: 1, values: [{ name: 'Mural (per m²)' }, { name: 'Sample' }] }],
+    variants: [
+      {
+        optionValues: [{ optionName: 'Title', name: 'Mural (per m²)' }],
+        price,
+        sku: row.dw_sku,
+        inventoryItem: { sku: row.dw_sku, tracked: false },
+        inventoryPolicy: 'CONTINUE',
+        taxable: true,
+      },
+      {
+        optionValues: [{ optionName: 'Title', name: 'Sample' }],
+        price: SAMPLE_PRICE,
+        sku: `${row.dw_sku}-Sample`,
+        inventoryItem: { sku: `${row.dw_sku}-Sample`, tracked: false },
+        inventoryPolicy: 'CONTINUE',
+        taxable: true,
+      },
+    ],
+  };
+  if (row.image_url) {
+    input.files = [{ originalSource: row.image_url, contentType: 'IMAGE', alt: buildTitle(row).replace(/\s*\|.*$/, '') }];
+  }
+  return input;
+}
+
+const MUTATION = `mutation push($input: ProductSetInput!) {
+  productSet(synchronous: true, input: $input) {
+    product { id handle status
+      variants(first: 5) { edges { node { sku price } } }
+      media(first: 1) { edges { node { mediaContentType status } } } }
+    userErrors { field message }
+  }
+}`;
+
+async function pushRow(row) {
+  const input = buildInput(row);
+  if (DRY) { return { dry: true, title: input.title, status: input.status, skus: input.variants.map(v=>v.sku), price: input.variants[0].price, image: !!input.files }; }
+  for (let attempt = 1; attempt <= 5; attempt++) {
+    const r = await gql(MUTATION, { input });
+    if (r.status === 429 || (r.json && r.json.errors && JSON.stringify(r.json.errors).includes('Throttled'))) {
+      const wait = 2000 * attempt; await sleep(wait); continue;
+    }
+    if (r.status !== 200) { await sleep(1500 * attempt); if (attempt === 5) throw new Error(`HTTP ${r.status}: ${JSON.stringify(r.json).slice(0,300)}`); continue; }
+    const data = r.json.data && r.json.data.productSet;
+    if (r.json.errors) throw new Error(`GraphQL errors: ${JSON.stringify(r.json.errors).slice(0,400)}`);
+    if (data.userErrors && data.userErrors.length) {
+      // genuine validation errors are not retryable
+      throw new Error(`userErrors: ${JSON.stringify(data.userErrors)}`);
+    }
+    // cost-aware throttle: respect remaining bucket
+    const ext = r.json.extensions && r.json.extensions.cost && r.json.extensions.cost.throttleStatus;
+    if (ext && ext.currentlyAvailable < 200) await sleep(1200);
+    return { product: data.product };
+  }
+  throw new Error('exhausted retries');
+}
+
+(async () => {
+  if (!CANARY && !DO_ALL && !IDS) { console.error('specify --canary N | --all | --ids a,b,c'); process.exit(1); }
+  const rows = fetchRows();
+  console.log(`[rebel-walls-push] ${rows.length} rows to push  (dry=${DRY})`);
+  let ok = 0, fail = 0;
+  const failures = [];
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i];
+    try {
+      const res = await pushRow(row);
+      if (DRY) { console.log(`DRY [${row.id}] ${res.title} | ${res.status} | ${res.skus.join(', ')} | $${res.price} | img=${res.image}`); ok++; continue; }
+      const gid = res.product.id;
+      markPushed(row.id, gid);
+      ok++;
+      const logLine = { ts: new Date().toISOString(), pg_id: row.id, dw_sku: row.dw_sku, shopify_id: gid, handle: res.product.handle, status: res.product.status, mediaStatus: (res.product.media.edges[0]||{}).node && res.product.media.edges[0].node.status };
+      fs.appendFileSync(PROGRESS_LOG, JSON.stringify(logLine) + '\n');
+      if (ok % 25 === 0 || i === rows.length - 1) console.log(`  pushed ${ok}/${rows.length} (last: ${row.dw_sku} -> ${gid})`);
+    } catch (e) {
+      fail++;
+      failures.push({ pg_id: row.id, dw_sku: row.dw_sku, error: e.message });
+      console.error(`  FAIL [${row.id}] ${row.dw_sku}: ${e.message}`);
+      fs.appendFileSync(PROGRESS_LOG, JSON.stringify({ ts: new Date().toISOString(), pg_id: row.id, dw_sku: row.dw_sku, error: e.message }) + '\n');
+    }
+    // gentle inter-call pacing
+    if (!DRY) await sleep(350);
+  }
+  console.log(`\n[rebel-walls-push] DONE  ok=${ok}  fail=${fail}`);
+  if (failures.length) { fs.writeFileSync(path.join(LOG_DIR, 'failures.json'), JSON.stringify(failures, null, 2)); console.log(`  failures -> data/failures.json`); }
+})().catch(e => { console.error('FATAL', e); process.exit(1); });

(oldest)  ·  back to Rebel Walls Push  ·  title-caser preserves 3D/ABC/acronym tokens 87b7133 →