[object Object]

← back to Designer Wallcoverings

TK-135: generic descriptor apply — bare tagsAdd + per-SKU *_tagged_at timestamp (gated)

d1a610978e497fbe681f1b5557055bd584de75aa · 2026-07-27 20:37:35 -0700 · Steve

Files touched

Diff

commit d1a610978e497fbe681f1b5557055bd584de75aa
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 27 20:37:35 2026 -0700

    TK-135: generic descriptor apply — bare tagsAdd + per-SKU *_tagged_at timestamp (gated)
---
 shopify/scripts/tk135-apply-descriptor.js | 94 +++++++++++++++++++++++++++++++
 1 file changed, 94 insertions(+)

diff --git a/shopify/scripts/tk135-apply-descriptor.js b/shopify/scripts/tk135-apply-descriptor.js
new file mode 100644
index 00000000..72770b5a
--- /dev/null
+++ b/shopify/scripts/tk135-apply-descriptor.js
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+/**
+ * TK-00135 — Apply a BARE descriptor tag catalog-wide (GATED live write, Shopify-authoritative).
+ * Generic: works for Style / Material / Collection. Reads a proposal TSV `<gid>\t<BareValue>`
+ * and per product: tagsAdd(bareValue) + set a per-SKU timestamp metafield custom.<key>.
+ *
+ * BARE values (no label prefix). Idempotent (tagsAdd no-op if present), resumable (progress log),
+ * never flips status, never removes tags. Mirror re-syncs from Shopify.
+ *
+ *   node tk135-apply-descriptor.js --in data/tk135/style-proposed.tsv --key style_tagged_at
+ *   node tk135-apply-descriptor.js --in <tsv> --key <k> --apply --limit 20   # canary (gated)
+ *   node tk135-apply-descriptor.js --in <tsv> --key <k> --apply --report <json>
+ *
+ * Required: --in <proposal.tsv>  --key <timestamp metafield key, e.g. style_tagged_at>
+ */
+const https = require('https'), fs = require('fs'), os = require('os'), path = require('path');
+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 IN = arg('--in', null);
+const KEY = arg('--key', null);
+if (!IN || !KEY) { console.error('need --in <proposal.tsv> and --key <timestamp_key>'); process.exit(1); }
+const LIMIT = parseInt(arg('--limit', '0'), 10) || Infinity;
+const REPORT = arg('--report', null);
+const PROGRESS = arg('--progress', IN.replace(/\.tsv$/, '') + '.progress.log');
+
+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 NOW = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
+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_ADD = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+
+function load() {
+  const out = [];
+  for (const l of fs.readFileSync(IN, 'utf8').trim().split('\n')) {
+    const [gid, val] = l.split('\t');
+    if (gid && val && val.trim()) out.push({ gid, val: val.trim() });
+  }
+  return out;
+}
+
+(async () => {
+  let rows = load();
+  if (fs.existsSync(PROGRESS)) {
+    const done = new Set(fs.readFileSync(PROGRESS, 'utf8').split('\n').filter(Boolean));
+    const b = rows.length; rows = rows.filter(r => !done.has(r.gid));
+    if (b !== rows.length) console.log(`resume: skipping ${b - rows.length} already-done\n`);
+  }
+  if (LIMIT !== Infinity) rows = rows.slice(0, LIMIT);
+  console.log(`tk135-apply-descriptor [${KEY}] — ${rows.length} products — ${APPLY ? '🔴 LIVE' : 'DRY-RUN'}\n  in: ${IN}  (Shopify-authoritative; mirror re-syncs)\n`);
+  const report = { ticket: 'TK-135', key: KEY, in: IN, started: NOW, apply: APPLY, updated: [], failed: [] };
+
+  if (!APPLY) {
+    rows.slice(0, 10).forEach(r => console.log(`  · ${r.gid.split('/').pop()}  +tag "${r.val}"  +custom.${KEY}`));
+    console.log(`\n  (dry-run) ${rows.length} products would get bare tag + timestamp. No writes.`);
+    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) {
+    let errs = [];
+    const a = await gqlR(M_ADD, { id: r.gid, tags: [r.val] }); errs = errs.concat(a.data?.tagsAdd?.userErrors || []);
+    const mf = await gqlR(M_MF, { mf: [{ ownerId: r.gid, namespace: 'custom', key: KEY, type: 'date_time', value: NOW }] }); errs = errs.concat(mf.data?.metafieldsSet?.userErrors || []);
+    if (errs.length) { report.failed.push({ gid: r.gid, errs }); console.log(`  ❌ ${r.gid.split('/').pop()} ${JSON.stringify(errs)}`); }
+    else { report.updated.push(r.gid); fs.appendFileSync(PROGRESS, r.gid + '\n'); }
+    if (report.updated.length % 500 === 0 && report.updated.length) console.log(`  …${report.updated.length} tagged`);
+    await sleep(150);
+  }
+  report.finished = new Date().toISOString();
+  report.totals = { updated: report.updated.length, failed: report.failed.length };
+  console.log(`\n=== SUMMARY [${KEY}] ===\ntagged=${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}`); }
+})();

← fcb3debd TK-135: material classifier — 24,581 bare primary-material t  ·  back to Designer Wallcoverings  ·  auto-save: 2026-07-27T20:54:34 (3 files) — shopify/scripts/d 7396cb9e →