[object Object]

← back to Rebel Walls Push

Rebel Walls: bake live-on-create (ACTIVE + all channels + inventory 2026) into pusher; add activation pass for already-pushed products

c54afd5e6c846d6fe1fef1b3a86eb1aaacaaf646 · 2026-06-04 08:29:40 -0700 · SteveStudio2

push.js now creates non-discontinued products as ACTIVE with tracked variants,
publishes to all 13 publications, and sets available=2026 on both variants after
each productSet. New activate.js does the same for the ~500 already-pushed DRAFT
products (idempotent, resumable via activated_at checkpoint). _shop.js shared
GraphQL/psql helper with 429/throttle backoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit c54afd5e6c846d6fe1fef1b3a86eb1aaacaaf646
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu Jun 4 08:29:40 2026 -0700

    Rebel Walls: bake live-on-create (ACTIVE + all channels + inventory 2026) into pusher; add activation pass for already-pushed products
    
    push.js now creates non-discontinued products as ACTIVE with tracked variants,
    publishes to all 13 publications, and sets available=2026 on both variants after
    each productSet. New activate.js does the same for the ~500 already-pushed DRAFT
    products (idempotent, resumable via activated_at checkpoint). _shop.js shared
    GraphQL/psql helper with 429/throttle backoff.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore          |   2 +
 scripts/_shop.js    |  67 ++++++++++++++++
 scripts/activate.js | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 scripts/push.js     |  83 ++++++++++++++++++-
 4 files changed, 372 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore
index 1c7862e..808d590 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ build/
 .next/
 data/push-progress.jsonl
 data/failures.json
