← back to All Designerwallcoverings

scripts/fm-wallpaper-sync.mjs

232 lines

#!/usr/bin/env node
// FileMaker WALLPAPER  →  dw_unified.filemaker_wallpaper  mirror.
// ─────────────────────────────────────────────────────────────────────────────
// WHY: all.designerwallcoverings.com / substitutefinder / dw-photo-capture all read
// the /api/catalog-full feed, which is Shopify-driven. Steve's rule: FileMaker Pro's
// WALLPAPER file is the SOURCE OF TRUTH — the catalog should show what FMPro says is
// live (no `Date Discontinued`), display what FMPro says the mfr number is, and be
// the source for a FileMaker→Shopify spec push. FileMaker's Data API is slow + rate-
// limited over ~160k master rows, so we mirror it into Postgres once (nightly) and the
// feed LEFT-JOINs the mirror by dw_sku. This script owns that mirror.
//
// READ LAYOUT: "*List Wallpapers - Shopify Detail Database" — the FileMaker layout
// purpose-built to stage every field for Shopify (Shopify Handle, comboskuwithdash,
// Mfr Pattern, mfr pattern number, Width, and the whole `… for Shopify MetaData`
// family). It reads cleanly via the Data API PROVIDED we obey the clean-query rule.
//
// CLEAN-QUERY RULE (hard gotcha, verified): a `Date Discontinued` clause in the find
// query silently breaks the Data API — foundCount>0 but zero rows returned. So we
// query on `Record Type` + `comboskuwithdash` (both safe) and evaluate Date
// Discontinued from the returned fieldData in code.
//
// COST: FileMaker Cloud Data API = already-paid, $0/call. Postgres = $0 (local).
// SAFETY: writes ONLY to the new `filemaker_wallpaper` table. Touches nothing else.
//
// Usage:
//   node scripts/fm-wallpaper-sync.mjs            # full sync (all Master rows)
//   FM_LIMIT=1000 node scripts/fm-wallpaper-sync.mjs   # cap total rows (smoke test)
//   FM_BATCH=300  node scripts/fm-wallpaper-sync.mjs   # page size (default 500)

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import pg from 'pg';

const FM_MCP_DIR = process.env.FM_MCP_DIR || '/Users/macstudio3/Projects/filemaker-mcp';
const FM_DATABASE = 'WALLPAPER';
const FM_LAYOUT = process.env.FM_LAYOUT || '*List Wallpapers - Shopify Detail Database';
const DSN = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
const BATCH = Math.max(50, +(process.env.FM_BATCH || 500) || 500);
const CAP = process.env.FM_LIMIT ? Math.max(1, +process.env.FM_LIMIT) : Infinity;

// ── FileMaker field names on the Shopify Detail layout (verified via field metadata) ──
const F = {
  dwSku: 'comboskuwithdash',            // "DWT-55001" — the dw_sku join key
  comboSku: 'combo sku',                // "DWT55001"
  handle: 'Shopify Handle',
  mfrPattern: 'Mfr Pattern',            // primary, usually-populated mfr number ("T35100")
  mfrPatternNumber: 'mfr pattern number', // secondary/alt mfr number (often blank)
  name: 'Name of Pattern',              // "SAROKA WALLPAPER - Pearl"
  color: 'Color of Pattern',
  title: 'Title',
  series: 'Series',
  vid: 'vid',                           // vendor id ("thib", "WQ", …)
  recordType: 'Record Type',            // "Master" == a real product record
  dateDiscontinued: 'Date Discontinued',
  bodyHtml: 'Descripton for Shopify Body',
  tags: 'Tags',
  dwRetail: 'Dw Retail Price', netPrice: 'Net Price', variantPrice: 'Variant Price', mfrMap: 'MFR MAP PRICE',
};
// Everything that counts as a "spec" for display + the FileMaker→Shopify push, stored
// as JSONB (non-empty only). Key = our snake_case; value = the FileMaker field name.
const SPEC_FIELDS = {
  width: 'Width', repeat: 'Repeat', content: 'Content',
  sold_per: 'Sold Per', minimum: 'Minimum', collection: 'Collection',
  durability: 'Durability for Shopify MetaData', lead_time: 'Lead Time for Shopify MetaData',
  length: 'Length for Shopify Meta Data', packaged: 'Packaged for Shopify Meta Data',
  qty_order_unit: 'Qty Order Unit for Shopify MetaData', qty_min_unit: 'Qty Minimum Unit for Shopify MetaData',
  unit_of_measure: 'Unit of Measure for Shopify Meta Data', style: 'Style for Shopify MetaData',
  type: 'Type for Shopify MetaData', brand: 'Brand for Shopify MetaData', color: 'Color for Shopify MetaData',
  product_category: 'Product Category for Shopify Sample Data', variant_grams: 'Variant Grams',
  keyword_tags: 'KEYWORD TAGS', collection_tag: 'COLLECTION TAG', mfr_name: 'Mfr Name of Pattern',
};

