← back to New Import Viewer

generate-manifests.js

166 lines

#!/usr/bin/env node
// CADENCE MANIFEST GENERATOR — fills data/launch-batches/ with small, ready-to-run
// batches for the staggered 1,000-published-SKUs/24h cadence (default 42 rows each,
// 42/hr × 24 ≈ 1,008/day).
//
// WHY small batches: the existing 1000-row manifests are burst-shaped; they trip
// Shopify's daily VARIANT-creation limit when drained fast. The cadence wrapper
// (run-cadence.sh, --limit 42) consumes ONE batch worth per hour. consumer.js
// already dedups across manifests by id, so manifest size is purely a cadence knob.
//
// SELECTION — mirrors consumer.js eligibility EXACTLY so a manifested row will
// actually CREATE, not get skipped at consume time:
//   • net-new (sync_status='new' OR not-on-shopify) with mfr_sku
//   • THREE-SOURCE NEVER-DUPLICATE: mfr_sku not in shopify_products AND not in
//     dw_sku_registry (the same NETNEW_PRED server.js uses)
//   • image-bearing (image_url present) — hard rule, no imageless products
//   • dw_sku + pattern_name present
//   • vendor NOT denylisted (cowtan_tout), NOT on-hold (command54),
//     NOT skip_shopify, and if private-label MUST have a mask name (else the
//     consumer skips it to avoid leaking the real vendor)
//   • OLDEST-FIRST within each vendor (lowest vc.id = scraped earliest)
//   • ROUND-ROBIN across vendors so a single mega-vendor can't monopolize a day
//
// IDEMPOTENT: skips ids already in the ledger as 'created' AND ids already present
// in existing launch-*.json manifests on disk, so re-running tops up rather than
// duplicating. Cadence-tagged manifests are named launch-cad-<n>-<seq>.json.
//
// Usage:
//   node generate-manifests.js                 # default: 24 batches × 42 = 1008 rows
//   node generate-manifests.js --batches 24 --size 42
//   node generate-manifests.js --batches 4 --size 42 --dry   # plan only, write nothing
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');

const ARGS = process.argv.slice(2);
const opt = (n, d) => { const i = ARGS.indexOf(n); return i >= 0 ? ARGS[i + 1] : d; };
const flag = (n) => ARGS.includes(n);

const PSQL = process.env.PSQL || '/opt/homebrew/opt/postgresql@14/bin/psql';
const PGUSER = process.env.PGUSER || 'stevestudio2';
const PGDB = process.env.PGDATABASE || 'dw_unified';
const US = '\x1f', RS = '\x1e';
function q(sql) {
  const r = spawnSync(PSQL, ['-U', PGUSER, '-d', PGDB, '-tA', '-F', US, '-R', RS, '-c', sql],
    { encoding: 'utf8', timeout: 180000, maxBuffer: 1 << 28 });
  if (r.status !== 0) throw new Error((r.stderr || 'psql failed').slice(0, 600));
  return (r.stdout || '').replace(/\n+$/, '').split(RS).filter(l => l.length).map(l => l.split(US));
}

const BATCHES = parseInt(opt('--batches', '24'), 10) || 24;
const SIZE = parseInt(opt('--size', '42'), 10) || 42;
const DRY = flag('--dry');
const TARGET = BATCHES * SIZE;
const DIR = path.join(__dirname, 'data', 'launch-batches');
const LEDGER = path.join(__dirname, 'data', 'launch-consumer-ledger.jsonl');

// ── exclusion set: ids already created OR already manifested ──
function alreadySeenIds() {
  const seen = new Set();
  // ledger 'created'
  try {
    for (const line of fs.readFileSync(LEDGER, 'utf8').split('\n')) {
      if (!line.trim()) continue;
      try { const e = JSON.parse(line); if (e.id != null && e.status === 'created') seen.add(+e.id); } catch {}
    }
  } catch {}
  // existing manifests (any launch-*.json) — don't re-stage what's queued
  let manifested = 0;
  try {
    for (const f of fs.readdirSync(DIR)) {
      if (!f.startsWith('launch-') || !f.endsWith('.json')) continue;
      try {
        const m = JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8'));
        if (m && Array.isArray(m.skus)) for (const s of m.skus) { if (s.id != null) { seen.add(+s.id); manifested++; } }
      } catch {}
    }
  } catch {}
  return { seen, manifested };
}

