@mailwoman/resolver-wof-sqlite 7.2.0 → 7.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/address-point-interpolation.ts +207 -0
  2. package/address-point-schema.ts +107 -0
  3. package/address-point.ts +122 -0
  4. package/ancestry-backfill.ts +205 -0
  5. package/ancestry.ts +70 -0
  6. package/build-candidate.ts +351 -0
  7. package/build-slim.ts +394 -0
  8. package/candidate-fts.ts +43 -0
  9. package/candidate-lookup.ts +382 -0
  10. package/candidate-schema.ts +166 -0
  11. package/coincident-roles.ts +240 -0
  12. package/convention.ts +152 -0
  13. package/fst-autocomplete.ts +187 -0
  14. package/fst-builder.ts +291 -0
  15. package/fst-deserialize-web.ts +164 -0
  16. package/fst-matcher.ts +150 -0
  17. package/fst-serialize.ts +311 -0
  18. package/fst-types.ts +78 -0
  19. package/fts.ts +318 -0
  20. package/geo.ts +140 -0
  21. package/geonames-aliases.ts +317 -0
  22. package/geonames-postal.ts +150 -0
  23. package/index.ts +117 -0
  24. package/interpolation.ts +232 -0
  25. package/lookup.ts +1498 -0
  26. package/package.json +168 -82
  27. package/poi-lookup.ts +319 -0
  28. package/poi-schema.ts +147 -0
  29. package/postal-city-alias-lookup.ts +89 -0
  30. package/postal-city-alias-schema.ts +75 -0
  31. package/postal-city-candidate-schema.ts +81 -0
  32. package/postcode-point-lookup.ts +64 -0
  33. package/reverse.ts +429 -0
  34. package/schema.ts +176 -0
  35. package/sharding.ts +235 -0
  36. package/sqlite-convention-source.ts +61 -0
  37. package/sqlite-utils.ts +25 -0
  38. package/street-centroid-schema.ts +124 -0
  39. package/street-centroid.ts +124 -0
  40. package/street-morphology-fst-builder.ts +230 -0
  41. package/street-name-lookup.ts +101 -0
  42. package/street-normalize.ts +302 -0
  43. package/street-segment-schema.ts +104 -0
  44. package/types.ts +164 -0
  45. package/unified-schema.ts +171 -0
