← back to Dw Vendor Registry Sync
sync.js
144 lines
#!/usr/bin/env node
/**
* dw-vendor-registry-sync — Option C (DTD verdict, 2026-06-30)
*
* Sanctioned, parameterized, NO-shell-out write-back of vendor_registry metadata
* from the LOCAL Mac2 dw_unified mirror to the CANONICAL Kamatera dw_unified.
*
* Why this exists: Mac2 can EDIT vendor_registry but had no sanctioned write-back
* to canonical Kamatera, so every local edit was a latent split-brain (a
* Kamatera->Mac2 sync would silently revert it). Raw `ssh my-server psql` is
* (correctly) hard-blocked by the auto-mode classifier because psql can run
* arbitrary code (\!, COPY TO PROGRAM). This tool removes that vector:
* - reads LOCAL only on a dry run (no remote connection at all)
* - on --apply, connects REMOTE and runs ONLY parameterized UPDATEs
* - restricts writes to a hard COLUMN WHITELIST (purchasing/pricing metadata)
* - never INSERTs vendors, never DROP/TRUNCATE/DELETE, never DDL
*
* Usage:
* node sync.js --vendors DWCQ-,DWXL-,DWAL-,DWOI- # dry run (default cols)
* node sync.js --vendors DWCQ- --columns buy_from,buy_from_confirmed,buy_from_notes
* node sync.js --vendors DWCQ-,DWXL-,DWAL-,DWOI- --apply # writes to Kamatera
*
* Env (.env, gitignored):
* LOCAL_DATABASE_URL default postgresql:///dw_unified?host=/tmp
* REMOTE_DATABASE_URL the canonical Kamatera dw_unified DSN (required for --apply)
*/
const { Client } = require('pg');
require('node:fs');
try { require('dotenv').config(); } catch (_) { /* dotenv optional; env may be preset */ }
// Hard whitelist — the ONLY columns this tool may ever write to canonical prod.
const ALLOWED_COLUMNS = new Set([
'buy_from', 'buy_from_confirmed', 'buy_from_notes',
'vendor_discount_pct', 'discount_confirmed', 'discount_confirmed_at', 'discount_notes',
'pricing_model', 'do_not_price', 'sample_price', 'display_prices',
'tariff_pct', 'tariff_notes', 'tariff_source', 'tariff_confirmed', 'country_of_origin', 'hts_code',
'parent_brand', 'is_active',
]);
const DEFAULT_COLUMNS = ['buy_from', 'buy_from_confirmed', 'buy_from_notes'];
function parseArgs(argv) {
const out = { vendors: [], columns: DEFAULT_COLUMNS, apply: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--apply') out.apply = true;
else if (a === '--vendors') out.vendors = (argv[++i] || '').split(',').map(s => s.trim()).filter(Boolean);
else if (a === '--columns') out.columns = (argv[++i] || '').split(',').map(s => s.trim()).filter(Boolean);
else { console.error(`Unknown arg: ${a}`); process.exit(2); }
}
return out;
}
function quoteIdent(id) {
// identifiers come ONLY from the whitelist, but double-quote defensively anyway
if (!ALLOWED_COLUMNS.has(id)) throw new Error(`Refusing non-whitelisted column: ${id}`);
return '"' + id.replace(/"/g, '""') + '"';
}
async function main() {
const args = parseArgs(process.argv);
if (!args.vendors.length) { console.error('Need --vendors <sku_prefix-or-vendor_code,...>'); process.exit(2); }
// validate every requested column against the whitelist BEFORE touching any DB
const bad = args.columns.filter(c => !ALLOWED_COLUMNS.has(c));
if (bad.length) { console.error(`Refusing non-whitelisted column(s): ${bad.join(', ')}`); process.exit(2); }
const localUrl = process.env.LOCAL_DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
const selCols = ['vendor_name', 'vendor_code', 'sku_prefix', ...args.columns];
const local = new Client({ connectionString: localUrl });
await local.connect();
const rows = (await local.query(
`SELECT ${selCols.map(quoteIdentSafe).join(', ')}
FROM vendor_registry
WHERE sku_prefix = ANY($1) OR vendor_code = ANY($1) OR sku_prefix = ANY($2)`,
[args.vendors, args.vendors.map(v => v.endsWith('-') ? v : v + '-')]
)).rows;
await local.end();
if (!rows.length) { console.error('No matching vendor_registry rows for: ' + args.vendors.join(', ')); process.exit(1); }
console.log(`\nLOCAL source: ${localUrl}`);
console.log(`Columns to sync: ${args.columns.join(', ')}`);
console.log(`Matched ${rows.length} vendor(s):\n`);
for (const r of rows) {
console.log(` • ${r.vendor_name} [${r.sku_prefix} / ${r.vendor_code}]`);
for (const c of args.columns) console.log(` ${c} = ${JSON.stringify(r[c])}`);
}
if (!args.apply) {
console.log(`\nDRY RUN — no remote connection made. Re-run with --apply to write these values`);
console.log(`to the canonical Kamatera dw_unified (REMOTE_DATABASE_URL).`);
return;
}
const remoteUrl = process.env.REMOTE_DATABASE_URL;
if (!remoteUrl) { console.error('\n--apply needs REMOTE_DATABASE_URL (set it in .env). Aborting.'); process.exit(2); }
const remote = new Client({ connectionString: remoteUrl, statement_timeout: 15000 });
await remote.connect();
console.log(`\nREMOTE target: ${remoteUrl.replace(/:[^:@/]+@/, ':***@')}`);
let updated = 0;
try {
await remote.query('BEGIN');
for (const r of rows) {
const sets = args.columns.map((c, i) => `${quoteIdent(c)} = $${i + 1}`);
const vals = args.columns.map(c => r[c]);
vals.push(r.vendor_code); // WHERE param
const res = await remote.query(
`UPDATE vendor_registry SET ${sets.join(', ')}, updated_at = now()
WHERE vendor_code = $${vals.length}`,
vals
);
updated += res.rowCount;
console.log(` ✓ ${r.vendor_code}: ${res.rowCount} row updated`);
}
await remote.query('COMMIT');
} catch (e) {
await remote.query('ROLLBACK').catch(() => {});
await remote.end();
console.error('\nFAILED — rolled back, no changes written. ' + e.message);
process.exit(1);
}
// verify
const check = (await remote.query(
`SELECT vendor_name, ${args.columns.map(quoteIdent).join(', ')}
FROM vendor_registry WHERE vendor_code = ANY($1) ORDER BY vendor_name`,
[rows.map(r => r.vendor_code)]
)).rows;
await remote.end();
console.log(`\nApplied ${updated} update(s) to canonical Kamatera. Verified remote state:`);
for (const r of check) {
console.log(` • ${r.vendor_name}`);
for (const c of args.columns) console.log(` ${c} = ${JSON.stringify(r[c])}`);
}
}
// selCols may include the 3 fixed identity cols (not in the write-whitelist) for SELECT only
function quoteIdentSafe(id) {
const ok = ALLOWED_COLUMNS.has(id) || ['vendor_name', 'vendor_code', 'sku_prefix'].includes(id);
if (!ok) throw new Error(`Refusing column in SELECT: ${id}`);
return '"' + id.replace(/"/g, '""') + '"';
}
main().catch(e => { console.error(e); process.exit(1); });