// ── candidate pool, OLDEST-FIRST per vendor, consumer-eligible only ──
// Window-numbered by vendor so we can round-robin in JS. We pull a generous
// multiple of TARGET so the exclusion filter still leaves enough.
function pullPool(excludeIds, want) {
  const excl = excludeIds.size ? `AND vc.id NOT IN (${[...excludeIds].join(',')})` : '';
  // Pull up to want*4 oldest eligible rows per the consumer's full gate set.
  const rows = q(`
    WITH elig AS (
      SELECT vc.id, vc.vendor_code, vc.mfr_sku, vc.dw_sku, vc.pattern_name,
             ROW_NUMBER() OVER (PARTITION BY vc.vendor_code ORDER BY vc.id ASC) AS rn
      FROM vendor_catalog vc
      JOIN vendor_registry vr ON vr.vendor_code = vc.vendor_code
      WHERE (vc.sync_status='new' OR ((vc.on_shopify IS NOT TRUE) AND vc.shopify_product_id IS NULL))
        -- belt: a stale sync_status='new' can coexist with on_shopify=TRUE / a real
        -- product id (seen on rebel_walls). The consumer skips these as 'already
        -- linked' at consume time, but excluding them here keeps cadence slots full.
        AND vc.shopify_product_id IS NULL AND vc.on_shopify IS NOT TRUE
        AND vc.mfr_sku IS NOT NULL AND vc.mfr_sku <> ''
        AND vc.image_url IS NOT NULL AND vc.image_url <> ''
        AND vc.dw_sku IS NOT NULL AND vc.pattern_name IS NOT NULL
        AND vc.vendor_code NOT IN ('cowtan_tout','command54')          -- deny + hold
        AND COALESCE(vr.skip_shopify, FALSE) = FALSE                    -- skip_shopify
        AND NOT (vr.is_private_label = TRUE                             -- PL w/ no mask = leak risk
                 AND (vr.private_label_name IS NULL OR trim(vr.private_label_name)=''))
        AND NOT EXISTS (SELECT 1 FROM shopify_products sx WHERE upper(trim(sx.mfr_sku)) = upper(trim(vc.mfr_sku)))
        AND NOT EXISTS (SELECT 1 FROM dw_sku_registry rx WHERE upper(trim(rx.mfr_sku)) = upper(trim(vc.mfr_sku)))
        ${excl}
    )
    SELECT id, vendor_code, mfr_sku, dw_sku, pattern_name, rn
    FROM elig
    WHERE rn <= ${Math.ceil(want)}            -- cap per-vendor depth so RR is fair
    ORDER BY rn ASC, vendor_code ASC          -- rn-major = round-robin across vendors
  `);
  return rows.map(r => ({
    id: +r[0], vendor: r[1], sku: r[2], dwSku: r[3], pattern: r[4], rn: +r[5],
  }));
}

(function main() {
  if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true });
  const { seen, manifested } = alreadySeenIds();
  console.log(`exclusion set: ${seen.size.toLocaleString()} ids (${manifested.toLocaleString()} already in on-disk manifests, rest ledger-created)`);

  // rn<=ceil(TARGET/vendorCount) would need vendor count; simpler: cap per-vendor
  // depth at TARGET (a single vendor can at most fill the whole day if it's the
  // only one). Pool is already rn-major ordered = round-robin.
  const pool = pullPool(seen, TARGET);
  console.log(`eligible pool (oldest-first, round-robin): ${pool.length.toLocaleString()} rows available`);

  const pick = pool.slice(0, TARGET);
  if (pick.length < TARGET) {
    console.warn(`  note: only ${pick.length} eligible rows after exclusions (wanted ${TARGET}) — generating ${Math.ceil(pick.length / SIZE)} batch(es)`);
  }

  // vendor distribution of the pick
  const dist = {};
  for (const p of pick) dist[p.vendor] = (dist[p.vendor] || 0) + 1;
  console.log(`pick = ${pick.length} rows across ${Object.keys(dist).length} vendors; top:`,
    Object.entries(dist).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([v, n]) => `${v}:${n}`).join(' '));

  const nBatches = Math.ceil(pick.length / SIZE);
  const now = new Date();
  const written = [];
  for (let b = 0; b < nBatches; b++) {
    const slice = pick.slice(b * SIZE, (b + 1) * SIZE);
    if (!slice.length) break;
    // stagger the 'at' timestamps 1 hour apart so consumer's oldest-first manifest
    // ordering drains them in cadence order even if run out of band.
    const at = new Date(now.getTime() + b * 3600 * 1000).toISOString();
    const seq = Math.random().toString(36).slice(2, 10);
    const batchId = `launch-cad-${slice.length}-${seq}`;
    const manifest = {
      batchId, action: 'launch', mode: 'active', cadence: true,
      at, count: slice.length,
      skus: slice.map(s => ({ id: s.id, vendor: s.vendor, sku: s.sku, dwSku: s.dwSku, pattern: s.pattern, from: 'CADENCE' })),
    };
    const file = path.join(DIR, `${batchId}.json`);
    if (DRY) { console.log(`  [dry] would write ${path.basename(file)} (${slice.length} rows, at ${at})`); }
    else { fs.writeFileSync(file, JSON.stringify(manifest, null, 1)); written.push(path.basename(file)); }
  }

  console.log(`\n${DRY ? 'DRY-RUN — wrote nothing' : `wrote ${written.length} cadence manifest(s)`} · ${pick.length} rows · ${SIZE}/batch`);
  if (!DRY && written.length) console.log('  first/last:', written[0], '…', written[written.length - 1]);
})();