[object Object]

← back to Designer Wallcoverings

TK-135: gated apply script (metafield color_palette + color: tag, PG-first, dry-run default)

d33e8553935b396882c6e29880c43c5440054578 · 2026-07-27 18:20:32 -0700 · Steve

Files touched

Diff

commit d33e8553935b396882c6e29880c43c5440054578
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 27 18:20:32 2026 -0700

    TK-135: gated apply script (metafield color_palette + color: tag, PG-first, dry-run default)
---
 shopify/scripts/tk135-apply-palette.js | 113 +++++++++++++++++++++++++++++++++
 1 file changed, 113 insertions(+)

diff --git a/shopify/scripts/tk135-apply-palette.js b/shopify/scripts/tk135-apply-palette.js
new file mode 100644
index 00000000..47d80e2e
--- /dev/null
+++ b/shopify/scripts/tk135-apply-palette.js
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+/**
+ * TK-00135 — Apply image color palette to products (GATED live write).
+ * Reads the proposed-writes TSV from tk135-image-palette.py:
+ *     <gid>\t<color:Bucket>\t<palette_json>
+ * and, per product:
+ *   1. PG-first: update dw_unified.shopify_products.tags (append color:Bucket, idempotent)
+ *   2. Shopify: metafieldsSet custom.color_palette (json) + tagsAdd color:Bucket
+ *
+ * DRY-RUN by default. Requires --apply for live. --limit N, --report FILE.
+ * Idempotent: skips products that already carry the same color: tag AND a
+ * color_palette metafield. Never flips status, never touches other tags.
+ *
+ *   node tk135-apply-palette.js                 # DRY-RUN
+ *   node tk135-apply-palette.js --apply --limit 20   # canary (gated)
+ *   node tk135-apply-palette.js --apply --report data/tk135/apply-run.json
+ */
+const https = require('https'), fs = require('fs'), os = require('os'), path = require('path');
+const { execFileSync } = require('child_process');
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const arg = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
+const LIMIT = parseInt(arg('--limit', '0'), 10) || Infinity;
+const IN = arg('--in', path.join(__dirname, 'data/tk135/palette-proposed.tsv'));
+const REPORT = arg('--report', null);
+
+const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function gql(query, variables = {}) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query, variables });
+    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
+      r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(d); } }); });
+    req.on('error', reject); req.write(body); req.end();
+  });
+}
+async function gqlR(q, v, tries = 4) {
+  for (let i = 0; i < tries; i++) {
+    const r = await gql(q, v);
+    if (r && !r.errors) return r;
+    if (!(r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)))) return r;
+    await sleep(1500 * (i + 1));
+  }
+  return gql(q, v);
+}
+const M_MF = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}`;
+const M_TAG = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+
+// PG-first: append color tag idempotently in the mirror
+function pgAppendTags(rows) {
+  const values = rows.map(r => `('${r.gid.replace(/'/g, "''")}', '${r.tag.replace(/'/g, "''")}')`).join(',\n');
+  const sql = `
+BEGIN;
+CREATE TEMP TABLE _tk135(gid text, tag text);
+INSERT INTO _tk135(gid,tag) VALUES
+${values};
+UPDATE shopify_products sp SET tags =
+  CASE WHEN sp.tags IS NULL OR sp.tags='' THEN b.tag
+       WHEN sp.tags ILIKE '%'||b.tag||'%' THEN sp.tags
+       ELSE sp.tags || ', ' || b.tag END
+  FROM _tk135 b WHERE sp.shopify_id=b.gid;
+SELECT count(*) FROM _tk135 b JOIN shopify_products sp ON sp.shopify_id=b.gid;
+COMMIT;`;
+  return execFileSync('ssh', ['my-server', `sudo -u postgres psql -d dw_unified -v ON_ERROR_STOP=1 -t -A`],
+    { input: sql, encoding: 'utf8', maxBuffer: 1 << 24 }).trim();
+}
+
+function load() {
+  const lines = fs.readFileSync(IN, 'utf8').trim().split('\n');
+  const out = [];
+  for (const l of lines) {
+    const [gid, tag, pj] = l.split('\t');
+    if (!gid || !tag || !pj || pj.startsWith('ERR:')) continue;
+    out.push({ gid, tag, palette: pj });
+  }
+  return out;
+}
+
+(async () => {
+  let rows = load();
+  if (LIMIT !== Infinity) rows = rows.slice(0, LIMIT);
+  console.log(`tk135-apply-palette — ${rows.length} products — ${APPLY ? '🔴 LIVE' : 'DRY-RUN'}\n  in: ${IN}\n`);
+  const report = { ticket: 'TK-135', started: new Date().toISOString(), apply: APPLY, updated: [], failed: [] };
+
+  if (APPLY) {
+    try { const c = pgAppendTags(rows); console.log(`PG mirror tags appended — matched ${c}\n`); report.pg = c; }
+    catch (e) { console.error('PG write FAILED — aborting before Shopify:', e.message || e); process.exit(2); }
+  } else {
+    console.log(`(dry-run) would PG-append ${rows.length} color tags, then Shopify metafield+tag each\n`);
+    rows.slice(0, 8).forEach(r => console.log(`  · ${r.gid.split('/').pop()}  ${r.tag}  palette=${r.palette}`));
+    console.log(`\n  (dry-run) no writes made.`);
+    if (REPORT) { fs.mkdirSync(path.dirname(REPORT), { recursive: true }); fs.writeFileSync(REPORT, JSON.stringify({ ...report, totals: { would_update: rows.length } }, null, 2)); }
+    return;
+  }
+
+  for (const r of rows) {
+    const mf = await gqlR(M_MF, { mf: [{ ownerId: r.gid, namespace: 'custom', key: 'color_palette', type: 'json', value: r.palette }] });
+    const me = mf.data?.metafieldsSet?.userErrors || [];
+    const tg = await gqlR(M_TAG, { id: r.gid, tags: [r.tag] });
+    const te = tg.data?.tagsAdd?.userErrors || [];
+    if (me.length || te.length) { report.failed.push({ gid: r.gid, me, te }); console.log(`  ❌ ${r.gid.split('/').pop()} ${JSON.stringify(me.concat(te))}`); }
+    else report.updated.push(r.gid);
+    await sleep(150);
+  }
+  report.finished = new Date().toISOString();
+  report.totals = { updated: report.updated.length, failed: report.failed.length };
+  console.log(`\n=== SUMMARY ===\nupdated=${report.totals.updated} failed=${report.totals.failed}`);
+  if (REPORT) { fs.mkdirSync(path.dirname(REPORT), { recursive: true }); fs.writeFileSync(REPORT, JSON.stringify(report, null, 2)); console.log(`report -> ${REPORT}`); }
+})();

← 6853b03a TK-135: image-based hex+% palette extractor (bg-discount, HS  ·  back to Designer Wallcoverings  ·  TK-00134 TaskC: make cleanWallpaper() brand-aware + derived- 818dd15e →