[object Object]

← back to Designer Wallcoverings

Suncloth retro-flip: 119 live products Wallcovering->Fabric (PG-staged, GraphQL batched)

670bf751bb797790fd35bcf1dd6e5c23cc548219 · 2026-06-29 16:22:48 -0700 · Steve

Reclassified all 119 vendor:Suncloth products (incl. DWUX-800105) product_type
Wallcovering->Fabric. Selector vendor:Suncloth (1:1 with DWUX- prefix; NOT the suncloth
tag which false-positives on China Seas). Staged into shopify_product_type_updates (PG)
before pushing batched productUpdate via SHOPIFY_ADMIN_TOKEN. 119 ok / 0 fail, post-check
0 remaining Wallcovering. Side effects benign (smart-collections survive, GMC+facet move
to Fabric bucket = the desired correction). Steve approved 2026-06-29.

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

Files touched

Diff

commit 670bf751bb797790fd35bcf1dd6e5c23cc548219
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 16:22:48 2026 -0700

    Suncloth retro-flip: 119 live products Wallcovering->Fabric (PG-staged, GraphQL batched)
    
    Reclassified all 119 vendor:Suncloth products (incl. DWUX-800105) product_type
    Wallcovering->Fabric. Selector vendor:Suncloth (1:1 with DWUX- prefix; NOT the suncloth
    tag which false-positives on China Seas). Staged into shopify_product_type_updates (PG)
    before pushing batched productUpdate via SHOPIFY_ADMIN_TOKEN. 119 ok / 0 fail, post-check
    0 remaining Wallcovering. Side effects benign (smart-collections survive, GMC+facet move
    to Fabric bucket = the desired correction). Steve approved 2026-06-29.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 shopify/scripts/fix-suncloth-to-fabric.js | 154 ++++++++++++++++++++++++++++++
 1 file changed, 154 insertions(+)

