@mailwoman/resolver-wof-sqlite 7.2.0 → 7.3.0

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 (57) 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/out/poi-lookup.d.ts +14 -2
  27. package/out/poi-lookup.d.ts.map +1 -1
  28. package/out/poi-lookup.js +55 -21
  29. package/out/poi-lookup.js.map +1 -1
  30. package/out/poi-schema.d.ts +9 -0
  31. package/out/poi-schema.d.ts.map +1 -1
  32. package/out/poi-schema.js +16 -0
  33. package/out/poi-schema.js.map +1 -1
  34. package/out/reverse.d.ts +8 -1
  35. package/out/reverse.d.ts.map +1 -1
  36. package/out/reverse.js +10 -1
  37. package/out/reverse.js.map +1 -1
  38. package/package.json +168 -82
  39. package/poi-lookup.ts +375 -0
  40. package/poi-schema.ts +164 -0
  41. package/postal-city-alias-lookup.ts +89 -0
  42. package/postal-city-alias-schema.ts +75 -0
  43. package/postal-city-candidate-schema.ts +81 -0
  44. package/postcode-point-lookup.ts +64 -0
  45. package/reverse.ts +439 -0
  46. package/schema.ts +176 -0
  47. package/sharding.ts +235 -0
  48. package/sqlite-convention-source.ts +61 -0
  49. package/sqlite-utils.ts +25 -0
  50. package/street-centroid-schema.ts +124 -0
  51. package/street-centroid.ts +124 -0
  52. package/street-morphology-fst-builder.ts +230 -0
  53. package/street-name-lookup.ts +101 -0
  54. package/street-normalize.ts +302 -0
  55. package/street-segment-schema.ts +104 -0
  56. package/types.ts +164 -0
  57. package/unified-schema.ts +171 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Address-point interpolation — "Method 2" of the resolution ladder (#483, Phase 1 of
7
+ * `docs/articles/plan/2026-06-11-resolution-ladder.md`): when the exact address-point tier (#476)
8
+ * misses a house number, bracket the number with REAL neighbor points on the same street from the
9
+ * same #476 shard and interpolate linearly in house-number space between them. Real occupancy
10
+ * replaces TIGER's uniform-spacing assumption — the dominant error term of the TIGER pilot's gate
11
+ * miss; TIGER range interpolation (`StreetInterpolator`) demotes to the fallback for streets too
12
+ * sparse to bracket.
13
+ *
14
+ * Matching key is `street_key` — THE shared normalizer plus the route fold
15
+ * (`canonicalizeRouteKey`), identical at build time (`scripts/build-address-point-shard.ts`) and
16
+ * query time, by construction. Scope is postcode-first like the segment tier; a query without a
17
+ * postcode goes straight to the fallback (which carries its own statewide-ambiguity abstention).
18
+ *
19
+ * Bracketing contract:
20
+ *
21
+ * - Neighbor candidates NEVER include the queried number itself (any unit/duplicate row of it) — in
22
+ * production the exact tier would already have answered an on-file number, and in the eval
23
+ * this is what makes grading against the same shard non-circular by construction.
24
+ * - Both-sided bracket (`bracket: "both"`): linear interpolation between the nearest known number
25
+ * below and above; `uncertaintyM` = half the distance between them.
26
+ * - Single-sided (`bracket: "single"`): linear extrapolation along the two nearest known numbers on
27
+ * that side, capped at one pair-span beyond the nearest point (`t ≤ 2` — beyond that the line
28
+ * carries no evidence and the query falls through); `uncertaintyM` = the pair distance plus
29
+ * the extrapolated overshoot, explicitly larger than the both-sided radius.
30
+ * - No bracket (no neighbors, a single known number, or past the extrapolation cap): fall through to
31
+ * the TIGER fallback when configured, else null.
32
+ *
33
+ * Standalone like the segment tier — core wiring rides the Phase 2 ordered `spatialTiers` list.
34
+ */
35
+
36
+ import { DatabaseSync } from "node:sqlite"
37
+
38
+ import type { InterpolationLookup } from "@mailwoman/resolver"
39
+
40
+ import { haversineKm } from "./geo.ts"
41
+ import type { InterpolatedHit, InterpolationQuery, StreetInterpolator } from "./interpolation.ts"
42
+ import { hasTable } from "./sqlite-utils.ts"
43
+ import { canonicalizeRouteKey, normalizeStreetForKey } from "./street-normalize.ts"
44
+
45
+ /**
46
+ * Extrapolation cap for a single-sided bracket: at most one pair-span beyond the nearest known point (`t = 2`). Past
47
+ * it, the two-point line carries no evidence about the query number.
48
+ */
49
+ const MAX_EXTRAPOLATION_T = 2
50
+
51
+ interface PointRow {
52
+ n: number
53
+ lat: number
54
+ lon: number
55
+ source: string
56
+ release: string
57
+ }
58
+
59
+ /** One known house number on the street: the centroid of its rows (unit siblings collapse). */
60
+ interface NumberAnchor {
61
+ n: number
62
+ lat: number
63
+ lon: number
64
+ source: string
65
+ release: string
66
+ }
67
+
68
+ export class AddressPointInterpolator implements InterpolationLookup {
69
+ readonly #db: DatabaseSync
70
+ readonly #ownsDB: boolean
71
+ readonly #fallback: StreetInterpolator | undefined
72
+ readonly #byPostcode: ReturnType<DatabaseSync["prepare"]> | undefined
73
+
74
+ constructor(opts: { dbPath?: string; database?: DatabaseSync; fallback?: StreetInterpolator }) {
75
+ if (opts.database) {
76
+ this.#db = opts.database
77
+ this.#ownsDB = false
78
+ } else if (opts.dbPath) {
79
+ this.#db = new DatabaseSync(opts.dbPath, { readOnly: true })
80
+ this.#ownsDB = true
81
+ } else {
82
+ throw new Error("AddressPointInterpolator: one of dbPath or database is required")
83
+ }
84
+ this.#fallback = opts.fallback
85
+
86
+ // Degrade gracefully on an empty/tableless shard (#568): with no `address_point` table this tier
87
+ // is skipped, deferring to the segment fallback rather than crashing at construction.
88
+ if (hasTable(this.#db, "address_point")) {
89
+ // Strictly-numeric neighbor numbers on the route-folded street key within the ZIP. The
90
+ // queried number itself is excluded HERE (see module doc: non-circular by construction).
91
+ this.#byPostcode = this.#db.prepare(
92
+ `SELECT CAST(number AS INTEGER) AS n, lat, lon, source, release
93
+ FROM address_point
94
+ WHERE postcode = ? AND street_key = ?
95
+ AND number GLOB '[0-9]*' AND number NOT GLOB '*[^0-9]*'
96
+ AND CAST(number AS INTEGER) != ?`
97
+ )
98
+ }
99
+ }
100
+
101
+ find(query: InterpolationQuery): InterpolatedHit | null {
102
+ const streetKey = canonicalizeRouteKey(normalizeStreetForKey(query.street))
103
+ const numberRaw = query.number.trim()
104
+
105
+ if (!streetKey || !/^\d+$/.test(numberRaw)) return null
106
+ const n = Number(numberRaw)
107
+
108
+ // No own table (empty shard) or no postcode → defer to the segment fallback rather than query.
109
+ if (!this.#byPostcode || !query.postcode) return this.#fallback?.find(query) ?? null
110
+
111
+ const rows = this.#byPostcode.all(query.postcode.trim(), streetKey, n) as unknown as PointRow[]
112
+ const hit = rows.length >= 2 ? interpolateFromNeighbors(rows, n) : null
113
+
114
+ return hit ?? this.#fallback?.find(query) ?? null
115
+ }
116
+
117
+ close(): void {
118
+ if (this.#ownsDB) {
119
+ this.#db.close()
120
+ }
121
+ }
122
+ }
123
+
124
+ /** Collapse rows to one centroid anchor per distinct house number, sorted ascending. */
125
+ function anchorsByNumber(rows: readonly PointRow[]): NumberAnchor[] {
126
+ const byN = new Map<number, PointRow[]>()
127
+
128
+ for (const row of rows) {
129
+ const group = byN.get(row.n)
130
+
131
+ if (group) {
132
+ group.push(row)
133
+ } else {
134
+ byN.set(row.n, [row])
135
+ }
136
+ }
137
+
138
+ return [...byN.entries()]
139
+ .map(([n, group]) => ({
140
+ n,
141
+ lat: group.reduce((sum, r) => sum + r.lat, 0) / group.length,
142
+ lon: group.reduce((sum, r) => sum + r.lon, 0) / group.length,
143
+ source: group[0]!.source,
144
+ release: group[0]!.release,
145
+ }))
146
+ .sort((a, b) => a.n - b.n)
147
+ }
148
+
149
+ function interpolateFromNeighbors(rows: readonly PointRow[], n: number): InterpolatedHit | null {
150
+ const anchors = anchorsByNumber(rows)
151
+
152
+ // Nearest known number below and above the query (the rows never contain n itself).
153
+ let below: NumberAnchor | undefined
154
+ let above: NumberAnchor | undefined
155
+
156
+ for (const anchor of anchors) {
157
+ if (anchor.n < n) {
158
+ below = anchor
159
+ } else {
160
+ above = anchor
161
+ break
162
+ }
163
+ }
164
+
165
+ if (below && above) {
166
+ const t = (n - below.n) / (above.n - below.n)
167
+ const spanM = haversineKm(below.lat, below.lon, above.lat, above.lon) * 1000
168
+
169
+ return {
170
+ lat: below.lat + (above.lat - below.lat) * t,
171
+ lon: below.lon + (above.lon - below.lon) * t,
172
+ interpolated: true,
173
+ method: "address_point",
174
+ bracket: "both",
175
+ uncertaintyM: Math.round(spanM / 2),
176
+ source: below.source,
177
+ release: below.release,
178
+ }
179
+ }
180
+
181
+ // Single-sided: extrapolate along the two nearest known numbers on the populated side.
182
+ // `near` is the anchor closest to n, `far` the next one out; t > 1 by construction.
183
+ const side = below ? anchors.slice(-2) : anchors.slice(0, 2)
184
+
185
+ if (side.length < 2) return null
186
+ const [far, near] = below ? [side[0]!, side[1]!] : [side[1]!, side[0]!]
187
+ const t = (n - far.n) / (near.n - far.n)
188
+
189
+ if (t > MAX_EXTRAPOLATION_T) return null
190
+
191
+ const lat = far.lat + (near.lat - far.lat) * t
192
+ const lon = far.lon + (near.lon - far.lon) * t
193
+ const pairM = haversineKm(near.lat, near.lon, far.lat, far.lon) * 1000
194
+ const overshootM = haversineKm(lat, lon, near.lat, near.lon) * 1000
195
+
196
+ return {
197
+ lat,
198
+ lon,
199
+ interpolated: true,
200
+ method: "address_point",
201
+ bracket: "single",
202
+ // Explicitly larger than the both-sided radius: the whole pair span plus the overshoot.
203
+ uncertaintyM: Math.round(pairM + overshootM),
204
+ source: near.source,
205
+ release: near.release,
206
+ }
207
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Typed schema for the SITUS / rooftop ADDRESS-POINT shards (`address-points-<cc>-<slug>.db`, built
7
+ * by `scripts/build-address-point-shard.ts` — the #476/#567 national rooftop tier behind the
8
+ * demo's "type any US address, get the building"). Single source of truth for the columns shared
9
+ * by the BUILDER and the READER ({@link AddressPointSqliteLookup}), so a column rename in one is a
10
+ * compile error in the other.
11
+ *
12
+ * The builder's hot INSERT (tens of millions of rows per state) stays a POSITIONAL prepared
13
+ * statement for throughput — but its column list is derived from {@link ADDRESS_POINT_COLUMNS}
14
+ * here, and its table comes from {@link createAddressPointTable}, so the positional order can't
15
+ * silently drift from what the reader expects. (Same convention as the candidate build: typed
16
+ * schema guards the contract; positional inserts keep the speed.)
17
+ */
18
+
19
+ import type { Kysely } from "kysely"
20
+
21
+ /**
22
+ * One rooftop address point. `(street_norm, number)` within a `postcode` (preferred) or `locality_norm` scope is the
23
+ * lookup; `street_key` is the #483 route-fold key for interpolation. Coordinates are non-null (the builder drops
24
+ * non-finite coords). `unit`/`postcode`/`locality_norm` are nullable (not every source carries all three).
25
+ */
26
+ export interface AddressPointTable {
27
+ /** Shared {@link normalizeStreetForKey} of the street — the build/query-consistent probe key. */
28
+ street_norm: string
29
+ /** `canonicalizeRouteKey(street_norm)` — the route-fold key (#483 Method 2). */
30
+ street_key: string
31
+ /** House number, normalized lower-case (kept TEXT — "123-A", "12 1/2" must survive). */
32
+ number: string
33
+ unit: string | null
34
+ postcode: string | null
35
+ /** Shared {@link normalizeLocalityForKey} of the locality — the fallback scope. */
36
+ locality_norm: string | null
37
+ /** The street as it appeared in the source (kept for display / debugging). */
38
+ street_raw: string
39
+ lat: number
40
+ lon: number
41
+ /** Provenance: the dataset this point came from (e.g. `overture:us`, `openaddresses`). */
42
+ source: string
43
+ /** The pinned data release the point was ingested from. */
44
+ release: string
45
+ }
46
+
47
+ /** The address-point database schema for `new DatabaseClient<AddressPointDatabase>(...)`. */
48
+ export interface AddressPointDatabase {
49
+ address_point: AddressPointTable
50
+ }
51
+
52
+ /**
53
+ * The `address_point` columns in INSERT order. The builder's positional prepared statement derives its placeholder list
54
+ * from this, so the positional order can't drift from the DDL / the reader.
55
+ */
56
+ export const ADDRESS_POINT_COLUMNS = [
57
+ "street_norm",
58
+ "street_key",
59
+ "number",
60
+ "unit",
61
+ "postcode",
62
+ "locality_norm",
63
+ "street_raw",
64
+ "lat",
65
+ "lon",
66
+ "source",
67
+ "release",
68
+ ] as const
69
+
70
+ /** Create the `address_point` table — called before the streaming bulk load. */
71
+ export async function createAddressPointTable(db: Kysely<AddressPointDatabase>): Promise<void> {
72
+ await db.schema
73
+ .createTable("address_point")
74
+ .addColumn("street_norm", "text", (c) => c.notNull())
75
+ // `street_key` = canonicalizeRouteKey(street_norm): the route-fold key (#483 Method 2).
76
+ .addColumn("street_key", "text", (c) => c.notNull())
77
+ .addColumn("number", "text", (c) => c.notNull())
78
+ .addColumn("unit", "text")
79
+ .addColumn("postcode", "text")
80
+ .addColumn("locality_norm", "text")
81
+ .addColumn("street_raw", "text", (c) => c.notNull())
82
+ .addColumn("lat", "real", (c) => c.notNull())
83
+ .addColumn("lon", "real", (c) => c.notNull())
84
+ .addColumn("source", "text", (c) => c.notNull())
85
+ .addColumn("release", "text", (c) => c.notNull())
86
+ .execute()
87
+ }
88
+
89
+ /** Create the three probe indexes the reader relies on (postcode-scope, locality-scope, route-key). */
90
+ export async function createAddressPointIndexes(db: Kysely<AddressPointDatabase>): Promise<void> {
91
+ await db.schema
92
+ .createIndex("idx_ap_postcode")
93
+ .on("address_point")
94
+ .columns(["postcode", "street_norm", "number"])
95
+ .execute()
96
+ await db.schema
97
+ .createIndex("idx_ap_locality")
98
+ .on("address_point")
99
+ .columns(["locality_norm", "street_norm", "number"])
100
+ .execute()
101
+ await db.schema.createIndex("idx_ap_streetkey").on("address_point").columns(["postcode", "street_key"]).execute()
102
+ // Street-first index for the BBOX scope (#247): OSM points often carry no postcode/locality, so the
103
+ // reader scopes a `(street_norm, number)` probe by the resolved locality's bbox (lat/lon BETWEEN). The
104
+ // postcode/locality indexes lead with their scope column and can't serve this; US situs never probes by
105
+ // bbox so it simply carries one extra (cheap) index on a future rebuild.
106
+ await db.schema.createIndex("idx_ap_street").on("address_point").columns(["street_norm", "number"]).execute()
107
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * SQLite implementation of core's `AddressPointLookup` (#476): exact `(street, number)` within a
7
+ * postcode (preferred), locality, or — for shards whose points carry no scope tag (OSM, #247) —
8
+ * the resolved locality's BBOX. Query-side normalization is THE shared normalizer
9
+ * (`street-normalize.ts`), selected per the shard's `streetLocale` so build-side and probe-side
10
+ * stay identical by construction (US delegates to the USPS pipeline; FR/DE/NL use the locale rules).
11
+ *
12
+ * Matching is exact-after-normalization only — no fuzzy street matching in this tier (measure how
13
+ * far exact gets first; fuzz is a later, separate decision). Scope order is most-selective first:
14
+ * postcode, then locality, then the bbox fall-through (only when a bbox is supplied AND the prior
15
+ * scopes missed). Multiple hits return the first by rowid — unit siblings share the building coord.
16
+ */
17
+
18
+ import { DatabaseSync } from "node:sqlite"
19
+
20
+ import type { AddressPointHit, AddressPointLookup } from "@mailwoman/resolver"
21
+
22
+ import type { AddressPointTable } from "./address-point-schema.ts"
23
+ import { hasTable } from "./sqlite-utils.ts"
24
+ import {
25
+ normalizeLocalityForKey,
26
+ normalizeStreetForKeyLocale,
27
+ stripArrondissement,
28
+ type StreetLocale,
29
+ } from "./street-normalize.ts"
30
+
31
+ /**
32
+ * The columns this lookup projects — a typed slice of the SHARED {@link AddressPointTable}, so a column rename in
33
+ * `build-address-point-shard.ts` (the writer) is a compile error here (the reader).
34
+ */
35
+ type AddressPointRow = Pick<AddressPointTable, "lat" | "lon" | "source" | "release">
36
+
37
+ /**
38
+ * The 4 columns the reader SELECTs, in the schema's order — referenced by the prepared SELECTs so the projected
39
+ * `AddressPointRow` stays in lockstep with the shared schema.
40
+ */
41
+ const SELECT_COLS = "lat, lon, source, release"
42
+
43
+ export class AddressPointSqliteLookup implements AddressPointLookup {
44
+ readonly #db: DatabaseSync
45
+ readonly #locale: StreetLocale
46
+ readonly #byPostcode: ReturnType<DatabaseSync["prepare"]> | undefined
47
+ readonly #byLocality: ReturnType<DatabaseSync["prepare"]> | undefined
48
+ readonly #byBbox: ReturnType<DatabaseSync["prepare"]> | undefined
49
+
50
+ /**
51
+ * @param dbPath Shard path.
52
+ * @param opts.streetLocale The street-normalization locale this shard was BUILT with — must match, or every key
53
+ * misses. Defaults to `"us"` (the situs tier), so existing callers are unchanged.
54
+ */
55
+ constructor(dbPath: string, opts: { streetLocale?: StreetLocale } = {}) {
56
+ this.#db = new DatabaseSync(dbPath, { readOnly: true })
57
+ this.#locale = opts.streetLocale ?? "us"
58
+
59
+ // Degrade gracefully on an empty/tableless shard (interrupted build, stray 0-byte file): with no
60
+ // `address_point` table this lookup is a no-op miss, not a crash that loses the whole state (#568).
61
+ if (hasTable(this.#db, "address_point")) {
62
+ this.#byPostcode = this.#db.prepare(
63
+ `SELECT ${SELECT_COLS} FROM address_point
64
+ WHERE postcode = ? AND street_norm = ? AND number = ? LIMIT 1`
65
+ )
66
+ this.#byLocality = this.#db.prepare(
67
+ `SELECT ${SELECT_COLS} FROM address_point
68
+ WHERE locality_norm = ? AND street_norm = ? AND number = ? LIMIT 1`
69
+ )
70
+ this.#byBbox = this.#db.prepare(
71
+ `SELECT ${SELECT_COLS} FROM address_point
72
+ WHERE street_norm = ? AND number = ? AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ? LIMIT 1`
73
+ )
74
+ }
75
+ }
76
+
77
+ find(query: {
78
+ street: string
79
+ number: string
80
+ postcode?: string
81
+ locality?: string
82
+ bbox?: { minLat: number; maxLat: number; minLon: number; maxLon: number }
83
+ }): AddressPointHit | null {
84
+ if (!this.#byPostcode || !this.#byLocality || !this.#byBbox) return null
85
+ const streetNorm = normalizeStreetForKeyLocale(query.street, this.#locale)
86
+ const number = query.number.trim().toLowerCase()
87
+
88
+ if (!streetNorm || !number) return null
89
+
90
+ let row: AddressPointRow | undefined
91
+
92
+ if (query.postcode) {
93
+ row = this.#byPostcode.get(query.postcode.trim(), streetNorm, number) as AddressPointRow | undefined
94
+ }
95
+
96
+ if (!row && query.locality) {
97
+ // FR shards key arrondissement communes at the base city (both-sides fold, see the BAN
98
+ // builder + stripArrondissement) — fold the probe too so "Paris 13e Arrondissement" and
99
+ // "Paris" both hit. No-op for "us" shards and every non-arrondissement commune.
100
+ const localityKey =
101
+ this.#locale === "fr"
102
+ ? stripArrondissement(normalizeLocalityForKey(query.locality))
103
+ : normalizeLocalityForKey(query.locality)
104
+ row = this.#byLocality.get(localityKey, streetNorm, number) as AddressPointRow | undefined
105
+ }
106
+
107
+ // Bbox fall-through (#247): the point carries no postcode/locality of its own, but its coordinate falls
108
+ // inside the resolved locality's box. Only reached when the scoped probes missed AND a bbox was supplied.
109
+ if (!row && query.bbox) {
110
+ const b = query.bbox
111
+ row = this.#byBbox.get(streetNorm, number, b.minLat, b.maxLat, b.minLon, b.maxLon) as AddressPointRow | undefined
112
+ }
113
+
114
+ if (!row) return null
115
+
116
+ return { lat: row.lat, lon: row.lon, source: row.source, release: row.release }
117
+ }
118
+
119
+ close(): void {
120
+ this.#db.close()
121
+ }
122
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Repair the only-self ancestry that {@link populateAncestors} (the parent_id closure in
7
+ * unified-schema.ts) leaves for places whose `wof:parent_id` is the WOF `-4` "ambiguous /
8
+ * multi-parent" sentinel.
9
+ *
10
+ * Root cause (#440 / #832): a place that straddles multiple parents — New York City spans five
11
+ * counties (its boroughs), London 30+ — carries `wof:parent_id = -4`, so the parent_id closure
12
+ * dead-ends and the place gets NO region/county/country ancestry. The resolver's region-descendant
13
+ * filter then can't reach it: given "New York, NY", NYC (with no NY-state ancestor) is excluded and
14
+ * a correctly-parented namesake ("New York Mills", pop 3,190) wins over NYC's 8.8M. The same defect
15
+ * orphans London, Singapore, and ~2,850 other localities — the most demo-visible queries.
16
+ *
17
+ * The authoritative hierarchy IS in the source geojson: `wof:hierarchy` is an array of branches,
18
+ * each a `<placetype>_id` → id map (region_id, county_id, country_id, …), fully populated even when
19
+ * parent_id is -4. This reads it for every only-self place and inserts the missing ancestor rows
20
+ * (one per distinct ancestor across branches).
21
+ *
22
+ * MUST run AFTER populateAncestors and BEFORE the build freezes (VACUUM INTO), so the rows land in
23
+ * the shipped artifact — `scripts/build-unified-wof.ts` Phase 3 calls it inline. The standalone
24
+ * `scripts/backfill-ancestors-from-hierarchy.ts` is a thin CLI over the same function for ad-hoc
25
+ * repair of an already-built DB. Idempotent: only touches places with <= 1 ancestor row (self), and
26
+ * inserts each (id, ancestor_id) at most once.
27
+ */
28
+
29
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
30
+ import { join } from "node:path"
31
+ import type { DatabaseSync } from "node:sqlite"
32
+
33
+ /** Genuinely top-level placetypes — they never have (or need) an ancestor, so skip them. */
34
+ const TOP_PLACETYPES = new Set(["country", "continent", "empire", "ocean", "marinearea", "planet"])
35
+
36
+ export interface AncestryBackfillResult {
37
+ /** Places that gained at least one ancestor row. */
38
+ placesFixed: number
39
+ /** Total ancestor rows inserted. */
40
+ rowsAdded: number
41
+ /**
42
+ * Only-self candidates whose source geojson could not be found (non-WOF backfilled places, or repos not present
43
+ * locally) — skipped, not an error.
44
+ */
45
+ noGeojson: number
46
+ }
47
+
48
+ /**
49
+ * Discover the `data` directories under a WOF repos root that hold sharded geojson, e.g.
50
+ * `<root>/whosonfirst-data/whosonfirst-data-admin-us/data`. Resolves an id to its geojson via these roots. Accepts both
51
+ * the nested lab layout (a `whosonfirst-data` group dir holding the admin repos) and a flat layout (admin repos
52
+ * directly under the root); searches at most two directory levels deep.
53
+ */
54
+ export function discoverAdminDataRoots(reposRoot: string): string[] {
55
+ const roots: string[] = []
56
+
57
+ const visit = (dir: string, depth: number): void => {
58
+ if (depth > 2) return
59
+
60
+ let names: string[]
61
+
62
+ try {
63
+ names = readdirSync(dir, { withFileTypes: true })
64
+ .filter((e) => e.isDirectory())
65
+ .map((e) => e.name)
66
+ } catch {
67
+ return
68
+ }
69
+
70
+ for (const name of names) {
71
+ const child = join(dir, name)
72
+
73
+ if (name === "data") {
74
+ roots.push(child)
75
+ } else if (name.startsWith("whosonfirst-data")) {
76
+ visit(child, depth + 1)
77
+ }
78
+ }
79
+ }
80
+
81
+ visit(reposRoot, 0)
82
+
83
+ return roots
84
+ }
85
+
86
+ /** WOF geojson lives sharded: an id resolves to `<3-char chunks>/<id>.geojson` under each data root. */
87
+ function geojsonForID(id: number, roots: readonly string[]): Record<string, unknown> | null {
88
+ const s = String(id)
89
+ const chunks: string[] = []
90
+
91
+ for (let i = 0; i < s.length; i += 3) {
92
+ chunks.push(s.slice(i, i + 3))
93
+ }
94
+ const rel = join(chunks.join("/"), `${s}.geojson`)
95
+
96
+ for (const root of roots) {
97
+ const fp = join(root, rel)
98
+
99
+ if (existsSync(fp)) {
100
+ try {
101
+ return JSON.parse(readFileSync(fp, "utf8")) as Record<string, unknown>
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+ }
107
+
108
+ return null
109
+ }
110
+
111
+ // `<placetype>_id` key → ancestor placetype. WOF hierarchy keys are e.g. region_id, county_id. Self
112
+ // is filtered downstream by the `aid === id` check, so we do NOT special-case locality here: for a
113
+ // locality candidate `locality_id` IS self (dropped by aid===id), but for a neighbourhood candidate
114
+ // `locality_id` is its PARENT locality — a real ancestor we must keep.
115
+ function placetypeFromKey(key: string): string | null {
116
+ if (!key.endsWith("_id")) return null
117
+
118
+ return key.slice(0, -3)
119
+ }
120
+
121
+ /**
122
+ * Insert missing ancestor rows for only-self places by reading `wof:hierarchy` from their source geojson under
123
+ * `geojsonRoots` (see {@link discoverAdminDataRoots}). Runs inside a single transaction; caller owns connection
124
+ * lifecycle (open, WAL checkpoint, close).
125
+ *
126
+ * `opts.maxId` bounds the candidate scan to ids BELOW it — pass the synthetic-id base (`OVERTURE_ID_BASE`, 8e12) so the
127
+ * backfill considers only real WOF places. Overture/GeoNames rows carry synthetic ids and have NO `wof:hierarchy`
128
+ * geojson, so probing them is pure waste: on a wide-coverage DB the only-self set is millions of Overture/GeoNames leaf
129
+ * localities, and the per-candidate geojson probe across every repo root turns a seconds-long WOF-only pass into a
130
+ * ~40-minute one (their ancestry comes from the parent_id closure, not this backfill). Correctness-preserving — the
131
+ * skipped rows would have `noGeojson`-skipped anyway. Omit `maxId` (default) for the legacy WOF-only DBs.
132
+ */
133
+ export function backfillAncestorsFromHierarchy(
134
+ db: DatabaseSync,
135
+ geojsonRoots: readonly string[],
136
+ opts: { maxId?: number } = {}
137
+ ): AncestryBackfillResult {
138
+ const maxId = opts.maxId ?? Number.MAX_SAFE_INTEGER
139
+ // `s.id < ?` first lets SQLite prune by the PK index before the correlated only-self subquery runs at all.
140
+ const candidates = db
141
+ .prepare(
142
+ `SELECT s.id AS id, s.placetype AS placetype FROM spr s
143
+ WHERE s.id < ? AND (SELECT count(*) FROM ancestors a WHERE a.id = s.id) <= 1`
144
+ )
145
+ .all(maxId) as Array<{ id: number; placetype: string }>
146
+
147
+ const insert = db.prepare(
148
+ "INSERT INTO ancestors (id, ancestor_id, ancestor_placetype, lastmodified) VALUES (?, ?, ?, 0)"
149
+ )
150
+ const hasRow = db.prepare("SELECT 1 FROM ancestors WHERE id = ? AND ancestor_id = ? LIMIT 1")
151
+
152
+ let placesFixed = 0
153
+ let rowsAdded = 0
154
+ let noGeojson = 0
155
+ db.exec("BEGIN")
156
+
157
+ for (const { id, placetype } of candidates) {
158
+ if (TOP_PLACETYPES.has(placetype)) continue
159
+ const gj = geojsonForID(id, geojsonRoots)
160
+ const props = (gj?.["properties"] ?? null) as Record<string, unknown> | null
161
+ const hierarchy = (props?.["wof:hierarchy"] ?? null) as Array<Record<string, number>> | null
162
+
163
+ if (!hierarchy || hierarchy.length === 0) {
164
+ if (!gj) {
165
+ noGeojson++
166
+ }
167
+ continue
168
+ }
169
+
170
+ // Collect distinct (ancestor_id, placetype) across all hierarchy branches, excluding self.
171
+ const seen = new Map<number, string>()
172
+
173
+ for (const branch of hierarchy) {
174
+ for (const [key, val] of Object.entries(branch)) {
175
+ const pt = placetypeFromKey(key)
176
+
177
+ if (!pt) continue
178
+ const aid = Number(val)
179
+
180
+ if (!Number.isFinite(aid) || aid <= 0 || aid === id) continue
181
+
182
+ if (!seen.has(aid)) {
183
+ seen.set(aid, pt)
184
+ }
185
+ }
186
+ }
187
+
188
+ let added = 0
189
+
190
+ for (const [aid, pt] of seen) {
191
+ if (hasRow.get(id, aid)) continue
192
+ insert.run(id, aid, pt)
193
+ added++
194
+ }
195
+
196
+ if (added > 0) {
197
+ placesFixed++
198
+ rowsAdded += added
199
+ }
200
+ }
201
+
202
+ db.exec("COMMIT")
203
+
204
+ return { placesFixed, rowsAdded, noGeojson }
205
+ }