← 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
A scripts/apply-shopify-tags-PROD.mjs
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 →