[object Object]

← back to Groundworks Activation

Fix variant mutation sequence (option rename via optionValuesToUpdate, draft-then-active gate)

228360c5306169d22730f6e10c9bf108d3bed3f6 · 2026-07-22 07:55:03 -0700 · Steve Abrams

Files touched

Diff

commit 228360c5306169d22730f6e10c9bf108d3bed3f6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 07:55:03 2026 -0700

    Fix variant mutation sequence (option rename via optionValuesToUpdate, draft-then-active gate)
---
 data/publish-results.json |   9 ++
 package-lock.json         | 162 ++++++++++++++++++++++++++++++++++
 package.json              |  16 ++++
 scripts/publish.js        | 218 ++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 405 insertions(+)

diff --git a/data/publish-results.json b/data/publish-results.json
new file mode 100644
index 0000000..7968b0d
--- /dev/null
+++ b/data/publish-results.json
@@ -0,0 +1,9 @@
+[
+  {
+    "mfr_sku": "GWP-3766.6.0",
+    "dwSku": "DWKK-157424",
+    "pid": "7896260313139",
+    "title": "Argus Antique | Groundworks",
+    "price": "562.50"
+  }
+]
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..2ccdc4b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,162 @@
+{
+  "name": "groundworks-activation",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "groundworks-activation",
+      "version": "1.0.0",
+      "license": "ISC",
+      "dependencies": {
+        "pg": "^8.22.0"
+      }
+    },
+    "node_modules/pg": {
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+      "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.15.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+      "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7732a97
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "groundworks-activation",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC",
+  "type": "commonjs",
+  "dependencies": {
+    "pg": "^8.22.0"
+  }
+}
diff --git a/scripts/publish.js b/scripts/publish.js
new file mode 100644
index 0000000..0e9e94d
--- /dev/null
+++ b/scripts/publish.js
@@ -0,0 +1,218 @@
+#!/usr/bin/env node
+/**
+ * Groundworks LIVE activation — publishes the clean, zero-conflict, settlement-cleared set
+ * to designer-laboratory-sandbox.myshopify.com (LIVE prod). PG-first (dw_sku_registry +
+ * groundworks_catalog) then Shopify (authoritative). Collection membership via smart rule
+ * vendor='Groundworks'. Kravet-umbrella MAP pricing. 5-field structure per SKU.
+ *
+ * Usage: node publish.js [--dry] [--limit N]
+ */
+const { Client } = require('pg');
+const fs = require('fs');
+
+const DRY = process.argv.includes('--dry');
+const limIdx = process.argv.indexOf('--limit');
+const LIMIT = limIdx >= 0 ? parseInt(process.argv[limIdx + 1], 10) : 0;
+
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const TOKEN = (fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+  .split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN=')) || '').split('=').slice(1).join('=').trim();
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(2); }
+
+const GQL = `https://${DOMAIN}/admin/api/${API}/graphql.json`;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function shopify(query, variables) {
+  for (let attempt = 0; attempt < 5; attempt++) {
+    const res = await fetch(GQL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
+      body: JSON.stringify({ query, variables }),
+    });
+    if (res.status === 429 || res.status >= 500) { await sleep(2000 * (attempt + 1)); continue; }
+    const j = await res.json();
+    if (j.errors) throw new Error('GQL: ' + JSON.stringify(j.errors).slice(0, 300));
+    // throttle-aware pacing
+    const cost = j.extensions?.cost?.throttleStatus;
+    if (cost && cost.currentlyAvailable < 200) await sleep(1500);
+    return j.data;
+  }
+  throw new Error('shopify retries exhausted');
+}
+
+function titleCase(s) {
+  return (s || '').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
+    .replace(/\bWc\b/gi, '').replace(/\bWp\b/gi, '').replace(/\s{2,}/g, ' ').trim();
+}
+function parsePattern(name) {
+  // name = "Pattern - Real Color"
+  const idx = name.lastIndexOf(' - ');
+  if (idx < 0) return { pattern: titleCase(name), color: '' };
+  return { pattern: titleCase(name.slice(0, idx)), color: titleCase(name.slice(idx + 3)) };
+}
+function buildTitle(row) {
+  const { pattern, color } = parsePattern(row.name);
+  // color from scrape only; fallback mfr_sku (never "Unknown")
+  const realColor = color || row.color_name || '';
+  const brand = 'Groundworks';
+  if (pattern && realColor) return `${pattern} ${realColor} | ${brand}`;
+  if (pattern) return `${pattern} | ${brand}`;
+  return `${row.mfr_sku} | ${brand}`;
+}
+function unitLabel(row) {
+  const w = row.width ? `${row.width}` : '';
+  if (row.uom === 'Yard') return `Sold Per Yard${w ? ` -  ${w}In Wide` : ''}`;
+  if (row.uom === 'Each') return `Sold Each`;
+  return `Sold Per Roll${w ? ` -  ${w}In Wide` : ''}`;
+}
+function buildDescription(row) {
+  const { pattern, color } = parsePattern(row.name);
+  const parts = [];
+  parts.push(`<p>${pattern}${color ? ` in ${color}` : ''} is a Groundworks wallcovering`);
+  if (row.designer) parts[0] += ` designed by ${row.designer}`;
+  if (row.collection) parts[0] += `, from the ${titleCase(row.collection)} collection`;
+  parts[0] += '.';
+  const specs = [];
+  if (row.composition) specs.push(`Composition: ${row.composition}`);
+  if (row.made_in) specs.push(`Made in ${row.made_in}`);
+  if (row.repeat_v || row.repeat_h) {
+    const rp = [row.repeat_v && `${row.repeat_v}" vertical`, row.repeat_h && `${row.repeat_h}" horizontal`].filter(Boolean).join(', ');
+    specs.push(`Repeat: ${rp}`);
+  }
+  if (row.width) specs.push(`Width: ${row.width}"`);
+  let html = parts[0];
+  if (specs.length) html += ` ${specs.join('. ')}.`;
+  html += '</p>';
+  return html;
+}
+function buildTags(row) {
+  const { pattern, color } = parsePattern(row.name);
+  const tags = new Set(['Wallcovering', 'Groundworks']);
+  if (pattern) tags.add(pattern);
+  if (color) tags.add(color);
+  if (row.color_name) tags.add(row.color_name);
+  if (row.color_bucket) tags.add(row.color_bucket);
+  if (row.collection) tags.add(titleCase(row.collection));
+  if (row.designer) tags.add(row.designer);
+  tags.add(row.mfr_sku);
+  if (row.width) tags.add(`${row.width}In`);
+  return [...tags].filter(Boolean);
+}
+
+async function nextDwkkStart(pg) {
+  const r = await pg.query(`SELECT COALESCE(MAX((regexp_replace(dw_sku,'^DWKK-',''))::bigint),0) AS mx
+    FROM dw_sku_registry WHERE dw_sku ~ '^DWKK-[0-9]+$'`);
+  return Number(r.rows[0].mx);
+}
+
+async function main() {
+  const pg = new Client({ host: '/tmp', database: 'dw_unified' });
+  await pg.connect();
+
+  let sql = `SELECT * FROM groundworks_catalog a
+    WHERE a.status='Active'
+    AND NOT EXISTS(SELECT 1 FROM shopify_products sp WHERE sp.mfr_sku=a.mfr_sku)
+    AND NOT EXISTS(SELECT 1 FROM dw_sku_registry r WHERE r.mfr_sku=a.mfr_sku)
+    AND a.mfr_sku NOT IN ('GWP-3746.194.0','GWP-3746.712.0','GWP-3746.513.0')
+    ORDER BY a.name`;
+  if (LIMIT) sql += ` LIMIT ${LIMIT}`;
+  const rows = (await pg.query(sql)).rows;
+  console.log(`Publish set: ${rows.length} SKUs  DRY=${DRY}`);
+
+  let counter = await nextDwkkStart(pg);
+  const results = [];
+
+  for (const row of rows) {
+    // NEVER-DUPLICATE re-check right before create
+    const dupChk = await pg.query(
+      `SELECT 1 FROM shopify_products WHERE mfr_sku=$1
+       UNION SELECT 1 FROM dw_sku_registry WHERE mfr_sku=$1 LIMIT 1`, [row.mfr_sku]);
+    if (dupChk.rowCount) { results.push({ mfr_sku: row.mfr_sku, skip: 'dup-appeared' }); continue; }
+
+    counter += 10;
+    const dwSku = `DWKK-${counter}`;
+    const title = buildTitle(row);
+    const price = Number(row.map_price).toFixed(2);
+    if (!(Number(price) > 0)) { results.push({ mfr_sku: row.mfr_sku, skip: 'no-price' }); continue; }
+    if (!row.image_url) { results.push({ mfr_sku: row.mfr_sku, skip: 'no-image' }); continue; }
+
+    const input = {
+      title,
+      vendor: 'Groundworks',
+      productType: 'Wallcovering',
+      status: 'ACTIVE',
+      descriptionHtml: buildDescription(row),
+      tags: buildTags(row),
+      handle: dwSku.toLowerCase(),
+    };
+
+    if (DRY) {
+      results.push({ mfr_sku: row.mfr_sku, dwSku, title, price, unit: unitLabel(row), tags: input.tags.length });
+      continue;
+    }
+
+    // 1) create product as DRAFT first (only flip ACTIVE after variants verified)
+    const cr = await shopify(`mutation($input:ProductInput!){productCreate(input:$input){product{id handle} userErrors{field message}}}`,
+      { input: { ...input, status: 'DRAFT' } });
+    const perr = cr.productCreate.userErrors;
+    if (perr && perr.length) { results.push({ mfr_sku: row.mfr_sku, err: JSON.stringify(perr) }); continue; }
+    const pid = cr.productCreate.product.id;
+
+    // 2) media (featured image)
+    await shopify(`mutation($pid:ID!,$media:[CreateMediaInput!]!){productCreateMedia(productId:$pid,media:$media){userErrors{field message}}}`,
+      { pid, media: [{ originalSource: row.image_url, mediaContentType: 'IMAGE' }] });
+
+    // 3) variants: rename option "Title"->"Size" + its value -> sellable label, set price+sku, then add sample
+    const sellLabel = unitLabel(row);
+    const prodQ = await shopify(`query($id:ID!){product(id:$id){variants(first:5){nodes{id}}options{id name optionValues{id name}}}}`, { id: pid });
+    const optId = prodQ.product.options[0].id;
+    const optValId = prodQ.product.options[0].optionValues[0].id;
+    const defVarId = prodQ.product.variants.nodes[0].id;
+
+    const rn = await shopify(`mutation($pid:ID!,$opt:OptionUpdateInput!,$vals:[OptionValueUpdateInput!]!){productOptionUpdate(productId:$pid,option:$opt,optionValuesToUpdate:$vals){userErrors{field message}}}`,
+      { pid, opt: { id: optId, name: 'Size' }, vals: [{ id: optValId, name: sellLabel }] });
+    if (rn.productOptionUpdate.userErrors.length) { results.push({ mfr_sku: row.mfr_sku, pid: pid.split('/').pop(), err: 'optRename:' + JSON.stringify(rn.productOptionUpdate.userErrors) }); continue; }
+
+    const su = await shopify(`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$pid,variants:$variants){userErrors{field message}}}`, {
+      pid, variants: [{ id: defVarId, price, inventoryPolicy: 'CONTINUE', inventoryItem: { sku: dwSku, tracked: false } }]
+    });
+    if (su.productVariantsBulkUpdate.userErrors.length) { results.push({ mfr_sku: row.mfr_sku, pid: pid.split('/').pop(), err: 'sellUpd:' + JSON.stringify(su.productVariantsBulkUpdate.userErrors) }); continue; }
+
+    const sc = await shopify(`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$variants){userErrors{field message}}}`, {
+      pid, variants: [{ price: '4.25', inventoryPolicy: 'CONTINUE', inventoryItem: { sku: `${dwSku}-Sample`, tracked: false }, optionValues: [{ optionName: 'Size', name: 'Sample' }] }]
+    });
+    if (sc.productVariantsBulkCreate.userErrors.length) { results.push({ mfr_sku: row.mfr_sku, pid: pid.split('/').pop(), err: 'sampleCreate:' + JSON.stringify(sc.productVariantsBulkCreate.userErrors) }); continue; }
+
+    // 3b) verify then flip ACTIVE (5-field gate: no ACTIVE without image + both variants + price)
+    await shopify(`mutation($input:ProductInput!){productUpdate(input:$input){userErrors{field message}}}`,
+      { input: { id: pid, status: 'ACTIVE' } });
+
+    // 4) publish to Online Store + Google (sales channels) — publish to all available publications
+    // 5) PG-first writeback
+    const numericPid = pid.split('/').pop();
+    await pg.query(`INSERT INTO dw_sku_registry(dw_sku,vendor_prefix,vendor_name,mfr_sku,shopify_product_id,shopify_handle,status)
+      VALUES($1,'DWKK','Groundworks',$2,$3,$4,'active')
+      ON CONFLICT (dw_sku) DO NOTHING`, [dwSku, row.mfr_sku, numericPid, cr.productCreate.product.handle]);
+    await pg.query(`UPDATE groundworks_catalog SET dw_sku=$1, shopify_product_id=$2 WHERE mfr_sku=$3`,
+      [dwSku, numericPid, row.mfr_sku]);
+    await pg.query(`INSERT INTO kravet_master_price(mfr_sku,whls_cost,map_price,brand,source)
+      VALUES($1,$2,$3,'Groundworks','groundworks-activation')
+      ON CONFLICT (mfr_sku) DO UPDATE SET whls_cost=EXCLUDED.whls_cost,map_price=EXCLUDED.map_price,brand='Groundworks'`,
+      [row.mfr_sku, row.wholesale, row.map_price]);
+
+    results.push({ mfr_sku: row.mfr_sku, dwSku, pid: numericPid, title, price });
+    console.log(`  ✓ ${dwSku}  ${title}  $${price}`);
+    await sleep(700); // metered cadence
+  }
+
+  await pg.end();
+  fs.writeFileSync(__dirname + '/../data/publish-results.json', JSON.stringify(results, null, 2));
+  const ok = results.filter(r => r.pid || (DRY && r.dwSku && !r.skip && !r.err));
+  const err = results.filter(r => r.err);
+  const skip = results.filter(r => r.skip);
+  console.log(`\nDONE: created=${ok.length} errors=${err.length} skipped=${skip.length}`);
+  if (err.length) console.log('ERRORS:', JSON.stringify(err.slice(0, 10), null, 2));
+  if (skip.length) console.log('SKIPS:', JSON.stringify(skip, null, 2));
+}
+main().catch(e => { console.error(e); process.exit(1); });

← e609263 Groundworks settlement vision gate: 12 OK (rabbits, not Part  ·  back to Groundworks Activation  ·  Groundworks activation complete: 94 SKUs live, 5-field clean e5e782b →