← 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
A shopify/scripts/fix-suncloth-to-fabric.js
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 →