/* * gazetteer.ts — the dataset loader + alias index (D-06, D-07, D-09). * ------------------------------------------------------------------ * Three hard laws: * * LAW 1 — LAZY, MEMOIZED, NEVER AT MODULE SCOPE (RESEARCH Pitfall 8, * Workers 1 s startup limit / "avoid expensive work in global scope"). * The index is built inside a function on first access and cached in * module scope — the same lazy-load shape as bot/test/initdata.test.mjs. * * LAW 2 — THE LOADER MUST SURVIVE THREE ENVIRONMENTS: the * wrangler/esbuild bundle (Worker), the Vite bundle (browser), and bare * Node 24 (the corpus runner). The mechanism is the STATIC JSON IMPORT * below — Workers have no fs and a runtime retrieval would be a hard * failure. esbuild and Vite both inline a static JSON import at build * time; the `with { type: "json" }` attribute is required by Node ESM * and harmless under both bundlers (loader-env suite is the arbiter). * * LAW 3 — NO LOGGING AND NO STORAGE (D-15): the query text is user * payload. This module contains no logging at all and touches no * storage surface. * * The index maps each entry's NORMALIZED name and each NORMALIZED alias * to the entry (the D-09 symmetry law — both sides of every comparison * are canonical). Normalization happens ONCE per dataset load, never * per query. */ import raw from "./data/kl-gazetteer.json" with { type: "json" }; import { normalize } from "./normalize.ts"; import type { GazetteerEntry } from "./types.ts"; /** The committed dataset's envelope (the ODbL notice travels inside it). */ interface GazetteerFile { _license: string; _attribution: string; _notice: string; version: number; entries: GazetteerEntry[]; } let entriesCache: readonly GazetteerEntry[] | null = null; let byIdCache: Map | null = null; let indexCache: Map | null = null; /** The parsed dataset — one V8-native JSON parse, memoized. */ function loadEntries(): readonly GazetteerEntry[] { if (entriesCache === null) { const file = raw as GazetteerFile; entriesCache = Object.freeze([...file.entries]); } return entriesCache; } /** id -> entry, built lazily. */ function loadById(): Map { if (byIdCache === null) { const map = new Map(); for (const entry of loadEntries()) { map.set(entry.id, entry); } byIdCache = map; } return byIdCache; } /** normalized alias -> entry, built lazily and memoized (LAW 1). */ function loadIndex(): Map { if (indexCache === null) { const index = new Map(); for (const entry of loadEntries()) { const keys = new Set(); const nameKey = normalize(entry.name); if (nameKey !== "") keys.add(nameKey); for (const alias of entry.aliases) { const aliasKey = normalize(alias); if (aliasKey !== "") keys.add(aliasKey); } for (const key of keys) { const bucket = index.get(key); if (bucket === undefined) { index.set(key, [entry]); } else { bucket.push(entry); } } } indexCache = index; } return indexCache; } /** All curated entries (frozen — the dataset is a build-time constant). */ export function getEntries(): readonly GazetteerEntry[] { return loadEntries(); } /** id -> entry; honest null when the id is unknown (never a fabricated entry). */ export function getEntryById(id: string): GazetteerEntry | null { return loadById().get(id) ?? null; } /** The memoized normalized-alias index (normalized key -> entries). */ export function getIndex(): Map { return loadIndex(); }