← 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
M .gitignoreA scripts/_shop.jsA scripts/activate.jsM scripts/push.js
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 →