← back to Nationalrealestate

src/jobs/listing-lifecycle-sweep.ts

107 lines

/**
 * TK-10155: listing-lifecycle SWEEP — the batch driver that catches "gone from feed"
 * withdrawals (the withdrawal-snapshot headline feature).
 *
 * Loads every listing plus the per-source MOST-RECENT ingest reference (max last_seen),
 * runs recordLifecycle() for each, and prints a summary (counts per derived state, # new
 * lifecycle events written, # snapshots captured). Idempotent — safe to run repeatedly; a
 * second run with no feed change writes 0 events / 0 snapshots.
 *
 *   npm run lifecycle:sweep
 *   npx tsx src/jobs/listing-lifecycle-sweep.ts
 */
import { pool, query } from '../../db/pool.ts';
import {
  recordLifecycle,
  ensureBaselineSnapshot,
  classifyState,
  type LifecycleListing,
  type LifecycleState,
} from '../server/listing-lifecycle.ts';

async function main() {
  // per-source most-recent successful ingest reference = max(last_seen) for that source.
  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));
  }

  // TK-10155 fix 1: per-source LAST FULL RESCAN time (max started_at over mode='full' runs).
  // A source with no full run at all is absent from this map → lastFullRescanAt=null → its
  // listings are NEVER aged into gone-from-feed withdrawal (incremental-only sources).
  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 snapshots = 0;
  let baselinesBackfilled = 0; // TK-10155 Cody FIX-FIRST: snapshots created for listings that had none
  let skippedNoRef = 0;
  // # listings on incremental-only sources (no full rescan) that are structurally NOT eligible
  // for gone-from-feed withdrawal — surfaced in the sweep output for transparency.
  let incrementalNotEligible = 0;

  for (const l of rows.rows) {
    const ref = sourceMax.get(l.source);
    if (!ref) { skippedNoRef++; continue; }
    // null when the source has never had a full rescan (incremental-only) → gone-from-feed
    // withdrawal is suppressed for this listing.
    const lastFullRescanAt = lastFullRescan.get(l.source) ?? null;
    if (lastFullRescanAt === null) incrementalNotEligible++;
    const ctx = { sourceMaxLastSeen: ref, lastFullRescanAt, now };

    // tally the DERIVED state for every listing (the "counts per state" summary).
    const derived: LifecycleState = classifyState(l, ctx);
    stateCounts[derived] = (stateCounts[derived] || 0) + 1;

    const res = await recordLifecycle(query, l, ctx);
    if (res.eventInserted) events++;
    if (res.snapshotInserted) snapshots++;

    // TK-10155 Cody FIX-FIRST gate: guarantee EVERY listing has >=1 preserved snapshot, so the
    // "data survives after the listing vanishes" promise is a design property, not a side-effect of
    // "nothing ever deletes a listing." Closes the gap where a listing whose first-see snapshot
    // FAILED and that derives straight to 'active' (not a snapshot state) would sit with NO snapshot.
    // No-op once any snapshot exists — so this only writes for the leaked cases, not every sweep.
    if (await ensureBaselineSnapshot(query, l)) baselinesBackfilled++;
  }

  console.log('\n[lifecycle-sweep] 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(`[lifecycle-sweep] listings scanned:   ${rows.rows.length}`);
  console.log(`[lifecycle-sweep] new events written: ${events}`);
  console.log(`[lifecycle-sweep] snapshots captured: ${snapshots}`);
  console.log(`[lifecycle-sweep] baseline snapshots backfilled (had none): ${baselinesBackfilled}`);
  if (skippedNoRef) console.log(`[lifecycle-sweep] skipped (no source ref): ${skippedNoRef}`);
  // TK-10155 fix 1 transparency line.
  console.log(
    `[lifecycle-sweep] ${incrementalNotEligible} listings on incremental-only sources are NOT ` +
    `eligible for gone-from-feed withdrawal (needs a full rescan).`,
  );

  await pool.end();
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});