[object Object]

← back to Corkwallcovering

cork: prod-capable tag-apply (PG-first → Shopify, dual-scope A+B, gated) + read-only dry-run

c8777999a48df19dab7c3def053738729104911d · 2026-07-01 11:32:10 -0700 · Steve Abrams

Reversible build only — no live writes. Scope A (5 deterministic diff adds) +
Scope B (30 Gemini-enriched vocab tags, runtime-diffed vs pre-enrich commit).
Additive/idempotent, 200-cap, kill-switch, PG-before-Shopify ordering. Creds
supplied at apply time via env (never scavenged from disk).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit c8777999a48df19dab7c3def053738729104911d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 1 11:32:10 2026 -0700

    cork: prod-capable tag-apply (PG-first → Shopify, dual-scope A+B, gated) + read-only dry-run
    
    Reversible build only — no live writes. Scope A (5 deterministic diff adds) +
    Scope B (30 Gemini-enriched vocab tags, runtime-diffed vs pre-enrich commit).
    Additive/idempotent, 200-cap, kill-switch, PG-before-Shopify ordering. Creds
    supplied at apply time via env (never scavenged from disk).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 scripts/apply-shopify-tags-PROD.mjs | 233 ++++++++++++++++++++++++++++++++++++
 1 file changed, 233 insertions(+)

