[object Object]

← back to Nationalrealestate

TK-10155: lifecycle self-tests + fix withdrawal-snapshot FK to survive listing deletion

83c8fb2a1f0582207aec051f7766db7ce67829f2 · 2026-08-24 19:51:14 -0700 · Steve Abrams

- add src/server/listing-lifecycle.selftest.ts: pure DB-free unit test of classifyState
  (every state branch, the fix-1 false-withdrawal guard, status-string precedence) +
  recordLifecycle transitions/snapshot logic driven by an in-memory fake db. npm run test:lifecycle.
- add src/server/listing-lifecycle.dbtest.ts: end-to-end withdrawal-snapshot self-test on the
  real schema (house provenance-selftest pattern: synthetic source, assert, purge). Proves a
  withdrawn listing preserves a full snapshot AND that the snapshot still reconstructs the
  listing after the listing row is DELETED. npm run test:lifecycle:db.
- migration 023: change listing_lifecycle_event/listing_snapshot listing_id FKs from the
  shipped NO ACTION to ON DELETE SET NULL. Migration 014 called these columns 'nullable: a
  listing may vanish/be deleted' but the FKs BLOCKED that delete (error 23503) — so the
  'data survives after the listing vanishes' promise could not hold. SET NULL keeps every
  event/snapshot (source/source_id/address_key/snapshot JSONB intact), only nulling listing_id.
- applied 023 to the LOCAL usre dev DB (reversible: re-add FK as NO ACTION). Kamatera-canonical
  prod apply is drafted to pending-approval, NOT run here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 83c8fb2a1f0582207aec051f7766db7ce67829f2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 24 19:51:14 2026 -0700

    TK-10155: lifecycle self-tests + fix withdrawal-snapshot FK to survive listing deletion
    
    - add src/server/listing-lifecycle.selftest.ts: pure DB-free unit test of classifyState
      (every state branch, the fix-1 false-withdrawal guard, status-string precedence) +
      recordLifecycle transitions/snapshot logic driven by an in-memory fake db. npm run test:lifecycle.
    - add src/server/listing-lifecycle.dbtest.ts: end-to-end withdrawal-snapshot self-test on the
      real schema (house provenance-selftest pattern: synthetic source, assert, purge). Proves a
      withdrawn listing preserves a full snapshot AND that the snapshot still reconstructs the
      listing after the listing row is DELETED. npm run test:lifecycle:db.
    - migration 023: change listing_lifecycle_event/listing_snapshot listing_id FKs from the
      shipped NO ACTION to ON DELETE SET NULL. Migration 014 called these columns 'nullable: a
      listing may vanish/be deleted' but the FKs BLOCKED that delete (error 23503) — so the
      'data survives after the listing vanishes' promise could not hold. SET NULL keeps every
      event/snapshot (source/source_id/address_key/snapshot JSONB intact), only nulling listing_id.
    - applied 023 to the LOCAL usre dev DB (reversible: re-add FK as NO ACTION). Kamatera-canonical
      prod apply is drafted to pending-approval, NOT run here.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 db/migrations/023_lifecycle_fk_on_delete.sql |  28 ++++
 package.json                                 |   4 +-
 src/server/listing-lifecycle.dbtest.ts       | 123 +++++++++++++++
 src/server/listing-lifecycle.selftest.ts     | 222 +++++++++++++++++++++++++++
 4 files changed, 376 insertions(+), 1 deletion(-)