package/reverse.ts ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Reverse geocoding (#484): `(lat, lon)` → the containing admin hierarchy. Assembly over existing
7
+ * machinery, per the 2026-06-11 scoping notes:
8
+ *
9
+ * 1. **Candidate fetch** — the admin DB's `place_bbox` R*Tree (built by `fts.ts`) for places whose
10
+ * bbox contains the point, smallest-area-first (so the FIRST polygon confirmation is the
11
+ * deepest).
12
+ * 2. **PIP confirmation** — ray-cast (geo.ts, the canonical TS port of
13
+ * `scripts/eval/pip-containment.py`) against the polygon sidecar DB (`wof-polygons.db`,
14
+ * `polygons(id, geom)` with GeoJSON text — built by `scripts/build-wof-polygons.mjs` for the
15
+ * demo map). A candidate whose polygon EXISTS but rejects the point is a bbox false positive
16
+ * and is dropped entirely; a candidate with no polygon row stays eligible for the
17
+ * approximate fallback.
18
+ * 3. **Approximate descent** — WOF carries point geometry for most localities (#292: ~99% of JP
19
+ * municipalities; ~half of US localities have degenerate bboxes too), so the polygon walk
20
+ * usually bottoms out at county level. We then descend tier-by-tier (county → localadmin →
21
+ * locality → …) through the winner's DESCENDANTS (the `ancestors` table, reversed), taking
22
+ * the PIP-confirmed child when a polygon exists and the nearest-centroid child otherwise —
23
+ * the latter flagged `containment: "approximate"`, the demo's honesty convention.
24
+ * 4. **Hierarchy assembly** — the deepest place's ancestor chain via the SAME walk forward resolution
25
+ * uses (`ancestry.ts`, #404), so consumers get a symmetric tree.
26
+ *
27
+ * Reverse quality is country-dependent (polygon coverage: see the #292 JP finding); `containment`
28
+ * says so per result rather than pretending.
29
+ */
30
+
31
+ import { DatabaseSync } from "node:sqlite"
32
+
33
+ import { ancestorLineage, placetypeDepth } from "./ancestry.ts"
34
+ import { PLACE_BBOX_TABLE } from "./fts.ts"
35
+ import { geometryContains, haversineKm, type GeojsonGeometry } from "./geo.ts"
36
+ import type { PlaceCandidate, WOFPlacetype } from "./types.ts"
37
+
38
+ /**
39
+ * How the deepest returned place was confirmed:
40
+ *
41
+ * - `"polygon"` — the point ray-cast INSIDE the place's real (DP-simplified) admin boundary.
42
+ * - `"approximate"` — the place has no polygon on record; it won by nearest-centroid among the candidates whose bbox (or
43
+ * parent) contains the point. The same honesty convention as the demo's approximate circles — country-dependent data
44
+ * reality, surfaced instead of hidden.
45
+ */
46
+ export type ContainmentKind = "polygon" | "approximate"
47
+
48
+ export interface ReverseGeocodeResult {
49
+ /**
50
+ * The containment chain, DEEPEST-FIRST (`[0]` is the winning place, then its ancestors up to country) — the same tree
51
+ * shape forward resolution attaches via `includeAncestors`. Empty when no candidate's bbox contains the point (open
52
+ * ocean, or outside the gazetteer's coverage).
53
+ */
54
+ hierarchy: PlaceCandidate[]
55
+ /** Containment kind of the DEEPEST place in `hierarchy` (see {@link ContainmentKind}). */
56
+ containment: ContainmentKind
57
+ }
58
+
59
+ export interface WOFReverseGeocoderOpts {
60
+ /**
61
+ * Path to the admin gazetteer DB (e.g. `admin-global-priority.db`) — must carry `spr`, `ancestors`, and the
62
+ * package-built `place_bbox` R*Tree (`mailwoman gazetteer build fts`). Mutually exclusive with `adminDatabase`.
63
+ */
64
+ adminDBPath?: string
65
+ /** Pre-opened admin DB — primarily for tests against an inline fixture. */
66
+ adminDatabase?: DatabaseSync
67
+ /**
68
+ * Path to the polygon sidecar DB (`wof-polygons.db`, table `polygons(id, geom)`). OPTIONAL — without it every result
69
+ * is `containment: "approximate"` (centroid-only mode). Mutually exclusive with `polygonDatabase`.
70
+ */
71
+ polygonDBPath?: string
72
+ /** Pre-opened polygon DB — primarily for tests. */
73
+ polygonDatabase?: DatabaseSync
74
+ }
75
+
76
+ export interface ReverseGeocodeOpts {
77
+ /**
78
+ * Restrict the hierarchy to these placetypes (both the bbox candidates and the descent tiers). Default: every admin
79
+ * placetype the gazetteer carries. E.g. `["region", "county", "locality"]` to skip the neighbourhood grain.
80
+ */
81
+ placetypes?: WOFPlacetype[]
82
+ /**
83
+ * Cap on the bbox candidate fetch. Default 128 — comfortably covers a dense metro (the most bbox-overlapping point
84
+ * we've measured is a few dozen neighbourhoods + the admin chain).
85
+ */
86
+ maxCandidates?: number
87
+ /**
88
+ * Approximate (nearest-centroid) steps further than this from the query point are not taken — keeps a sparse
89
+ * gazetteer from "refining" to a far-away sibling. Polygon-confirmed steps ignore it (containment is exact regardless
90
+ * of centroid distance). Default 25 km.
91
+ */
92
+ maxApproximateKm?: number
93
+ }
94
+
95
+ const DEFAULT_MAX_CANDIDATES = 128
96
+ const DEFAULT_MAX_APPROXIMATE_KM = 25
97
+
98
+ /**
99
+ * The tier ladder for the approximate descent, coarsest-first. Each tier is attempted among the CURRENT winner's
100
+ * descendants; a tier with no rows is skipped (e.g. counties without localadmins jump straight to locality).
101
+ */
102
+ const DESCENT_TIERS: readonly WOFPlacetype[] = [
103
+ "county",
104
+ "localadmin",
105
+ "locality",
106
+ "borough",
107
+ "neighbourhood",
108
+ "microhood",
109
+ ]
110
+
111
+ /** Internal candidate row off `spr` (+ optional bbox area / centroid distance bookkeeping). */
112
+ interface CandidateRow {
113
+ id: number
114
+ name: string
115
+ placetype: string
116
+ country: string | null
117
+ parent_id: number | null
118
+ lat: number
119
+ lon: number
120
+ }
121
+
122
+ function toPlaceCandidate(row: CandidateRow, distanceKm?: number): PlaceCandidate {
123
+ const c: PlaceCandidate = {
124
+ id: row.id,
125
+ name: row.name,
126
+ placetype: row.placetype as WOFPlacetype,
127
+ country: row.country ?? "",
128
+ lat: row.lat,
129
+ lon: row.lon,
130
+ parent_id: row.parent_id ?? undefined,
131
+ score: 0,
132
+ }
133
+
134
+ if (distanceKm !== undefined) {
135
+ c.distanceKm = distanceKm
136
+ }
137
+
138
+ return c
139
+ }
140
+
141
+ export class WOFReverseGeocoder implements Disposable {
142
+ readonly #admin: DatabaseSync
143
+ readonly #ownsAdmin: boolean
144
+ readonly #polygons: DatabaseSync | null
145
+ readonly #ownsPolygons: boolean
146
+ /**
147
+ * Parsed-geometry cache. Reverse queries cluster geographically (an eval run hits the same ~15 county polygons 1400
148
+ * times), so caching the JSON.parse pays for itself immediately. Bounded — cleared wholesale at the cap rather than
149
+ * LRU-tracked; the polygons are DP-simplified and small, the cap exists only to keep a long-lived server process
150
+ * honest.
151
+ */
152
+ readonly #geometryCache = new Map<number, GeojsonGeometry | null>()
153
+ static readonly #GEOMETRY_CACHE_CAP = 4096
154
+
155
+ constructor(opts: WOFReverseGeocoderOpts) {
156
+ if (opts.adminDatabase && opts.adminDBPath) {
157
+ throw new Error("WOFReverseGeocoder: pass either `adminDatabase` or `adminDBPath`, not both")
158
+ }
159
+
160
+ if (!opts.adminDatabase && !opts.adminDBPath) {
161
+ throw new Error("WOFReverseGeocoder: one of `adminDatabase` or `adminDBPath` is required")
162
+ }
163
+
164
+ if (opts.polygonDatabase && opts.polygonDBPath) {
165
+ throw new Error("WOFReverseGeocoder: pass either `polygonDatabase` or `polygonDBPath`, not both")
166
+ }
167
+
168
+ this.#admin = opts.adminDatabase ?? new DatabaseSync(opts.adminDBPath!, { readOnly: true })
169
+ this.#ownsAdmin = !opts.adminDatabase
170
+ this.#polygons =
171
+ opts.polygonDatabase ?? (opts.polygonDBPath ? new DatabaseSync(opts.polygonDBPath, { readOnly: true }) : null)
172
+ this.#ownsPolygons = !opts.polygonDatabase && Boolean(opts.polygonDBPath)
173
+
174
+ // Fail loudly up front — the R*Tree is a build artifact, not part of the upstream WOF
175
+ // distribution, and a missing index would otherwise surface as an opaque SQL error per query.
176
+ const hasBbox = this.#admin
177
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
178
+ .get(PLACE_BBOX_TABLE)
179
+
180
+ if (!hasBbox) {
181
+ throw new Error(
182
+ `WOFReverseGeocoder: the admin DB has no \`${PLACE_BBOX_TABLE}\` R*Tree. Build it with ` +
183
+ "`mailwoman gazetteer build fts <path-to-wof.db>` (see resolver-wof-sqlite/README.md)."
184
+ )
185
+ }
186
+
187
+ if (this.#polygons) {
188
+ const hasPolygons = this.#polygons
189
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'polygons'`)
190
+ .get()
191
+
192
+ if (!hasPolygons) {
193
+ throw new Error(
194
+ "WOFReverseGeocoder: the polygon DB has no `polygons` table. Expected a `wof-polygons.db` " +
195
+ "built by scripts/build-wof-polygons.mjs."
196
+ )
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Resolve a WGS-84 point to its containing admin hierarchy. Async for symmetry with `PlaceLookup.findPlace` (the work
203
+ * is sync `node:sqlite` underneath — same convention).
204
+ */
205
+ async reverseGeocode(lat: number, lon: number, opts: ReverseGeocodeOpts = {}): Promise<ReverseGeocodeResult> {
206
+ if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) {
207
+ throw new RangeError(`WOFReverseGeocoder.reverseGeocode: (${lat}, ${lon}) is not a WGS-84 coordinate`)
208
+ }
209
+ const maxApproximateKm = opts.maxApproximateKm ?? DEFAULT_MAX_APPROXIMATE_KM
210
+ const candidates = this.#bboxCandidates(lat, lon, opts)
211
+
212
+ // PIP walk, smallest-bbox-first: the first polygon that contains the point is the deepest
213
+ // polygon-confirmable place. Polygon-rejected candidates are bbox false positives — dropped.
214
+ let winner: CandidateRow | null = null
215
+ let winnerConfirmed = false
216
+ const pointOnly: CandidateRow[] = []
217
+
218
+ for (const c of candidates) {
219
+ const contains = geometryContains(this.#geometry(c.id), lon, lat)
220
+
221
+ if (contains === true) {
222
+ winner = c
223
+ winnerConfirmed = true
224
+ break
225
+ }
226
+
227
+ if (contains === null) {
228
+ pointOnly.push(c)
229
+ }
230
+ }
231
+
232
+ if (!winner) {
233
+ // No polygon confirmed anywhere — nearest centroid among the polygon-less bbox candidates.
234
+ let bestKm = Infinity
235
+
236
+ for (const c of pointOnly) {
237
+ const km = haversineKm(lat, lon, c.lat, c.lon)
238
+
239
+ if (km < bestKm) {
240
+ bestKm = km
241
+ winner = c
242
+ }
243
+ }
244
+
245
+ if (!winner) return { hierarchy: [], containment: "approximate" }
246
+ }
247
+
248
+ // Approximate descent into finer tiers than the winner.
249
+ let current = winner
250
+ let currentConfirmed = winnerConfirmed
251
+ let currentDistanceKm = currentConfirmed ? undefined : haversineKm(lat, lon, current.lat, current.lon)
252
+
253
+ for (const tier of DESCENT_TIERS) {
254
+ if (placetypeDepth(tier) <= placetypeDepth(current.placetype)) continue
255
+
256
+ if (opts.placetypes && !opts.placetypes.includes(tier)) continue
257
+ const kids = this.#descendants(current.id, tier, lat, lon, maxApproximateKm)
258
+ let next: CandidateRow | null = null
259
+ let nextConfirmed = false
260
+ let nextKm: number | undefined
261
+ let bestKm = Infinity
262
+
263
+ for (const k of kids) {
264
+ const contains = geometryContains(this.#geometry(k.id), lon, lat)
265
+
266
+ if (contains === true) {
267
+ next = k
268
+ nextConfirmed = true
269
+ nextKm = undefined
270
+ break
271
+ }
272
+
273
+ if (contains === false) continue // known not-here — polygon rejected
274
+ const km = haversineKm(lat, lon, k.lat, k.lon)
275
+
276
+ if (km <= maxApproximateKm && km < bestKm) {
277
+ bestKm = km
278
+ next = k
279
+ nextConfirmed = false
280
+ nextKm = km
281
+ }
282
+ }
283
+
284
+ if (next) {
285
+ current = next
286
+ currentConfirmed = nextConfirmed
287
+ currentDistanceKm = nextKm
288
+ }
289
+ // An empty tier is NOT terminal — counties without localadmins jump straight to locality.
290
+ }
291
+
292
+ // Hierarchy assembly via the shared ancestor walk. If the descent crossed an ancestry gap
293
+ // (the deepest place's recorded lineage misses the PIP root), merge the root's own chain so
294
+ // region/country are always present when a polygon confirmed them.
295
+ const byID = new Map<number, PlaceCandidate>()
296
+ byID.set(current.id, toPlaceCandidate(current, currentDistanceKm))
297
+
298
+ for (const a of ancestorLineage(this.#admin, current.id)) {
299
+ if (!byID.has(a.id)) {
300
+ byID.set(a.id, { ...a, placetype: a.placetype as WOFPlacetype, country: a.country ?? "", score: 0 })
301
+ }
302
+ }
303
+
304
+ if (!byID.has(winner.id)) {
305
+ byID.set(winner.id, toPlaceCandidate(winner))
306
+
307
+ for (const a of ancestorLineage(this.#admin, winner.id)) {
308
+ if (!byID.has(a.id)) {
309
+ byID.set(a.id, { ...a, placetype: a.placetype as WOFPlacetype, country: a.country ?? "", score: 0 })
310
+ }
311
+ }
312
+ }
313
+ const hierarchy = [...byID.values()]
314
+
315
+ if (opts.placetypes) {
316
+ const allowed = new Set<string>(opts.placetypes)
317
+
318
+ for (let i = hierarchy.length - 1; i >= 0; i--) {
319
+ if (!allowed.has(hierarchy[i]!.placetype)) {
320
+ hierarchy.splice(i, 1)
321
+ }
322
+ }
323
+ }
324
+ hierarchy.sort((a, b) => placetypeDepth(b.placetype) - placetypeDepth(a.placetype))
325
+
326
+ return { hierarchy, containment: currentConfirmed ? "polygon" : "approximate" }
327
+ }
328
+
329
+ /** Bbox candidates containing the point, smallest-area-first, via the `place_bbox` R*Tree. */
330
+ #bboxCandidates(lat: number, lon: number, opts: ReverseGeocodeOpts): CandidateRow[] {
331
+ const where: string[] = [
332
+ "bbox.min_lat <= ?",
333
+ "bbox.max_lat >= ?",
334
+ "bbox.min_lon <= ?",
335
+ "bbox.max_lon >= ?",
336
+ "spr.is_current != 0",
337
+ "spr.is_deprecated = 0",
338
+ ]
339
+ const params: Array<number | string> = [lat, lat, lon, lon]
340
+
341
+ if (opts.placetypes && opts.placetypes.length > 0) {
342
+ where.push(`spr.placetype IN (${opts.placetypes.map(() => "?").join(", ")})`)
343
+ params.push(...opts.placetypes)
344
+ }
345
+ params.push(opts.maxCandidates ?? DEFAULT_MAX_CANDIDATES)
346
+
347
+ return this.#admin
348
+ .prepare(
349
+ `SELECT spr.id AS id, spr.name AS name, spr.placetype AS placetype, spr.country AS country,
350
+ spr.parent_id AS parent_id, spr.latitude AS lat, spr.longitude AS lon
351
+ FROM ${PLACE_BBOX_TABLE} bbox JOIN spr ON spr.id = bbox.id
352
+ WHERE ${where.join(" AND ")}
353
+ ORDER BY (bbox.max_lat - bbox.min_lat) * (bbox.max_lon - bbox.min_lon) ASC
354
+ LIMIT ?`
355
+ )
356
+ .all(...params) as unknown as CandidateRow[]
357
+ }
358
+
359
+ /**
360
+ * Descendants of `parentID` at one placetype tier, pre-filtered to a centroid window around the query point (a
361
+ * generous 4× the approximate cap — polygon-holding children may legitimately have far centroids, e.g. a sprawling
362
+ * consolidated city; the precise cap is applied per-candidate in the caller, and only to centroid-fallback steps).
363
+ */
364
+ #descendants(
365
+ parentID: number,
366
+ placetype: string,
367
+ lat: number,
368
+ lon: number,
369
+ maxApproximateKm: number
370
+ ): CandidateRow[] {
371
+ const windowDeg = (maxApproximateKm * 4) / 111
372
+
373
+ return this.#admin
374
+ .prepare(
375
+ `SELECT s.id AS id, s.name AS name, s.placetype AS placetype, s.country AS country,
376
+ s.parent_id AS parent_id, s.latitude AS lat, s.longitude AS lon
377
+ FROM ancestors a JOIN spr s ON s.id = a.id
378
+ WHERE a.ancestor_id = ? AND s.placetype = ? AND s.is_current != 0 AND s.is_deprecated = 0
379
+ AND s.latitude BETWEEN ? AND ? AND s.longitude BETWEEN ? AND ?`
380
+ )
381
+ .all(
382
+ parentID,
383
+ placetype,
384
+ lat - windowDeg,
385
+ lat + windowDeg,
386
+ lon - windowDeg,
387
+ lon + windowDeg
388
+ ) as unknown as CandidateRow[]
389
+ }
390
+
391
+ /** Parsed GeoJSON geometry for a WOF id, or null when absent / unparseable / no polygon DB. */
392
+ #geometry(id: number): GeojsonGeometry | null {
393
+ if (!this.#polygons) return null
394
+ const cached = this.#geometryCache.get(id)
395
+
396
+ if (cached !== undefined) return cached
397
+
398
+ if (this.#geometryCache.size >= WOFReverseGeocoder.#GEOMETRY_CACHE_CAP) {
399
+ this.#geometryCache.clear()
400
+ }
401
+ const row = this.#polygons.prepare(`SELECT geom FROM polygons WHERE id = ?`).get(id) as { geom: string } | undefined
402
+ let geometry: GeojsonGeometry | null = null
403
+
404
+ if (row) {
405
+ try {
406
+ geometry = JSON.parse(row.geom) as GeojsonGeometry
407
+ } catch {
408
+ geometry = null // malformed row — treat as no-polygon rather than failing the query
409
+ }
410
+ }
411
+ this.#geometryCache.set(id, geometry)
412
+
413
+ return geometry
414
+ }
415
+
416
+ close(): void {
417
+ if (this.#ownsAdmin) {
418
+ this.#admin.close()
419
+ }
420
+
421
+ if (this.#ownsPolygons) {
422
+ this.#polygons?.close()
423
+ }
424
+ }
425
+
426
+ [Symbol.dispose](): void {
427
+ this.close()
428
+ }
429
+ }
package/schema.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Kysely table types for the subset of the Who's On First SQLite schema we touch in Phase 4.2.
7
+ *
8
+ * The full upstream distribution at data.geocode.earth/wof/dist/sqlite/ ships ~7 tables; this file
9
+ * models only the ones we read. Pretending to model the others would be misleading — we haven't
10
+ * verified their shapes and they're not part of the resolver's contract.
11
+ *
12
+ * Authoritative schema docs:
13
+ *
14
+ * - Whosonfirst SQLite README: https://github.com/whosonfirst/go-whosonfirst-sqlite
15
+ * - Per-table sources under https://github.com/whosonfirst/go-whosonfirst-sqlite-features
16
+ */
17
+
18
+ /**
19
+ * The FTS5 virtual table built by this package on first open (NOT shipped by upstream WOF).
20
+ *
21
+ * `content` is unindexed — it's there so we can roundtrip the original name back to the caller without a second SELECT.
22
+ * The actual FTS rebuild happens in `fts.ts::buildPlaceSearchFTS`.
23
+ */
24
+ export interface PlaceSearchTable {
25
+ rowid: number
26
+ wof_id: number
27
+ name: string
28
+ alt_names: string | null
29
+ }
30
+
31
+ /**
32
+ * `spr` — the Who's On First "Standard Places Response": a denormalized lightweight summary of one row per place. The
33
+ * resolver's main lookup table.
34
+ *
35
+ * Lifecycle flags carry TWO conventions, both meaning "currently valid": `is_current = -1` (modern Who's On First) and
36
+ * `is_current = 1` (legacy Mapzen-era). Only `is_current = 0` means "not current". Filters in `lookup.ts` and `fts.ts`
37
+ * use `is_current != 0 AND is_deprecated = 0` — see #91 for the diagnostic that uncovered the mixed-convention
38
+ * reality.
39
+ *
40
+ * Lat/lon live directly on this row — no GeoJSON extraction needed for centroid resolution. `min_*` / `max_*` form a
41
+ * bounding box if callers want one (Phase 4.3 candidate).
42
+ */
43
+ export interface SprTable {
44
+ id: number
45
+ parent_id: number | null
46
+ name: string | null
47
+ placetype: string | null
48
+ country: string | null
49
+ latitude: number
50
+ longitude: number
51
+ min_latitude: number
52
+ min_longitude: number
53
+ max_latitude: number
54
+ max_longitude: number
55
+ is_current: number
56
+ is_deprecated: number
57
+ is_ceased: number
58
+ is_superseded: number
59
+ is_superseding: number
60
+ superseded_by: string | null
61
+ supersedes: string | null
62
+ lastmodified: number
63
+ }
64
+
65
+ /**
66
+ * Alternate names per place, keyed by language tag subfields (BCP-47 components). Joins back to `spr.id` via `id` (NOT
67
+ * `place_id` — the real WOF schema uses the same column name as the spr primary key; this is a normal join across two
68
+ * tables with the same FK column name).
69
+ *
70
+ * No `kind` column in real WOF — the FTS build just concatenates ALL names per id.
71
+ *
72
+ * `official` (#936 ingest bit, our unified builds only; absent in real WOF dumps) marks a PREFERRED-form name in an
73
+ * official language of the place's country — the aliases eligible to join the name-exact tier under the option-3 rule.
74
+ * See `unified-schema.ts` for the full contract.
75
+ */
76
+ export interface NamesTable {
77
+ id: number
78
+ placetype: string | null
79
+ country: string | null
80
+ language: string | null // ISO-639 alpha-3
81
+ extlang: string | null
82
+ script: string | null
83
+ region: string | null
84
+ variant: string | null
85
+ extension: string | null
86
+ privateuse: string | null
87
+ official: number | null
88
+ name: string
89
+ lastmodified: number
90
+ }
91
+
92
+ /**
93
+ * Per-place GeoJSON blob. Centroid lat/lon are already exposed via `spr.{latitude,longitude}` so the resolver doesn't
94
+ * need to parse this; we keep the table modeled in case Phase 4.3 wants the full geometry for bbox / polygon work.
95
+ */
96
+ export interface GeojsonTable {
97
+ id: number
98
+ body: string
99
+ source: string | null
100
+ alt_label: string | null
101
+ is_alt: number
102
+ lastmodified: number
103
+ }
104
+
105
+ /**
106
+ * Adjacency table for ancestor relationships. One row per (place, ancestor) pair, including transitive ancestors. Used
107
+ * to implement `FindPlaceQuery.parentID` (descendant lookup).
108
+ */
109
+ export interface AncestorsTable {
110
+ id: number
111
+ ancestor_id: number
112
+ ancestor_placetype: string | null
113
+ lastmodified: number
114
+ }
115
+
116
+ /**
117
+ * `place_population` — `id → wof:population`, split off `spr` so a population-rank join is a single indexed probe.
118
+ * Written by the build/augment ingest + the GeoNames backfill; read by the candidate build's `neg_rank`. WOF carries
119
+ * population for ~15% of localities; absent = unknown, not zero.
120
+ */
121
+ export interface PlacePopulationTable {
122
+ id: number
123
+ population: number
124
+ }
125
+
126
+ /**
127
+ * `place_abbr` — `id → abbreviation` (e.g. `IL → Illinois`), derived from `names` rows whose `language = 'abbr'`. Lets
128
+ * the resolver accept a 2-letter region abbreviation as an exact match.
129
+ */
130
+ export interface PlaceAbbrTable {
131
+ id: number
132
+ abbr: string
133
+ }
134
+
135
+ /**
136
+ * `concordances` — external-id cross-references per place (`id → (other_source, other_id)`), e.g. a GeoNames or
137
+ * Overture GERS id. Metadata only; not part of the resolve path.
138
+ */
139
+ export interface ConcordancesTable {
140
+ id: number
141
+ other_id: string
142
+ other_source: string
143
+ lastmodified: number
144
+ }
145
+
146
+ /**
147
+ * `coincident_roles` (#402) — the dual-role relation: a place that is BOTH an admin region AND a locality (Berlin the
148
+ * city-state). One row per (admin, locality) pair the resolver can complete a hierarchy with. Surfaced by
149
+ * {@link MailwomanLookupLike.coincidentRolesFor}.
150
+ */
151
+ export interface CoincidentRolesTable {
152
+ admin_id: number
153
+ locality_id: number
154
+ relationship_type: string
155
+ admin_placetype: string
156
+ distance_km: number
157
+ locality_population: number
158
+ }
159
+
160
+ /**
161
+ * The full schema we hand to `Kysely<WOFDatabase>` / `new DatabaseClient<WOFDatabase>(...)`. Tables not listed here
162
+ * will fail type-checked queries — by design. The reader ({@link WOFSqlitePlaceLookup}) already consumes this; the
163
+ * build/augment WRITERS adopt it so a column rename is a compile error on both sides (the drift that bit the corpus
164
+ * TIGER adapter).
165
+ */
166
+ export interface WOFDatabase {
167
+ place_search: PlaceSearchTable
168
+ spr: SprTable
169
+ names: NamesTable
170
+ geojson: GeojsonTable
171
+ ancestors: AncestorsTable
172
+ place_population: PlacePopulationTable
173
+ place_abbr: PlaceAbbrTable
174
+ concordances: ConcordancesTable
175
+ coincident_roles: CoincidentRolesTable
176
+ }