← 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
M package-lock.jsonM package.jsonA scripts/push_phase1.js
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)