← back to Commercialrealestate
scripts/export-assessor-sqlite.js
117 lines
// export-assessor-sqlite.js — dump the LA County assessor parcel roll (public.assessor_parcel in the
// local `cre` Postgres, ~2.43M rows) to a read-only SQLite file: data/assessor.sqlite.
//
// WHY: prod (Kamatera) has no Postgres server, so the live crcp.agentabrams.com serves data-backed
// pages from static artifacts with a DB->fallback pattern (data/condos-redfin.json, data/sfr-redfin.json).
// Those are JSON because condos/sfr are only a few thousand rows. The assessor roll is 2.43M rows —
// a JSON snapshot would be ~1GB and can't be loaded into node memory per request. So the "static
// artifact" for this dataset is a SQLite file: it ships in the deploy exactly like the JSON snapshots,
// but better-sqlite3 queries it per-address (situs_house_no + situs_street) with zero DB server.
// (DTD verdict A, 2026-07-07 — 3/3: SQLite is the only option that delivers the data AND keeps the
// "ship a file, no server" contract.)
//
// Run AFTER the assessor ingest (ingest-assessor.js / fetch-assessor*.js):
// node scripts/export-assessor-sqlite.js
//
// The runtime fallback in scripts/serve.js (/api/address-history) reads the file this produces.
'use strict';
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const Database = require('better-sqlite3');
const ROOT = path.join(__dirname, '..');
const OUT = path.join(ROOT, 'data', 'assessor.sqlite');
const TMP = OUT + '.tmp'; // build to .tmp, atomic-rename at the end
const BATCH = 50000; // keyset page size — keeps node memory flat
const pool = new Pool({
host: process.env.CRE_PGHOST || '/tmp', port: +(process.env.CRE_PGPORT || 5432),
database: process.env.CRE_PGDATABASE || 'cre', user: process.env.CRE_PGUSER || process.env.USER || 'stevestudio2',
});
// The exact projection the /api/address-history endpoint reads back, PLUS the two lookup keys
// (situs_house_no, situs_street) it filters on. Keeping the SQLite column set aligned with the
// endpoint's SELECT is what makes the fallback a drop-in for the Postgres row.
const SELECT = `
SELECT ain, situs_house_no, situs_street, property_location,
year_built, effective_year_built, sqft_main, bedrooms, bathrooms, units,
use_desc1, use_desc2, roll_total_value, roll_land_value, roll_imp_value,
roll_year, recording_date
FROM assessor_parcel
WHERE ain > $1
ORDER BY ain
LIMIT ${BATCH}`;
(async () => {
fs.rmSync(TMP, { force: true });
const db = new Database(TMP);
// Bulk-load PRAGMAs: no journal / no fsync while building a throwaway file. Reset to safe WAL after.
db.pragma('journal_mode = OFF');
db.pragma('synchronous = OFF');
db.exec(`
CREATE TABLE assessor_parcel (
ain TEXT PRIMARY KEY,
situs_house_no INTEGER,
situs_street TEXT,
property_location TEXT,
year_built INTEGER,
effective_year_built INTEGER,
sqft_main INTEGER,
bedrooms INTEGER,
bathrooms INTEGER,
units INTEGER,
use_desc1 TEXT,
use_desc2 TEXT,
roll_total_value INTEGER,
roll_land_value INTEGER,
roll_imp_value INTEGER,
roll_year TEXT,
recording_date TEXT
);`);
const cols = ['ain','situs_house_no','situs_street','property_location','year_built',
'effective_year_built','sqft_main','bedrooms','bathrooms','units','use_desc1','use_desc2',
'roll_total_value','roll_land_value','roll_imp_value','roll_year','recording_date'];
const insert = db.prepare(
`INSERT OR IGNORE INTO assessor_parcel (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`);
const insertMany = db.transaction((rows) => {
for (const r of rows) {
insert.run(cols.map(c => {
const v = r[c];
if (v == null) return null;
if (c === 'recording_date') return v instanceof Date ? v.toISOString().slice(0, 10) : String(v);
return v; // pg already returns numbers as JS numbers
}));
}
});
let cursor = ''; // ain is TEXT; '' sorts before any real AIN
let total = 0;
for (;;) {
const { rows } = await pool.query(SELECT, [cursor]);
if (!rows.length) break;
insertMany(rows);
total += rows.length;
cursor = rows[rows.length - 1].ain;
if (total % (BATCH * 5) === 0) process.stderr.write(` ...${total.toLocaleString()} rows\n`);
if (rows.length < BATCH) break; // last (short) page
}
// Address-lookup index: mirrors the endpoint filter (situs_house_no = ? AND situs_street LIKE ?).
process.stderr.write(' building index...\n');
db.exec('CREATE INDEX idx_assessor_addr ON assessor_parcel (situs_house_no, situs_street)');
// DELETE (rollback-journal) mode, NOT WAL: the shipped file is read-only on prod, and WAL would
// require writable -wal/-shm sidecars alongside it. DELETE mode leaves ONE self-contained file that
// opens cleanly read-only anywhere. VACUUM after to compact.
db.pragma('journal_mode = DELETE');
db.exec('VACUUM');
db.close();
fs.rmSync(OUT, { force: true });
fs.renameSync(TMP, OUT);
const mb = (fs.statSync(OUT).size / 1048576).toFixed(1);
console.log(JSON.stringify({ rows: total, out: OUT, size_mb: +mb }));
await pool.end();
})().catch(e => { console.error(e.message); try { fs.rmSync(TMP, { force: true }); } catch (_) {} process.exit(1); });