← back to All Designerwallcoverings
FileMaker WALLPAPER as source of truth across all./substitute/photo
8ed57a08c5f8d96034738c3fec508d2cb82d3b89 · 2026-07-08 11:03:12 -0700 · Steve
- scripts/fm-wallpaper-sync.mjs: mirror FileMaker WALLPAPER (Shopify Detail layout,
clean-query rule, Record Type=Master) into dw_unified.filemaker_wallpaper
- server.js: LEFT-JOIN the mirror by dw_sku -> fm_mfr_number, fm_discontinued,
fm_width, fm_specs, mfr_mismatch; hide FMPro-discontinued by default (union);
FM stats on /healthz + /api/facets; fields flow to /api/catalog-full (substitute+photo)
- public/index.html: show both mfr numbers with mismatch flag, FM specs on cards,
FM header chip + Mismatches/Show-FM-disco toggles + bulk Preview-sync modal,
per-card Update Shopify button (dry-run)
- scripts/shopify-push.mjs: FileMaker->Shopify diff engine (name/mfr/width/specs);
writes only non-empty changed fields, never downgrades richer Shopify data, never
pushes a name into the mfr field; commit gated behind SHOPIFY_PUSH_ENABLED
Files touched
A scripts/shopify-push.mjs
Diff
commit 8ed57a08c5f8d96034738c3fec508d2cb82d3b89
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 8 11:03:12 2026 -0700
FileMaker WALLPAPER as source of truth across all./substitute/photo
- scripts/fm-wallpaper-sync.mjs: mirror FileMaker WALLPAPER (Shopify Detail layout,
clean-query rule, Record Type=Master) into dw_unified.filemaker_wallpaper
- server.js: LEFT-JOIN the mirror by dw_sku -> fm_mfr_number, fm_discontinued,
fm_width, fm_specs, mfr_mismatch; hide FMPro-discontinued by default (union);
FM stats on /healthz + /api/facets; fields flow to /api/catalog-full (substitute+photo)
- public/index.html: show both mfr numbers with mismatch flag, FM specs on cards,
FM header chip + Mismatches/Show-FM-disco toggles + bulk Preview-sync modal,
per-card Update Shopify button (dry-run)
- scripts/shopify-push.mjs: FileMaker->Shopify diff engine (name/mfr/width/specs);
writes only non-empty changed fields, never downgrades richer Shopify data, never
pushes a name into the mfr field; commit gated behind SHOPIFY_PUSH_ENABLED
---
scripts/shopify-push.mjs | 181 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 181 insertions(+)
diff --git a/scripts/shopify-push.mjs b/scripts/shopify-push.mjs
new file mode 100644
index 0000000..4f0487e
--- /dev/null
+++ b/scripts/shopify-push.mjs
@@ -0,0 +1,181 @@
+#!/usr/bin/env node
+// FileMaker → Shopify spec push worker (per-SKU). DRY-RUN by default.
+// ─────────────────────────────────────────────────────────────────────────────
+// Steve: "Create button to update shopify with name, mfr, width, all specs" — the
+// data flowing FROM FileMaker's WALLPAPER master INTO the live Shopify product. This
+// worker owns one SKU: read the FileMaker mirror row + the live Shopify product, compute
+// the diff, and (only when explicitly gated) write the non-empty, changed fields.
+//
+// Spawned by all-designerwallcoverings/server.js (the /api/shopify-push endpoint), same
+// pattern as scripts/live-scrape.js. Emits ONE JSON object on stdout.
+//
+// HARD SAFETY RAILS:
+// • NEVER writes an empty FileMaker value (a blank FM cell must not blank live Shopify data).
+// • NEVER touches custom.material (the double-encoding-bug field) or any color_* / color_details
+// metafield (owned by the local PIL color-enrichment pipeline).
+// • Only writes metafield keys that ALREADY EXIST on the product, except the core mfr + width
+// keys which are allowed to be created — so the push can't sprawl new metafields.
+// • --commit is a NO-OP unless SHOPIFY_PUSH_ENABLED=1 is ALSO set in the environment. The
+// dry-run diff is always free and side-effect-free. Live writes stay Steve-gated.
+//
+// Usage:
+// node scripts/shopify-push.mjs <dw_sku> # dry-run: prints the diff, writes nothing
+// node scripts/shopify-push.mjs <dw_sku> --commit # write (requires SHOPIFY_PUSH_ENABLED=1)
+
+import pg from 'pg';
+import { gql } from '/Users/macstudio3/Projects/designerwallcoverings/scripts/lib/shopify.mjs';
+
+const DSN = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
+const dwSkuArg = process.argv[2];
+const COMMIT = process.argv.includes('--commit');
+const PUSH_ENABLED = process.env.SHOPIFY_PUSH_ENABLED === '1';
+const out = (o) => { process.stdout.write(JSON.stringify(o)); process.exit(o.ok === false ? 1 : 0); };
+
+if (!dwSkuArg) out({ ok: false, reason: 'dw_sku required' });
+
+// ── FileMaker-field → Shopify-metafield target map ───────────────────────────────
+// Each FM value fans out to the DW metafield keys that mirror it (discovered from a real
+// product). `create:true` = allowed to be created if absent (the core identity fields);
+// everything else is written ONLY if the key already exists on the product (no sprawl).
+// Deliberately EXCLUDED: custom.material (double-encode bug), custom.color* / color_details
+// (local color pipeline owns those).
+const T = (namespace, key, create = false) => ({ namespace, key, create });
+const FIELD_TARGETS = {
+ // key = FileMaker mirror field OR specs.<key>; value = metafield targets
+ mfr_number: [T('custom', 'manufacturer_sku', true), T('dwc', 'manufacturer_sku', true)],
+ width: [T('specifications', 'width', true), T('global', 'width', true), T('dwc', 'width'), T('custom', 'width')],
+ content: [T('global', 'Contents'), T('dwc', 'contents'), T('specifications', 'material'), T('global', 'material')],
+ collection: [T('global', 'Collection'), T('custom', 'collection_name')],
+ length: [T('global', 'length'), T('custom', 'length'), T('specifications', 'roll_length')],
+ brand: [T('global', 'Brand'), T('custom', 'brand')],
+ type: [T('global', 'type')],
+ style: [T('specs', 'style')],
+ sold_per: [T('global', 'sold_by')],
+ product_category: [T('specs', 'pattern')],
+};
+
+async function main() {
+ const pool = new pg.Pool({ connectionString: DSN, max: 2 });
+ // FileMaker's comboskuwithdash is bare (no -SAMPLE); a card may pass the Shopify variant sku
+ // WITH the suffix. Normalize once so BOTH lookups key on the same bare dw_sku.
+ const dwBare = dwSkuArg.replace(/-sample$/i, '');
+ // 1. FileMaker source of truth
+ const { rows: fmRows } = await pool.query(
+ `SELECT * FROM filemaker_wallpaper WHERE upper(dw_sku)=upper($1) LIMIT 1`, [dwBare]);
+ const fm = fmRows[0];
+ if (!fm) { await pool.end(); return out({ ok: false, dw_sku: dwSkuArg, reason: 'not in FileMaker WALLPAPER file' }); }
+ if (fm.is_discontinued) { await pool.end(); return out({ ok: false, dw_sku: dwSkuArg, reason: 'FileMaker marks this DISCONTINUED — refusing to push' }); }
+
+ // 2. the live Shopify product (GID via the mirror). The SKU may live in dw_sku, variant_sku,
+ // OR sku (dw_sku is often null on the sample-variant rows) — match any, -SAMPLE-stripped.
+ const { rows: spRows } = await pool.query(
+ `SELECT shopify_id, handle, title, status FROM shopify_products
+ WHERE upper($1) IN (
+ upper(regexp_replace(coalesce(dw_sku,''),'-SAMPLE$','','i')),
+ upper(regexp_replace(coalesce(variant_sku,''),'-SAMPLE$','','i')),
+ upper(regexp_replace(coalesce(sku,''),'-SAMPLE$','','i')) )
+ AND shopify_id IS NOT NULL
+ ORDER BY (upper(status)='ACTIVE') DESC LIMIT 1`, [dwBare]);
+ await pool.end();
+ const sp = spRows[0];
+ if (!sp) return out({ ok: false, dw_sku: dwSkuArg, reason: 'no Shopify product for this SKU (FileMaker-only — would need CREATE, not update)', fm_only: true });
+ const gid = /^gid:/.test(sp.shopify_id) ? sp.shopify_id : `gid://shopify/Product/${String(sp.shopify_id).match(/\d+$/)?.[0]}`;
+
+ // 3. read the product's current title + existing metafields
+ const pd = await gql(`{ product(id:${JSON.stringify(gid)}) { id title status
+ metafields(first:80){ nodes { namespace key type value } } } }`);
+ if (pd?.__err || !pd?.product) return out({ ok: false, dw_sku: dwSkuArg, reason: 'Shopify product read failed', detail: pd?.__err });
+ const prod = pd.product;
+ const have = new Map(); // "ns.key" → {type,value}
+ for (const m of prod.metafields.nodes) have.set(`${m.namespace}.${m.key}`, { type: m.type, value: m.value });
+
+ // 4. build the FileMaker source values (non-empty only)
+ const specs = fm.specs || {};
+ const srcVal = (fieldKey) => {
+ if (fieldKey === 'mfr_number') return (fm.mfr_number || '').trim();
+ if (fieldKey === 'width') return (fm.width || specs.width || '').trim();
+ return (specs[fieldKey] || '').trim();
+ };
+
+ // Don't-downgrade guard: if Shopify's current value already CONTAINS the FileMaker value
+ // (normalized to alphanumerics), Shopify is a richer superset ("36\" (91CM)" ⊇ "36\"",
+ // "Linen Gesso - Ivory … | Schumacher" ⊇ "Linen Gesso") — pushing would strip detail, so we
+ // record it as an info-only skip rather than a proposed change. FileMaker is authoritative
+ // for the mfr NUMBER (identity), not for how richly a spec/name is merchandised.
+ const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
+ const shopifyRicher = (before, val) => { const b = norm(before), v = norm(val); return !!b && !!v && b.includes(v) && b !== v; };
+
+ // 5. diff
+ const changes = []; // proposed metafield changes
+ const skipped = []; // info-only: differs but Shopify is richer
+ const metafieldsToSet = [];
+ for (const [fieldKey, targets] of Object.entries(FIELD_TARGETS)) {
+ const val = srcVal(fieldKey);
+ if (!val) continue; // RULE: never push an empty FM value
+ // Guard: an mfr NUMBER never contains whitespace. When FileMaker's mfr value looks like a
+ // name (e.g. "Cabochon Verdigris") but Shopify already has a compact code, don't propose
+ // overwriting — surface it for human verification instead. FMPro is authoritative for the
+ // mfr, but only when it actually holds a number.
+ if (fieldKey === 'mfr_number' && /\s/.test(val)) { skipped.push({ field: fieldKey, to: val, reason: 'FileMaker mfr looks like a name, not a number — verify in FileMaker' }); break; }
+ for (const t of targets) {
+ const id = `${t.namespace}.${t.key}`;
+ const cur = have.get(id);
+ if (!cur && !t.create) continue; // no sprawl: skip keys not already present
+ const before = cur ? (cur.value || '') : null;
+ if (String(before ?? '') === val) continue; // already matches
+ if (shopifyRicher(before, val)) { skipped.push({ field: fieldKey, metafield: id, from: before, to: val, reason: 'shopify value is richer' }); continue; }
+ changes.push({ field: fieldKey, metafield: id, from: before, to: val });
+ metafieldsToSet.push({ ownerId: gid, namespace: t.namespace, key: t.key,
+ type: cur?.type || 'single_line_text_field', value: val });
+ }
+ }
+ // title (name) — only when FileMaker has a name, it differs, AND Shopify isn't already richer
+ const fmName = (fm.name_of_pattern || fm.title || '').trim();
+ let titleChange = null;
+ if (fmName && fmName !== (prod.title || '').trim()) {
+ if (shopifyRicher(prod.title, fmName)) skipped.push({ field: 'title', from: prod.title, to: fmName, reason: 'shopify title is richer' });
+ else titleChange = { from: prod.title, to: fmName };
+ }
+
+ const plan = {
+ ok: true, dw_sku: fm.dw_sku, product_id: gid, handle: sp.handle,
+ shopify_title: prod.title, fm_name: fmName || null,
+ fm_mfr_number: fm.mfr_number || null,
+ title_change: titleChange,
+ metafield_changes: changes,
+ skipped_richer: skipped,
+ change_count: changes.length + (titleChange ? 1 : 0),
+ committed: false,
+ };
+
+ if (!COMMIT || plan.change_count === 0) {
+ plan.note = plan.change_count === 0 ? 'No changes — Shopify already matches FileMaker.'
+ : 'DRY RUN — call with --commit (and SHOPIFY_PUSH_ENABLED=1) to write.';
+ return out(plan);
+ }
+
+ // ── COMMIT (gated) ──────────────────────────────────────────────────────────────
+ if (!PUSH_ENABLED) {
+ plan.note = 'COMMIT BLOCKED: SHOPIFY_PUSH_ENABLED is not set. This is the Steve-gated live-write switch.';
+ plan.blocked = true;
+ return out(plan);
+ }
+ const errors = [];
+ if (titleChange) {
+ const r = await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ userErrors{ field message } } }`,
+ { input: { id: gid, title: fmName } });
+ if (r?.__err || r?.productUpdate?.userErrors?.length) errors.push({ title: r?.productUpdate?.userErrors || r?.__err });
+ }
+ for (let i = 0; i < metafieldsToSet.length; i += 25) {
+ const batch = metafieldsToSet.slice(i, i + 25);
+ const r = await gql(`mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ userErrors{ field message } } }`,
+ { m: batch });
+ if (r?.__err || r?.metafieldsSet?.userErrors?.length) errors.push({ metafields: r?.metafieldsSet?.userErrors || r?.__err });
+ }
+ plan.committed = errors.length === 0;
+ plan.errors = errors;
+ plan.note = errors.length ? 'Committed with errors — see errors[].' : 'Committed to live Shopify.';
+ return out(plan);
+}
+
+main().catch((e) => out({ ok: false, dw_sku: dwSkuArg, reason: 'push worker error: ' + e.message }));
← 55b78db Add /api/discontinued + red successor banner: dead/archived
·
back to All Designerwallcoverings
·
chore: v1.1.0 → v1.2.0 (session close — feed + discontinued- f088c73 →