diff --git a/scripts/apply-shopify-tags-PROD.mjs b/scripts/apply-shopify-tags-PROD.mjs
new file mode 100644
index 0000000..2213acc
--- /dev/null
+++ b/scripts/apply-shopify-tags-PROD.mjs
@@ -0,0 +1,233 @@
+#!/usr/bin/env node
+/**
+ * PROD promotion of the corkwallcovering facet tags to the REAL Designer
+ * Wallcoverings store + the canonical dw_unified mirror.
+ *
+ * TWO SCOPES (both Steve-approved):
+ *   (A) the 5 deterministic adds from reports/cork-tag-diff.json (rows[] with
+ *       non-empty add_tags) — 4× "Cork", 1× "Brown".
+ *   (B) the 30 Gemini-enriched style/pattern tags now in data/products.json —
+ *       synced to the store wherever the store is MISSING them. Scope-B adds are
+ *       restricted to the enricher's vocab (STYLE_VOCAB + PATTERN_VOCAB) and to
+ *       the exact 30 SKUs the enrich commit (ee5ed29) touched, computed at
+ *       runtime by diffing data/products.json against its pre-enrich version.
+ *
+ * Cork's products.json uses the Shopify HANDLE as its "sku" field; real variant
+ * SKUs are short codes (DWKK-102147). Products are resolved by HANDLE.
+ *
+ * ORDERING (hard gate requirement): for every product that would change we write
+ * PostgreSQL (dw_unified.shopify_products.tags) FIRST, then the Shopify
+ * productUpdate. Source of truth is dw_unified — PG before Shopify, always.
+ *
+ * GATE:
+ *   (1) refuses if kill-switch ~/.dw-fixer-stop present
+ *   (2) caps at 200 products/run
+ *   (3) DRY-RUN by default — must pass --apply to write ANYTHING
+ *   (4) additive-only: never removes a tag; idempotent skip-if-present (ci dedupe)
+ *   (5) PG write precedes Shopify write per product; a PG failure aborts that SKU
+ *       (no Shopify write) so the two never diverge.
+ *
+ * CREDENTIALS — supplied by Steve at apply time, NEVER scavenged from disk:
+ *   DW_PROD_STORE_DOMAIN   real DW *.myshopify.com admin domain
+ *   DW_PROD_SHOPIFY_TOKEN  write-scoped Admin API token (write_products)
+ *   DW_UNIFIED_DSN         postgres:// DSN for the canonical dw_unified mirror
+ * The DRY-RUN runs read-only and degrades gracefully if a cred is absent (it
+ * reports the plan; store resolution only runs when domain+token are present).
+ *
+ *   node scripts/apply-shopify-tags-PROD.mjs                 # DRY-RUN (read-only)
+ *   DW_PROD_STORE_DOMAIN=... DW_PROD_SHOPIFY_TOKEN=... DW_UNIFIED_DSN=... \
+ *     node scripts/apply-shopify-tags-PROD.mjs --apply       # LIVE (PG-first, then Shopify)
+ */
+import fs from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { execFileSync } from 'node:child_process';
+
+const APPLY = process.argv.includes('--apply');
+const KILL = path.join(os.homedir(), '.dw-fixer-stop');
+const CAP = 200;
+const API = '2024-10';
+
+const STORE = process.env.DW_PROD_STORE_DOMAIN || null;
+const TOKEN = process.env.DW_PROD_SHOPIFY_TOKEN || null;
+const DSN = process.env.DW_UNIFIED_DSN || null;
+
+// vocab MIRRORS enrich-cork-ai.mjs / stage-shopify-tags.mjs — Scope-B adds must be one of these
+const STYLE_VOCAB   = ['Traditional','Contemporary','Modern','Coastal','Tropical','Art Deco','Mid-Century Modern','Bohemian','Transitional','Victorian','Chinoiserie','Minimalist','Maximalist','Rustic','Industrial','Scandinavian','Hollywood Regency','Japandi','Farmhouse'];
+const PATTERN_VOCAB = ['Botanical','Floral','Geometric','Damask','Palm','Trellis','Stripe','Abstract','Toile','Scenic','Paisley','Plaid','Animal Print','Medallion','Chevron','Herringbone','Solid','Textured','Faux','Mural'];
+const SCOPE_B_VOCAB = new Set([...STYLE_VOCAB, ...PATTERN_VOCAB]);
+const ENRICH_COMMIT = 'ee5ed29';
+
+// ── build the merged in-scope plan: handle -> Set(tagsToAdd) ──────────────────
+async function buildPlan() {
+  const products = JSON.parse(await fs.readFile('data/products.json', 'utf8'));
+  const byHandle = new Map(products.map(p => [p.handle || p.sku, p]));
+  const plan = new Map(); // handle -> { title, want:Set }
+
+  const want = (handle, tag, title) => {
+    if (!plan.has(handle)) plan.set(handle, { title: title || handle, want: new Set() });
+    plan.get(handle).want.add(tag);
+  };
+
+  // Scope A — deterministic adds from the staged diff
+  const diff = JSON.parse(await fs.readFile('reports/cork-tag-diff.json', 'utf8'));
+  let scopeA = 0;
+  for (const r of diff.rows) {
+    if (!r.add_tags?.length) continue;
+    const h = r.handle || r.sku;
+    for (const t of r.add_tags) { want(h, t, r.title); scopeA++; }
+  }
+
+  // Scope B — Gemini-enriched style/pattern tags: diff products.json vs pre-enrich version
+  let scopeB = 0;
+  try {
+    const beforeRaw = execFileSync('git', ['show', `${ENRICH_COMMIT}~1:data/products.json`], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
+    const before = JSON.parse(beforeRaw);
+    const beforeTags = new Map(before.map(p => [p.handle || p.sku, new Set((p.tags || []).map(String))]));
+    for (const p of products) {
+      const h = p.handle || p.sku;
+      const prev = beforeTags.get(h) || new Set();
+      for (const t of (p.tags || []).map(String)) {
+        if (!prev.has(t) && SCOPE_B_VOCAB.has(t)) { want(h, t, p.title); scopeB++; }
+      }
+    }
+  } catch (e) {
+    console.error(`WARN: could not reconstruct pre-enrich products.json (${e.message}). Scope B skipped.`);
+  }
+
+  return { plan, byHandle, scopeA, scopeB };
+}
+
+// ── Shopify GraphQL ───────────────────────────────────────────────────────────
+async function gql(query, variables) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
+    body: JSON.stringify({ query, variables }),
+  });
+  const j = await r.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 300));
+  return j.data;
+}
+async function resolveByHandle(handle) {
+  const d = await gql(
+    `query($q:String!){ products(first:1, query:$q){ edges{ node{ id title handle tags } } } }`,
+    { q: `handle:${handle}` }
+  );
+  const node = d?.products?.edges?.[0]?.node;
+  return node ? { id: node.id, title: node.title, tags: node.tags || [] } : null;
+}
+async function applyShopifyTags(id, tags) {
+  const d = await gql(
+    `mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id tags } userErrors{ field message } } }`,
+    { input: { id, tags } }
+  );
+  const ue = d?.productUpdate?.userErrors || [];
+  if (ue.length) throw new Error(ue.map(e => e.message).join('; '));
+  return d.productUpdate.product.tags;
+}
+
+// ── PG-first write to dw_unified.shopify_products.tags ────────────────────────
+// Lazy-load pg only when we actually need to write (keeps dry-run dependency-free).
+let PgClient = null;
+async function pgConnect() {
+  if (!DSN) throw new Error('DW_UNIFIED_DSN not set');
+  if (!PgClient) {
+    try { ({ Client: PgClient } = await import('pg')); }
+    catch { throw new Error("node 'pg' package not available — install pg to run --apply"); }
+  }
+  const c = new PgClient({ connectionString: DSN });
+  await c.connect();
+  return c;
+}
+/**
+ * Additive PG merge on shopify_products.tags. The real dw_unified stores tags as
+ * a comma-joined string per the Shopify importer convention; we read the row,
+ * union the fresh tags case-insensitively, and write back. Resolved by handle.
+ * Returns { matched, wrote } so a no-match aborts the Shopify write for that SKU.
+ */
+async function pgAddTags(pg, handle, freshTags) {
+  const sel = await pg.query('SELECT id, tags FROM shopify_products WHERE handle = $1 LIMIT 1', [handle]);
+  if (!sel.rows.length) return { matched: false, wrote: false };
+  const row = sel.rows[0];
+  const existing = String(row.tags || '').split(',').map(s => s.trim()).filter(Boolean);
+  const have = new Set(existing.map(t => t.toLowerCase()));
+  const add = freshTags.filter(t => !have.has(t.toLowerCase()));
+  if (!add.length) return { matched: true, wrote: false };
+  const next = [...existing, ...add].join(', ');
+  await pg.query('UPDATE shopify_products SET tags = $1 WHERE id = $2', [next, row.id]);
+  return { matched: true, wrote: true, added: add };
+}
+
+// ── main ──────────────────────────────────────────────────────────────────────
+if (existsSync(KILL)) { console.error('REFUSED: kill-switch present at ' + KILL); process.exit(2); }
+
+const { plan, byHandle, scopeA, scopeB } = await buildPlan();
+const entries = [...plan.entries()].slice(0, CAP);
+const overCap = plan.size > CAP;
+
+console.log(`Mode: ${APPLY ? 'LIVE APPLY (PG-first → Shopify)' : 'DRY-RUN (read-only)'}`);
+console.log(`Store: ${STORE || '(DW_PROD_STORE_DOMAIN unset — resolution skipped in dry-run)'}`);
+console.log(`dw_unified: ${DSN ? 'DSN present' : '(DW_UNIFIED_DSN unset)'}`);
+console.log(`In-scope products: ${plan.size}  (Scope A adds=${scopeA}, Scope B adds=${scopeB})  cap=${CAP}${overCap ? '  ⚠ OVER CAP — extra deferred' : ''}`);
+console.log(`Cost: $0 (Shopify Admin API + local PG)\n`);
+
+if (APPLY) {
+  if (!STORE || !TOKEN) { console.error('REFUSED --apply: DW_PROD_STORE_DOMAIN and DW_PROD_SHOPIFY_TOKEN required.'); process.exit(3); }
+  if (!DSN) { console.error('REFUSED --apply: DW_UNIFIED_DSN required (PG-first ordering is mandatory).'); process.exit(3); }
+}
+
+let resolved = 0, wouldChange = 0, noChange = 0, notFound = 0, failed = 0, applied = 0;
+const samples = [];
+let pg = null;
+if (APPLY) pg = await pgConnect();
+
+for (const [handle, spec] of entries) {
+  try {
+    let prod = null;
+    if (STORE && TOKEN) prod = await resolveByHandle(handle);
+
+    if (STORE && TOKEN && !prod) {
+      console.log(`  ✗ ${handle}: NOT FOUND in store`);
+      notFound++;
+      continue;
+    }
+    resolved += (prod ? 1 : 0);
+
+    const storeTags = prod ? prod.tags : [];
+    const have = new Set(storeTags.map(t => t.toLowerCase()));
+    const fresh = [...spec.want].filter(t => !have.has(t.toLowerCase()));
+
+    if (!fresh.length) {
+      // Already present (idempotent) — only meaningful when we actually resolved.
+      if (prod) { console.log(`  · ${handle}: already has ${[...spec.want].join(', ')} — skip`); noChange++; }
+      else { console.log(`  ~ ${handle}: would add ${[...spec.want].join(', ')} (unresolved — no store creds)`); wouldChange++; if (samples.length < 8) samples.push({ handle, add: [...spec.want], note: 'unresolved' }); }
+      continue;
+    }
+
+    wouldChange++;
+    if (samples.length < 8) samples.push({ handle, title: spec.title, storeTagCount: storeTags.length, add: fresh });
+
+    if (APPLY) {
+      // PG FIRST
+      const pgRes = await pgAddTags(pg, handle, fresh);
+      if (!pgRes.matched) { console.log(`  ✗ ${handle}: no dw_unified row — SKIP (no Shopify write)`); failed++; continue; }
+      // THEN Shopify
+      const next = [...storeTags, ...fresh];
+      const now = await applyShopifyTags(prod.id, next);
+      console.log(`  ✓ ${handle} → PG+${(pgRes.added || []).join(',') || '∅'} · Shopify +${fresh.join(', ')} (now ${now.length})`);
+      applied++;
+    } else {
+      console.log(`  ~ ${handle} [${prod ? prod.id.split('/').pop() : '?'}] would add +${fresh.join(', ')}  (store has ${storeTags.length} tags)`);
+    }
+  } catch (e) {
+    console.log(`  ✗ ${handle}: ${e.message}`);
+    failed++;
+  }
+}
+if (pg) await pg.end();
+
+console.log(`\n── ${APPLY ? 'APPLIED' : 'DRY-RUN'} SUMMARY ──`);
+console.log(`in-scope=${plan.size}  resolved=${resolved}  wouldChange=${wouldChange}  alreadyPresent=${noChange}  notFound=${notFound}  failed=${failed}${APPLY ? `  applied=${applied}` : ''}`);
+if (!APPLY) console.log(`\nTo apply: set DW_PROD_STORE_DOMAIN, DW_PROD_SHOPIFY_TOKEN, DW_UNIFIED_DSN and re-run with --apply (PG-first, then Shopify).`);

← 7f71902 auto-save: 2026-07-01T11:11:52 (1 files) — scripts/apply-sho  ·  back to Corkwallcovering  ·  cork: untrack dev screenshots (~9MB), exclude .image-cache + 516be0a →