← back to Nationalrealestate

src/ingest/parcels/date_guard.ts

109 lines

// Shared deed-date sanity guard for every parcel-deed ingester.
//
// Why this exists: each county feed carries occasional bad-entry dates — a
// future sale ("2027-04-01"), a mainframe near-epoch artifact ("1901-01-01"),
// or a typo'd century ("2921-06-23"). Before this util the future-date guard
// lived as a one-off inline check in arcgis_sales.ts (`> TODAY`), while
// wake_nc.ts had only a 1971 lower bound and miami_dade.ts had NO sanity guard
// at all. A bad-data run against Miami-Dade or Wake could therefore re-introduce
// impossible dates into parcel_event, exactly the class the arcgis guard was
// added to stop. This centralizes ONE tested rule so all four deed ingesters
// (arcgis_sales, miami_dade, wake_nc, sandiego_ca) reject the same defects.
//
// Contract: input is an already-format-normalized 'YYYY-MM-DD' string (or null).
// Output is that same string iff it is a plausible real recorded-deed date, else
// null. It never throws and never mutates its input — a rejected date simply
// drops the event (the parcel's last_sale_date should be nulled alongside it).

// Oldest plausible digitized US deed record. Nothing in these county feeds
// predates ~1900; anything earlier is an epoch/precision artifact, not a sale.
const MIN_DEED_YEAR = 1900;

/**
 * Return `iso` iff it is a well-formed 'YYYY-MM-DD' calendar date that is not
 * in the future and not older than MIN_DEED_YEAR; otherwise null.
 *
 * @param iso  an ISO date string 'YYYY-MM-DD', or null/undefined/''
 * @param today  today's date as 'YYYY-MM-DD' (injectable for deterministic tests)
 */
export function sanitizeEventDate(
  iso: string | null | undefined,
  today: string = new Date().toISOString().slice(0, 10),
): string | null {
  if (!iso) return null;
  const t = String(iso).trim();
  // strict shape: 4-digit year, 2-digit month, 2-digit day
  const m = t.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (!m) return null;
  const year = Number(m[1]);
  const month = Number(m[2]);
  const day = Number(m[3]);
  if (year < MIN_DEED_YEAR) return null;
  // reject non-calendar dates (month 00/13, day 00/32, Feb 30, etc.) by
  // round-tripping through Date and checking the parts survived unchanged.
  const d = new Date(Date.UTC(year, month - 1, day));
  if (
    d.getUTCFullYear() !== year ||
    d.getUTCMonth() !== month - 1 ||
    d.getUTCDate() !== day
  ) {
    return null;
  }
  // future-date guard: a recorded sale cannot be dated after today.
  if (t > today) return null;
  return t;
}

// --- self-test (run: `tsx src/ingest/parcels/date_guard.ts`) -----------------
// No test framework is installed in this repo, so the util ships its own
// deterministic self-test that exits non-zero on any failure. Runs only when
// executed directly, never on import.
const isMain = (() => {
  try {
    // import.meta.url is defined in ESM; process.argv[1] is the entry file.
    return typeof process !== 'undefined'
      && process.argv[1] != null
      && import.meta.url === new URL(`file://${process.argv[1]}`).href;
  } catch { return false; }
})();

if (isMain) {
  const TODAY = '2026-07-27';
  const cases: Array<[string | null | undefined, string | null, string]> = [
    // [input, expected, label]
    ['2021-06-23', '2021-06-23', 'ordinary valid deed date'],
    ['2026-07-27', '2026-07-27', 'today is allowed (boundary)'],
    ['2026-07-28', null, 'tomorrow rejected (future by 1 day)'],
    ['2027-04-01', null, 'future year rejected'],
    ['2921-06-23', null, 'typo century (2921) rejected'],
    ['1899-12-31', null, 'below 1900 floor rejected'],
    ['1900-01-01', '1900-01-01', '1900 floor allowed (boundary)'],
    ['1901-01-01', '1901-01-01', 'near-epoch-looking but valid 1901'],
    ['2021-13-01', null, 'month 13 rejected'],
    ['2021-00-10', null, 'month 00 rejected'],
    ['2021-02-30', null, 'Feb 30 (non-calendar) rejected'],
    ['2021-06-31', null, 'June 31 (non-calendar) rejected'],
    ['2021-6-3', null, 'unpadded parts rejected (shape)'],
    ['20210623', null, 'compact form rejected (shape)'],
    ['2021-06-23T00:00:00', null, 'datetime rejected (shape)'],
    ['', null, 'empty string -> null'],
    [null, null, 'null -> null'],
    [undefined, null, 'undefined -> null'],
    ['  2021-06-23  ', '2021-06-23', 'surrounding whitespace trimmed'],
  ];
  let failed = 0;
  for (const [input, expected, label] of cases) {
    const got = sanitizeEventDate(input, TODAY);
    const ok = got === expected;
    if (!ok) {
      failed++;
      console.error(`FAIL: ${label}\n  input=${JSON.stringify(input)} expected=${JSON.stringify(expected)} got=${JSON.stringify(got)}`);
    }
  }
  if (failed) {
    console.error(`\n${failed}/${cases.length} sanitizeEventDate cases FAILED`);
    process.exit(1);
  }
  console.log(`sanitizeEventDate: all ${cases.length} cases passed`);
}