+data/activate-progress.jsonl
+data/activate-failures.json
diff --git a/scripts/_shop.js b/scripts/_shop.js
new file mode 100644
index 0000000..0f3e7f3
--- /dev/null
+++ b/scripts/_shop.js
@@ -0,0 +1,67 @@
+'use strict';
+/* Shared Shopify GraphQL helper for Rebel Walls activation + push.
+ * Reusable module: gql(), getToken(), constants, pg(). */
+const https = require('https');
+const { execFileSync } = require('child_process');
+const fs = require('fs');
+
+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' };
+
+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();
+
+function gql(query, variables) {
+  const body = JSON.stringify({ query, variables: 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 { resolve({ status: res.statusCode, headers: res.headers, json: JSON.parse(data) }); }
+        catch (e) { reject(new Error(`bad JSON (${res.statusCode}): ${data.slice(0,300)}`)); }
+      });
+    });
+    req.on('error', reject);
+    req.write(body); req.end();
+  });
+}
+
+// Retry wrapper handling 429 / Throttled / transient 5xx with backoff.
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function gqlRetry(query, variables, label) {
+  for (let attempt = 1; attempt <= 6; attempt++) {
+    let r;
+    try { r = await gql(query, variables); }
+    catch (e) { if (attempt === 6) throw e; await sleep(1500 * attempt); continue; }
+    const throttled = r.status === 429 ||
+      (r.json && r.json.errors && JSON.stringify(r.json.errors).includes('Throttled'));
+    if (throttled) { await sleep(2500 * attempt); continue; }
+    if (r.status >= 500) { if (attempt === 6) throw new Error(`${label||''} HTTP ${r.status}`); await sleep(1500 * attempt); continue; }
+    // cost-aware: if bucket low, breathe
+    const ext = r.json && r.json.extensions && r.json.extensions.cost && r.json.extensions.cost.throttleStatus;
+    if (ext && ext.currentlyAvailable < 300) await sleep(1500);
+    return r;
+  }
+  throw new Error(`${label||''} exhausted retries`);
+}
+
+function psql(sql) {
+  const out = execFileSync('psql', [
+    '-h', PG.host, '-U', PG.user, '-d', PG.db, '-At', '-F', '\t', '-c', sql,
+  ], { env: { ...process.env, PGPASSWORD: PG.pass }, maxBuffer: 1 << 28 });
+  return out.toString();
+}
+
+module.exports = { gql, gqlRetry, psql, sleep, TOKEN, DOMAIN, API_VERSION, PG };
diff --git a/scripts/activate.js b/scripts/activate.js
new file mode 100644
index 0000000..39de924
--- /dev/null
+++ b/scripts/activate.js
@@ -0,0 +1,224 @@
+#!/usr/bin/env node
+'use strict';
+/*
+ * Rebel Walls activation pass — make already-pushed products fully live/sellable.
+ * Steve-authorized 2026-06-04: "publish all to active, assign all sales channels,
+ * and inventory at 2026 for all — rebel walls."
+ *
+ * For every rebelwalls_catalog row that HAS a checkpointed shopify_product_id:
+ *   1. status -> ACTIVE                      (productUpdate)
+ *   2. publish to ALL publications           (publishablePublish)
+ *   3. inventory = 2026 on BOTH variants     (inventoryItemUpdate tracked=true,
+ *                                             then inventorySetQuantities available=2026
+ *                                             at the store location)
+ *      inventoryPolicy already CONTINUE from the pusher; we keep it.
+ *
+ * Idempotent + resumable. Progress checkpointed to PG column
+ * rebelwalls_catalog.activated_at (added if missing) + a JSONL log.
+ *
+ * Usage:
+ *   node scripts/activate.js --canary 5     # first 5 pushed rows
+ *   node scripts/activate.js --all          # all pushed rows not yet activated
+ *   node scripts/activate.js --all --limit 100
+ *   node scripts/activate.js --all --reactivate   # ignore activated_at, redo all
+ */
+const fs = require('fs');
+const path = require('path');
+const { gqlRetry, psql, sleep } = require('./_shop.js');
+
+const QTY = 2026;
+const LOG_DIR = path.join(__dirname, '..', 'data');
+const ACT_LOG = path.join(LOG_DIR, 'activate-progress.jsonl');
+
+const args = process.argv.slice(2);
+const flag = n => args.includes(n);
+const val = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
+const CANARY = val('--canary', null);
+const DO_ALL = flag('--all');
+const LIMIT = val('--limit', null);
+const REACT = flag('--reactivate');
+const DRY = flag('--dry-run');
+
+function sql_(s) { return String(s).replace(/'/g, "''"); }
+
+// Ensure the checkpoint column exists.
+function ensureColumn() {
+  psql(`ALTER TABLE rebelwalls_catalog ADD COLUMN IF NOT EXISTS activated_at timestamptz;`);
+}
+
+function fetchRows() {
+  let where = "shopify_product_id IS NOT NULL AND shopify_product_id <> ''";
+  if (!REACT) where += ' AND activated_at IS NULL';
+  let lim = '';
+  if (CANARY) lim = `LIMIT ${parseInt(CANARY, 10)}`;
+  else if (LIMIT) lim = `LIMIT ${parseInt(LIMIT, 10)}`;
+  const raw = psql(
+    `SELECT id, dw_sku, shopify_product_id FROM rebelwalls_catalog ` +
+    `WHERE ${where} ORDER BY id ${lim};`
+  ).trim();
+  if (!raw) return [];
+  return raw.split('\n').map(line => {
+    const f = line.split('\t');
+    return { id: f[0], dw_sku: f[1], pid: f[2] };
+  });
+}
+
+function markActivated(pgId) {
+  psql(`UPDATE rebelwalls_catalog SET activated_at = NOW() WHERE id = ${parseInt(pgId, 10)};`);
+}
+
+// --- GraphQL ops ----------------------------------------------------------
+const Q_PROD = `query($id:ID!){
+  product(id:$id){
+    id status
+    variants(first:10){edges{node{
+      id sku inventoryPolicy
+      inventoryItem{ id tracked
+        inventoryLevels(first:10){edges{node{ location{id} quantities(names:["available"]){name quantity} }}}
+      }
+    }}}
+  }
+}`;
+
+const M_UPDATE_STATUS = `mutation($input:ProductInput!){
+  productUpdate(input:$input){ product{id status} userErrors{field message} }
+}`;
+
+const M_PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){
+  publishablePublish(id:$id, input:$input){ userErrors{field message} }
+}`;
+
+const M_INV_TRACK = `mutation($id:ID!,$input:InventoryItemInput!){
+  inventoryItemUpdate(id:$id, input:$input){ inventoryItem{id tracked} userErrors{field message} }
+}`;
+
+const M_SET_QTY = `mutation($input:InventorySetQuantitiesInput!){
+  inventorySetQuantities(input:$input){
+    inventoryAdjustmentGroup{createdAt reason}
+    userErrors{field message}
+  }
+}`;
+
+let PUBLICATION_IDS = null;
+async function loadPublications() {
+  const r = await gqlRetry(`{publications(first:50){edges{node{id name}}}}`, {}, 'publications');
+  PUBLICATION_IDS = r.json.data.publications.edges.map(e => e.node);
+  return PUBLICATION_IDS;
+}
+
+async function activateRow(row, locationId) {
+  const gid = `gid://shopify/Product/${row.pid}`;
+  const out = { pg_id: row.id, dw_sku: row.dw_sku, pid: row.pid, steps: {} };
+
+  // 1. fetch current state
+  const pr = await gqlRetry(Q_PROD, { id: gid }, 'product');
+  const prod = pr.json.data && pr.json.data.product;
+  if (!prod) throw new Error(`product ${row.pid} not found (deleted?)`);
+
+  // 1a. status -> ACTIVE (skip if already)
+  if (prod.status !== 'ACTIVE') {
+    if (!DRY) {
+      const r = await gqlRetry(M_UPDATE_STATUS, { input: { id: gid, status: 'ACTIVE' } }, 'status');
+      const ue = r.json.data.productUpdate.userErrors;
+      if (ue && ue.length) throw new Error('status: ' + JSON.stringify(ue));
+    }
+    out.steps.status = 'set-active';
+  } else out.steps.status = 'already-active';
+
+  // 2. publish to ALL publications
+  if (!DRY) {
+    const input = PUBLICATION_IDS.map(p => ({ publicationId: p.id }));
+    const r = await gqlRetry(M_PUBLISH, { id: gid, input }, 'publish');
+    const ue = r.json.data.publishablePublish.userErrors;
+    // tolerate "already published" style errors; surface real ones
+    if (ue && ue.length) {
+      const real = ue.filter(e => !/already/i.test(e.message || ''));
+      if (real.length) throw new Error('publish: ' + JSON.stringify(real));
+    }
+  }
+  out.steps.publish = `all(${PUBLICATION_IDS.length})`;
+
+  // 3. inventory = QTY on both variants
+  const variants = prod.variants.edges.map(e => e.node);
+  out.steps.inventory = [];
+  const setQ = []; // batch inventorySetQuantities
+  for (const v of variants) {
+    const itemId = v.inventoryItem.id;
+    // 3a. ensure tracked=true (required for quantities to surface)
+    if (!v.inventoryItem.tracked) {
+      if (!DRY) {
+        const r = await gqlRetry(M_INV_TRACK, { id: itemId, input: { tracked: true } }, 'track');
+        const ue = r.json.data.inventoryItemUpdate.userErrors;
+        if (ue && ue.length) throw new Error('track: ' + JSON.stringify(ue));
+      }
+    }
+    setQ.push({ inventoryItemId: itemId, locationId, quantity: QTY });
+    out.steps.inventory.push(v.sku);
+  }
+  // 3b. set quantities (one call per product covers both variants)
+  if (!DRY && setQ.length) {
+    const r = await gqlRetry(M_SET_QTY, {
+      input: {
+        name: 'available',
+        reason: 'correction',
+        ignoreCompareQuantity: true,
+        quantities: setQ,
+      },
+    }, 'setqty');
+    const ue = r.json.data.inventorySetQuantities.userErrors;
+    if (ue && ue.length) throw new Error('setqty: ' + JSON.stringify(ue));
+  }
+
+  return out;
+}
+
+// Discover the store location id from an already-pushed variant's inventory level.
+async function discoverLocation(samplePid) {
+  const r = await gqlRetry(Q_PROD, { id: `gid://shopify/Product/${samplePid}` }, 'loc');
+  const prod = r.json.data.product;
+  for (const e of prod.variants.edges) {
+    const lvls = e.node.inventoryItem.inventoryLevels.edges;
+    if (lvls.length) return lvls[0].node.location.id;
+  }
+  throw new Error('could not discover location id from sample product');
+}
+
+(async () => {
+  if (!CANARY && !DO_ALL) { console.error('specify --canary N | --all'); process.exit(1); }
+  ensureColumn();
+  await loadPublications();
+  console.log(`[activate] publications: ${PUBLICATION_IDS.map(p => p.name).join(', ')}`);
+
+  const rows = fetchRows();
+  console.log(`[activate] ${rows.length} pushed rows to activate (dry=${DRY}, reactivate=${REACT})`);
+  if (!rows.length) { console.log('nothing to do'); return; }
+
+  const locationId = await discoverLocation(rows[0].pid);
+  console.log(`[activate] location: ${locationId}  inventory target: ${QTY}`);
+
+  let ok = 0, fail = 0, variantsSet = 0;
+  const failures = [];
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i];
+    try {
+      const res = await activateRow(row, locationId);
+      if (!DRY) markActivated(row.id);
+      variantsSet += res.steps.inventory.length;
+      ok++;
+      fs.appendFileSync(ACT_LOG, JSON.stringify({ ts: new Date().toISOString(), ...res }) + '\n');
+      if (ok % 25 === 0 || i === rows.length - 1)
+        console.log(`  activated ${ok}/${rows.length} (last ${row.dw_sku}; ${variantsSet} variants @${QTY})`);
+    } catch (e) {
+      fail++;
+      failures.push({ pg_id: row.id, dw_sku: row.dw_sku, pid: row.pid, error: e.message });
+      console.error(`  FAIL [${row.id}] ${row.dw_sku}: ${e.message}`);
+      fs.appendFileSync(ACT_LOG, JSON.stringify({ ts: new Date().toISOString(), pg_id: row.id, dw_sku: row.dw_sku, error: e.message }) + '\n');
+    }
+    if (!DRY) await sleep(250);
+  }
+  console.log(`\n[activate] DONE ok=${ok} fail=${fail} variants-set=${variantsSet}`);
+  if (failures.length) {
+    fs.writeFileSync(path.join(LOG_DIR, 'activate-failures.json'), JSON.stringify(failures, null, 2));
+    console.log('  failures -> data/activate-failures.json');
+  }
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/push.js b/scripts/push.js
index ab56098..ee6d976 100644
--- a/scripts/push.js
+++ b/scripts/push.js
@@ -212,7 +212,14 @@ function buildMetafields(row) {
 }
 
 function buildInput(row) {
-  const status = row.discontinued === 't' || row.discontinued === true ? 'ARCHIVED' : 'DRAFT';
+  // Steve-authorized 2026-06-04 ("publish all to active, assign all sales channels,
+  // and inventory at 2026 for all — rebel walls"): Rebel Walls ship LIVE on create.
+  // Non-discontinued -> ACTIVE (every row has image + width so the
+  // "never ACTIVE without image+width" rule is satisfied for this vendor).
+  // Discontinued -> ARCHIVED. Variants tracked=true so inventory 2026 surfaces;
+  // policy CONTINUE = always-sellable made-to-order mural. After the productSet,
+  // pushRow publishes to ALL publications + sets available=2026 on both variants.
+  const status = row.discontinued === 't' || row.discontinued === true ? 'ARCHIVED' : 'ACTIVE';
   const price = String(row.price_retail);
   const input = {
     title: buildTitle(row),
@@ -229,7 +236,7 @@ function buildInput(row) {
         optionValues: [{ optionName: 'Title', name: 'Mural (per m²)' }],
         price,
         sku: row.dw_sku,
-        inventoryItem: { sku: row.dw_sku, tracked: false },
+        inventoryItem: { sku: row.dw_sku, tracked: true },
         inventoryPolicy: 'CONTINUE',
         taxable: true,
       },
@@ -237,7 +244,7 @@ function buildInput(row) {
         optionValues: [{ optionName: 'Title', name: 'Sample' }],
         price: SAMPLE_PRICE,
         sku: `${row.dw_sku}-Sample`,
-        inventoryItem: { sku: `${row.dw_sku}-Sample`, tracked: false },
+        inventoryItem: { sku: `${row.dw_sku}-Sample`, tracked: true },
         inventoryPolicy: 'CONTINUE',
         taxable: true,
       },
@@ -252,12 +259,71 @@ function buildInput(row) {
 const MUTATION = `mutation push($input: ProductSetInput!) {
   productSet(synchronous: true, input: $input) {
     product { id handle status
-      variants(first: 5) { edges { node { sku price } } }
+      variants(first: 5) { edges { node { sku price inventoryItem { id } } } }
       media(first: 1) { edges { node { mediaContentType status } } } }
     userErrors { field message }
   }
 }`;
 
+// ---- live-on-create helpers (publications + inventory) ------------------
+const QTY_2026 = 2026;
+let PUBLICATION_IDS = null;   // [{id,name}]
+let LOCATION_ID = null;       // gid://shopify/Location/...
+
+async function loadPublications() {
+  const r = await gql(`{publications(first:50){edges{node{id name}}}}`);
+  PUBLICATION_IDS = (r.json.data && r.json.data.publications.edges || []).map(e => e.node);
+}
+async function discoverLocation() {
+  // Pull the location off any already-pushed variant's inventory level. If none
+  // exist yet, fall back to the first store location via locations() (needs scope).
+  const r = await gql(`{locations(first:1){edges{node{id}}}}`);
+  if (r.json.data && r.json.data.locations && r.json.data.locations.edges.length) {
+    LOCATION_ID = r.json.data.locations.edges[0].node.id;
+    return;
+  }
+  // scope-restricted: read it off an existing pushed product
+  const row = psql(`SELECT shopify_product_id FROM rebelwalls_catalog WHERE shopify_product_id IS NOT NULL AND shopify_product_id <> '' ORDER BY id LIMIT 1;`).trim();
+  if (!row) throw new Error('cannot discover location (no scope, no pushed product)');
+  const pid = row.split('\t')[0];
+  const pr = await gql(`{product(id:"gid://shopify/Product/${pid}"){variants(first:5){edges{node{inventoryItem{inventoryLevels(first:1){edges{node{location{id}}}}}}}}}`);
+  for (const e of pr.json.data.product.variants.edges) {
+    const lv = e.node.inventoryItem.inventoryLevels.edges;
+    if (lv.length) { LOCATION_ID = lv[0].node.location.id; return; }
+  }
+  throw new Error('cannot discover location id');
+}
+
+const M_PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){
+  publishablePublish(id:$id, input:$input){ userErrors{field message} }
+}`;
+const M_SET_QTY = `mutation($input:InventorySetQuantitiesInput!){
+  inventorySetQuantities(input:$input){ userErrors{field message} }
+}`;
+
+async function publishAndStock(product) {
+  // publish to ALL publications
+  if (PUBLICATION_IDS && PUBLICATION_IDS.length) {
+    const input = PUBLICATION_IDS.map(p => ({ publicationId: p.id }));
+    const r = await gql(M_PUBLISH, { id: product.id, input });
+    const ue = r.json.data && r.json.data.publishablePublish.userErrors;
+    if (ue && ue.length) {
+      const real = ue.filter(e => !/already/i.test(e.message || ''));
+      if (real.length) throw new Error('publish: ' + JSON.stringify(real));
+    }
+  }
+  // inventory = 2026 on both variants (one call)
+  const setQ = product.variants.edges
+    .map(e => e.node.inventoryItem && e.node.inventoryItem.id)
+    .filter(Boolean)
+    .map(itemId => ({ inventoryItemId: itemId, locationId: LOCATION_ID, quantity: QTY_2026 }));
+  if (setQ.length && LOCATION_ID) {
+    const r = await gql(M_SET_QTY, { input: { name: 'available', reason: 'correction', ignoreCompareQuantity: true, quantities: setQ } });
+    const ue = r.json.data && r.json.data.inventorySetQuantities.userErrors;
+    if (ue && ue.length) throw new Error('setqty: ' + JSON.stringify(ue));
+  }
+}
+
 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 }; }
@@ -273,6 +339,10 @@ async function pushRow(row) {
       // genuine validation errors are not retryable
       throw new Error(`userErrors: ${JSON.stringify(data.userErrors)}`);
     }
+    // Live-on-create: publish to all channels + stock 2026 (non-discontinued only).
+    if (data.product.status === 'ACTIVE') {
+      await publishAndStock(data.product);
+    }
     // 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);
@@ -283,6 +353,11 @@ async function pushRow(row) {
 
 (async () => {
   if (!CANARY && !DO_ALL && !IDS) { console.error('specify --canary N | --all | --ids a,b,c'); process.exit(1); }
+  if (!DRY) {
+    await loadPublications();
+    await discoverLocation();
+    console.log(`[rebel-walls-push] live-on-create: ${PUBLICATION_IDS.length} publications, location ${LOCATION_ID}, inventory ${QTY_2026}`);
+  }
   const rows = fetchRows();
   console.log(`[rebel-walls-push] ${rows.length} rows to push  (dry=${DRY})`);
   let ok = 0, fail = 0;

← 6401830 add retitle.js: title-only bulk update inserting " Wallcover  ·  back to Rebel Walls Push  ·  dedup-guard push: skip 759 patterns already live under older 1e70258 →