← back to Nationalrealestate
db/backfill-listing-lifecycle.ts
122 lines
/**
* TK-10155: seed the lifecycle system with a full baseline from day one.
*
* Classifies EVERY current listing into its derived state and writes an initial
* lifecycle_event + a baseline listing_snapshot per listing (so the system has a complete
* baseline the moment it turns on). Re-runnable: recordLifecycle is append-only and no-ops
* when the latest event already matches the derived state, so running twice does not double-seed
* (a second run captures no baseline snapshot because the state is unchanged).
*
* NOTE: this baseline captures EXACTLY ONE snapshot per listing (captured_reason='baseline'),
* then lays down the derived-state event with suppressSnapshot=true so recordLifecycle does NOT
* fire a SECOND ('new') snapshot on top of it (fix 4 — that double-count made 599 listings write
* 1026 snapshots). One preserved copy per listing from the start = the withdrawal-snapshot promise.
*
* npm run lifecycle:backfill
* npx tsx db/backfill-listing-lifecycle.ts
*/
import 'dotenv/config';
import { pool, query } from './pool.ts';
import { computeAddressKey } from '../src/server/broker-of-record.ts';
import {
recordLifecycle,
classifyState,
type LifecycleListing,
type LifecycleState,
} from '../src/server/listing-lifecycle.ts';
async function main() {
const beforeEv = await query<{ n: string }>(`SELECT count(*)::text n FROM listing_lifecycle_event`);
const beforeSnap = await query<{ n: string }>(`SELECT count(*)::text n FROM listing_snapshot`);
console.log(`[backfill] events before: ${beforeEv.rows[0].n}`);
console.log(`[backfill] snapshots before: ${beforeSnap.rows[0].n}`);
const maxRes = await query<{ source: string; max_last_seen: string }>(
`SELECT source, MAX(last_seen) AS max_last_seen FROM listing GROUP BY source`,
);
const sourceMax = new Map<string, Date>();
for (const r of maxRes.rows) if (r.max_last_seen) sourceMax.set(r.source, new Date(r.max_last_seen));
// fix 1: last full rescan per source (null = incremental-only → no gone-from-feed withdrawal).
const fullRes = await query<{ source: string; last_full: string }>(
`SELECT source, MAX(started_at) AS last_full FROM source_ingest_run WHERE mode='full' GROUP BY source`,
);
const lastFullRescan = new Map<string, Date>();
for (const r of fullRes.rows) if (r.last_full) lastFullRescan.set(r.source, new Date(r.last_full));
const rows = await query<LifecycleListing>(
`SELECT id, source, source_id, address, price, status, first_seen, last_seen
FROM listing ORDER BY id`,
);
const now = new Date();
const stateCounts: Record<string, number> = {};
let events = 0;
let baselineSnaps = 0;
let stateSnaps = 0;
for (const l of rows.rows) {
const ref = sourceMax.get(l.source);
if (!ref) continue;
const ctx = { sourceMaxLastSeen: ref, lastFullRescanAt: lastFullRescan.get(l.source) ?? null, now };
const derived: LifecycleState = classifyState(l, ctx);
stateCounts[derived] = (stateCounts[derived] || 0) + 1;
// 1. baseline snapshot for EVERY listing (only if none exists yet — re-run safe).
if (l.id != null) {
const exists = await query<{ n: string }>(
`SELECT count(*)::text n FROM listing_snapshot WHERE source=$1 AND source_id=$2 AND captured_reason='baseline'`,
[l.source, l.source_id],
);
if (Number(exists.rows[0].n) === 0) {
const snap = await query<Record<string, any>>(
`SELECT l.*, b.name AS broker_name, f.name AS firm_name,
rg.name AS region_name, rg.name AS county_name, rg.state_code AS region_state_code
FROM listing l
LEFT JOIN broker b ON b.id = l.broker_id
LEFT JOIN firm f ON f.id = l.firm_id
LEFT JOIN region rg ON rg.id = l.region_id
WHERE l.id = $1`,
[l.id],
);
if (snap.rows[0]) {
await query(
`INSERT INTO listing_snapshot (listing_id, source, source_id, address_key, snapshot, captured_reason)
VALUES ($1,$2,$3,$4,$5,'baseline')`,
[l.id, l.source, l.source_id, computeAddressKey(l.address, null) || null, JSON.stringify(snap.rows[0])],
);
baselineSnaps++;
}
}
}
// 2. derived-state EVENT only. fix 4: suppressSnapshot=true so we do NOT also fire the
// state-triggered ('new') snapshot — step 1 already wrote exactly one 'baseline' snapshot
// per listing. Previously this double-counted (599 listings → 1026 snapshots).
const res = await recordLifecycle(query, l, ctx, { suppressSnapshot: true });
if (res.eventInserted) events++;
if (res.snapshotInserted) stateSnaps++;
}
const afterEv = await query<{ n: string }>(`SELECT count(*)::text n FROM listing_lifecycle_event`);
const afterSnap = await query<{ n: string }>(`SELECT count(*)::text n FROM listing_snapshot`);
console.log('\n[backfill] derived state (all listings):');
for (const [s, n] of Object.entries(stateCounts).sort((a, b) => b[1] - a[1])) {
console.log(` ${s.padEnd(12)} ${n}`);
}
console.log(`[backfill] listings scanned: ${rows.rows.length}`);
console.log(`[backfill] lifecycle events inserted: ${events}`);
console.log(`[backfill] baseline snapshots: ${baselineSnaps}`);
console.log(`[backfill] state-trigger snapshots: ${stateSnaps}`);
console.log(`[backfill] events after: ${afterEv.rows[0].n}`);
console.log(`[backfill] snapshots after: ${afterSnap.rows[0].n}`);
await pool.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});