diff --git a/shopify/scripts/fix-suncloth-to-fabric.js b/shopify/scripts/fix-suncloth-to-fabric.js
new file mode 100644
index 00000000..fe5b4e0d
--- /dev/null
+++ b/shopify/scripts/fix-suncloth-to-fabric.js
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+/**
+ * fix-suncloth-to-fabric.js — retroactively reclassify all live Suncloth products
+ * from product_type "Wallcovering" → "Fabric" (Steve approved 2026-06-29).
+ *
+ * Selector: vendor = "Suncloth" (verified 1:1 with the DWUX- SKU prefix; the `suncloth`
+ * TAG is NOT used — it false-positives on 347 China Seas fabrics + 9 wallcoverings).
+ *
+ * Safe by design:
+ *   - DRY-RUN by default; only writes with --execute.
+ *   - PostgreSQL BEFORE Shopify (stages every change into shopify_product_type_updates).
+ *   - Only flips products currently typed "Wallcovering" (idempotent; re-runs are no-ops).
+ *   - Batched GraphQL productUpdate (25/batch) with 429 throttle handling.
+ *
+ * Usage:
+ *   node fix-suncloth-to-fabric.js            # dry-run: scope + counts only
+ *   node fix-suncloth-to-fabric.js --execute  # stage in PG, then push to Shopify
+ */
+const https = require('https');
+const fs = require('fs');
+const path = require('path');
+const { Client } = require('pg');
+
+// --- load .env (DATABASE_URL, SHOPIFY_ADMIN_TOKEN) without a dep ---
+(() => {
+  const envPath = path.join(__dirname, '..', '..', '.env');
+  if (fs.existsSync(envPath)) for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
+    const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+    if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+  }
+})();
+
+const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API_VERSION   = '2024-10';
+const DB_URL = process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified';
+const EXECUTE = process.argv.includes('--execute');
+const BATCH = 25;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function gql(query) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query });
+    const req = https.request({
+      hostname: SHOPIFY_STORE, path: `/admin/api/${API_VERSION}/graphql.json`, method: 'POST',
+      headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
+    }, res => {
+      let d = ''; res.on('data', c => d += c);
+      res.on('end', () => {
+        if (res.statusCode === 429) return resolve({ throttled: true, retryAfter: parseFloat(res.headers['retry-after'] || '2') });
+        try { resolve(JSON.parse(d)); } catch { reject(new Error('Parse error: ' + d.slice(0, 200))); }
+      });
+    });
+    req.on('error', reject); req.write(body); req.end();
+  });
+}
+
+async function fetchSuncloth() {
+  const all = []; let cursor = null;
+  for (;;) {
+    const after = cursor ? `, after: "${cursor}"` : '';
+    const q = `{ products(first: 100, query: "vendor:Suncloth"${after}) {
+      pageInfo { hasNextPage endCursor }
+      edges { node { id title handle productType status } }
+    } }`;
+    let r = await gql(q);
+    if (r.throttled) { await sleep(r.retryAfter * 1000); continue; }
+    if (r.errors) throw new Error(JSON.stringify(r.errors));
+    const p = r.data.products;
+    p.edges.forEach(e => all.push(e.node));
+    if (!p.pageInfo.hasNextPage) break;
+    cursor = p.pageInfo.endCursor;
+    await sleep(300);
+  }
+  return all;
+}
+
+async function pushBatch(items) {
+  const muts = items.map((p, i) =>
+    `p${i}: productUpdate(input: {id: "${p.id}", productType: "Fabric"}) { product { id productType } userErrors { field message } }`
+  ).join('\n');
+  let attempt = 0;
+  while (attempt < 4) {
+    const r = await gql(`mutation { ${muts} }`);
+    if (r.throttled) { await sleep(r.retryAfter * 1000); attempt++; continue; }
+    if (r.errors) {
+      if (r.errors.find(e => /Throttled/i.test(e.message || ''))) { await sleep(2500); attempt++; continue; }
+      throw new Error(JSON.stringify(r.errors));
+    }
+    const out = [];
+    items.forEach((p, i) => {
+      const node = r.data?.[`p${i}`];
+      const ue = node?.userErrors || [];
+      out.push({ id: p.id, ok: ue.length === 0 && node?.product?.productType === 'Fabric', err: ue.map(e => e.message).join('; ') });
+    });
+    return out;
+  }
+  return items.map(p => ({ id: p.id, ok: false, err: 'throttled-out' }));
+}
+
+(async () => {
+  if (!SHOPIFY_TOKEN) { console.error('Missing SHOPIFY_ADMIN_TOKEN'); process.exit(2); }
+  console.log(`\nSUNCLOTH → Fabric  [${EXECUTE ? 'EXECUTE' : 'DRY-RUN'}]\n`);
+
+  const products = await fetchSuncloth();
+  const byType = {};
+  products.forEach(p => { byType[p.productType || '(none)'] = (byType[p.productType || '(none)'] || 0) + 1; });
+  const toFix = products.filter(p => p.productType === 'Wallcovering');
+  const target = products.find(p => /dwux-800105/i.test(p.handle));
+
+  console.log(`vendor:Suncloth products = ${products.length}`);
+  console.log(`product_type breakdown    = ${JSON.stringify(byType)}`);
+  console.log(`to fix (Wallcovering)     = ${toFix.length}`);
+  console.log(`DWUX-800105 present       = ${target ? `YES (${target.productType}, ${target.status})` : 'NO'}`);
+  console.log(`sample: ${toFix.slice(0, 3).map(p => p.handle + ' [' + p.productType + ']').join(', ')}`);
+
+  if (!toFix.length) { console.log('\nNothing to fix — all Suncloth already Fabric. ✅'); process.exit(0); }
+  if (!EXECUTE) { console.log('\nDRY-RUN only. Re-run with --execute to stage in PG + push to Shopify.'); process.exit(0); }
+
+  // --- PostgreSQL BEFORE Shopify: stage every change ---
+  // Local Mac2 PG authenticates via Unix-socket PEER auth as the OS user (the .env
+  // DATABASE_URL is passwordless and TCP scram-rejects it). Mirror `psql -d dw_unified`.
+  const db = new Client({ host: process.env.PGHOST || '/tmp', database: 'dw_unified', user: process.env.PGUSER || process.env.USER });
+  await db.connect();
+  await db.query(`CREATE TABLE IF NOT EXISTS shopify_product_type_updates (
+    id SERIAL PRIMARY KEY, table_name TEXT, shopify_product_id TEXT, new_product_type TEXT,
+    width_inches NUMERIC, pushed BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT now())`);
+  for (const p of toFix) {
+    await db.query(
+      `INSERT INTO shopify_product_type_updates (table_name, shopify_product_id, new_product_type, pushed)
+       VALUES ($1,$2,$3,FALSE)`, ['quadrille_house_catalog', p.id, 'Fabric']);
+  }
+  console.log(`\nStaged ${toFix.length} rows in shopify_product_type_updates (PG).`);
+
+  // --- push in batches ---
+  let ok = 0, fail = 0;
+  for (let i = 0; i < toFix.length; i += BATCH) {
+    const slice = toFix.slice(i, i + BATCH);
+    const res = await pushBatch(slice);
+    for (const r of res) {
+      if (r.ok) { ok++; await db.query('UPDATE shopify_product_type_updates SET pushed=true WHERE shopify_product_id=$1', [r.id]); }
+      else { fail++; console.log(`  FAIL ${r.id}: ${r.err}`); }
+    }
+    console.log(`  batch ${i / BATCH + 1}: ${ok} ok / ${fail} fail (of ${toFix.length})`);
+    await sleep(600);
+  }
+
+  // --- verify ---
+  const after = await fetchSuncloth();
+  const stillWall = after.filter(p => p.productType === 'Wallcovering').length;
+  console.log(`\nPushed: ${ok} ok, ${fail} fail. Post-check vendor:Suncloth still Wallcovering = ${stillWall}`);
+  await db.end();
+  process.exit(stillWall === 0 && fail === 0 ? 0 : 1);
+})().catch(e => { console.error('ERROR:', e.message); process.exit(2); });

← 4547b440 Andrew: final consolidated 36in letter drafted (Woven Grass  ·  back to Designer Wallcoverings  ·  auto-save: 2026-06-29T16:24:37 (3 files) — pending-approval/ a7ce3afe →