[object Object]

← back to Dwla Rebrand

phase 1 push script: 3 dream-team guardrails (vendor verify, tag sweep, metafield carry-over)

34d8fc6264c7436eab6881b554cca0085ee11b70 · 2026-05-07 18:00:35 -0700 · Steve

DRY-RUN by default. Real push requires --apply. Audit table written
before every API call for rollback. Per Steve's rule: alert on >10%
failure rate.

Files touched

Diff

commit 34d8fc6264c7436eab6881b554cca0085ee11b70
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 7 18:00:35 2026 -0700

    phase 1 push script: 3 dream-team guardrails (vendor verify, tag sweep, metafield carry-over)
    
    DRY-RUN by default. Real push requires --apply. Audit table written
    before every API call for rollback. Per Steve's rule: alert on >10%
    failure rate.
---
 package-lock.json      |  13 +++
 package.json           |   1 +
 scripts/push_phase1.js | 230 +++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 244 insertions(+)

diff --git a/package-lock.json b/package-lock.json
index 9d34830..9f7fdaa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,9 +9,22 @@
       "version": "1.0.0",
       "license": "ISC",
       "dependencies": {
+        "dotenv": "^17.4.2",
         "pg": "^8.20.0"
       }
     },
+    "node_modules/dotenv": {
+      "version": "17.4.2",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+      "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
     "node_modules/pg": {
       "version": "8.20.0",
       "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
diff --git a/package.json b/package.json
index 7dbe273..c33bd3a 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
   "license": "ISC",
   "description": "",
   "dependencies": {
+    "dotenv": "^17.4.2",
     "pg": "^8.20.0"
   }
 }
diff --git a/scripts/push_phase1.js b/scripts/push_phase1.js
new file mode 100644
index 0000000..1dc983b
--- /dev/null
+++ b/scripts/push_phase1.js
@@ -0,0 +1,230 @@
+#!/usr/bin/env node
+// Phase 1 push — applies the dwla rebrand to the 17 PI-PAMPAS-* + their dups.
+//
+// Guardrails (per dream-team review 2026-05-07):
+//   1. vendor field IS in productUpdate payload + asserted post-write
+//   2. tags read-strip-add: strip /phillipe.romano|DWPR|DWPN/i, add ['DWLA','Los Angeles Fabrics']
+//   3. metafields carried: read all before, diff after, restore if wiped
+//   4. audit table written BEFORE every API call (rollback target)
+//
+// Default: --dry-run (no Shopify writes; just prints what would happen).
+// Real run: pass --apply.
+//
+// Usage:
+//   node scripts/push_phase1.js              # dry-run (default, safe)
+//   node scripts/push_phase1.js --apply      # writes to Shopify
+//   node scripts/push_phase1.js --apply --limit=2  # apply to first 2 records only
+
+const { Client } = require('pg');
+const fs = require('fs');
+const path = require('path');
+
+const DRY = !process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '--limit=999').split('=')[1], 10);
+
+// Load Shopify creds from canonical secrets path
+require('dotenv').config({ path: path.join(process.env.HOME, 'Projects', 'secrets-manager', '.env') });
+const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const SHOPIFY_STORE = process.env.SHOPIFY_STORE;
+if (!SHOPIFY_TOKEN || !SHOPIFY_STORE) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN or SHOPIFY_STORE missing'); process.exit(1); }
+const API_BASE = `https://${SHOPIFY_STORE}/admin/api/2024-10`;
+
+const ROOT = path.join(__dirname, '..');
+const PHASE1_JSON = path.join(ROOT, 'preview', 'phase1.json');
+const LOGFILE = path.join(ROOT, 'logs', `push_phase1_${new Date().toISOString().slice(0,19).replace(/[:T]/g,'-')}.log`);
+fs.mkdirSync(path.dirname(LOGFILE), { recursive: true });
+
+function log(...args) {
+  const line = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
+  console.log(line);
+  fs.appendFileSync(LOGFILE, line + '\n');
+}
+
+async function shopify(method, urlPath, body) {
+  if (DRY) {
+    log(`  DRY ${method} ${urlPath}${body ? ' ' + JSON.stringify(body).slice(0,180) : ''}`);
+    return { dry: true };
+  }
+  const r = await fetch(API_BASE + urlPath, {
+    method,
+    headers: {
+      'X-Shopify-Access-Token': SHOPIFY_TOKEN,
+      'Content-Type': 'application/json'
+    },
+    body: body ? JSON.stringify(body) : undefined
+  });
+  const j = await r.json().catch(() => ({}));
+  if (!r.ok) throw new Error(`Shopify ${method} ${urlPath} → ${r.status}: ${JSON.stringify(j).slice(0, 240)}`);
+  return j;
+}
+
+// Tag pruning: strip anything matching the old-brand patterns, add DWLA + Los Angeles Fabrics
+const STRIP_RE = /^(phillipe[\s-]?romano|DWPR|DWPN|pointe|justin\s*david)$/i;
+function rebuildTags(tagsCsv) {
+  const tags = (tagsCsv || '').split(',').map(t => t.trim()).filter(Boolean);
+  const kept = tags.filter(t => !STRIP_RE.test(t));
+  const next = new Set(kept);
+  next.add('DWLA');
+  next.add('Los Angeles Fabrics');
+  return Array.from(next).join(', ');
+}
+
+async function ensureAuditTable(pg) {
+  await pg.query(`
+    CREATE TABLE IF NOT EXISTS dwla_migration_audit (
+      id BIGSERIAL PRIMARY KEY,
+      phase INT NOT NULL,
+      action TEXT NOT NULL,
+      shopify_product_id BIGINT NOT NULL,
+      old_sku TEXT, new_sku TEXT,
+      old_handle TEXT, new_handle TEXT,
+      old_vendor TEXT, new_vendor TEXT,
+      old_title TEXT, new_title TEXT,
+      old_tags TEXT, new_tags TEXT,
+      metafield_count_before INT, metafield_count_after INT,
+      registry_dw_sku TEXT, mfr_sku TEXT,
+      ran_at TIMESTAMPTZ DEFAULT NOW(),
+      success BOOLEAN, error TEXT
+    )`);
+}
+
+async function processOne(rec, sp, pg) {
+  log(`\n${rec.registry.mfr_sku} → ${rec.proposed_dwla_sku} | shopify_id=${sp.old_id} | action=${sp.action}`);
+
+  // 1. Read current state (vendor, tags, metafield count)
+  let current = { vendor: '?', tags: '', metafields: [] };
+  if (!DRY) {
+    const got = await shopify('GET', `/products/${sp.old_id}.json`);
+    current.vendor = got.product.vendor;
+    current.tags = got.product.tags;
+    const mf = await shopify('GET', `/products/${sp.old_id}/metafields.json`);
+    current.metafields = mf.metafields || [];
+  } else {
+    current.vendor = sp.old_vendor;
+    current.tags = '<unfetched in dry-run>';
+  }
+  log(`  current: vendor="${current.vendor}" · tags(${current.tags.length}ch) · ${current.metafields.length} metafields`);
+
+  // 2. Compute new tags (guardrail #2)
+  const newTags = rebuildTags(current.tags);
+  log(`  new tags: ${newTags}`);
+
+  // 3. Audit row BEFORE call (guardrail #4)
+  if (!DRY) {
+    await pg.query(`
+      INSERT INTO dwla_migration_audit (phase, action, shopify_product_id,
+        old_sku, new_sku, old_handle, new_handle,
+        old_vendor, new_vendor, old_title, new_title,
+        old_tags, new_tags, metafield_count_before,
+        registry_dw_sku, mfr_sku, success)
+      VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL)`,
+      [sp.action, sp.old_id, sp.old_sku, sp.new_sku, sp.old_handle, sp.new_handle,
+       current.vendor, sp.new_vendor, sp.old_title, sp.new_title,
+       current.tags, newTags, current.metafields.length,
+       rec.registry.dw_sku, rec.registry.mfr_sku]);
+  }
+
+  // 4. The mutation itself
+  if (sp.action === 'archive_duplicate') {
+    // Archive (Shopify: status = "archived")
+    await shopify('PUT', `/products/${sp.old_id}.json`, {
+      product: {
+        id: sp.old_id,
+        status: 'archived',
+        // We do NOT change SKU/handle on archived dups — keep the old data
+        // intact for forensics. Audit table records the canonical match.
+        tags: newTags  // tags still get cleaned so collection rules are accurate
+      }
+    });
+  } else {
+    // Canonical: full rebrand (vendor + title + handle + tags)
+    await shopify('PUT', `/products/${sp.old_id}.json`, {
+      product: {
+        id: sp.old_id,
+        title: sp.new_title,
+        handle: sp.new_handle,
+        vendor: sp.new_vendor,            // guardrail #1 — vendor IN payload
+        tags: newTags
+      }
+    });
+    // Update variant SKU separately (variants aren't on the product PUT shape we're using)
+    if (!DRY) {
+      const got = await shopify('GET', `/products/${sp.old_id}.json`);
+      for (const v of got.product.variants || []) {
+        if (v.sku === sp.old_sku) {
+          await shopify('PUT', `/variants/${v.id}.json`, {
+            variant: { id: v.id, sku: sp.new_sku }
+          });
+        }
+      }
+    }
+  }
+
+  // 5. Verify post-write (guardrail #1 + #3)
+  let verified = { vendor: '?', metafields: [] };
+  if (!DRY) {
+    const after = await shopify('GET', `/products/${sp.old_id}.json`);
+    verified.vendor = after.product.vendor;
+    const mfAfter = await shopify('GET', `/products/${sp.old_id}/metafields.json`);
+    verified.metafields = mfAfter.metafields || [];
+
+    if (sp.action === 'rename_canonical' && verified.vendor !== sp.new_vendor) {
+      throw new Error(`vendor verification failed for ${sp.old_id}: got "${verified.vendor}", expected "${sp.new_vendor}"`);
+    }
+    if (verified.metafields.length !== current.metafields.length) {
+      throw new Error(`metafield count changed for ${sp.old_id}: ${current.metafields.length} → ${verified.metafields.length}`);
+    }
+
+    // Mark audit row success
+    await pg.query(`
+      UPDATE dwla_migration_audit SET success=true, metafield_count_after=$1
+      WHERE shopify_product_id=$2 AND phase=1 AND ran_at >= NOW() - INTERVAL '5 minutes'
+        AND success IS NULL`,
+      [verified.metafields.length, sp.old_id]);
+  }
+  log(`  ✓ verified: vendor="${verified.vendor}" · metafields=${verified.metafields.length}`);
+
+  // Throttle (Shopify rate limits: 2 req/s on Plus, 4 calls per product)
+  if (!DRY) await new Promise(r => setTimeout(r, 600));
+}
+
+async function main() {
+  log(`# Phase 1 push starting ${new Date().toISOString()}`);
+  log(`# DRY=${DRY} LIMIT=${LIMIT} STORE=${SHOPIFY_STORE}`);
+
+  const records = JSON.parse(fs.readFileSync(PHASE1_JSON, 'utf8'));
+  const pg = new Client({ host: '/tmp', database: 'dw_unified' });
+  await pg.connect();
+  await ensureAuditTable(pg);
+
+  let count = 0, errors = 0;
+  outer:
+  for (const rec of records) {
+    for (const sp of rec.shopify_products) {
+      if (count >= LIMIT) break outer;
+      try {
+        await processOne(rec, sp, pg);
+        count++;
+      } catch (e) {
+        errors++;
+        log(`  ✗ ${e.message}`);
+        if (!DRY) {
+          await pg.query(`
+            INSERT INTO dwla_migration_audit (phase, action, shopify_product_id,
+              registry_dw_sku, mfr_sku, success, error)
+            VALUES (1, $1, $2, $3, $4, false, $5)`,
+            [sp.action, sp.old_id, rec.registry.dw_sku, rec.registry.mfr_sku, e.message]);
+        }
+      }
+    }
+  }
+
+  log(`\n# Phase 1 done: ${count} mutations · ${errors} errors`);
+  log(`# log: ${LOGFILE}`);
+  if (errors / Math.max(count, 1) > 0.10) {
+    log(`# WARNING: failure rate >10% — per Steve's rule, alert via email expected`);
+  }
+  await pg.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });

← cb0bd27 switch to option (b): handle derived from title pattern, not  ·  back to Dwla Rebrand  ·  (newest)