← back to Designer Wallcoverings
snapshot before fixing roll price validation
59499ae19d72e825922b4fbfb183fdd825624bc0 · 2026-08-27 17:05:09 -0700 · Steve
Files touched
M DW-Agents/vendor-command-center/server.jsM DW-Programming/ImportNewSkufromURL/lib/scrapers/glitter-walls-new-products-scraper.tsM DW-Programming/ImportNewSkufromURL/lib/scrapers/malibu-new-products-scraper.tsM DW-Programming/ImportNewSkufromURL/scripts/run-all-vendor-scrapers.tsM DW-Programming/anna-french-shopify-push.jsM DW-Programming/command54-shopify-push.jsM DW-Programming/fabricut-daily-post.jsA DW-Programming/fabricut-shopify-update.jsM DW-Programming/hollywood-shopify-push.jsM DW-Programming/justindavid-shopify-push.jsM DW-Programming/push-maya-romanoff-to-shopify.jsA mailers/assets/pj-fall-2026/probe.htmlA shopify/scripts/maharam-gmc-unpublish.jsA shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-28-08-055Z.jsA shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-36-35-341Z.jsA shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-45-53-920Z.jsA shopify/scripts/maharam-price-load.jsA shopify/scripts/zoffany-work/zof-recheck.mjsA shopify/scripts/zoffany-work/zof-repair.mjsA shopify/scripts/zoffany-work/zof-reprice.mjsA shopify/scripts/zoffany-work/zof-sample-run.mjsA shopify/scripts/zoffany-work/zof-vp.mjs
Diff
commit 59499ae19d72e825922b4fbfb183fdd825624bc0
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 27 17:05:09 2026 -0700
snapshot before fixing roll price validation
---
DW-Agents/vendor-command-center/server.js | 2 +-
.../scrapers/glitter-walls-new-products-scraper.ts | 6 +-
.../lib/scrapers/malibu-new-products-scraper.ts | 6 +-
.../scripts/run-all-vendor-scrapers.ts | 229 ++++++++++++++++++++-
DW-Programming/anna-french-shopify-push.js | 53 +++++
DW-Programming/command54-shopify-push.js | 73 +++++++
DW-Programming/fabricut-daily-post.js | 67 +++++-
DW-Programming/fabricut-shopify-update.js | Bin 0 -> 11680 bytes
DW-Programming/hollywood-shopify-push.js | 77 +++++++
DW-Programming/justindavid-shopify-push.js | 70 +++++++
DW-Programming/push-maya-romanoff-to-shopify.js | 70 +++++++
mailers/assets/pj-fall-2026/probe.html | 124 +++++++++++
shopify/scripts/maharam-gmc-unpublish.js | 114 ++++++++++
.../ROLLBACK-republish-2026-08-26T19-28-08-055Z.js | 9 +
.../ROLLBACK-republish-2026-08-26T19-36-35-341Z.js | 9 +
.../ROLLBACK-republish-2026-08-26T19-45-53-920Z.js | 9 +
shopify/scripts/maharam-price-load.js | 113 ++++++++++
shopify/scripts/zoffany-work/zof-recheck.mjs | 7 +
shopify/scripts/zoffany-work/zof-repair.mjs | 22 ++
shopify/scripts/zoffany-work/zof-reprice.mjs | 56 +++++
shopify/scripts/zoffany-work/zof-sample-run.mjs | 44 ++++
shopify/scripts/zoffany-work/zof-vp.mjs | 5 +
22 files changed, 1149 insertions(+), 16 deletions(-)
diff --git a/DW-Agents/vendor-command-center/server.js b/DW-Agents/vendor-command-center/server.js
index 8405ca9c..5202e540 100644
--- a/DW-Agents/vendor-command-center/server.js
+++ b/DW-Agents/vendor-command-center/server.js
@@ -6094,7 +6094,7 @@ th[data-sort].sort-asc, th[data-sort].sort-desc { color: var(--accent); }
<span class="stat-value" id="statTotalProducts">--</span>
<span class="stat-label">Catalog (DB)</span>
</div>
- <div class="stat-card" style="border-color:#60a5fa" onclick="openStatModal('shopify','On Shopify','shopify_count')">
+ <div class="stat-card" style="border-color:#60a5fa" onclick="openStatModal('shopify','On Shopify','shopify_count')" title="Products attributed to a registered vendor (VENDOR_NAME_MAP), excluding DELETED_FROM_SHOPIFY + DUPLICATE_IMPORT. This is a per-vendor rollup, NOT the raw shopify_products row count.">
<span class="stat-value" id="statShopifyCount" style="color:#60a5fa">--</span>
<span class="stat-label">On Shopify</span>
</div>
diff --git a/DW-Programming/ImportNewSkufromURL/lib/scrapers/glitter-walls-new-products-scraper.ts b/DW-Programming/ImportNewSkufromURL/lib/scrapers/glitter-walls-new-products-scraper.ts
index 0c56e8eb..8e5fe7aa 100644
--- a/DW-Programming/ImportNewSkufromURL/lib/scrapers/glitter-walls-new-products-scraper.ts
+++ b/DW-Programming/ImportNewSkufromURL/lib/scrapers/glitter-walls-new-products-scraper.ts
@@ -74,8 +74,10 @@ export async function scrapeGlitterWallsNewProducts(params?: ScrapeParams) {
}
export default async function scrapeGlitterWallsNewProductsScraper(params: ScrapeParams) {
- const products = await scrapeGlitterWallsNewProducts(params);
- return { vendor: 'glitter-walls', url: params?.url, maxResults: params?.maxResults, products };
+ // TK-10789: return the products array directly so run-all-vendor-scrapers.ts
+ // Array.isArray() check passes. The wrapper object shape caused "No products returned"
+ // even when the feed returned products correctly.
+ return await scrapeGlitterWallsNewProducts(params);
}
export { scrapeGlitterWallsNewProductsScraper };
diff --git a/DW-Programming/ImportNewSkufromURL/lib/scrapers/malibu-new-products-scraper.ts b/DW-Programming/ImportNewSkufromURL/lib/scrapers/malibu-new-products-scraper.ts
index f8fc1bd0..38893bee 100644
--- a/DW-Programming/ImportNewSkufromURL/lib/scrapers/malibu-new-products-scraper.ts
+++ b/DW-Programming/ImportNewSkufromURL/lib/scrapers/malibu-new-products-scraper.ts
@@ -76,8 +76,10 @@ export async function scrapeMalibuNewProducts(params?: ScrapeParams) {
}
export default async function scrapeMalibuNewProductsScraper(params: ScrapeParams) {
- const products = await scrapeMalibuNewProducts(params);
- return { vendor: 'malibu', url: params?.url, maxResults: params?.maxResults, products };
+ // TK-10789: return the products array directly (same fix as glitter-walls) so
+ // run-all-vendor-scrapers.ts Array.isArray() check passes. Wrapper object caused
+ // "No products returned" if this vendor ID ever appears in the monthly sweep.
+ return await scrapeMalibuNewProducts(params);
}
export { scrapeMalibuNewProductsScraper };
diff --git a/DW-Programming/ImportNewSkufromURL/scripts/run-all-vendor-scrapers.ts b/DW-Programming/ImportNewSkufromURL/scripts/run-all-vendor-scrapers.ts
index f28888e0..c02c3187 100644
--- a/DW-Programming/ImportNewSkufromURL/scripts/run-all-vendor-scrapers.ts
+++ b/DW-Programming/ImportNewSkufromURL/scripts/run-all-vendor-scrapers.ts
@@ -76,6 +76,194 @@ function resolveVendorList(): string[] {
return verified.filter((id) => hashDay(id) === day);
}
+// ── Per-vendor _catalog RETARGET (TK-10756) ──────────────────────────────────
+// The sweep's output must land in the matching per-vendor `<vendor>_catalog`
+// staging table (the canonical table the onboarding/cadence scripts read), NOT
+// only the generic `products` dead-end. Mapping is resolved DETERMINISTICALLY
+// off vendor_registry.catalog_table (never a fuzzy name guess), and the target
+// table's REAL columns + REAL single-column unique key are introspected at
+// runtime so we upsert on the table's true key and only touch columns it has.
+// STAGE-ONLY: this writes staging `_catalog` tables exclusively — zero live-store
+// contact (the runtime tripwire in the wrapper stays intact).
+
+type CatalogTarget = {
+ table: string; // e.g. "arte_catalog"
+ keyCol: string; // real single-column unique/PK, e.g. "arte_sku" | "mfr_sku"
+ columns: Set<string>; // real column set of the target table
+};
+
+// resolved-per-vendor cache for this run (short-lived process → run-scoped cache is safe)
+const catalogTargetCache = new Map<string, CatalogTarget | null>();
+
+// Quote a Postgres identifier safely (defends against poisoned registry values /
+// reserved words / mixed case). Values always stay parameterized ($1..$n).
+function quoteIdent(id: string): string {
+ return '"' + String(id).replace(/"/g, '""') + '"';
+}
+
+// Resolve vendorId → { catalog_table } via vendor_registry.catalog_table ONLY.
+// Match vendor_code exactly, then vendorId with hyphens→underscores. If no row,
+// or catalog_table is empty, or it is the generic shared `vendor_catalog`
+// (no vendor-specific identity + no unique key), return null → caller SKIPS + logs.
+// We NEVER invent a table name.
+async function resolveCatalogTarget(vendorId: string): Promise<CatalogTarget | null> {
+ if (catalogTargetCache.has(vendorId)) return catalogTargetCache.get(vendorId)!;
+
+ let table: string | null = null;
+ try {
+ const norm = vendorId.replace(/-/g, "_");
+ const reg = await pool.query(
+ `SELECT catalog_table FROM vendor_registry
+ WHERE vendor_code = $1 OR vendor_code = $2
+ ORDER BY (vendor_code = $1) DESC, id ASC
+ LIMIT 1`,
+ [vendorId, norm]
+ );
+ const ct = (reg.rows[0]?.catalog_table || "").trim();
+ if (!ct) {
+ console.log(` ↪ _catalog SKIP (${vendorId}): no catalog_table in vendor_registry`);
+ catalogTargetCache.set(vendorId, null);
+ return null;
+ }
+ if (ct === "vendor_catalog") {
+ // generic shared staging — no per-vendor identity, no enforced unique key →
+ // upserting here would misroute rows into a wrong/keyless table. Skip.
+ console.log(` ↪ _catalog SKIP (${vendorId}): registered to generic vendor_catalog (no unique key) — leaving to native pipeline`);
+ catalogTargetCache.set(vendorId, null);
+ return null;
+ }
+ table = ct;
+ } catch (e: any) {
+ console.log(` ↪ _catalog SKIP (${vendorId}): registry lookup error — ${e.message?.substring(0, 60)}`);
+ catalogTargetCache.set(vendorId, null);
+ return null;
+ }
+
+ // Confirm the table exists.
+ try {
+ const exists = await pool.query(`SELECT to_regclass('public.' || $1) AS oid`, [table]);
+ if (!exists.rows[0]?.oid) {
+ console.log(` ↪ _catalog SKIP (${vendorId}): registered table ${table} does not exist`);
+ catalogTargetCache.set(vendorId, null);
+ return null;
+ }
+ } catch {
+ catalogTargetCache.set(vendorId, null);
+ return null;
+ }
+
+ // Introspect the real column set.
+ const colRes = await pool.query(
+ `SELECT column_name FROM information_schema.columns
+ WHERE table_schema='public' AND table_name=$1`,
+ [table]
+ );
+ const columns = new Set<string>(colRes.rows.map((r: any) => r.column_name));
+
+ // Find the REAL single-column UNIQUE (or PK) key from constraints — NOT just a
+ // column that happens to exist. This is what makes arte_catalog resolve to its
+ // real key `arte_sku` (it has an mfr_sku COLUMN but the unique constraint is on
+ // arte_sku), and makes any keyless table resolve to null → skip.
+ const keyRes = await pool.query(
+ `SELECT MIN(kcu.column_name) AS column_name
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.key_column_usage kcu
+ ON tc.constraint_name = kcu.constraint_name
+ AND tc.table_schema = kcu.table_schema
+ WHERE tc.table_schema='public' AND tc.table_name=$1
+ AND tc.constraint_type IN ('UNIQUE','PRIMARY KEY')
+ GROUP BY tc.constraint_name
+ HAVING COUNT(kcu.column_name) = 1`,
+ [table]
+ );
+ // Map lower(colname) → real colname so priority match is case-insensitive
+ // (defends against a quoted mixed-case identifier in a future table).
+ const uniqueByLower = new Map<string, string>();
+ for (const r of keyRes.rows) uniqueByLower.set(String(r.column_name).toLowerCase(), r.column_name);
+ // Prefer a natural SKU key; ignore surrogate `id` PKs (never upsert on a serial id).
+ const KEY_PRIORITY = ["mfr_sku", "arte_sku", "vendor_sku", "sku"];
+ let keyCol: string | null = null;
+ for (const c of KEY_PRIORITY) {
+ if (uniqueByLower.has(c)) { keyCol = uniqueByLower.get(c)!; break; }
+ }
+ if (!keyCol) {
+ console.log(` ↪ _catalog SKIP (${vendorId}): ${table} has no single-column SKU unique key — cannot upsert safely`);
+ catalogTargetCache.set(vendorId, null);
+ return null;
+ }
+
+ const target: CatalogTarget = { table, keyCol, columns };
+ catalogTargetCache.set(vendorId, target);
+ console.log(` ↪ _catalog target (${vendorId}): ${table} ON CONFLICT(${keyCol})`);
+ return target;
+}
+
+// Upsert one scraped product into its per-vendor _catalog table (primary).
+// imageUrl = the ORIGINAL source image URL (a real URL, not a base64 data URI) —
+// base64 blobs belong in the legacy products.image_data monitor column, never in
+// the canonical image_url. Idempotent: ON CONFLICT(keyCol) DO UPDATE.
+async function upsertCatalog(
+ vendorId: string,
+ sku: string,
+ name: string,
+ color: string,
+ url: string,
+ imageUrl: string | null
+): Promise<boolean> {
+ const target = await resolveCatalogTarget(vendorId);
+ if (!target) return false; // skipped + logged in resolver
+ if (!sku) return false;
+
+ // Candidate field→value map, filtered to columns the target actually has.
+ const candidate: Record<string, any> = {
+ [target.keyCol]: sku,
+ pattern_name: name,
+ color_name: color,
+ product_url: url || null,
+ image_url: imageUrl || null,
+ updated_at: null, // marker → NOW()
+ };
+ const cols: string[] = [];
+ const vals: any[] = [];
+ const placeholders: string[] = [];
+ for (const [c, v] of Object.entries(candidate)) {
+ if (!target.columns.has(c)) continue;
+ cols.push(c);
+ if (c === "updated_at") { placeholders.push("NOW()"); }
+ else { vals.push(v); placeholders.push("$" + vals.length); }
+ }
+ if (!cols.includes(target.keyCol)) return false;
+
+ // Update every mapped col except the conflict key on conflict.
+ const updates = cols
+ .filter((c) => c !== target.keyCol)
+ .map((c) => c === "updated_at"
+ ? `${quoteIdent(c)} = NOW()`
+ : `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
+
+ // If the ONLY mapped column is the key (a degenerate table with no
+ // pattern/color/url/image column), still INSERT the new SKU — just DO NOTHING
+ // on conflict. Never conflate "nothing to update" with "drop the row".
+ const conflictClause = updates.length === 0
+ ? `ON CONFLICT (${quoteIdent(target.keyCol)}) DO NOTHING`
+ : `ON CONFLICT (${quoteIdent(target.keyCol)}) DO UPDATE SET ${updates.join(", ")}`;
+
+ const q =
+ `INSERT INTO ${quoteIdent(target.table)} (${cols.map(quoteIdent).join(", ")}) ` +
+ `VALUES (${placeholders.join(", ")}) ` +
+ conflictClause;
+
+ try {
+ await pool.query(q, vals);
+ return true;
+ } catch (e: any) {
+ // Self-heal on schema drift: invalidate cache so a rename/alter mid-run retries fresh next call.
+ if (/column|does not exist|undefined/i.test(e.message || "")) catalogTargetCache.delete(vendorId);
+ console.log(` ↪ _catalog upsert error (${vendorId}/${target.table}): ${e.message?.substring(0, 70)}`);
+ return false;
+ }
+}
+
async function downloadImage(url: string, referer: string): Promise<string | null> {
if (!url || url.startsWith("data:")) return null;
try {
@@ -105,8 +293,18 @@ async function upsertProduct(
name: string,
color: string,
url: string,
- imageData: string | null
+ imageData: string | null,
+ imageUrl: string | null
): Promise<boolean> {
+ // PRIMARY (TK-10756): land the row in the vendor's canonical `<vendor>_catalog`
+ // table. imageUrl is the ORIGINAL source URL (a real URL) — NOT the base64
+ // preview blob, which stays in the legacy products.image_data column below.
+ const catalogOk = await upsertCatalog(vendorId, sku, name, color, url, imageUrl);
+
+ // SECONDARY (best-effort): keep the legacy products/vendors staging rows so the
+ // existing ralph-wiggum monitor UI (reads `products`, previews image_data) keeps
+ // working. A failure here does NOT fail the row if the canonical _catalog landed.
+ let legacyOk = false;
try {
// Ensure vendor exists
await pool.query(
@@ -139,11 +337,13 @@ async function upsertProduct(
[vendorId, sku, name, color, url]
);
}
- return true;
+ legacyOk = true;
} catch (e: any) {
- console.log(` DB Error: ${e.message?.substring(0, 40)}`);
- return false;
+ console.log(` DB Error (legacy products/${vendorId}): ${e.message?.substring(0, 40)}`);
}
+
+ // Row is "saved" if the canonical _catalog OR the legacy staging landed it.
+ return catalogOk || legacyOk;
}
async function logTestResult(vendorId: string, success: boolean, productCount: number, errorMessage?: string) {
@@ -176,9 +376,15 @@ async function runVendorScraper(vendorId: string): Promise<{ success: boolean; c
}
console.log(` 📦 Running scraper...`);
- const products = await scrapeFn({ maxResults: 10 });
-
- if (!Array.isArray(products) || products.length === 0) {
+ const rawResult = await scrapeFn({ maxResults: 10 });
+ // TK-10789: handle both return shapes — plain array (legacy) and
+ // { vendor, products: [...] } wrapper (54+ newer scrapers) so neither
+ // shape triggers the false "No products returned" error.
+ const products: any[] = Array.isArray(rawResult)
+ ? rawResult
+ : (Array.isArray(rawResult?.products) ? rawResult.products : []);
+
+ if (products.length === 0) {
console.log(` ⚠️ No products returned`);
await logTestResult(vendorId, false, 0, "No products returned");
return { success: false, count: 0 };
@@ -189,16 +395,19 @@ async function runVendorScraper(vendorId: string): Promise<{ success: boolean; c
for (const p of products) {
const sku = p.sku || p.code || `${vendorId.toUpperCase()}-${savedCount + 1}`;
- const name = p.name || p.title || "Unknown";
+ // DW fallback order for pattern_name: pattern/title → MFR SKU → color.
+ // NEVER stage the literal "Unknown" (standing rule).
+ const name = p.name || p.title || sku || p.color || p.colors?.[0]?.name || "";
const color = p.color || p.colors?.[0]?.name || "Default";
const url = p.url || p.href || "";
+ const imageUrl: string | null = p.image || null; // ORIGINAL source URL → _catalog.image_url
let imageData: string | null = null;
if (p.image) {
imageData = await downloadImage(p.image, url || `https://www.${vendorId}.com`);
}
- const success = await upsertProduct(vendorId, sku, name, color, url, imageData);
+ const success = await upsertProduct(vendorId, sku, name, color, url, imageData, imageUrl);
if (success) {
savedCount++;
if (imageData) imageCount++;
@@ -258,7 +467,7 @@ async function run() {
console.log(`Successful Vendors: ${successfulVendors}`);
console.log(`Total Products Added: ${totalProducts}`);
- console.log("\n📦 Top Vendors by Product Count:");
+ console.log("\n📦 Top Vendors by Product Count (legacy products staging):");
for (const r of stats.rows) {
console.log(` ${r.vendor_id}: ${r.total} products (${r.with_images} with images)`);
}
diff --git a/DW-Programming/anna-french-shopify-push.js b/DW-Programming/anna-french-shopify-push.js
index 27f75ee6..1d651e40 100644
--- a/DW-Programming/anna-french-shopify-push.js
+++ b/DW-Programming/anna-french-shopify-push.js
@@ -16,6 +16,7 @@
const { Pool } = require('pg');
const https = require('https');
+const fs = require('fs');
const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
@@ -243,6 +244,9 @@ function buildMetafields(row) {
return mf;
}
+// Shopify derives a handle from the title (lowercase, non-alphanumerics→'-', collapse/trim).
+function shopifyHandle(title) { return String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }
+
async function createProduct(row, dryRun) {
const patternTitle = toTitleCase(row.pattern_name);
const colorTitle = toTitleCase(row.color_name);
@@ -254,6 +258,27 @@ async function createProduct(row, dryRun) {
return { sku: row.mfr_sku, status: 'skipped', reason: 'Unknown in title' };
}
+ // TK-10815 IDEMPOTENCY GUARD (Fabricut-class): never re-create a product whose handle already
+ // exists in Shopify. The shopify_product_id IS NULL query is not enough — a stale flag, a partial
+ // prior run, or two overlapping runs will POST the same handle twice and Shopify appends -1/-2…
+ // (the 2026-08-19 Fabricut double-fire that created 90 clone DRAFTs). Fail CLOSED — a lookup error
+ // propagates (never fall through to POST on an ambiguous "not found"). If the handle is already
+ // live, reconcile the DB and skip.
+ const handle = shopifyHandle(title);
+ const existing = await shopifyRestWithRetry('GET', `/products.json?handle=${encodeURIComponent(handle)}&fields=id,handle`);
+ const hit = existing && existing.products && existing.products.find(p => p.handle === handle);
+ if (hit) {
+ stats.skipped++;
+ if (!dryRun) {
+ await pool.query(
+ 'UPDATE anna_french_catalog SET shopify_product_id = $1, updated_at = NOW() WHERE mfr_sku = $2',
+ [hit.id.toString(), row.mfr_sku]
+ );
+ }
+ console.log(` ↺ ${row.mfr_sku} handle '${handle}' already in Shopify → ${dryRun ? 'would reconcile' : 'reconciled'} shopify_product_id, no dup created`);
+ return { sku: row.mfr_sku, status: 'skipped_exists', shopifyId: hit.id, handle };
+ }
+
const tags = buildTags(row);
const bodyHtml = buildDescription(row);
const metafields = buildMetafields(row);
@@ -334,6 +359,25 @@ async function sendSlack(msg) {
});
}
+// TK-10815 concurrency lock (Fabricut-class). The handle-exists guard is check-then-act and NOT
+// atomic: two OVERLAPPING live runs both GET-miss the handle, then both POST, re-creating the same
+// -N clones. A single-writer lockfile makes a second live run fail fast. Stale locks (crashed prior
+// run) are stolen via PID-liveness.
+const LOCK = '/tmp/anna-french-shopify-push.lock';
+function acquireLock() {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try { const fd = fs.openSync(LOCK, 'wx'); fs.writeSync(fd, String(process.pid)); return fd; }
+ catch (e) {
+ if (e.code !== 'EEXIST') throw e;
+ let old = NaN; try { old = parseInt(fs.readFileSync(LOCK, 'utf8'), 10); } catch {}
+ let alive = false; try { if (old) { process.kill(old, 0); alive = true; } } catch { alive = false; }
+ if (alive) { console.error(`FATAL: another anna-french-shopify-push (pid ${old}) holds ${LOCK}. Refusing to double-fire.`); process.exit(2); }
+ try { fs.unlinkSync(LOCK); } catch {} // stale lock — steal and retry once
+ }
+ }
+ throw new Error('could not acquire lock');
+}
+
async function main() {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
@@ -355,6 +399,15 @@ async function main() {
const limitIdx = args.indexOf('--limit');
if (limitIdx >= 0 && args[limitIdx + 1]) limit = parseInt(args[limitIdx + 1]);
+ let lockFd;
+ if (!dryRun) {
+ lockFd = acquireLock();
+ const release = () => { try { if (lockFd !== undefined) fs.closeSync(lockFd); fs.unlinkSync(LOCK); } catch {} };
+ process.on('exit', release);
+ process.on('SIGINT', () => { release(); process.exit(130); });
+ process.on('SIGTERM', () => { release(); process.exit(143); });
+ }
+
console.log(`=== Anna French Shopify Push (Phase 8) ===${dryRun ? ' [DRY RUN]' : ''}`);
console.log(`Started: ${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })} PT\n`);
diff --git a/DW-Programming/command54-shopify-push.js b/DW-Programming/command54-shopify-push.js
index ed4b849f..d43835b4 100644
--- a/DW-Programming/command54-shopify-push.js
+++ b/DW-Programming/command54-shopify-push.js
@@ -23,6 +23,7 @@
const { Pool } = require('pg');
const https = require('https');
+const fs = require('fs');
const pool = new Pool({
connectionString: (process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified')
@@ -51,6 +52,23 @@ function shopifyGql(query, variables) {
req.write(data); req.end();
});
}
+// Shopify derives a handle from the title (lowercase, non-alphanumerics→'-', collapse/trim).
+function shopifyHandle(title) { return String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }
+// REST GET (fail-closed): rejects on transport/HTTP error so the caller never treats a lookup
+// failure as "handle absent". Verified Shopify 2024-10 REST honors ?handle=. Reads only (id/handle)
+// — no public text touched, so no private-label leak risk.
+function shopifyRestGet(path) {
+ return new Promise((resolve, reject) => {
+ const req = https.request({ host: SHOPIFY_STORE, path: `/admin/api/${SHOPIFY_API}${path}`, method: 'GET',
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' }, timeout: 30000 },
+ r => { let d = ''; r.on('data', c => d += c); r.on('end', () => {
+ if (r.statusCode >= 400) { reject(new Error(`Shopify GET ${r.statusCode}: ${d.slice(0, 200)}`)); return; }
+ try { resolve(JSON.parse(d)); } catch (e) { reject(new Error(`Shopify GET parse error: ${e.message}`)); } }); });
+ req.on('error', reject);
+ req.on('timeout', () => { req.destroy(); reject(new Error('Shopify GET timeout')); });
+ req.end();
+ });
+}
let _PUBS = null;
async function _loadPubs() {
if (_PUBS) return _PUBS;
@@ -424,7 +442,34 @@ function buildProduct(row) {
// --- Queue insertion ---
+// TK-10815 concurrency lock (Fabricut-class). The handle-exists guard is check-then-act and NOT
+// atomic: two OVERLAPPING live enqueue runs both GET-miss the handle, then both enqueue, and Quinn
+// POSTs the same handle twice → -N clones. A single-writer lockfile makes a second live enqueue fail
+// fast. Stale locks (crashed prior run) are stolen via PID-liveness.
+const LOCK = '/tmp/command54-shopify-push.lock';
+function acquireLock() {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try { const fd = fs.openSync(LOCK, 'wx'); fs.writeSync(fd, String(process.pid)); return fd; }
+ catch (e) {
+ if (e.code !== 'EEXIST') throw e;
+ let old = NaN; try { old = parseInt(fs.readFileSync(LOCK, 'utf8'), 10); } catch {}
+ let alive = false; try { if (old) { process.kill(old, 0); alive = true; } } catch { alive = false; }
+ if (alive) { console.error(`FATAL: another command54-shopify-push (pid ${old}) holds ${LOCK}. Refusing to double-fire.`); process.exit(2); }
+ try { fs.unlinkSync(LOCK); } catch {} // stale lock — steal and retry once
+ }
+ }
+ throw new Error('could not acquire lock');
+}
+
async function queueProducts(limit, dryRun) {
+ let lockFd;
+ if (!dryRun) {
+ lockFd = acquireLock();
+ const release = () => { try { if (lockFd !== undefined) fs.closeSync(lockFd); fs.unlinkSync(LOCK); } catch {} };
+ process.on('exit', release);
+ process.on('SIGINT', () => { release(); process.exit(130); });
+ process.on('SIGTERM', () => { release(); process.exit(143); });
+ }
const effectiveLimit = limit || LAUNCH_PAD_BATCH;
const limitClause = `LIMIT ${parseInt(effectiveLimit)}`;
@@ -474,6 +519,34 @@ async function queueProducts(limit, dryRun) {
continue;
}
+ // TK-10815 IDEMPOTENCY GUARD (Fabricut-class, queue variant): before ENQUEUEING, check whether
+ // this product's handle already exists live in Shopify. Re-running this enqueue script (or two
+ // overlapping runs) would otherwise re-queue products already live and Quinn would POST a dup →
+ // Shopify appends -1/-2… (the 2026-08-19 Fabricut double-fire class). If it's already live,
+ // reconcile the DB and skip the enqueue. Fail CLOSED — a lookup error propagates. Only runs
+ // when a token is configured (env-guarded; Quinn owns the actual POST). READ-only lookup, so
+ // no private-label leak surface.
+ if (!dryRun && SHOPIFY_TOKEN) {
+ const handle = shopifyHandle(payload.product.title);
+ const existing = await shopifyRestGet(`/products.json?handle=${encodeURIComponent(handle)}&fields=id,handle`);
+ const hit = existing && existing.products && existing.products.find(p => p.handle === handle);
+ if (hit) {
+ await pool.query(
+ `UPDATE vendor_catalog SET shopify_product_id = $1, on_shopify = true, shopify_synced_at = NOW()
+ WHERE dw_sku = $2 AND vendor_code = $3`,
+ [hit.id.toString(), row.dw_sku, VENDOR_CODE]
+ );
+ await pool.query(
+ `UPDATE enrichment_tracking SET phase8_shopify_at = NOW(), phase8_shopify_id = $1
+ WHERE vendor_code = $2 AND mfr_sku = $3`,
+ [hit.id, VENDOR_CODE, row.mfr_sku]
+ );
+ skipped++;
+ console.log(` ↺ ${row.dw_sku} handle '${handle}' already in Shopify #${hit.id} → reconciled, not re-queued`);
+ continue;
+ }
+ }
+
if (dryRun) {
console.log(` [DRY] ${row.dw_sku} | ${row.mfr_sku} (hidden)`);
console.log(` Title: ${payload.product.title}`);
diff --git a/DW-Programming/fabricut-daily-post.js b/DW-Programming/fabricut-daily-post.js
index 971fd3ae..938206cc 100644
--- a/DW-Programming/fabricut-daily-post.js
+++ b/DW-Programming/fabricut-daily-post.js
@@ -77,7 +77,72 @@ async function main(){
if(!TOKEN){console.error('no SHOPIFY_ADMIN_TOKEN');process.exit(1);}
console.log(`=== Fabricut DAILY POST ===${dry?' [DRY]':''} min=${MIN} SKUs, FULL patterns`);
// all eligible products, grouped by pattern, patterns ordered by their smallest dw_sku (stable progression)
- const {rows}=await pool.query(`SELECT * FROM fabricut_catalog WHERE product_url ~ 'fabricut.com' AND on_shopify=true AND shopify_product_id IS NOT NULL AND full_scraped=true AND NULLIF(pattern_name,'') IS NOT NULL ORDER BY pattern_name, dw_sku`);
+ const {rows:rawRows}=await pool.query(`SELECT * FROM fabricut_catalog WHERE product_url ~ 'fabricut.com' AND on_shopify=true AND shopify_product_id IS NOT NULL AND full_scraped=true AND NULLIF(pattern_name,'') IS NOT NULL ORDER BY pattern_name, dw_sku`);
+ // TK-10834 recurrence guard: NEVER re-introduce an image-content-hash duplicate.
+ // Fabricut double-imported the same physical jpg under two adjacent SKUs (one row's
+ // color got the real name, the other got the trailing numeric variant code). The old
+ // UNIQUE(mfr_sku) guard was blind to it (different mfr_sku). Dedup here on
+ // (pattern_name + image content-hash), NOT on title/mfr_sku. Keeper rule: prefer the
+ // color-NAMED variant; drop the row whose color_name is a bare 3-4-digit number.
+ const imgHash = u => { const m = String(u||'').match(/\/([0-9a-f]{32})/i); return m ? m[1].toLowerCase() : ('url:'+String(u||'').trim()); };
+ const isNumericColor = c => /^\d{2,4}$/.test(String(c||'').trim());
+ // TK-10834 phase 2: the VENDOR COLORWAY IDENTITY key. The image-content-hash key above
+ // MISSED a residual class (Harlien 503 == Harlien Clay) because the vendor re-saved the
+ // catalog image under a fresh URL, so the numeric & named twins no longer shared a hash on
+ // the LIVE store. But they DO share a vendor colorway: the mfr_sku's LAST-2-DIGITS are the
+ // colorway POSITION within a pattern series (e.g. Harlien named 29097xx / numeric 29098xx,
+ // pos 03 == 03 == "Clay" == "503"). Collapsing numeric<->named twins on (pattern + colorway
+ // position) does NOT depend on the image hash matching, so a re-saved image can't hide it.
+ // We ALSO source the catalog image filename as a corroborator, matching whichever signal
+ // holds. Keeper rule (both layers): prefer the color-NAMED twin; drop the numeric one.
+ const catImgFile = u => { const m = String(u||'').match(/\/([^\/?#]+)\.(jpe?g|png|webp)/i); return m ? m[1].toLowerCase() : null; };
+ const colorwayPos = mfr => { const s = String(mfr||'').trim(); return /^\d{4,}$/.test(s) ? s.slice(-2) : null; };
+ const isNamed = r => !isNumericColor(r.color_name);
+ const dedupByHash = new Map(); // pattern|hash -> chosen row
+ const droppedDupes = [];
+ for(const r of rawRows){
+ if(!r.image_url){ dedupByHash.set('nohash:'+r.dw_sku, r); continue; } // no image -> can't be a hash-dup, keep as-is
+ const key = (r.pattern_name||'')+'|'+imgHash(r.image_url);
+ const prev = dedupByHash.get(key);
+ if(!prev){ dedupByHash.set(key, r); continue; }
+ // conflict: same pattern + same image content-hash => same colorway imported twice.
+ // keep the color-NAMED one; drop the numeric-titled twin.
+ const keepPrev = isNumericColor(r.color_name) && !isNumericColor(prev.color_name);
+ const keepNew = isNumericColor(prev.color_name) && !isNumericColor(r.color_name);
+ if(keepNew){ droppedDupes.push({dropped:prev.dw_sku, kept:r.dw_sku, hash:imgHash(r.image_url), reason:'prev-numeric'}); dedupByHash.set(key, r); }
+ else { droppedDupes.push({dropped:r.dw_sku, kept:prev.dw_sku, hash:imgHash(r.image_url), reason: keepPrev?'new-numeric':'tie-keep-first'}); }
+ }
+ let rows = [...dedupByHash.values()];
+ if(droppedDupes.length) console.log(`[TK-10834 image-hash dedup] filtered ${droppedDupes.length} duplicate row(s) sharing a pattern+image content-hash (kept the color-named twin): `+droppedDupes.map(d=>`${d.dropped}->${d.kept}`).join(', '));
+
+ // TK-10834 phase 2 — VENDOR COLORWAY IDENTITY dedup (catches re-saved-image twins the hash layer misses).
+ // For each pattern, for each colorway position (mfr last-2), if a NUMERIC-color row and a NAMED-color row
+ // both survive, drop the numeric one — but ONLY on a confident 1:1 same-colorway match (exactly one named
+ // at that position, corroborated by a byte-identical catalog image filename when both filenames are known).
+ const byPatPos = new Map(); // pattern|pos -> {numeric:[], named:[]}
+ for(const r of rows){
+ const pos = colorwayPos(r.mfr_sku);
+ if(pos===null) continue;
+ const k = (r.pattern_name||'')+'|'+pos;
+ if(!byPatPos.has(k)) byPatPos.set(k, {numeric:[], named:[]});
+ (isNamed(r) ? byPatPos.get(k).named : byPatPos.get(k).numeric).push(r);
+ }
+ const cwDropped = new Set(); const cwLog = [];
+ for(const [k, g] of byPatPos){
+ if(g.numeric.length && g.named.length === 1){ // confident 1:1
+ const named = g.named[0];
+ for(const num of g.numeric){
+ const nf = catImgFile(num.image_url), mf = catImgFile(named.image_url);
+ const corrob = (nf && mf) ? (nf === mf) : true; // when both filenames known, require match; else accept position match
+ if(corrob){ cwDropped.add(num.dw_sku); cwLog.push(`${num.dw_sku}(${num.color_name})->${named.dw_sku}(${named.color_name})`); }
+ }
+ }
+ // >1 named at a position = ambiguous -> DO NOT drop (needs eyeball); leave both.
+ }
+ if(cwDropped.size){
+ rows = rows.filter(r => !cwDropped.has(r.dw_sku));
+ console.log(`[TK-10834 colorway-identity dedup] filtered ${cwDropped.size} numeric<->named twin(s) by vendor colorway identity (pattern+mfr colorway position, catalog-image corroborated; kept the color-named twin): `+cwLog.join(', '));
+ }
const byPattern=new Map();
for(const r of rows){ if(!byPattern.has(r.pattern_name)) byPattern.set(r.pattern_name,[]); byPattern.get(r.pattern_name).push(r); }
const order=[...byPattern.keys()].sort((a,b)=>{const am=byPattern.get(a)[0].dw_sku||'',bm=byPattern.get(b)[0].dw_sku||'';return am<bm?-1:am>bm?1:0;});
diff --git a/DW-Programming/fabricut-shopify-update.js b/DW-Programming/fabricut-shopify-update.js
new file mode 100644
index 00000000..149971dc
Binary files /dev/null and b/DW-Programming/fabricut-shopify-update.js differ
diff --git a/DW-Programming/hollywood-shopify-push.js b/DW-Programming/hollywood-shopify-push.js
index 70197b04..fbd3e7f7 100644
--- a/DW-Programming/hollywood-shopify-push.js
+++ b/DW-Programming/hollywood-shopify-push.js
@@ -24,11 +24,34 @@
const { Pool } = require('pg');
const https = require('https');
+const fs = require('fs');
const pool = new Pool({
connectionString: (process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified')
});
+// TK-10815 Shopify REST GET client for the pre-enqueue handle guard (env-guarded — the real POST is
+// Quinn's; this only READS to detect an already-live handle).
+const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_API = '2024-10';
+const SHOPIFY_TOKEN = (process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_ACCESS_TOKEN || '').replace(/['"]/g, '').trim();
+// Shopify derives a handle from the title (lowercase, non-alphanumerics→'-', collapse/trim).
+function shopifyHandle(title) { return String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }
+// REST GET (fail-closed): rejects on transport/HTTP error so the caller never treats a lookup
+// failure as "handle absent". Verified Shopify 2024-10 REST honors ?handle=.
+function shopifyRestGet(path) {
+ return new Promise((resolve, reject) => {
+ const req = https.request({ host: SHOPIFY_STORE, path: `/admin/api/${SHOPIFY_API}${path}`, method: 'GET',
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' }, timeout: 30000 },
+ r => { let d = ''; r.on('data', c => d += c); r.on('end', () => {
+ if (r.statusCode >= 400) { reject(new Error(`Shopify GET ${r.statusCode}: ${d.slice(0, 200)}`)); return; }
+ try { resolve(JSON.parse(d)); } catch (e) { reject(new Error(`Shopify GET parse error: ${e.message}`)); } }); });
+ req.on('error', reject);
+ req.on('timeout', () => { req.destroy(); reject(new Error('Shopify GET timeout')); });
+ req.end();
+ });
+}
+
const SOURCE_AGENT = 'hollywood-p8-push';
const VENDOR_CODE = 'hollywood';
const VENDOR_DISPLAY = 'Hollywood Wallcoverings';
@@ -285,7 +308,34 @@ function buildProduct(row) {
// --- Queue insertion ---
+// TK-10815 concurrency lock (Fabricut-class). The handle-exists guard is check-then-act and NOT
+// atomic: two OVERLAPPING live enqueue runs both GET-miss the handle, then both enqueue, and Quinn
+// POSTs the same handle twice → -N clones. A single-writer lockfile makes a second live enqueue fail
+// fast. Stale locks (crashed prior run) are stolen via PID-liveness.
+const LOCK = '/tmp/hollywood-shopify-push.lock';
+function acquireLock() {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try { const fd = fs.openSync(LOCK, 'wx'); fs.writeSync(fd, String(process.pid)); return fd; }
+ catch (e) {
+ if (e.code !== 'EEXIST') throw e;
+ let old = NaN; try { old = parseInt(fs.readFileSync(LOCK, 'utf8'), 10); } catch {}
+ let alive = false; try { if (old) { process.kill(old, 0); alive = true; } } catch { alive = false; }
+ if (alive) { console.error(`FATAL: another hollywood-shopify-push (pid ${old}) holds ${LOCK}. Refusing to double-fire.`); process.exit(2); }
+ try { fs.unlinkSync(LOCK); } catch {} // stale lock — steal and retry once
+ }
+ }
+ throw new Error('could not acquire lock');
+}
+
async function queueProducts(limit, dryRun) {
+ let lockFd;
+ if (!dryRun) {
+ lockFd = acquireLock();
+ const release = () => { try { if (lockFd !== undefined) fs.closeSync(lockFd); fs.unlinkSync(LOCK); } catch {} };
+ process.on('exit', release);
+ process.on('SIGINT', () => { release(); process.exit(130); });
+ process.on('SIGTERM', () => { release(); process.exit(143); });
+ }
const effectiveLimit = limit || LAUNCH_PAD_BATCH;
const limitClause = `LIMIT ${parseInt(effectiveLimit)}`;
@@ -335,6 +385,33 @@ async function queueProducts(limit, dryRun) {
continue;
}
+ // TK-10815 IDEMPOTENCY GUARD (Fabricut-class, queue variant): before ENQUEUEING, check whether
+ // this product's handle already exists live in Shopify. Re-running this enqueue script (or two
+ // overlapping runs) would otherwise re-queue products already live and Quinn would POST a dup →
+ // Shopify appends -1/-2… (the 2026-08-19 Fabricut double-fire class). If it's already live,
+ // reconcile the DB and skip the enqueue. Fail CLOSED — a lookup error propagates. Only runs when
+ // a token is configured (env-guarded; Quinn owns the actual POST).
+ if (!dryRun && SHOPIFY_TOKEN) {
+ const handle = shopifyHandle(payload.product.title);
+ const existing = await shopifyRestGet(`/products.json?handle=${encodeURIComponent(handle)}&fields=id,handle`);
+ const hit = existing && existing.products && existing.products.find(p => p.handle === handle);
+ if (hit) {
+ await pool.query(
+ `UPDATE vendor_catalog SET shopify_product_id = $1, on_shopify = true, shopify_synced_at = NOW()
+ WHERE dw_sku = $2 AND vendor_code = $3`,
+ [hit.id.toString(), row.dw_sku, VENDOR_CODE]
+ );
+ await pool.query(
+ `UPDATE enrichment_tracking SET phase8_shopify_at = NOW(), phase8_shopify_id = $1
+ WHERE vendor_code = $2 AND mfr_sku = $3`,
+ [hit.id, VENDOR_CODE, row.mfr_sku]
+ );
+ skipped++;
+ console.log(` ↺ ${row.mfr_sku} handle '${handle}' already in Shopify #${hit.id} → reconciled, not re-queued`);
+ continue;
+ }
+ }
+
if (dryRun) {
console.log(` [DRY] ${row.dw_sku} | ${row.mfr_sku}`);
console.log(` Title: ${payload.product.title}`);
diff --git a/DW-Programming/justindavid-shopify-push.js b/DW-Programming/justindavid-shopify-push.js
index b3d35e7b..d3052dbb 100644
--- a/DW-Programming/justindavid-shopify-push.js
+++ b/DW-Programming/justindavid-shopify-push.js
@@ -20,6 +20,7 @@
const { Pool } = require('pg');
const https = require('https');
+const fs = require('fs');
const pool = new Pool({
connectionString: (process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified')
@@ -48,6 +49,22 @@ function shopifyGql(query, variables) {
req.write(data); req.end();
});
}
+// Shopify derives a handle from the title (lowercase, non-alphanumerics→'-', collapse/trim).
+function shopifyHandle(title) { return String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }
+// REST GET (fail-closed): rejects on transport/HTTP error so the caller never treats a lookup
+// failure as "handle absent". Verified Shopify 2024-10 REST honors ?handle=.
+function shopifyRestGet(path) {
+ return new Promise((resolve, reject) => {
+ const req = https.request({ host: SHOPIFY_STORE, path: `/admin/api/${SHOPIFY_API}${path}`, method: 'GET',
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' }, timeout: 30000 },
+ r => { let d = ''; r.on('data', c => d += c); r.on('end', () => {
+ if (r.statusCode >= 400) { reject(new Error(`Shopify GET ${r.statusCode}: ${d.slice(0, 200)}`)); return; }
+ try { resolve(JSON.parse(d)); } catch (e) { reject(new Error(`Shopify GET parse error: ${e.message}`)); } }); });
+ req.on('error', reject);
+ req.on('timeout', () => { req.destroy(); reject(new Error('Shopify GET timeout')); });
+ req.end();
+ });
+}
let _PUBS = null;
async function _loadPubs() {
if (_PUBS) return _PUBS;
@@ -306,7 +323,34 @@ function buildProduct(row) {
// --- Queue insertion ---
+// TK-10815 concurrency lock (Fabricut-class). The handle-exists guard is check-then-act and NOT
+// atomic: two OVERLAPPING live enqueue runs both GET-miss the handle, then both enqueue, and Quinn
+// POSTs the same handle twice → -N clones. A single-writer lockfile makes a second live enqueue fail
+// fast. Stale locks (crashed prior run) are stolen via PID-liveness.
+const LOCK = '/tmp/justindavid-shopify-push.lock';
+function acquireLock() {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try { const fd = fs.openSync(LOCK, 'wx'); fs.writeSync(fd, String(process.pid)); return fd; }
+ catch (e) {
+ if (e.code !== 'EEXIST') throw e;
+ let old = NaN; try { old = parseInt(fs.readFileSync(LOCK, 'utf8'), 10); } catch {}
+ let alive = false; try { if (old) { process.kill(old, 0); alive = true; } } catch { alive = false; }
+ if (alive) { console.error(`FATAL: another justindavid-shopify-push (pid ${old}) holds ${LOCK}. Refusing to double-fire.`); process.exit(2); }
+ try { fs.unlinkSync(LOCK); } catch {} // stale lock — steal and retry once
+ }
+ }
+ throw new Error('could not acquire lock');
+}
+
async function queueProducts(limit, dryRun) {
+ let lockFd;
+ if (!dryRun) {
+ lockFd = acquireLock();
+ const release = () => { try { if (lockFd !== undefined) fs.closeSync(lockFd); fs.unlinkSync(LOCK); } catch {} };
+ process.on('exit', release);
+ process.on('SIGINT', () => { release(); process.exit(130); });
+ process.on('SIGTERM', () => { release(); process.exit(143); });
+ }
const limitClause = limit ? `LIMIT ${parseInt(limit)}` : '';
const { rows } = await pool.query(`
@@ -336,6 +380,32 @@ async function queueProducts(limit, dryRun) {
continue;
}
+ // TK-10815 IDEMPOTENCY GUARD (Fabricut-class, queue variant): before ENQUEUEING, check whether
+ // this product's handle already exists live in Shopify. Re-running this enqueue script (or two
+ // overlapping runs) would otherwise re-queue products already live and Quinn would POST a dup →
+ // Shopify appends -1/-2… (the 2026-08-19 Fabricut double-fire class). If it's already live,
+ // reconcile the DB and skip the enqueue. Fail CLOSED — a lookup error propagates. Only runs when
+ // a token is configured (this script's Shopify-HTTP layer is env-guarded; Quinn owns the POST).
+ if (!dryRun && SHOPIFY_TOKEN) {
+ const handle = shopifyHandle(payload.product.title);
+ const existing = await shopifyRestGet(`/products.json?handle=${encodeURIComponent(handle)}&fields=id,handle`);
+ const hit = existing && existing.products && existing.products.find(p => p.handle === handle);
+ if (hit) {
+ await pool.query(
+ `UPDATE justindavid_catalog SET shopify_product_id = $1, on_shopify = true, imported_at = NOW() WHERE dw_sku = $2`,
+ [hit.id.toString(), row.dw_sku]
+ );
+ await pool.query(
+ `UPDATE enrichment_tracking SET phase8_shopify_at = NOW(), phase8_shopify_id = $1
+ WHERE vendor_code = 'justin_david' AND mfr_sku = $2`,
+ [hit.id, row.mfr_sku]
+ );
+ skipped++;
+ console.log(` ↺ ${row.mfr_sku} handle '${handle}' already in Shopify #${hit.id} → reconciled, not re-queued`);
+ continue;
+ }
+ }
+
if (dryRun) {
if (i < 10) {
console.log(` [DRY] ${row.dw_sku} | ${row.mfr_sku} -> ${payload.product.title}`);
diff --git a/DW-Programming/push-maya-romanoff-to-shopify.js b/DW-Programming/push-maya-romanoff-to-shopify.js
index f918f18f..098cdd52 100644
--- a/DW-Programming/push-maya-romanoff-to-shopify.js
+++ b/DW-Programming/push-maya-romanoff-to-shopify.js
@@ -161,6 +161,26 @@ function buildBodyHtml(product, creativeColor) {
return `${specsTable}${description}`;
}
+// ── Shopify derives a handle from the title (lowercase, non-alphanumerics→'-', collapse/trim). ──
+function shopifyHandle(title) { return String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }
+
+// ── Shopify REST GET (fail-closed — throws propagate to caller; never swallow into "not found"). ──
+async function shopifyGet(endpoint) {
+ const url = `https://${CONFIG.SHOPIFY_STORE}/admin/api/${CONFIG.API_VERSION}${endpoint}`;
+ const res = await fetch(url, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': CONFIG.SHOPIFY_TOKEN }
+ });
+ if (res.status === 429) {
+ const retryAfter = parseInt(res.headers.get('Retry-After') || '2', 10);
+ console.log(` ⏳ Rate limited (GET) — waiting ${retryAfter}s...`);
+ await sleep(retryAfter * 1000);
+ return shopifyGet(endpoint);
+ }
+ if (!res.ok) throw new Error(`Shopify GET ${res.status}: ${(await res.text()).slice(0, 200)}`);
+ return res.json();
+}
+
// ── Shopify REST API Call ──
async function shopifyPost(endpoint, body) {
const url = `https://${CONFIG.SHOPIFY_STORE}/admin/api/${CONFIG.API_VERSION}${endpoint}`;
@@ -223,9 +243,33 @@ function buildProductImages(product) {
return images;
}
+// TK-10815 concurrency lock (Fabricut-class). The handle-exists guard is check-then-act and NOT
+// atomic: two OVERLAPPING runs both GET-miss the handle, then both POST, re-creating the same -N
+// clones. A single-writer lockfile makes a second run fail fast. Stale locks (crashed prior run) are
+// stolen via PID-liveness. This script has no dry-run mode — every run writes, so always lock.
+const LOCK = '/tmp/push-maya-romanoff-to-shopify.lock';
+function acquireLock() {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try { const fd = fs.openSync(LOCK, 'wx'); fs.writeSync(fd, String(process.pid)); return fd; }
+ catch (e) {
+ if (e.code !== 'EEXIST') throw e;
+ let old = NaN; try { old = parseInt(fs.readFileSync(LOCK, 'utf8'), 10); } catch {}
+ let alive = false; try { if (old) { process.kill(old, 0); alive = true; } } catch { alive = false; }
+ if (alive) { console.error(`FATAL: another push-maya-romanoff-to-shopify (pid ${old}) holds ${LOCK}. Refusing to double-fire.`); process.exit(2); }
+ try { fs.unlinkSync(LOCK); } catch {} // stale lock — steal and retry once
+ }
+ }
+ throw new Error('could not acquire lock');
+}
+
// ── Main Push Function ──
async function main() {
const startTime = Date.now();
+ const lockFd = acquireLock();
+ const release = () => { try { if (lockFd !== undefined) fs.closeSync(lockFd); fs.unlinkSync(LOCK); } catch {} };
+ process.on('exit', release);
+ process.on('SIGINT', () => { release(); process.exit(130); });
+ process.on('SIGTERM', () => { release(); process.exit(143); });
console.log('🚀 Maya Romanoff — Push New Products to Shopify');
console.log('================================================\n');
@@ -260,6 +304,7 @@ async function main() {
let pushed = 0;
let errors = 0;
+ let skipped = 0;
const errorList = [];
for (let i = 0; i < products.length; i++) {
@@ -285,6 +330,30 @@ async function main() {
const title = `${titleParts.join(' ')} Wallcovering | Maya Romanoff`;
+ // TK-10815 IDEMPOTENCY GUARD (Fabricut-class): never re-create a product whose handle already
+ // exists in Shopify. The shopify_product_id IS NULL query is not enough — a stale flag, a partial
+ // prior run, or two overlapping runs POST the same handle twice and Shopify appends -1/-2… (the
+ // 2026-08-19 Fabricut double-fire that created 90 clone DRAFTs). Fail CLOSED — a lookup error
+ // propagates (never fall through to POST). If the handle is live, reconcile the DB and skip.
+ const handle = shopifyHandle(title);
+ const existing = await shopifyGet(`/products.json?handle=${encodeURIComponent(handle)}&fields=id,handle`);
+ const hit = existing && existing.products && existing.products.find(x => x.handle === handle);
+ if (hit) {
+ const updateClient = await pool.connect();
+ try {
+ await updateClient.query(
+ 'UPDATE maya_catalog SET shopify_product_id = $1, imported_at = NOW() WHERE mfr_sku = $2',
+ [hit.id.toString(), p.mfr_sku]
+ );
+ } finally {
+ updateClient.release();
+ }
+ skipped++;
+ console.log(` ↺ [${i + 1}/${products.length}] handle '${handle}' already in Shopify #${hit.id} → reconciled, no dup created`);
+ if (i < products.length - 1) await sleep(CONFIG.DELAY_MS);
+ continue;
+ }
+
// SEO title
const seoTitle = creativeColor
? `Shop ${toTitleCase(p.pattern_name || p.mfr_sku)} in ${toTitleCase(creativeColor)} | Maya Romanoff`
@@ -412,6 +481,7 @@ async function main() {
console.log('\n================================================');
console.log(`🏁 COMPLETE — ${duration} min`);
console.log(` ✅ Pushed: ${pushed}`);
+ console.log(` ↺ Skipped (handle exists): ${skipped}`);
console.log(` ❌ Errors: ${errors}`);
if (errorList.length > 0) {
console.log('\n Failed products:');
diff --git a/mailers/assets/pj-fall-2026/probe.html b/mailers/assets/pj-fall-2026/probe.html
new file mode 100644
index 00000000..8a6b74cc
--- /dev/null
+++ b/mailers/assets/pj-fall-2026/probe.html
@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>404 - Page Not Found | Phillip Jeffries</title>
+ <script src="https://cdn.tailwindcss.com"></script>
+ <style>
+ /* Custom styles for hover effects */
+ .nav-link {
+ position: relative;
+ overflow: hidden;
+ }
+
+ .nav-link-bg {
+ position: absolute;
+ inset: 0;
+ background-color: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ transition: all 0.3s;
+ }
+
+ .nav-link:hover .nav-link-bg {
+ background-color: white;
+ border-color: white;
+ }
+
+ .nav-link-text {
+ position: relative;
+ color: white;
+ letter-spacing: 0.05em;
+ transition: color 0.3s;
+ }
+
+ .nav-link:hover .nav-link-text {
+ color: black;
+ }
+
+ .nav-link-icon {
+ position: relative;
+ color: white;
+ transition: color 0.3s;
+ }
+
+ .nav-link:hover .nav-link-icon {
+ color: black;
+ }
+ </style>
+</head>
+<body>
+ <div class="relative min-h-screen w-full overflow-hidden">
+ <!-- Background Image with Overlay -->
+ <div class="absolute inset-0 z-0">
+ <img
+ src="https://cms.phillipjeffries.com/files/general/error_page_background.jpg"
+ alt="Luxury wallpaper background"
+ class="w-full h-full object-cover"
+ />
+ <div class="absolute inset-0 bg-black/70"></div>
+ </div>
+
+ <!-- Content -->
+ <div class="relative z-10 flex flex-col items-center justify-center min-h-screen px-4 py-12">
+ <div class="max-w-3xl w-full text-center space-y-12">
+ <!-- 404 Message -->
+ <div class="space-y-8">
+ <div class="space-y-3">
+ <h2 class="text-white text-7xl md:text-8xl tracking-tight">404</h2>
+ <div class="h-px w-24 bg-white/40 mx-auto"></div>
+ <p class="text-white/95 text-2xl md:text-3xl tracking-wide">Page Not Found</p>
+ </div>
+ <p class="text-white/80 text-lg md:text-xl max-w-xl mx-auto leading-relaxed">
+ You've reached a blank wall.<br>
+ Not our usual look. Let's find something more your style.
+ </p>
+ </div>
+
+ <!-- Navigation Links -->
+ <div class="space-y-6">
+ <p class="text-white/60 tracking-widest uppercase text-sm">Explore Our Collections</p>
+ <div class="flex flex-col sm:flex-row gap-3 justify-center items-center">
+ <a
+ href="https://www.phillipjeffries.com/shop/wallcoverings"
+ class="nav-link px-8 py-3 w-full sm:w-56"
+ >
+ <div class="nav-link-bg"></div>
+ <span class="nav-link-text whitespace-nowrap">Shop All</span>
+ </a>
+ <a
+ href="https://www.phillipjeffries.com/shop/whats-new"
+ class="nav-link px-8 py-3 w-full sm:w-56"
+ >
+ <div class="nav-link-bg"></div>
+ <span class="nav-link-text whitespace-nowrap">What's New</span>
+ </a>
+ <a
+ href="https://www.phillipjeffries.com/shop/collections"
+ class="nav-link px-8 py-3 w-full sm:w-56"
+ >
+ <div class="nav-link-bg"></div>
+ <span class="nav-link-text whitespace-nowrap">Shop by Category</span>
+ </a>
+ </div>
+ </div>
+
+ <!-- Home Button -->
+ <div class="pt-4">
+ <a
+ href="https://www.phillipjeffries.com/"
+ class="nav-link inline-flex items-center justify-center px-8 py-3 w-full sm:w-56"
+ >
+ <div class="nav-link-bg"></div>
+ <svg class="nav-link-icon mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
+ <polyline points="9 22 9 12 15 12 15 22"/>
+ </svg>
+ <span class="nav-link-text whitespace-nowrap">Return to Home</span>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/shopify/scripts/maharam-gmc-unpublish.js b/shopify/scripts/maharam-gmc-unpublish.js
new file mode 100644
index 00000000..40bf57ca
--- /dev/null
+++ b/shopify/scripts/maharam-gmc-unpublish.js
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+/**
+ * MaharamSOURCE — remove all DWAX- (Maharam) products from the Google & YouTube
+ * sales channel so the $4.25-sample-as-price GMC disapproval vector stops.
+ *
+ * Reversible: the exact working-set of product IDs is written to a manifest, and a
+ * matching re-publish (rollback) script is emitted, so `--apply` is one-command undo-able.
+ *
+ * DRY-RUN by default (read-only: enumerates + writes manifest, makes NO mutation).
+ * node scripts/maharam-gmc-unpublish.js
+ * LIVE (customer-facing — GATED, Steve-approved only):
+ * node scripts/maharam-gmc-unpublish.js --apply --yes-i-am-steve
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const GOOGLE_PUB = 'gid://shopify/Publication/29646651457'; // Google & YouTube
+const APPLY = process.argv.includes('--apply');
+const CONFIRM = process.argv.includes('--yes-i-am-steve');
+const OUTDIR = path.join(__dirname, 'maharam-gmc-unpublish.out');
+
+function token() {
+ for (const p of [path.join(process.env.HOME, 'Projects/secrets-manager/.env'),
+ path.join(__dirname, '../.env'), path.join(__dirname, '../../.env')]) {
+ try {
+ const m = fs.readFileSync(p, 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+ if (m) return m[1].replace(/["']/g, '').trim();
+ } catch {}
+ }
+ throw new Error('SHOPIFY_ADMIN_TOKEN not found');
+}
+const TOKEN = token();
+
+async function gql(query, variables) {
+ const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+ const j = await r.json();
+ if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors).slice(0, 300));
+ // throttle courtesy
+ const cost = j.extensions && j.extensions.cost && j.extensions.cost.throttleStatus;
+ if (cost && cost.currentlyAvailable < 200) await new Promise(z => setTimeout(z, 700));
+ return j.data;
+}
+
+async function collectWorkingSet() {
+ const q = `query($cur:String,$g:ID!){ products(first:100, query:"sku:DWAX-*", after:$cur){
+ pageInfo{hasNextPage endCursor}
+ edges{ node{ id title status onGoogle:publishedOnPublication(publicationId:$g) } } } }`;
+ let cur = null, ids = [];
+ do {
+ const d = await gql(q, { cur, g: GOOGLE_PUB });
+ for (const e of d.products.edges) {
+ if (e.node.onGoogle) ids.push({ id: e.node.id, title: e.node.title, status: e.node.status });
+ }
+ cur = d.products.pageInfo.hasNextPage ? d.products.pageInfo.endCursor : null;
+ } while (cur);
+ return ids;
+}
+
+const M_UNPUB = `mutation($id:ID!,$input:[PublicationInput!]!){
+ publishableUnpublish(id:$id, input:$input){
+ publishable{ availablePublicationsCount{ count } } userErrors{ field message } } }`;
+
+(async () => {
+ fs.mkdirSync(OUTDIR, { recursive: true });
+ console.log(`[MaharamSOURCE] Google&YouTube unpublish — mode: ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'}`);
+ const set = await collectWorkingSet();
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const manifest = path.join(OUTDIR, `working-set-${stamp}.json`);
+ fs.writeFileSync(manifest, JSON.stringify({ publication: GOOGLE_PUB, count: set.length, products: set }, null, 2));
+ // rollback script (re-publish the exact set)
+ const rollback = path.join(OUTDIR, `ROLLBACK-republish-${stamp}.js`);
+ fs.writeFileSync(rollback, `#!/usr/bin/env node
+// AUTO-GENERATED rollback for MaharamSOURCE unpublish ${stamp}.
+// Re-publishes the exact ${set.length} products back onto Google & YouTube (${GOOGLE_PUB}).
+const fs=require('fs'),path=require('path');
+const STORE='${STORE}',API='${API}',PUB='${GOOGLE_PUB}';
+const set=require(${JSON.stringify(manifest)}).products;
+const TOKEN=fs.readFileSync(path.join(process.env.HOME,'Projects/secrets-manager/.env'),'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].replace(/["']/g,'').trim();
+const M='mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{message}}}';
+(async()=>{let ok=0,err=0;for(const p of set){const r=await fetch(\`https://\${STORE}/admin/api/\${API}/graphql.json\`,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:M,variables:{id:p.id,input:[{publicationId:PUB}]}})});const j=await r.json();const e=j.data&&j.data.publishablePublish.userErrors||[];if(e.length){err++;console.error(p.id,e)}else ok++;await new Promise(z=>setTimeout(z,120));}console.log('republished',ok,'errors',err);})();
+`);
+
+ console.log(` working-set on Google channel : ${set.length}`);
+ console.log(` status breakdown :`, set.reduce((a,p)=>{a[p.status]=(a[p.status]||0)+1;return a;},{}));
+ console.log(` manifest (reversibility) : ${manifest}`);
+ console.log(` rollback script (re-publish) : ${rollback}`);
+ console.log(` mutation to run per product : publishableUnpublish(id, input:[{publicationId:"${GOOGLE_PUB}"}])`);
+
+ if (!APPLY) {
+ console.log(`\n DRY-RUN — no mutations sent. To apply (Steve-gated):`);
+ console.log(` node scripts/maharam-gmc-unpublish.js --apply --yes-i-am-steve`);
+ return;
+ }
+ if (!CONFIRM) { console.error('\n REFUSED: --apply requires --yes-i-am-steve (customer-facing gate).'); process.exit(2); }
+
+ let ok = 0, err = 0;
+ for (const p of set) {
+ try {
+ const d = await gql(M_UNPUB, { id: p.id, input: [{ publicationId: GOOGLE_PUB }] });
+ const ue = d.publishableUnpublish.userErrors || [];
+ if (ue.length) { err++; console.error(' ERR', p.id, JSON.stringify(ue)); }
+ else { ok++; if (ok % 100 === 0) console.log(` …unpublished ${ok}/${set.length}`); }
+ } catch (e) { err++; console.error(' EXC', p.id, e.message); }
+ await new Promise(z => setTimeout(z, 120));
+ }
+ console.log(`\n DONE — unpublished ${ok}, errors ${err}. Rollback: node ${path.relative(process.cwd(), rollback)}`);
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-28-08-055Z.js b/shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-28-08-055Z.js
new file mode 100644
index 00000000..2a3dc427
--- /dev/null
+++ b/shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-28-08-055Z.js
@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+// AUTO-GENERATED rollback for MaharamSOURCE unpublish 2026-08-26T19-28-08-055Z.
+// Re-publishes the exact 1429 products back onto Google & YouTube (gid://shopify/Publication/29646651457).
+const fs=require('fs'),path=require('path');
+const STORE='designer-laboratory-sandbox.myshopify.com',API='2024-10',PUB='gid://shopify/Publication/29646651457';
+const set=require("/Users/macstudio3/Projects/Designer-Wallcoverings/shopify/scripts/maharam-gmc-unpublish.out/working-set-2026-08-26T19-28-08-055Z.json").products;
+const TOKEN=fs.readFileSync(path.join(process.env.HOME,'Projects/secrets-manager/.env'),'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].replace(/["']/g,'').trim();
+const M='mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{message}}}';
+(async()=>{let ok=0,err=0;for(const p of set){const r=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:M,variables:{id:p.id,input:[{publicationId:PUB}]}})});const j=await r.json();const e=j.data&&j.data.publishablePublish.userErrors||[];if(e.length){err++;console.error(p.id,e)}else ok++;await new Promise(z=>setTimeout(z,120));}console.log('republished',ok,'errors',err);})();
diff --git a/shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-36-35-341Z.js b/shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-36-35-341Z.js
new file mode 100644
index 00000000..1c11b4d0
--- /dev/null
+++ b/shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-36-35-341Z.js
@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+// AUTO-GENERATED rollback for MaharamSOURCE unpublish 2026-08-26T19-36-35-341Z.
+// Re-publishes the exact 1429 products back onto Google & YouTube (gid://shopify/Publication/29646651457).
+const fs=require('fs'),path=require('path');
+const STORE='designer-laboratory-sandbox.myshopify.com',API='2024-10',PUB='gid://shopify/Publication/29646651457';
+const set=require("/Users/macstudio3/Projects/Designer-Wallcoverings/shopify/scripts/maharam-gmc-unpublish.out/working-set-2026-08-26T19-36-35-341Z.json").products;
+const TOKEN=fs.readFileSync(path.join(process.env.HOME,'Projects/secrets-manager/.env'),'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].replace(/["']/g,'').trim();
+const M='mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{message}}}';
+(async()=>{let ok=0,err=0;for(const p of set){const r=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:M,variables:{id:p.id,input:[{publicationId:PUB}]}})});const j=await r.json();const e=j.data&&j.data.publishablePublish.userErrors||[];if(e.length){err++;console.error(p.id,e)}else ok++;await new Promise(z=>setTimeout(z,120));}console.log('republished',ok,'errors',err);})();
diff --git a/shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-45-53-920Z.js b/shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-45-53-920Z.js
new file mode 100644
index 00000000..f7c8ead9
--- /dev/null
+++ b/shopify/scripts/maharam-gmc-unpublish.out/ROLLBACK-republish-2026-08-26T19-45-53-920Z.js
@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+// AUTO-GENERATED rollback for MaharamSOURCE unpublish 2026-08-26T19-45-53-920Z.
+// Re-publishes the exact 645 products back onto Google & YouTube (gid://shopify/Publication/29646651457).
+const fs=require('fs'),path=require('path');
+const STORE='designer-laboratory-sandbox.myshopify.com',API='2024-10',PUB='gid://shopify/Publication/29646651457';
+const set=require("/Users/macstudio3/Projects/Designer-Wallcoverings/shopify/scripts/maharam-gmc-unpublish.out/working-set-2026-08-26T19-45-53-920Z.json").products;
+const TOKEN=fs.readFileSync(path.join(process.env.HOME,'Projects/secrets-manager/.env'),'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].replace(/["']/g,'').trim();
+const M='mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{message}}}';
+(async()=>{let ok=0,err=0;for(const p of set){const r=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:M,variables:{id:p.id,input:[{publicationId:PUB}]}})});const j=await r.json();const e=j.data&&j.data.publishablePublish.userErrors||[];if(e.length){err++;console.error(p.id,e)}else ok++;await new Promise(z=>setTimeout(z,120));}console.log('republished',ok,'errors',err);})();
diff --git a/shopify/scripts/maharam-price-load.js b/shopify/scripts/maharam-price-load.js
new file mode 100644
index 00000000..f23b97f2
--- /dev/null
+++ b/shopify/scripts/maharam-price-load.js
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+/**
+ * MaharamSOURCE — load real per-yard prices onto live Maharam (DWAX-) products.
+ * Mirrors the DW yard-sold convention (ref: Kravet DWKK-103268):
+ * option "Size" = [ "Sold Per Yard - <W>In Wide" (pos1, sellable, sku=DW_SKU, price=$/yd),
+ * "Sample" (pos2, sku=DW_SKU-Sample, $4.25) ]
+ * + metafields: global.unit_of_measure="Priced Per Yard", dwc.order_unit="Yard",
+ * global.v_prods_quantity_order_units="1", global.v_prods_quantity_order_min="1"
+ *
+ * SAFE + surgical: renames the existing "Type" option to "Size", ADDS the sellable variant
+ * (never deletes the Sample), reorders sellable to position 1, sets metafields.
+ * Idempotent: skips a product that already has a non-Sample variant.
+ *
+ * node scripts/maharam-price-load.js --pilot # dry-run, 1 SKU
+ * node scripts/maharam-price-load.js --pilot --apply --yes-i-am-steve
+ * node scripts/maharam-price-load.js --apply --yes-i-am-steve # all 1,429
+ */
+'use strict';
+const fs = require('fs'), path = require('path');
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const APPLY = process.argv.includes('--apply');
+const CONFIRM = process.argv.includes('--yes-i-am-steve');
+const PILOT = process.argv.includes('--pilot');
+const PILOT_SKU = 'DWAX-1025005'; // Accord argentite, $79/yd, 36"
+const TOKEN = (() => {
+ for (const p of [path.join(process.env.HOME,'Projects/secrets-manager/.env'), path.join(__dirname,'../.env')]) {
+ try { const m = fs.readFileSync(p,'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m); if (m) return m[1].replace(/["']/g,'').trim(); } catch {}
+ } throw new Error('no token');
+})();
+const OUT = path.join(__dirname, 'maharam-price-load.out'); fs.mkdirSync(OUT, {recursive:true});
+
+async function gql(query, variables) {
+ const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+ method:'POST', headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},
+ body: JSON.stringify({query, variables}) });
+ const j = await r.json();
+ if (j.errors) throw new Error('GQL '+JSON.stringify(j.errors).slice(0,300));
+ const t = j.extensions?.cost?.throttleStatus; if (t && t.currentlyAvailable<300) await new Promise(z=>setTimeout(z,600));
+ return j.data;
+}
+const widthIn = (w) => { const m = String(w||'').match(/(\d+(?:\.\d+)?)\s*"/); return m?m[1]:null; };
+
+const Q_PROD = `query($q:String!){ products(first:1, query:$q){ edges{ node{
+ id title status
+ options{ id name optionValues{ id name } }
+ variants(first:20){ edges{ node{ id title sku price position selectedOptions{name value} } } } } } } }`;
+
+const M_OPT_RENAME = `mutation($pid:ID!,$oid:ID!){ productOptionUpdate(productId:$pid, option:{id:$oid, name:"Size"}){ userErrors{field message} } }`;
+const M_VAR_CREATE = `mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
+ productVariantsBulkCreate(productId:$pid, variants:$vars, strategy: DEFAULT){
+ productVariants{ id title price sku } userErrors{ field message } } }`;
+const M_VAR_REORDER = `mutation($pid:ID!,$pos:[ProductVariantPositionInput!]!){
+ productVariantsBulkReorder(productId:$pid, positions:$pos){ userErrors{ field message } } }`;
+const M_MF_SET = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`;
+
+async function loadOne(rec, dry) {
+ const q = `sku:${rec.mfr_sku ? '' : ''}${rec.dw_sku}`; // products carry dw_sku as the sellable sku target; query by dw_sku sample
+ const data = await gql(Q_PROD, {q:`sku:${rec.dw_sku}-Sample`});
+ const node = data.products.edges[0]?.node;
+ if (!node) return {sku:rec.dw_sku, status:'NOT_FOUND'};
+ const hasSellable = node.variants.edges.some(e => !/sample/i.test(e.node.title));
+ if (hasSellable) return {sku:rec.dw_sku, status:'ALREADY_HAS_SELLABLE'};
+ const w = widthIn(rec.width); const yTitle = `Sold Per Yard - ${w||'54'}In Wide`;
+ const opt = node.options[0];
+ const plan = {
+ sku: rec.dw_sku, title: node.title, price: rec.price_per_yard, width: w,
+ yardVariantTitle: yTitle, renameOptionFrom: opt?.name,
+ };
+ if (dry) return {...plan, status:'DRY'};
+ // 1. rename option -> Size
+ if (opt && opt.name !== 'Size') { const r=await gql(M_OPT_RENAME,{pid:node.id,oid:opt.id}); if(r.productOptionUpdate.userErrors.length) return {...plan,status:'ERR_OPT',err:r.productOptionUpdate.userErrors}; }
+ // 2. add sellable yard variant
+ const cr = await gql(M_VAR_CREATE, {pid:node.id, vars:[{
+ optionValues:[{optionName:'Size', name:yTitle}],
+ price:String(rec.price_per_yard),
+ inventoryItem:{sku:rec.dw_sku, tracked:false},
+ inventoryPolicy:'CONTINUE'
+ }]});
+ if (cr.productVariantsBulkCreate.userErrors.length) return {...plan,status:'ERR_VAR',err:cr.productVariantsBulkCreate.userErrors};
+ const newVarId = cr.productVariantsBulkCreate.productVariants[0].id;
+ const sampleVar = node.variants.edges[0].node.id;
+ // 3. reorder: sellable pos1, sample pos2
+ await gql(M_VAR_REORDER, {pid:node.id, pos:[{id:newVarId,position:1},{id:sampleVar,position:2}]});
+ // 4. metafields
+ await gql(M_MF_SET, {mf:[
+ {ownerId:node.id, namespace:'global', key:'unit_of_measure', type:'single_line_text_field', value:'Priced Per Yard'},
+ {ownerId:node.id, namespace:'dwc', key:'order_unit', type:'single_line_text_field', value:'Yard'},
+ {ownerId:node.id, namespace:'global', key:'v_prods_quantity_order_units', type:'single_line_text_field', value:'1'},
+ {ownerId:node.id, namespace:'global', key:'v_prods_quantity_order_min', type:'single_line_text_field', value:'1'},
+ ]});
+ return {...plan, status:'LOADED', newVarId};
+}
+
+(async () => {
+ let recs = JSON.parse(fs.readFileSync(path.join(__dirname,'../.playwright-mcp/maharam-sku-prices.json'),'utf8'))
+ .filter(r => r.price_per_yard);
+ if (PILOT) recs = recs.filter(r => r.dw_sku === PILOT_SKU);
+ console.log(`[MaharamSOURCE price-load] mode=${APPLY?'APPLY':'DRY-RUN'} ${PILOT?'(PILOT '+PILOT_SKU+')':''} · ${recs.length} product(s)`);
+ if (APPLY && !CONFIRM) { console.error('REFUSED: --apply needs --yes-i-am-steve (customer-facing).'); process.exit(2); }
+ const results = []; let n=0;
+ for (const rec of recs) {
+ try { const r = await loadOne(rec, !APPLY); results.push(r);
+ if (APPLY) { n++; if (n%50===0) console.log(` …${n}/${recs.length}`); }
+ } catch(e){ results.push({sku:rec.dw_sku, status:'EXC', err:e.message}); }
+ if (APPLY) await new Promise(z=>setTimeout(z,120));
+ }
+ const stamp=new Date().toISOString().replace(/[:.]/g,'-');
+ fs.writeFileSync(path.join(OUT,`run-${stamp}.json`), JSON.stringify(results,null,1));
+ const by = results.reduce((a,r)=>{a[r.status]=(a[r.status]||0)+1;return a;},{});
+ console.log(' summary:', by);
+ if (!APPLY) results.slice(0,3).forEach(r=>console.log(' DRY sample:', JSON.stringify(r)));
+ console.log(' results ->', path.join(OUT,`run-${stamp}.json`));
+})().catch(e=>{console.error(e);process.exit(1);});
diff --git a/shopify/scripts/zoffany-work/zof-recheck.mjs b/shopify/scripts/zoffany-work/zof-recheck.mjs
new file mode 100644
index 00000000..a8ef99f4
--- /dev/null
+++ b/shopify/scripts/zoffany-work/zof-recheck.mjs
@@ -0,0 +1,7 @@
+const TOKEN=process.env.TOKEN,STORE="designer-laboratory-sandbox.myshopify.com",API="2024-10";
+const url=`https://${STORE}/admin/api/${API}/graphql.json`;
+const gql=async(q,v)=>{const r=await fetch(url,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});return (await r.json()).data;};
+const Q=`query($c:String){products(first:100,query:"vendor:Zoffany status:active",after:$c){pageInfo{hasNextPage endCursor}edges{node{legacyResourceId variants(first:20){edges{node{title sku}}}}}}}`;
+let c=null,total=0,missing=0,badsku=0;
+do{const d=await gql(Q,{c});const p=d.products;for(const e of p.edges){total++;const vs=e.node.variants.edges.map(x=>x.node);const hs=vs.some(v=>/sample/i.test(v.title||''));if(!hs)missing++;const s=vs.find(v=>/sample/i.test(v.title||''));if(s&&!/-sample$/i.test(s.sku||''))badsku++;}c=p.pageInfo.hasNextPage?p.pageInfo.endCursor:null;}while(c);
+console.log('ACTIVE',total,'| missing_sample',missing,'| sample_with_bad_sku',badsku);
diff --git a/shopify/scripts/zoffany-work/zof-repair.mjs b/shopify/scripts/zoffany-work/zof-repair.mjs
new file mode 100644
index 00000000..e25aa348
--- /dev/null
+++ b/shopify/scripts/zoffany-work/zof-repair.mjs
@@ -0,0 +1,22 @@
+import fs from 'fs';
+const TOKEN=process.env.TOKEN, STORE="designer-laboratory-sandbox.myshopify.com", API="2024-10";
+const url=`https://${STORE}/admin/api/${API}/graphql.json`;
+const gql=async(q,v)=>{const r=await fetch(url,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});return (await r.json());};
+const rmap=JSON.parse(fs.readFileSync('zoffany-restore-map.json')).products;
+const ids=["7793952849971","7793952882739"];
+// guard: only repair if the product currently has ONLY a Sample variant (i.e. still broken)
+const QCHECK=`query($id:ID!){product(id:$id){variants(first:10){edges{node{title sku price}}}}}`;
+const M=`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$variants){productVariants{id title sku price} userErrors{field message}}}`;
+for(const id of ids){
+ const gid=`gid://shopify/Product/${id}`;
+ const cur=(await gql(QCHECK,{id:gid})).data?.product?.variants?.edges?.map(e=>e.node)||[];
+ const hasRoll=cur.some(v=>!/sample/i.test(v.title||''));
+ if(hasRoll){console.log(id,'ALREADY OK (has non-sample variant), skipping:',cur.map(v=>v.title+':'+v.sku).join(','));continue;}
+ const orig=rmap.find(m=>String(m.id)===id).variants[0];
+ const vin=[{price:String(orig.price), optionValues:[{optionName:"Title", name:"Sold Per Roll"}], inventoryItem:{sku:orig.sku, tracked:false}, inventoryPolicy:"CONTINUE"}];
+ const d=await gql(M,{pid:gid,variants:vin});
+ console.log(id, JSON.stringify(d.data?.productVariantsBulkCreate));
+}
+// final verify
+const QV=`query($id:ID!){product(id:$id){title variants(first:10){edges{node{title sku price}}}}}`;
+for(const id of ids){const d=(await gql(QV,{id:`gid://shopify/Product/${id}`})).data;console.log('VERIFY',id,d.product.title,'=>',d.product.variants.edges.map(e=>`${e.node.title}:${e.node.sku}:$${e.node.price}`).join(' | '));}
diff --git a/shopify/scripts/zoffany-work/zof-reprice.mjs b/shopify/scripts/zoffany-work/zof-reprice.mjs
new file mode 100644
index 00000000..75f56abd
--- /dev/null
+++ b/shopify/scripts/zoffany-work/zof-reprice.mjs
@@ -0,0 +1,56 @@
+import fs from 'fs';
+// Zoffany below-cost reprice → DW-standard "vendor cost plus": retail = cost/0.65/0.85
+// Steve-authorized basis 2026-08-26: zoffany_catalog.price_trade = vendor NET cost.
+// Mirrors the proven zof-repair.mjs mechanic (same store/API/gql). Restore-map written BEFORE each write.
+// Usage: TOKEN=$SHOPIFY_ADMIN_TOKEN node zof-reprice.mjs <verify|apply>
+const TOKEN = process.env.TOKEN, STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+if (!TOKEN) { console.error('NO TOKEN (set TOKEN=$SHOPIFY_ADMIN_TOKEN)'); process.exit(1); }
+const url = `https://${STORE}/admin/api/${API}/graphql.json`;
+const gql = async (q, v) => {
+ const r = await fetch(url, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+ return await r.json();
+};
+const MODE = process.argv[2] || 'verify';
+const SANITY_MAX_UPLIFT = 4.0; // below-cost fix is ~2.54x; anything >4x = bad cost data → SKIP, never write
+
+const reprice = JSON.parse(fs.readFileSync('/tmp/zoffany-reprice-79.json', 'utf8'));
+const active = JSON.parse(fs.readFileSync('zoffany-active-all.json', 'utf8'));
+
+// dw_sku -> live Shopify product id (match the SELLABLE, non-sample variant)
+const skuToProduct = {};
+for (const p of active) for (const v of (p.variants || [])) {
+ if (v.sku && !/-sample$/i.test(v.sku)) skuToProduct[v.sku] = p.id;
+}
+
+const restorePath = 'zoffany-reprice-restore-map.json';
+const restore = fs.existsSync(restorePath) ? JSON.parse(fs.readFileSync(restorePath, 'utf8')) : {};
+const Q = `query($id:ID!){product(id:$id){id title variants(first:20){edges{node{id title sku price}}}}}`;
+const M = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$pid,variants:$variants){productVariants{id sku price} userErrors{field message}}}`;
+
+let done = 0, skip = 0, fail = 0, suspect = 0;
+const rows = [];
+for (const r of reprice) {
+ const pid = skuToProduct[r.dw_sku];
+ if (!pid) { skip++; rows.push({ dw_sku: r.dw_sku, status: 'NO_PRODUCT_MATCH' }); continue; }
+ const prod = (await gql(Q, { id: `gid://shopify/Product/${pid}` })).data?.product;
+ if (!prod) { fail++; rows.push({ dw_sku: r.dw_sku, status: 'GET_FAIL' }); continue; }
+ const sell = prod.variants.edges.map(e => e.node).find(n => !/sample/i.test(n.title || '') && !/-sample$/i.test(n.sku || ''));
+ if (!sell) { skip++; rows.push({ dw_sku: r.dw_sku, status: 'NO_SELLABLE_VARIANT' }); continue; }
+ const oldP = Number(sell.price), newP = Number(r.new_price);
+ const uplift = oldP > 0 ? +(newP / oldP).toFixed(2) : null;
+ const rec = { dw_sku: r.dw_sku, title: prod.title, old: sell.price, new: r.new_price, uplift, variant: sell.id };
+ rows.push(rec);
+ if (uplift && uplift > SANITY_MAX_UPLIFT) { suspect++; rec.status = 'SUSPECT_SKIP_uplift>' + SANITY_MAX_UPLIFT; continue; }
+ if (MODE === 'apply') {
+ restore[sell.id] = { product: pid, dw_sku: r.dw_sku, old_price: sell.price };
+ fs.writeFileSync(restorePath, JSON.stringify(restore, null, 2)); // persist BEFORE write
+ const d = await gql(M, { pid: `gid://shopify/Product/${pid}`, variants: [{ id: sell.id, price: String(r.new_price) }] });
+ const err = d.data?.productVariantsBulkUpdate?.userErrors || [];
+ if (err.length) { fail++; rec.status = 'ERR'; rec.err = err; } else { done++; rec.status = 'REPRICED'; }
+ await new Promise(res => setTimeout(res, 400));
+ } else { rec.status = 'WOULD_REPRICE'; }
+}
+fs.writeFileSync(`zof-reprice-${MODE}.json`, JSON.stringify({ mode: MODE, priced: done, skipped: skip, suspect, failed: fail, rows }, null, 2));
+console.log(`MODE=${MODE} priced=${done} would/skip=${skip} suspect=${suspect} fail=${fail} (full: zof-reprice-${MODE}.json)`);
+for (const x of rows.slice(0, 8)) console.log(` ${x.dw_sku} ${x.old} -> ${x.new} (${x.uplift}x) ${x.status}`);
+// Undo after apply: for each id in zoffany-reprice-restore-map.json, productVariantsBulkUpdate price back to old_price.
diff --git a/shopify/scripts/zoffany-work/zof-sample-run.mjs b/shopify/scripts/zoffany-work/zof-sample-run.mjs
new file mode 100644
index 00000000..21d3886c
--- /dev/null
+++ b/shopify/scripts/zoffany-work/zof-sample-run.mjs
@@ -0,0 +1,44 @@
+import fs from 'fs';
+const TOKEN=process.env.TOKEN, STORE="designer-laboratory-sandbox.myshopify.com", API="2024-10";
+const LIMIT=process.env.LIMIT?parseInt(process.env.LIMIT):999999;
+const ONLY=process.env.ONLY? process.env.ONLY.split(','):null;
+const url=`https://${STORE}/admin/api/${API}/graphql.json`;
+const gql=async(q,v)=>{const r=await fetch(url,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});return (await r.json());};
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const missing=JSON.parse(fs.readFileSync('zoffany-missing-sample.json'));
+const QP=`query($id:ID!){product(id:$id){variants(first:25){edges{node{id title sku price selectedOptions{name value}}}}}}`;
+const MU=`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$pid,variants:$variants){productVariants{id title sku} userErrors{field message}}}`;
+const MC=`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$variants){productVariants{id title sku price} userErrors{field message}}}`;
+let targets=missing;
+if(ONLY) targets=missing.filter(p=>ONLY.includes(String(p.id)));
+targets=targets.slice(0,LIMIT);
+const out=[];
+for(const p of targets){
+ const gid=p.gid; const rec={id:p.id,title:p.title};
+ try{
+ const cur=(await gql(QP,{id:gid})).data?.product?.variants?.edges?.map(e=>e.node)||[];
+ const roll=cur.find(v=>!/sample/i.test(v.title||''));
+ const hasSample=cur.some(v=>/sample/i.test(v.title||''));
+ if(hasSample){rec.status='already-has-sample';out.push(rec);continue;}
+ if(!roll||!roll.sku){rec.status='no-sellable-sku';out.push(rec);continue;}
+ const base=roll.sku.trim();
+ // Step 1: rename the sellable variant's Title option value -> "Sold Per Roll" (GID-preserving)
+ if((roll.title||'')==='Default Title'){
+ const u=await gql(MU,{pid:gid,variants:[{id:roll.id,optionValues:[{optionName:"Title",name:"Sold Per Roll"}]}]});
+ const ue=u.data?.productVariantsBulkUpdate?.userErrors||[];
+ if(ue.length){rec.status='rename-failed';rec.err=ue;out.push(rec);continue;}
+ }
+ // Step 2: add Sample
+ const c=await gql(MC,{pid:gid,variants:[{price:"4.25",optionValues:[{optionName:"Title",name:"Sample"}],inventoryItem:{sku:`${base}-Sample`,tracked:false},inventoryPolicy:"DENY"}]});
+ const ce=c.data?.productVariantsBulkCreate?.userErrors||[];
+ if(ce.length){rec.status='sample-create-failed';rec.err=ce;out.push(rec);continue;}
+ rec.status='ok';rec.base=base;rec.sample_gid=c.data.productVariantsBulkCreate.productVariants[0].id;
+ out.push(rec);
+ }catch(e){rec.status='exception';rec.err=String(e);out.push(rec);}
+ await sleep(300);
+}
+const ok=out.filter(r=>r.status==='ok').length;
+fs.writeFileSync(process.env.OUTFILE||'zof-sample-run-out.json',JSON.stringify(out,null,2));
+console.log('processed',out.length,'ok',ok);
+const byStatus={};out.forEach(r=>byStatus[r.status]=(byStatus[r.status]||0)+1);console.log(JSON.stringify(byStatus));
+out.filter(r=>r.status!=='ok').slice(0,10).forEach(r=>console.log('NONOK',r.id,r.status,JSON.stringify(r.err||'')));
diff --git a/shopify/scripts/zoffany-work/zof-vp.mjs b/shopify/scripts/zoffany-work/zof-vp.mjs
new file mode 100644
index 00000000..d879b015
--- /dev/null
+++ b/shopify/scripts/zoffany-work/zof-vp.mjs
@@ -0,0 +1,5 @@
+const TOKEN=process.env.TOKEN,STORE="designer-laboratory-sandbox.myshopify.com",API="2024-10";
+const url=`https://${STORE}/admin/api/${API}/graphql.json`;
+const gql=async(q,v)=>{const r=await fetch(url,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});return (await r.json()).data;};
+const d=await gql(`query($id:ID!){product(id:$id){title options{name values} variants(first:10){edges{node{id title sku price}}}}}`,{id:"gid://shopify/Product/7793953112115"});
+console.log(JSON.stringify(d.product,null,2));
← 2c253463 auto-data-snapshot: 2026-08-27T14:29:15 (1 data files) — pac
·
back to Designer Wallcoverings
·
block sample prices from roll imports 3c6d03f6 →