diff --git a/db/migrations/023_lifecycle_fk_on_delete.sql b/db/migrations/023_lifecycle_fk_on_delete.sql
new file mode 100644
index 0000000..d6c01b4
--- /dev/null
+++ b/db/migrations/023_lifecycle_fk_on_delete.sql
@@ -0,0 +1,28 @@
+-- TK-10155 fix: make the withdrawal-snapshot tables SURVIVE a listing-row deletion.
+--
+-- Migration 014 declared listing_lifecycle_event.listing_id / listing_snapshot.listing_id as
+-- "nullable: a listing may vanish/be deleted" — but shipped both FKs with the default NO ACTION.
+-- That is self-contradictory: if the ingest pipeline ever DELETEs a listing row, the FK BLOCKS
+-- the delete (error 23503), and the whole promise — "the data survives after the listing vanishes
+-- from the vendor" — cannot hold, because you can't remove the listing without first destroying
+-- its preserved history. (Caught by the dbtest reconstruction step + Cody's snapshot-durability
+-- review.)
+--
+-- Fix: ON DELETE SET NULL on both FKs. When a listing row is deleted, its lifecycle events and
+-- preserved snapshots REMAIN — only listing_id is nulled. Every event/snapshot still carries
+-- source + source_id + address_key + the full snapshot JSONB, so the record is fully browsable
+-- and re-linkable (by source/source_id) after the listing itself is gone. This is exactly the
+-- durability the preservation feature was built for.
+--
+-- Purely a constraint swap — no data touched, no columns added/dropped, idempotent, reversible
+-- (re-add as NO ACTION to revert). migrate.ts wraps this file in a transaction; no BEGIN/COMMIT.
+
+ALTER TABLE listing_lifecycle_event DROP CONSTRAINT IF EXISTS listing_lifecycle_event_listing_id_fkey;
+ALTER TABLE listing_lifecycle_event
+  ADD CONSTRAINT listing_lifecycle_event_listing_id_fkey
+  FOREIGN KEY (listing_id) REFERENCES listing(id) ON DELETE SET NULL;
+
+ALTER TABLE listing_snapshot DROP CONSTRAINT IF EXISTS listing_snapshot_listing_id_fkey;
+ALTER TABLE listing_snapshot
+  ADD CONSTRAINT listing_snapshot_listing_id_fkey
+  FOREIGN KEY (listing_id) REFERENCES listing(id) ON DELETE SET NULL;
diff --git a/package.json b/package.json
index ab1607f..ee1e409 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,9 @@
     "loop": "tsx src/jobs/hourly_loop.ts",
     "ingest:zipcounty": "tsx src/ingest/zip_county.ts",
     "lifecycle:sweep": "tsx src/jobs/listing-lifecycle-sweep.ts",