const s = (f, k) => { const v = f[k]; return v == null ? '' : String(v).trim(); };
const numOrNull = (f, k) => {
  const raw = s(f, k); if (!raw) return null;
  const v = parseFloat(raw.replace(/[^0-9.\-]/g, '')); return Number.isFinite(v) ? v : null;
};
// mfr numbers carry human notes ("SBG3207 -- MUST USE QUOTE #G45"). Keep the raw for
// display + push-safety, and a normalized token for mismatch comparison.
const cleanMfr = (raw) => String(raw || '').split(/--|·|;|\bMUST\b|\bQUOTE\b/i)[0]
  .replace(/[^A-Za-z0-9./-]+/g, '').toUpperCase();

// Load filemaker-mcp/.env (host + Cognito creds) without clobbering our own env.
function loadFmEnv() {
  const raw = readFileSync(join(FM_MCP_DIR, '.env'), 'utf8');
  for (const line of raw.split('\n')) {
    const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!m) continue;
    let v = m[2];
    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
    if (process.env[m[1]] === undefined) process.env[m[1]] = v;
  }
}

const DDL = `
CREATE TABLE IF NOT EXISTS filemaker_wallpaper (
  dw_sku              text PRIMARY KEY,
  combo_sku           text,
  shopify_handle      text,
  mfr_pattern         text,
  mfr_pattern_number  text,
  mfr_number          text,          -- resolved: Mfr Pattern || mfr pattern number
  mfr_number_clean    text,          -- normalized token for mismatch comparison
  name_of_pattern     text,
  color_of_pattern    text,
  title               text,
  series              text,
  vid                 text,
  record_type         text,
  date_discontinued   text,
  is_discontinued     boolean,
  width               text,
  body_html           text,
  tags                text,
  dw_retail_price     numeric,
  net_price           numeric,
  variant_price       numeric,
  mfr_map_price       numeric,
  specs               jsonb,
  fm_record_id        text,
  synced_at           timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS filemaker_wallpaper_disco_idx ON filemaker_wallpaper (is_discontinued);
CREATE INDEX IF NOT EXISTS filemaker_wallpaper_mfrclean_idx ON filemaker_wallpaper (mfr_number_clean);
`;

const UPSERT = `
INSERT INTO filemaker_wallpaper (
  dw_sku, combo_sku, shopify_handle, mfr_pattern, mfr_pattern_number, mfr_number, mfr_number_clean,
  name_of_pattern, color_of_pattern, title, series, vid, record_type,
  date_discontinued, is_discontinued, width, body_html, tags,
  dw_retail_price, net_price, variant_price, mfr_map_price, specs, fm_record_id, synced_at
) VALUES (
  $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24, now()
)
ON CONFLICT (dw_sku) DO UPDATE SET
  combo_sku=EXCLUDED.combo_sku, shopify_handle=EXCLUDED.shopify_handle,
  mfr_pattern=EXCLUDED.mfr_pattern, mfr_pattern_number=EXCLUDED.mfr_pattern_number,
  mfr_number=EXCLUDED.mfr_number, mfr_number_clean=EXCLUDED.mfr_number_clean,
  name_of_pattern=EXCLUDED.name_of_pattern, color_of_pattern=EXCLUDED.color_of_pattern,
  title=EXCLUDED.title, series=EXCLUDED.series, vid=EXCLUDED.vid, record_type=EXCLUDED.record_type,
  date_discontinued=EXCLUDED.date_discontinued, is_discontinued=EXCLUDED.is_discontinued,
  width=EXCLUDED.width, body_html=EXCLUDED.body_html, tags=EXCLUDED.tags,
  dw_retail_price=EXCLUDED.dw_retail_price, net_price=EXCLUDED.net_price,
  variant_price=EXCLUDED.variant_price, mfr_map_price=EXCLUDED.mfr_map_price,
  specs=EXCLUDED.specs, fm_record_id=EXCLUDED.fm_record_id, synced_at=now()
`;

