← back to Wolfgordon Crawl
push-shopify.js
691 lines
#!/usr/bin/env node
// ============================================================================
// Wolf Gordon -> Shopify pusher (LIVE store: designer-laboratory-sandbox)
//
// Catalog-driven, idempotent-by-SKU, image-ADD-only (never deletes), throttled,
// resumable. DEFAULTS TO DRY-RUN — writes nothing unless --apply is passed.
//
// node push-shopify.js # dry-run, whole catalog plan
// node push-shopify.js --canary # dry-run, the 10-item canary set
// node push-shopify.js --canary --apply # LIVE writes, 10-item canary
// node push-shopify.js --limit 50 # dry-run, first 50 actionable
//
// Source of truth = local dw_unified.wolf_gordon_catalog (PostgreSQL BEFORE
// Shopify). Records the returned shopify_product_id back into the catalog.
//
// DW standing rules enforced:
// - word "Wallpaper" BANNED -> "Wallcovering(s)".
// - title format: "Pattern Real-Color | Wolf Gordon Wallcoverings", Title Case.
// - NEVER "Unknown" in a title; fall back mfr_sku -> color -> skip.
// - every product gets a Yard variant + a {DW_SKU}-Sample variant ($4.25, no
// inventory tracking).
// - NEVER ACTIVE without image AND width metafield (else draft + Needs-* tag).
// - Discontinued = ARCHIVE (never delete).
// - price = price_retail (already = round(cost/0.65/0.85, 2)).
// - throttle ~2 req/s; >=90s gap between bulk batches (canary is one batch).
// ============================================================================
const { pool } = require('./scraper-utils');
const os = require('os');
const fs = require('fs');
// ---------- shared cap coordination (DTD-C variant-budget ledger) ----------
// The net-new create path used to bulk-dump products outside the paced cadence,
// which is what blew the ~1,000/day Shopify variant cap on 2026-06-19 (10–11am).
// It now draws from the SAME shared daily ledger every other DW writer uses
// (budget.cjs, category 'upload' — the ACTIVE phase), so creates-per-run can
// never exceed the day's remaining grant. Honors the shared kill-switch too.
// Pattern mirrors shopify/scripts/cadence/run-cadence-hourly.sh.
const KILL_SWITCH = os.homedir() + '/.dw-fixer-stop';
let budget = null;
try {
budget = require(os.homedir() + '/Projects/designerwallcoverings/scripts/variant-budget/budget.cjs');
} catch (e) {
console.error(' [budget] WARN: ledger not loadable (' + e.message + ') — net-new creates will be BLOCKED (fail-CLOSED on the cap).');
}
const VARIANTS_PER_PRODUCT = 2; // every product ships Yard + Sample
// ---------- config ----------
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const GQL = `https://${SHOP}/admin/api/${API}/graphql.json`;
const REST = `https://${SHOP}/admin/api/${API}`;
const VENDOR = 'Wolf Gordon';
const SAMPLE_PRICE = '4.25';
const THROTTLE_MS = 500; // ~2 req/s
// ---------- args ----------
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const CANARY = argv.includes('--canary');
// Phase 1 = updates + archives only, and VARIANT-FREE (no Sample variant added
// to existing products) so it never touches the daily variant-create cap.
const PHASE1 = argv.includes('--phase1');
// Phase 2 = create net-new products (new + draft) only; staggered by variant cap.
const NEW_ONLY = argv.includes('--new-only');
const MAX = (() => {
const i = argv.indexOf('--max');
return i !== -1 && argv[i + 1] ? parseInt(argv[i + 1], 10) : null;
})();
const LIMIT = (() => {
const i = argv.indexOf('--limit');
return i !== -1 && argv[i + 1] ? parseInt(argv[i + 1], 10) : null;
})();
// ---------- token (master .env via secrets manager) ----------
function loadToken() {
if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
const fs = require('fs');
const p = require('os').homedir() + '/Projects/secrets-manager/.env';
const line = fs.readFileSync(p, 'utf8').split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
if (!line) throw new Error('SHOPIFY_ADMIN_TOKEN not found in secrets .env');
return line.slice('SHOPIFY_ADMIN_TOKEN='.length).replace(/^["']|["']$/g, '').trim();
}
const TOKEN = loadToken();
const wait = (ms) => new Promise(r => setTimeout(r, ms));
async function gql(query, variables) {
await wait(THROTTLE_MS);
const res = await fetch(GQL, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const j = await res.json();
if (j.errors) {
const throttled = JSON.stringify(j.errors).includes('VARIANT_THROTTLE_EXCEEDED');
const err = new Error('GraphQL: ' + JSON.stringify(j.errors));
err.variantThrottled = throttled;
throw err;
}
return j.data;
}
async function rest(method, path, body) {
await wait(THROTTLE_MS);
const res = await fetch(REST + path, {
method,
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const txt = await res.text();
let j; try { j = JSON.parse(txt); } catch { j = { _raw: txt }; }
if (!res.ok) throw new Error(`REST ${method} ${path} -> ${res.status}: ${txt.slice(0, 300)}`);
return j;
}
// ---------- normalization helpers (shared w/ reconcile.js) ----------
function norm(s) {
if (!s) return '';
return String(s).toLowerCase().replace(/[®™©]/g, '').replace(/&/g, '&')
.replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function nsku(s) {
if (!s) return '';
return String(s).toLowerCase().replace(/_8$/, '').replace(/[^a-z0-9]+/g, '');
}
function legacyColorFromTitle(title) {
if (!title) return null;
const head = (title || '').split('|')[0].trim();
const idx = head.indexOf(' - ');
return idx === -1 ? null : head.slice(idx + 3).trim();
}
function titleCase(s) {
return String(s || '').toLowerCase().replace(/\b([a-z])/g, (m, c) => c.toUpperCase());
}
function banWallpaper(s) {
return String(s || '').replace(/wallpapers/gi, 'Wallcoverings').replace(/wallpaper/gi, 'Wallcovering');
}
// Build the DW title: "Pattern Real-Color | Wolf Gordon Wallcoverings".
// Color from catalog ONLY; never "Unknown"; fall back mfr_sku -> color -> skip.
function buildTitle(row) {
const pat = titleCase((row.pattern_name || '').trim());
let color = (row.color_name || '').trim();
if (!color || /^unknown$/i.test(color)) color = (row.mfr_sku || '').trim();
if (!color || /^unknown$/i.test(color)) return null; // skip — no usable color
const left = `${pat} ${titleCase(color)}`.trim();
if (!left || /unknown/i.test(left)) return null;
return banWallpaper(`${left} | Wolf Gordon Wallcoverings`);
}
// ---------- store-side index (idempotent-by-SKU) ----------
// Map: variant SKU (lower) -> { productId, handle, status, variantId, price }.
// Also a nsku index of live WG products for pattern/sku reconciliation.
async function loadStoreIndex() {
const bySku = new Map();
let cursor = null, pages = 0;
do {
const data = await gql(`
query($cursor:String){
products(first:200, query:"vendor:'Wolf Gordon'", after:$cursor){
pageInfo{ hasNextPage endCursor }
edges{ node{
id handle title status
variants(first:5){ edges{ node{ id sku price title
inventoryItem{ id } } } }
media(first:50){ edges{ node{ ... on MediaImage { image{ url } } } } }
} }
}
}`, { cursor });
const conn = data.products;
for (const e of conn.edges) {
const n = e.node;
const imgs = (n.media.edges || []).map(m => m.node?.image?.url).filter(Boolean);
for (const ve of n.variants.edges) {
const v = ve.node;
if (!v.sku) continue;
bySku.set(v.sku.toLowerCase(), {
productId: n.id, handle: n.handle, status: n.status,
variantId: v.id, price: v.price, vTitle: v.title,
invItemId: v.inventoryItem?.id, images: imgs,
});
}
}
cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
pages++;
} while (cursor && pages < 60);
return bySku;
}
// ---------- plan builder ----------
// Decide per catalog row + per live WG product what action applies.
async function buildPlan(storeBySku) {
const cat = (await pool.query(`
SELECT id, mfr_sku, dw_sku, pattern_name, color_name, collection, product_type,
width, width_inches, material, price_retail, price_trade, image_url,
all_images, product_url, discontinued, shopify_product_id,
-- full crawled enrichment so createProduct ships COMPLETE products
repeat_v, repeat_h, match_type, fire_rating, finish, design,
color_hex, dominant_color_hex, ai_description, ai_styles, ai_patterns
FROM wolf_gordon_catalog`)).rows;
// Live WG legacy products (the reconcile source) for UPDATE/ARCHIVE matching.
const leg = (await pool.query(`
SELECT sku, mfr_sku, pattern_name, title, status, handle
FROM shopify_products WHERE vendor = 'Wolf Gordon'`)).rows;
// Catalog indexes for legacy->catalog join.
const catByNsku = new Map(), catByPC = new Map();
for (const r of cat) {
const k = nsku(r.mfr_sku); if (k && !catByNsku.has(k)) catByNsku.set(k, r);
const pc = norm(r.pattern_name) + '|' + norm(r.color_name);
if (!catByPC.has(pc)) catByPC.set(pc, []); catByPC.get(pc).push(r);
}
// Which catalog rows are covered by a live legacy peer (so NEW = uncovered).
const coveredNsku = new Set(), coveredPC = new Set();
for (const r of leg) {
coveredNsku.add(nsku(r.mfr_sku));
coveredPC.add(norm(r.pattern_name) + '|' + norm(legacyColorFromTitle(r.title)));
}
const plan = { new: [], update: [], draft: [], archive: [] };
// NEW + DRAFT: catalog rows with no live peer AND no existing SKU on store.
for (const r of cat) {
const skuLower = (r.dw_sku || '').toLowerCase();
const onStore = skuLower && storeBySku.has(skuLower);
const hasPeer = coveredNsku.has(nsku(r.mfr_sku)) ||
coveredPC.has(norm(r.pattern_name) + '|' + norm(r.color_name));
if (onStore || hasPeer) continue; // idempotent-by-SKU + has a legacy peer => not a fresh NEW
if (r.price_retail == null) plan.draft.push(r);
else plan.new.push(r);
}
// UPDATE: live WG products currently at $4.25 that match a catalog row (by
// normalized mfr_sku, else unique pattern+color) WITH a real price.
for (const r of leg) {
const liveSku = (r.sku || '').toLowerCase();
const live = storeBySku.get(liveSku);
if (!live) continue;
if (parseFloat(live.price) !== 4.25) continue; // only fix the $4.25 placeholders
const sh = catByNsku.get(nsku(r.mfr_sku));
const pc = catByPC.get(norm(r.pattern_name) + '|' + norm(legacyColorFromTitle(r.title)));
const tgt = sh || (pc && pc.length === 1 ? pc[0] : null);
if (tgt && tgt.price_retail != null) {
plan.update.push({ live, legacy: r, target: tgt });
}
}
// ARCHIVE: live WG products with NO catalog match at all (discontinued).
for (const r of leg) {
const live = storeBySku.get((r.sku || '').toLowerCase());
if (!live) continue;
if (live.status === 'ARCHIVED') continue;
const sh = catByNsku.get(nsku(r.mfr_sku));
const pc = catByPC.get(norm(r.pattern_name) + '|' + norm(legacyColorFromTitle(r.title)));
if (!sh && !(pc && pc.length)) plan.archive.push({ live, legacy: r });
}
return plan;
}
// ---------- writers ----------
async function recordPid(mfrSku, pid) {
try {
await pool.query(
`UPDATE wolf_gordon_catalog SET shopify_product_id=$1, on_shopify=true, updated_at=NOW() WHERE mfr_sku=$2`,
[pid, mfrSku]);
} catch (e) { console.error(' recordPid fail:', e.message); }
}
function imageList(row) {
const imgs = [];
if (row.image_url) imgs.push(row.image_url);
if (row.all_images) {
try {
const arr = typeof row.all_images === 'string' ? JSON.parse(row.all_images) : row.all_images;
if (Array.isArray(arr)) for (const u of arr) if (u && !imgs.includes(u)) imgs.push(u);
} catch {/* all_images may be a comma string */
String(row.all_images).split(',').map(s => s.trim()).filter(Boolean)
.forEach(u => { if (!imgs.includes(u)) imgs.push(u); });
}
}
return imgs;
}
function widthInches(row) {
if (row.width_inches != null) return String(row.width_inches);
const m = String(row.width || '').match(/([\d.]+)\s*("|in|inch)/i);
return m ? m[1] : null;
}
// ----------------------------------------------------------------------------
// FULL-ENRICHMENT builders. ROOT-CAUSE FIX (2026-06-19): the original
// createProduct() shipped only a `custom.width` metafield and NO description,
// producing 479 ACTIVE bare-shell products even though the full crawled record
// (ai_description, material, fire_rating, collection, mfr_sku, colors, specs)
// was already in wolf_gordon_catalog. These builders pull that crawled data so
// every product is created COMPLETE. They mirror backfill-enrichment.js so the
// repair path and the create path can never diverge again.
// Steve's standing rules honored: never AI-invent (description comes from the
// crawled ai_description only), word "Wallpaper" banned, never "Unknown".
function buildDescriptionHtml(row) {
let d = (row.ai_description || '').trim();
if (!d || d.length < 20) return null; // no crawled description -> none (never AI-invent)
d = banWallpaper(d).replace(/</g, '<').replace(/>/g, '>');
return `<p>${d}</p>`;
}
function firstOfJson(j) { try { const a = typeof j === 'string' ? JSON.parse(j) : j; return Array.isArray(a) && a.length ? String(a[0]) : null; } catch { return null; } }
function buildFullMetafields(row) {
const mf = [];
const add = (namespace, key, type, value) => {
if (value == null) return;
const v = String(value).trim();
if (!v || /^unknown$/i.test(v)) return;
mf.push({ namespace, key, type, value: banWallpaper(v) });
};
const wIn = widthInches(row);
const widthDisp = row.width ? String(row.width).trim() : (wIn ? `${wIn}"` : null);
// global.* (store convention for WG)
add('global', 'width', 'single_line_text_field', widthDisp);
add('global', 'material', 'single_line_text_field', row.material);
add('global', 'color', 'single_line_text_field', row.color_name);
add('global', 'Collection', 'single_line_text_field', row.collection);
add('global', 'finish', 'single_line_text_field', row.finish);
if (row.repeat_v) add('global', 'repeat', 'single_line_text_field', row.repeat_v);
add('global', 'title_tag', 'single_line_text_field',
`${titleCase(row.pattern_name || '')} - ${titleCase(row.color_name || '')} Wallcovering by Wolf Gordon Wallcoverings`.trim());
add('global', 'description_tag', 'single_line_text_field',
`Authorized Dealer of ${row.mfr_sku || row.dw_sku} by WOLF GORDON WALLCOVERINGS at Designer Wallcoverings`);
// custom.*
add('custom', 'width', 'single_line_text_field', wIn ? `${wIn} Inches` : widthDisp);
add('custom', 'pattern_name', 'single_line_text_field', row.pattern_name);
add('custom', 'manufacturer_sku', 'single_line_text_field', row.mfr_sku);
add('custom', 'material', 'multi_line_text_field', row.material);
add('custom', 'collection_name', 'single_line_text_field', row.collection);
add('custom', 'color', 'single_line_text_field', row.color_name);
add('custom', 'fire_rating', 'single_line_text_field', row.fire_rating);
add('custom', 'color_hex', 'single_line_text_field', row.color_hex || row.dominant_color_hex);
if (row.match_type) add('custom', 'pattern_repeat', 'single_line_text_field', row.match_type);
// dwc.* (manufacturer_sku backup + real vendor + pattern/color)
add('dwc', 'manufacturer_sku', 'single_line_text_field', row.mfr_sku);
add('dwc', 'pattern_name', 'single_line_text_field', row.pattern_name);
add('dwc', 'color', 'single_line_text_field', row.color_name);
add('dwc', 'brand', 'single_line_text_field', 'Wolf Gordon');
// specs.*
add('specs', 'style', 'single_line_text_field', firstOfJson(row.ai_styles));
add('specs', 'pattern', 'single_line_text_field', firstOfJson(row.ai_patterns) || row.design);
return mf;
}
// Create a NEW product (active+published if image+width present, else draft+tag).
async function createProduct(row, { draft } = {}) {
const title = buildTitle(row);
if (!title) { console.log(` SKIP (no usable color) mfr=${row.mfr_sku}`); return null; }
const imgs = imageList(row);
const width = widthInches(row);
const descriptionHtml = buildDescriptionHtml(row);
const fullMetafields = buildFullMetafields(row);
// Activation gate (Steve's hard rules): a product goes ACTIVE only with an
// image AND a width AND a non-trivial description. Missing any -> DRAFT + the
// matching Needs-* tag, never a bare live product. (feedback_all_new_products_
// must_have_description, 2026-06-19.)
const hasDescription = !!descriptionHtml;
const isDraft = draft || imgs.length === 0 || !width || !hasDescription;
const tags = [VENDOR, row.collection].filter(Boolean);
if (imgs.length === 0) tags.push('Needs-Image');
if (!width) tags.push('Needs-Width');
if (!hasDescription) tags.push('Needs-Description');
if (draft) tags.push('Quote-Only');
const productSet = {
title,
vendor: VENDOR,
productType: row.product_type || 'Wallcovering',
status: isDraft ? 'DRAFT' : 'ACTIVE',
tags,
...(descriptionHtml ? { descriptionHtml } : {}),
productOptions: [{ name: 'Variant', values: [{ name: 'Yard' }, { name: 'Sample' }] }],
variants: [
{
optionValues: [{ optionName: 'Variant', name: 'Yard' }],
price: draft ? '0.00' : String(row.price_retail),
inventoryItem: { sku: row.dw_sku, tracked: false },
inventoryPolicy: 'CONTINUE',
},
{
optionValues: [{ optionName: 'Variant', name: 'Sample' }],
price: SAMPLE_PRICE,
inventoryItem: { sku: `${row.dw_sku}-Sample`, tracked: false },
inventoryPolicy: 'CONTINUE',
},
],
// FULL metafield set from the crawled record (was: only custom.width).
metafields: fullMetafields,
files: imgs.map(u => ({ originalSource: u, contentType: 'IMAGE' })),
};
const data = await gql(`
mutation($p:ProductSetInput!){
productSet(input:$p, synchronous:true){
product{ id handle status }
userErrors{ field message }
}
}`, { p: productSet });
const ue = data.productSet.userErrors;
if (ue && ue.length) throw new Error('productSet: ' + JSON.stringify(ue));
const prod = data.productSet.product;
if (!isDraft) {
// Steve 2026-06-20 (memory activation-all-channels-and-2026-inventory): every
// ACTIVE product must be published to ALL sales channels AND carry inventory
// 2026 at Ventura Blvd on its sellable variant. Mirrors cadence-import.js.
await publish(prod.id); // ALL channels (was: Online Store only)
await setInventory2026([row.dw_sku]); // on_hand=2026 on the sellable (non-Sample) variant
}
await recordPid(row.mfr_sku, prod.id.split('/').pop());
return prod;
}
// Publish a product to ALL sales channels (was: Online Store only — the bug that
// left 419 active WG products on ~6 of 13 channels). "already published" userErrors
// are benign. Copies cadence-import.js publishToChannels().
let _PUBLICATION_IDS = null;
async function loadPublications() {
if (_PUBLICATION_IDS) return _PUBLICATION_IDS;
const r = await gql(`{ publications(first:50){ edges{ node{ id name } } } }`);
_PUBLICATION_IDS = (r.publications?.edges || []).map(e => e.node);
return _PUBLICATION_IDS;
}
async function publish(productId) {
try {
const pubs = await loadPublications();
if (!pubs.length) return;
const input = pubs.map(p => ({ publicationId: p.id }));
await gql(`
mutation($id:ID!,$input:[PublicationInput!]!){
publishablePublish(id:$id, input:$input){ userErrors{ field message } }
}`, { id: productId, input });
} catch (e) { console.error(' publish warn:', e.message); }
}
// Inventory on_hand=2026 at the Ventura Blvd location on the given (sellable) SKUs.
// Cap-free (touches existing variants). Copies cadence-import.js setInventory2026().
const INV_LOCATION_2026 = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd.
async function setInventory2026(skus) {
if (!skus.length) return;
try {
const map = new Map();
for (let i = 0; i < skus.length; i += 40) {
const batch = skus.slice(i, i + 40);
const q = batch.map(s => `sku:"${String(s).replace(/"/g, '\\"')}"`).join(' OR ');
const r = await gql(`query($q:String!){ productVariants(first:250,query:$q){ edges{ node{ sku inventoryItem{ id } } } } }`, { q });
for (const e of (r.productVariants?.edges || [])) {
if (e.node.sku && e.node.inventoryItem?.id) map.set(e.node.sku, e.node.inventoryItem.id);
}
}
const pairs = skus.filter(s => map.has(s)).map(s => ({ inventoryItemId: map.get(s), locationId: INV_LOCATION_2026, quantity: 2026 }));
for (let i = 0; i < pairs.length; i += 250) {
await gql(`mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{ field message code } } }`,
{ input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: pairs.slice(i, i + 250) } });
}
} catch (e) { console.error(' inventory-2026 warn:', e.message); }
}
// UPDATE: fix the $4.25 placeholder -> catalog retail price + replace SKU with
// the sequential DW SKU. Images ADD-only (never delete).
async function updateProduct(item) {
const { live, target } = item;
// 1. price + SKU on the existing variant.
const data = await gql(`
mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkUpdate(productId:$pid, variants:$variants){
productVariants{ id sku price }
userErrors{ field message }
}
}`, {
pid: live.productId,
variants: [{ id: live.variantId, price: String(target.price_retail),
inventoryItem: { sku: target.dw_sku } }],
});
const ue = data.productVariantsBulkUpdate.userErrors;
if (ue && ue.length) throw new Error('variantsBulkUpdate: ' + JSON.stringify(ue));
// 2. ensure a Sample variant exists ({DW_SKU}-Sample @ $4.25, untracked).
// PHASE 1 is VARIANT-FREE: creating a Sample variant consumes the daily
// variant-create cap, which Phase 2's ~10,400-variant rollout needs. So
// skip the sample-create entirely in Phase 1 (Steve's explicit rule).
if (!PHASE1) {
const sampleSku = `${target.dw_sku}-Sample`;
await gql(`
mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkCreate(productId:$pid, variants:$variants){
productVariants{ id sku } userErrors{ field message }
}
}`, {
pid: live.productId,
variants: [{ price: SAMPLE_PRICE, inventoryItem: { sku: sampleSku, tracked: false },
inventoryPolicy: 'CONTINUE',
optionValues: [{ optionName: 'Title', name: 'Sample' }] }],
}).catch(e => console.error(' sample-variant warn:', e.message));
}
// 3. images ADD/diff — add any catalog image not already on the product.
const have = new Set((live.images || []).map(u => u.split('?')[0]));
const want = imageList(target).filter(u => u && !have.has(u.split('?')[0]));
if (want.length) {
await gql(`
mutation($pid:ID!,$media:[CreateMediaInput!]!){
productCreateMedia(productId:$pid, media:$media){
media{ ... on MediaImage { id } } mediaUserErrors{ message }
}
}`, { pid: live.productId,
media: want.map(u => ({ originalSource: u, mediaContentType: 'IMAGE' })) })
.catch(e => console.error(' image-add warn:', e.message));
}
await recordPid(target.mfr_sku, live.productId.split('/').pop());
return { handle: live.handle, sku: target.dw_sku, price: String(target.price_retail) };
}
// ARCHIVE: set status archived (discontinued — never delete).
async function archiveProduct(item) {
const data = await gql(`
mutation($id:ID!){
productUpdate(input:{ id:$id, status:ARCHIVED }){
product{ id handle status } userErrors{ message }
}
}`, { id: item.live.productId });
const ue = data.productUpdate.userErrors;
if (ue && ue.length) throw new Error('archive: ' + JSON.stringify(ue));
return { handle: item.live.handle, sku: item.legacy.sku };
}
// ---------- canary subset ----------
// Deterministic 3 new / 3 update / 2 draft / 1 archive picked from the plan.
function pickCanary(plan) {
const NEW_IDS = [5, 7, 8]; // Macao Melon/Pumpkin/Ginger
const ARCHIVE_SKU = 'DWWG-KRN 5611_8'; // Keren Maize (gone from catalog)
const newAdds = plan.new.filter(r => NEW_IDS.includes(r.id)).slice(0, 3);
const updates = plan.update
.filter(u => /^(GSG|DVTS|CCT)/i.test(u.target.mfr_sku)).slice(0, 3);
const DRAFT_SKUS = ['DWWG-530101', 'DWWG-530113']; // Marker Strokes / Wonder World (real names)
let drafts = plan.draft.filter(r => DRAFT_SKUS.includes(r.dw_sku)).slice(0, 2);
if (drafts.length < 2) drafts = plan.draft.slice(0, 2); // fallback
const archive = plan.archive.filter(a => a.legacy.sku === ARCHIVE_SKU).slice(0, 1);
const fallbackArch = archive.length ? archive : plan.archive.slice(0, 1);
return { new: newAdds, update: updates, draft: drafts, archive: fallbackArch };
}
// ---------- main ----------
async function main() {
console.log('='.repeat(74));
const phaseTag = PHASE1 ? ' [PHASE1: update+archive, variant-free]'
: NEW_ONLY ? ` [PHASE2: new-only${MAX != null ? `, max ${MAX}` : ''}]` : '';
console.log(` WOLF GORDON -> SHOPIFY PUSHER mode=${APPLY ? 'APPLY (LIVE WRITES)' : 'DRY-RUN'}` +
`${CANARY ? ' [CANARY]' : ''}${phaseTag}${LIMIT ? ` [limit ${LIMIT}]` : ''}`);
console.log(` store=${SHOP} api=${API} token=...${TOKEN.slice(-4)}`);
console.log('='.repeat(74));
// token sanity
const shop = await rest('GET', '/shop.json');
console.log(` shop: ${shop.shop.name} (${shop.shop.myshopify_domain})`);
console.log(' loading live WG store index...');
const storeBySku = await loadStoreIndex();
console.log(` live WG variants indexed: ${storeBySku.size}`);
let plan = await buildPlan(storeBySku);
console.log(` plan: new=${plan.new.length} update=${plan.update.length} draft=${plan.draft.length} archive=${plan.archive.length}`);
let work;
if (CANARY) {
work = pickCanary(plan);
} else if (PHASE1) {
// Phase 1: re-price/re-SKU matched live products + archive discontinued.
// ZERO new variants (updates touch existing variants; sample-create skipped).
work = { new: [], update: plan.update, draft: [], archive: plan.archive };
} else if (NEW_ONLY) {
// Phase 2: create net-new only, capped to today's variant budget (--max
// products; each = 2 variants). draft (quote-only) counts toward the cap too.
const cap = MAX != null ? MAX : (LIMIT != null ? LIMIT : plan.new.length + plan.draft.length);
const newAdds = plan.new.slice(0, cap);
const draftAdds = plan.draft.slice(0, Math.max(0, cap - newAdds.length));
work = { new: newAdds, update: [], draft: draftAdds, archive: [] };
} else if (LIMIT) {
work = { new: plan.new.slice(0, LIMIT), update: [], draft: [], archive: [] };
} else {
work = plan;
}
// ── shared-cap gate on the NET-NEW create path ──────────────────────────────
// Updates/archives create NO new variants, so they are never cap-gated. Only
// the create path (work.new + work.draft, 2 variants each) draws the budget.
if (APPLY && (work.new.length || work.draft.length)) {
// 1. shared kill-switch — if present, drop all creates this run.
if (fs.existsSync(KILL_SWITCH)) {
console.log(` KILL-SWITCH ${KILL_SWITCH} present — skipping ALL net-new creates this run.`);
work.new = []; work.draft = [];
} else {
// 2. draw from the shared daily ledger ('upload' = the ACTIVE-phase
// category). The grant is in VARIANTS; each product = 2 variants.
// Cap creates-per-run to floor(grant/2) so this trickles within the
// 18/hr paced budget instead of dumping hundreds.
const wantProducts = work.new.length + work.draft.length;
const wantVariants = wantProducts * VARIANTS_PER_PRODUCT;
let grantVariants;
if (!budget) {
// ledger unreachable -> fail-CLOSED on the create path (the cap is the
// whole point of this rewire; never burst it on a missing ledger).
grantVariants = 0;
console.log(' [budget] ledger unavailable — creating 0 net-new products this run (fail-closed on the cap).');
} else {
grantVariants = budget.take('upload', wantVariants);
}
const grantProducts = Math.floor(grantVariants / VARIANTS_PER_PRODUCT);
console.log(` [budget] net-new requested ${wantProducts} products (${wantVariants} variants) -> granted ${grantVariants} variants = ${grantProducts} products this run.`);
// Spend the grant on NEW first, then DRAFT (quote-only).
const newKeep = Math.min(work.new.length, grantProducts);
const draftKeep = Math.min(work.draft.length, Math.max(0, grantProducts - newKeep));
if (newKeep < work.new.length || draftKeep < work.draft.length) {
console.log(` [budget] capping creates: new ${work.new.length}->${newKeep}, draft ${work.draft.length}->${draftKeep} (deferred to a later paced run).`);
}
work.new = work.new.slice(0, newKeep);
work.draft = work.draft.slice(0, draftKeep);
}
}
const total = work.new.length + work.update.length + work.draft.length + work.archive.length;
console.log(` ACTIONING: new=${work.new.length} update=${work.update.length} draft=${work.draft.length} archive=${work.archive.length} (total ${total})`);
console.log('-'.repeat(74));
const results = { new: [], update: [], draft: [], archive: [] };
const deferred = []; // items blocked by Shopify daily variant-create quota
// NEW
for (const r of work.new) {
const t = buildTitle(r);
console.log(` [NEW] ${r.dw_sku} ${t} $${r.price_retail}`);
if (APPLY) {
try {
const p = await createProduct(r);
if (p) results.new.push({ handle: p.handle, sku: r.dw_sku, price: String(r.price_retail), status: p.status });
} catch (e) {
if (e.variantThrottled) { console.log(' DEFERRED — daily variant-create quota reached'); deferred.push({ action: 'new', sku: r.dw_sku }); }
else throw e;
}
}
}
// DRAFT (quote-only, null price)
for (const r of work.draft) {
const t = buildTitle(r);
console.log(` [DRAFT] ${r.dw_sku} ${t} (quote-only)`);
if (APPLY) {
try {
const p = await createProduct(r, { draft: true });
if (p) results.draft.push({ handle: p.handle, sku: r.dw_sku, price: 'quote-only', status: p.status });
} catch (e) {
if (e.variantThrottled) { console.log(' DEFERRED — daily variant-create quota reached'); deferred.push({ action: 'draft', sku: r.dw_sku }); }
else throw e;
}
}
}
// UPDATE
for (const u of work.update) {
console.log(` [UPD] ${u.live.handle} ${u.live.price}->$${u.target.price_retail} sku->${u.target.dw_sku}`);
if (APPLY) results.update.push(await updateProduct(u));
}
// ARCHIVE
for (const a of work.archive) {
console.log(` [ARCH] ${a.live.handle} (${a.legacy.sku})`);
if (APPLY) results.archive.push(await archiveProduct(a));
}
console.log('-'.repeat(74));
if (!APPLY) {
console.log(' DRY-RUN — nothing written. Re-run with --apply to execute.');
} else {
console.log(' APPLIED. Results:');
console.log(JSON.stringify(results, null, 2));
if (deferred.length) {
console.log(` DEFERRED (Shopify daily variant-create quota, retry tomorrow): ${deferred.length}`);
console.log(' ' + JSON.stringify(deferred));
}
}
await pool.end();
}
main().catch(e => { console.error('FATAL:', e); process.exit(1); });