-    "lifecycle:backfill": "tsx db/backfill-listing-lifecycle.ts"
+    "lifecycle:backfill": "tsx db/backfill-listing-lifecycle.ts",
+    "test:lifecycle": "tsx src/server/listing-lifecycle.selftest.ts",
+    "test:lifecycle:db": "tsx src/server/listing-lifecycle.dbtest.ts"
   },
   "dependencies": {
     "better-sqlite3": "^13.0.1",
diff --git a/src/server/listing-lifecycle.dbtest.ts b/src/server/listing-lifecycle.dbtest.ts
new file mode 100644
index 0000000..1cedbcb
--- /dev/null
+++ b/src/server/listing-lifecycle.dbtest.ts
@@ -0,0 +1,123 @@
+/**
+ * TK-10155 withdrawal-snapshot DB self-test — end-to-end against the real schema.
+ *   run: `tsx src/server/listing-lifecycle.dbtest.ts`  (or `npm run test:lifecycle:db`)
+ *
+ * Follows the house self-test pattern (provenance_selftest.ts): no test framework, exits
+ * non-zero on failure, uses a clearly-SYNTHETIC source ('selftest-tk10155') so it can never
+ * collide with, or leave residue in, real ingested data — it purges everything it created.
+ *
+ * Proves the HEADLINE promise on the real tables: when a listing transitions to WITHDRAWN,
+ * recordLifecycle preserves a FULL listing_snapshot (the entire listing row + joined
+ * broker/firm/region names) so the property data survives after the listing vanishes — AND
+ * that the preserved snapshot can reconstruct the listing after the row itself is deleted.
+ */
+import { pool, query } from '../../db/pool.ts';
+import { recordLifecycle, type LifecycleListing, type LifecycleCtx } from './listing-lifecycle.ts';
+
+const SOURCE = 'selftest-tk10155';
+const SID = 'SNAP-1';
+const DAY = 86_400_000;
+
+let failed = 0;
+function check(ok: boolean, label: string, detail?: unknown) {
+  if (ok) { console.log(`  ok   ${label}`); return; }
+  failed++;
+  console.error(`  FAIL ${label}${detail !== undefined ? `\n       ${JSON.stringify(detail)}` : ''}`);
+}
+
+async function purge() {
+  await query(`DELETE FROM listing_snapshot        WHERE source=$1`, [SOURCE]);
+  await query(`DELETE FROM listing_lifecycle_event WHERE source=$1`, [SOURCE]);
+  await query(`DELETE FROM listing                 WHERE source=$1`, [SOURCE]);
+}
+
+async function main() {
+  await purge(); // clean slate in case a prior run aborted before its own purge
+
+  const now = new Date();
+  const address = '742 Evergreen Terrace, Springfield';
+  const price = 875000;
+
+  // 1. insert a synthetic listing, freshly seen.
+  const ins = await query<{ id: number }>(
+    `INSERT INTO listing (source, source_id, address, price, beds, baths, sqft, status, first_seen, last_seen)
+     VALUES ($1,$2,$3,$4,4,3,2600,NULL,$5,$5) RETURNING id`,
+    [SOURCE, SID, address, price, new Date(now.getTime() - 120 * DAY).toISOString()],
+  );
+  const id = ins.rows[0].id;
+  check(Number.isInteger(id) && id > 0, 'synthetic listing inserted', { id });
+
+  const listing: LifecycleListing = {
+    id, source: SOURCE, source_id: SID, address, price,
+    status: null, first_seen: new Date(now.getTime() - 120 * DAY), last_seen: new Date(now),
+  };
+  // full rescan happened now → gone-from-feed aging is eligible.
+  const ctxSeen: LifecycleCtx = { sourceMaxLastSeen: new Date(now), lastFullRescanAt: new Date(now), now };
+
+  // 2. first record. first_seen is 120d ago so it classifies 'active' (not 'new'), and 'active'
+  //    is NOT a snapshot state — so no snapshot yet. The withdrawal snapshot (step 3) is the one
+  //    the durability promise hinges on.
+  const r1 = await recordLifecycle(query, listing, ctxSeen);
+  check(r1.state === 'active' && r1.eventInserted && !r1.snapshotInserted,
+    "first-see (old first_seen) → 'active' event, no snapshot (not a snapshot state)", r1);
+
+  // 3. now the listing has vanished from the feed for > WITHDRAWN_DAYS. Its last_seen is old,
+  //    but the source's reference (max last_seen) has moved forward (later listings were seen).
+  const goneListing: LifecycleListing = {
+    ...listing,
+    last_seen: new Date(now.getTime() - 45 * DAY), // 45d behind the source reference
+  };
+  const ctxGone: LifecycleCtx = {
+    sourceMaxLastSeen: new Date(now),      // source was refreshed at "now"
+    lastFullRescanAt: new Date(now),       // via a full rescan
+    now,
+  };
+  const r2 = await recordLifecycle(query, goneListing, ctxGone);
+  check(
+    r2.state === 'withdrawn' && r2.prev_state === 'active' && r2.snapshotInserted,
+    'gone-from-feed (active → withdrawn) + a preserved snapshot',
+    r2,
+  );
+
+  // 4. assert the WITHDRAWAL snapshot carries the full property info + joined names.
+  const snapRes = await query<{ captured_reason: string; snapshot: any }>(
+    `SELECT captured_reason, snapshot FROM listing_snapshot
+      WHERE source=$1 AND source_id=$2 AND captured_reason='withdrawn'
+      ORDER BY captured_at DESC LIMIT 1`,
+    [SOURCE, SID],
+  );
+  const snap = snapRes.rows[0]?.snapshot;
+  check(!!snap, 'withdrawal snapshot row exists');
+  if (snap) {
+    check(Number(snap.price) === price, 'snapshot preserved price', snap.price);
+    check(snap.address === address, 'snapshot preserved address', snap.address);
+    check(snap.beds != null && snap.sqft != null, 'snapshot preserved beds/sqft', { beds: snap.beds, sqft: snap.sqft });
+    check('broker_name' in snap && 'firm_name' in snap && 'region_name' in snap,
+      'snapshot carries joined broker/firm/region name fields (nullable ok)');
+  }
+
+  // 5. reconstruction test: DELETE the listing row (it "vanished"), the snapshot must still
+  //    fully describe it — the whole point of preservation.
+  await query(`DELETE FROM listing WHERE id=$1`, [id]);
+  const afterDel = await query<{ snapshot: any }>(
+    `SELECT snapshot FROM listing_snapshot WHERE source=$1 AND source_id=$2 AND captured_reason='withdrawn' LIMIT 1`,
+    [SOURCE, SID],
+  );
+  const preserved = afterDel.rows[0]?.snapshot;
+  check(
+    !!preserved && preserved.address === address && Number(preserved.price) === price,
+    'after listing row DELETED, snapshot still fully reconstructs the listing',
+    preserved && { address: preserved.address, price: preserved.price },
+  );
+
+  await purge();
+  console.log(`\n${failed === 0 ? 'PASS — withdrawal snapshot survives listing deletion' : `FAIL — ${failed} assertion(s) failed`}\n`);
+  await pool.end();
+  process.exit(failed === 0 ? 0 : 1);
+}
+
+main().catch(async (e) => {
+  console.error(e);
+  try { await purge(); await pool.end(); } catch {}
+  process.exit(1);
+});
diff --git a/src/server/listing-lifecycle.selftest.ts b/src/server/listing-lifecycle.selftest.ts
new file mode 100644
index 0000000..618b62f
--- /dev/null
+++ b/src/server/listing-lifecycle.selftest.ts
@@ -0,0 +1,222 @@
+/**
+ * TK-10155 listing-lifecycle self-test — PURE STATE MACHINE (no DB).
+ *   run: `tsx src/server/listing-lifecycle.selftest.ts`  (or `npm run test:lifecycle`)
+ *
+ * No test framework is installed in this repo (the house pattern is a deterministic
+ * tsx self-test that exits non-zero on any failure — see provenance_selftest.ts).
+ *
+ * This file exercises the RISKY part — classifyState(), the pure lifecycle state model,
+ * plus recordLifecycle()'s transition/snapshot logic driven by an IN-MEMORY fake db so it
+ * needs zero database. It asserts:
+ *   (a) every classifyState branch: new / active / stale / withdrawn / closed
+ *   (b) the fix-1 false-withdrawal guard: gone-from-feed is suppressed when there was no
+ *       full rescan AFTER the listing's last_seen (incremental-only sources)
+ *   (c) status strings (sold / withdrawn / cancelled / expired) win over feed-age
+ *   (d) recordLifecycle append-only discipline: an event is written ONLY on a state change,
+ *       a full snapshot is preserved ONLY on transitions INTO withdrawn/closed/stale/new,
+ *       'reappeared' is emitted (and NOT snapshotted) coming back from stale/withdrawn,
+ *       and suppressSnapshot skips the state-triggered snapshot.
+ */
+import {
+  classifyState,
+  recordLifecycle,
+  NEW_DAYS,
+  STALE_DAYS,
+  WITHDRAWN_DAYS,
+  type LifecycleListing,
+  type LifecycleCtx,
+  type LifecycleState,
+} from './listing-lifecycle.ts';
+
+let failed = 0;
+function check(ok: boolean, label: string, detail?: unknown) {
+  if (ok) { console.log(`  ok   ${label}`); return; }
+  failed++;
+  console.error(`  FAIL ${label}${detail !== undefined ? `\n       ${JSON.stringify(detail)}` : ''}`);
+}
+
+const NOW = new Date('2026-08-24T12:00:00Z');
+const DAY = 86_400_000;
+const daysAgo = (n: number) => new Date(NOW.getTime() - n * DAY).toISOString();
+
+// A listing that WAS just seen (last_seen == the source's freshest ingest) is "currently seen".
+const FRESH_REF = new Date(NOW.getTime()); // source's most-recent ingest reference
+
+/** ctx where a full rescan happened just now → gone-from-feed aging IS eligible. */
+function ctxFull(refLastSeen = FRESH_REF): LifecycleCtx {
+  return { sourceMaxLastSeen: refLastSeen, lastFullRescanAt: NOW, now: NOW };
+}
+/** ctx for an incremental-only source (no full rescan ever) → gone-from-feed SUPPRESSED. */
+function ctxIncremental(refLastSeen = FRESH_REF): LifecycleCtx {
+  return { sourceMaxLastSeen: refLastSeen, lastFullRescanAt: null, now: NOW };
+}
+
+function base(over: Partial<LifecycleListing> = {}): LifecycleListing {
+  return {
+    id: 1,
+    source: 'selftest',
+    source_id: 'X1',
+    address: '1 Test Way, Testville',
+    price: 500000,
+    status: null,
+    first_seen: daysAgo(90),
+    last_seen: daysAgo(0), // seen in the latest ingest
+    ...over,
+  };
+}
+
+function testClassify() {
+  console.log('classifyState — pure state model');
+
+  // new: first_seen within NEW_DAYS AND currently seen
+  check(
+    classifyState(base({ first_seen: daysAgo(NEW_DAYS - 1), last_seen: daysAgo(0) }), ctxFull()) === 'new',
+    'new: first_seen within NEW_DAYS and currently seen',
+  );
+
+  // active: currently seen but old first_seen
+  check(
+    classifyState(base({ first_seen: daysAgo(200), last_seen: daysAgo(0) }), ctxFull()) === 'active',
+    'active: currently seen, not new',
+  );
+
+  // stale: gone from feed >= STALE_DAYS but < WITHDRAWN_DAYS (with a full rescan)
+  check(
+    classifyState(base({ last_seen: daysAgo(STALE_DAYS + 1) }), ctxFull()) === 'stale',
+    'stale: gone from feed >= STALE_DAYS (< WITHDRAWN_DAYS)',
+  );
+
+  // withdrawn: gone from feed >= WITHDRAWN_DAYS (with a full rescan)
+  check(
+    classifyState(base({ last_seen: daysAgo(WITHDRAWN_DAYS + 5) }), ctxFull()) === 'withdrawn',
+    'withdrawn: gone from feed >= WITHDRAWN_DAYS',
+  );
+
+  // closed: status transacted wins outright, even if currently seen
+  check(
+    classifyState(base({ status: 'Sold', last_seen: daysAgo(0) }), ctxFull()) === 'closed',
+    'closed: status="Sold" wins over feed-age',
+  );
+  check(classifyState(base({ status: 'Closed' }), ctxFull()) === 'closed', 'closed: status="Closed"');
+
+  // withdrawn by status string (currently seen but explicitly withdrawn/cancelled/expired)
+  for (const s of ['Withdrawn', 'Cancelled', 'Canceled', 'Expired']) {
+    check(
+      classifyState(base({ status: s, last_seen: daysAgo(0) }), ctxFull()) === 'withdrawn',
+      `withdrawn: status="${s}" wins over feed-age`,
+    );
+  }
+
+  // fix-1 guard: gone from feed 60d BUT incremental-only source ⇒ NOT withdrawn (stays active).
+  const goneOnIncremental = classifyState(base({ last_seen: daysAgo(60), first_seen: daysAgo(200) }), ctxIncremental());
+  check(
+    goneOnIncremental === 'active',
+    'fix-1 guard: gone 60d on incremental-only source is NOT withdrawn (structural absence)',
+    { got: goneOnIncremental },
+  );
+
+  // fix-1 guard: full rescan predates last_seen ⇒ still cannot prove gone ⇒ suppressed.
+  const rescanBeforeLastSeen: LifecycleCtx = {
+    sourceMaxLastSeen: FRESH_REF,
+    lastFullRescanAt: new Date(NOW.getTime() - 90 * DAY), // rescan 90d ago, before last_seen 60d ago
+    now: NOW,
+  };
+  check(
+    classifyState(base({ last_seen: daysAgo(60), first_seen: daysAgo(200) }), rescanBeforeLastSeen) === 'active',
+    'fix-1 guard: full rescan older than last_seen cannot prove gone ⇒ suppressed',
+  );
+
+  // fix-1 guard does NOT suppress an explicit withdrawn STATUS on an incremental source.
+  check(
+    classifyState(base({ status: 'Withdrawn', last_seen: daysAgo(60) }), ctxIncremental()) === 'withdrawn',
+    'fix-1 guard: explicit withdrawn STATUS still classifies on incremental source',
+  );
+}
+
+// ── in-memory fake db to drive recordLifecycle without Postgres ──────────────
+interface EventRow { source: string; source_id: string; state: LifecycleState; observed_at: number }
+interface SnapRow { source: string; source_id: string; captured_reason: string }
+function makeFakeDb() {
+  const events: EventRow[] = [];
+  const snaps: SnapRow[] = [];
+  let clock = 0;
+  const query = async <T extends Record<string, any> = any>(text: string, params: unknown[] = []) => {
+    const t = text.replace(/\s+/g, ' ').trim();
+    // latest event for source/source_id
+    if (t.startsWith('SELECT state FROM listing_lifecycle_event')) {
+      const [source, source_id] = params as [string, string];
+      const matching = events.filter((e) => e.source === source && e.source_id === source_id)
+        .sort((a, b) => b.observed_at - a.observed_at);
+      return { rows: (matching[0] ? [{ state: matching[0].state }] : []) as unknown as T[] };
+    }
+    // buildSnapshot's listing read — return a synthetic full row
+    if (t.startsWith('SELECT l.*,')) {
+      return { rows: [{ id: 1, address: '1 Test Way', price: 500000, broker_name: 'B', firm_name: 'F' }] as unknown as T[] };
+    }
+    if (t.startsWith('INSERT INTO listing_lifecycle_event')) {
+      const [, source, source_id, , state] = params as any[];
+      events.push({ source, source_id, state, observed_at: clock++ });
+      return { rows: [] as T[] };
+    }
+    if (t.startsWith('INSERT INTO listing_snapshot')) {
+      const [, source, source_id, , , captured_reason] = params as any[];
+      snaps.push({ source, source_id, captured_reason });
+      return { rows: [] as T[] };
+    }
+    throw new Error('fake db: unhandled query: ' + t.slice(0, 60));
+  };
+  return { query, events, snaps };
+}
+
+async function testRecord() {
+  console.log('recordLifecycle — transitions + snapshot preservation (in-memory)');
+  const db = makeFakeDb();
+  const L = base();
+
+  // 1. first-ever record on a currently-seen listing → 'new' event + a 'new' snapshot.
+  const r1 = await recordLifecycle(db.query, base({ first_seen: daysAgo(2), last_seen: daysAgo(0) }), ctxFull());
+  check(r1.state === 'new' && r1.eventInserted && r1.snapshotInserted, 'first record: new event + new snapshot', r1);
+
+  // 2. same state again → NO-OP (append-only records only transitions).
+  const r2 = await recordLifecycle(db.query, base({ first_seen: daysAgo(2), last_seen: daysAgo(0) }), ctxFull());
+  check(!r2.eventInserted && !r2.snapshotInserted, 'unchanged state is a no-op (append-only)', r2);
+
+  // 3. transition new→withdrawn (gone from feed) → event + a preserved WITHDRAWN snapshot.
+  const r3 = await recordLifecycle(db.query, base({ last_seen: daysAgo(WITHDRAWN_DAYS + 5) }), ctxFull());
+  check(
+    r3.state === 'withdrawn' && r3.prev_state === 'new' && r3.eventInserted && r3.snapshotInserted,
+    'transition → withdrawn preserves a full snapshot',
+    r3,
+  );
+  check(
+    db.snaps.some((s) => s.captured_reason === 'withdrawn'),
+    'withdrawal snapshot recorded with captured_reason="withdrawn"',
+  );
+
+  // 4. withdrawn→seen-again → 'reappeared' event, but NO snapshot (coming back, not lost).
+  const r4 = await recordLifecycle(db.query, base({ first_seen: daysAgo(200), last_seen: daysAgo(0) }), ctxFull());
+  check(
+    r4.state === 'reappeared' && r4.eventInserted && !r4.snapshotInserted,
+    "reappeared: event written, NO snapshot (it's returning, not vanishing)",
+    r4,
+  );
+
+  // 5. suppressSnapshot skips the state-triggered snapshot but still writes the event.
+  const db2 = makeFakeDb();
+  const r5 = await recordLifecycle(db2.query, base({ status: 'Sold', last_seen: daysAgo(0) }), ctxFull(), { suppressSnapshot: true });
+  check(
+    r5.state === 'closed' && r5.eventInserted && !r5.snapshotInserted && db2.snaps.length === 0,
+    'suppressSnapshot: event written, snapshot skipped',
+    r5,
+  );
+}
+
+async function main() {
+  console.log(`\nTK-10155 listing-lifecycle self-test (NEW_DAYS=${NEW_DAYS} STALE_DAYS=${STALE_DAYS} WITHDRAWN_DAYS=${WITHDRAWN_DAYS})\n`);
+  testClassify();
+  await testRecord();
+  console.log(`\n${failed === 0 ? 'PASS — all lifecycle assertions held' : `FAIL — ${failed} assertion(s) failed`}\n`);
+  process.exit(failed === 0 ? 0 : 1);
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });

← 958bcb3 auto-data-snapshot: 2026-08-24T15:30:55 (1 data files) — dat  ·  back to Nationalrealestate  ·  TK-10155 (Cody FIX-FIRST): guarantee every listing has >=1 p ae4eb11 →