function rowFromRecord(rec) {
  const f = rec.fieldData || {};
  const dwSku = s(f, F.dwSku);
  if (!dwSku) return null;                              // skip blank / phantom rows
  const mfrPattern = s(f, F.mfrPattern);
  const mfrPatternNumber = s(f, F.mfrPatternNumber);
  const mfrNumber = mfrPattern || mfrPatternNumber || '';
  const disco = s(f, F.dateDiscontinued);
  const specs = {};
  for (const [key, fmField] of Object.entries(SPEC_FIELDS)) { const v = s(f, fmField); if (v) specs[key] = v; }
  return [
    dwSku, s(f, F.comboSku), s(f, F.handle),
    mfrPattern, mfrPatternNumber, mfrNumber, cleanMfr(mfrNumber),
    s(f, F.name), s(f, F.color), s(f, F.title), s(f, F.series), s(f, F.vid), s(f, F.recordType),
    disco, !!disco, s(f, F.width), s(f, F.bodyHtml), s(f, F.tags),
    numOrNull(f, F.dwRetail), numOrNull(f, F.netPrice), numOrNull(f, F.variantPrice), numOrNull(f, F.mfrMap),
    JSON.stringify(specs), rec.recordId,
  ];
}

async function main() {
  const t0 = Date.now();
  loadFmEnv();
  const fm = await import(pathToFileURL(join(FM_MCP_DIR, 'src', 'fm-client.js')).href);
  const pool = new pg.Pool({ connectionString: DSN, max: 3 });
  await pool.query(DDL);

  // Clean-query rule: Record Type=Master + comboskuwithdash present. NEVER Date Discontinued.
  const query = [{ [F.recordType]: 'Master', [F.dwSku]: '*' }];
  let offset = 1, total = 0, upserted = 0, skipped = 0, disco = 0, found = null;
  console.log(`FM sync: layout="${FM_LAYOUT}", batch=${BATCH}, cap=${CAP === Infinity ? 'all' : CAP}`);

  for (;;) {
    const want = Math.min(BATCH, CAP - total);
    if (want <= 0) break;
    let res;
    try {
      res = await fm.findRecords(FM_DATABASE, FM_LAYOUT, query, { limit: want, offset });
    } catch (e) {
      if (String(e.fmCode) === '401') break;             // "no more records"
      throw e;
    }
    const recs = res.records || [];
    if (found == null) found = res.dataInfo?.foundCount ?? null;
    if (!recs.length) break;

    const values = [];
    for (const rec of recs) {
      const row = rowFromRecord(rec);
      if (!row) { skipped++; continue; }
      if (row[14]) disco++;                              // is_discontinued
      values.push(row);
    }
    // upsert this batch inside one transaction
    if (values.length) {
      const client = await pool.connect();
      try {
        await client.query('BEGIN');
        for (const v of values) await client.query(UPSERT, v);
        await client.query('COMMIT');
        upserted += values.length;
      } catch (e) { await client.query('ROLLBACK'); throw e; }
      finally { client.release(); }
    }
    total += recs.length;
    offset += recs.length;
    if (total % 5000 < BATCH) {
      console.log(`  …${total.toLocaleString()}${found ? '/' + found.toLocaleString() : ''} read · ${upserted.toLocaleString()} upserted · ${disco.toLocaleString()} disco · ${skipped} skipped`);
    }
    if (recs.length < want) break;                       // last page
  }

  const { rows: [c] } = await pool.query(
    `SELECT count(*) total, count(*) FILTER (WHERE is_discontinued) disco,
            count(*) FILTER (WHERE NOT is_discontinued AND mfr_number<>'') live_with_mfr
       FROM filemaker_wallpaper`);
  console.log(`\nFM sync DONE in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
  console.log(`  read ${total.toLocaleString()} · upserted ${upserted.toLocaleString()} · skipped ${skipped}`);
  console.log(`  table now: ${(+c.total).toLocaleString()} rows · ${(+c.disco).toLocaleString()} discontinued · ${(+c.live_with_mfr).toLocaleString()} live-with-mfr`);
  await pool.end();
}

main().catch((e) => { console.error('FM sync FAILED:', e.message); process.exit(1); });