@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
package/ancestry.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The shared ancestor-lineage walk over the WOF `ancestors` table — one place's containment chain
7
+ * joined with `spr` for canonical names + centroids, ordered NEAREST-FIRST (deepest placetype
8
+ * first, country last).
9
+ *
10
+ * Factored out of `WOFSqlitePlaceLookup.ancestors()` (#404) so the reverse geocoder (`reverse.ts`,
11
+ * #484) reuses the SAME walk instead of growing a second one. The placetype-specificity ordering
12
+ * lives here as `PLACETYPE_DEPTH` — a single TS map instead of the previous SQL CASE, and
13
+ * extended below `localadmin` (locality/borough/neighbourhood/microhood now rank correctly
14
+ * instead of sorting last; forward resolution rarely saw those as ANCESTOR placetypes, reverse
15
+ * geocoding always does).
16
+ */
17
+
18
+ import type { DatabaseSync } from "node:sqlite"
19
+
20
+ /**
21
+ * WOF placetype → containment depth, coarsest = 1. Higher = finer. Placetypes we never resolve (continent, empire, …)
22
+ * map to 0 and sort last. NOT the same table as the FST's `PLACETYPE_ORDER` (fst-serialize.ts) — that one is a
23
+ * serialization order, this one is containment depth.
24
+ */
25
+ export const PLACETYPE_DEPTH: Readonly<Record<string, number>> = {
26
+ country: 1,
27
+ macroregion: 2,
28
+ region: 3,
29
+ macrocounty: 4,
30
+ county: 5,
31
+ localadmin: 6,
32
+ locality: 7,
33
+ borough: 8,
34
+ macrohood: 9,
35
+ neighbourhood: 10,
36
+ microhood: 11,
37
+ }
38
+
39
+ /** Containment depth for a placetype — 0 (sorts coarsest) when unknown. */
40
+ export function placetypeDepth(placetype: string): number {
41
+ return PLACETYPE_DEPTH[placetype] ?? 0
42
+ }
43
+
44
+ /** One ancestor row, enriched with the `spr` columns both consumers need. */
45
+ export interface AncestorPlaceRow {
46
+ id: number
47
+ placetype: string
48
+ name: string
49
+ country: string
50
+ lat: number
51
+ lon: number
52
+ }
53
+
54
+ /**
55
+ * The ancestor lineage of `id` — self excluded, nearest-first. Returns `[]` when the place has no recorded ancestry.
56
+ * NOT memoized here; `WOFSqlitePlaceLookup` keeps its own per-id cache.
57
+ */
58
+ export function ancestorLineage(db: DatabaseSync, id: number, schemaName = "main"): AncestorPlaceRow[] {
59
+ const rows = db
60
+ .prepare(
61
+ `SELECT a.ancestor_id AS id, a.ancestor_placetype AS placetype, s.name AS name,
62
+ s.country AS country, s.latitude AS lat, s.longitude AS lon
63
+ FROM ${schemaName}.ancestors a JOIN ${schemaName}.spr s ON s.id = a.ancestor_id
64
+ WHERE a.id = ? AND a.ancestor_id != a.id`
65
+ )
66
+ .all(id) as unknown as AncestorPlaceRow[]
67
+ rows.sort((a, b) => placetypeDepth(b.placetype) - placetypeDepth(a.placetype))
68
+
69
+ return rows
70
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Build the global "candidate" lookup DB from a unified admin WOF DB — the byte-range-optimal
7
+ * gazetteer the browser demo resolves against. Instead of FTS5 (whose postings for a common name
8
+ * scatter across a multi-GB file → hundreds of serial range fetches), this materializes one
9
+ * `WITHOUT ROWID` B-tree keyed `(name_key, country_id, region_id, placetype_id, neg_rank,
10
+ * spr_id)`: every place's normalized name + distinct aliases + region abbreviations become rows,
11
+ * population rank is precomputed into `neg_rank`, and the rows are bulk-loaded PRE-SORTED so a
12
+ * resolve is one contiguous B-tree probe (a handful of pages → 1-2 chunk fetches, regardless of
13
+ * global volume).
14
+ *
15
+ * Each row is DENORMALIZED — it carries the place's display `name`, centroid (`latitude`/
16
+ * `longitude`), and `min/max` bbox — so a resolve is one statement, no FTS, no join to spr:
17
+ * SELECT spr_id, name, latitude, longitude, min_lat, ... FROM candidate WHERE name_key = ? AND
18
+ * country_id = ? AND placetype_id IN (...) [AND latitude BETWEEN ...] ORDER BY neg_rank ASC LIMIT
19
+ * K; The demo cascade resolves a parsed region first (its bbox), then constrains the locality to
20
+ * that bbox; `region_id` (the place's region-tier ancestor) is also carried for a future region
21
+ * 2-step.
22
+ *
23
+ * The name_key normalizer is the SHARED {@link normalizeLocalityForKey} — the query side (the demo
24
+ * resolver {@link WOFCandidateTableLookup}) MUST use the same function, the one-normalizer
25
+ * discipline the address-point shard uses, so build/query stay consistent by construction.
26
+ *
27
+ * Measured (2026-06-20, vs the 2.6 GB full-DB FTS): ~5 M rows; ~12 range fetches per 8-query
28
+ * session (the full DB needs 243); US locality 96.8% (region bbox), EU coord parity 88.6%.
29
+ */
30
+
31
+ import { existsSync, rmSync } from "node:fs"
32
+ import { DatabaseSync } from "node:sqlite"
33
+
34
+ import { DatabaseClient } from "@mailwoman/core/kysley/client"
35
+
36
+ import { createCandidateFTS } from "./candidate-fts.ts"
37
+ import {
38
+ CANDIDATE_COLUMNS,
39
+ createCandidateStagingTables,
40
+ createCandidateTable,
41
+ type CandidateDatabase,
42
+ } from "./candidate-schema.ts"
43
+ import { normalizeLocalityForKey } from "./street-normalize.ts"
44
+
45
+ /** Boundary-preserving alias-bag separator (#523, U+E000). */
46
+ const ALIAS_SEP = "\u{E000}"
47
+
48
+ export interface BuildCandidateOptions {
49
+ /** Source unified admin DB — needs spr, place_population, place_search, place_abbr, ancestors. */
50
+ input: string
51
+ /** Output candidate DB path (overwritten if present). */
52
+ output: string
53
+ /**
54
+ * Optional postcode shards (`spr` rows with `placetype='postalcode'` + real coords, e.g. postalcode-us.db) — folded
55
+ * in as `postalcode` candidate rows so `findPlace(postalcode)` resolves a ZIP directly (the demo's primary postcode
56
+ * path; the postcode-*.bin anchor stays the fallback). Matches the slim wof-hot.db, which took one such postcode DB.
57
+ */
58
+ postcodes?: string[]
59
+ /** Optional progress callback for CLI / test introspection. */
60
+ onProgress?: (phase: string, message: string) => void
61
+ }
62
+
63
+ export interface BuildCandidateResult {
64
+ rows: number
65
+ places: number
66
+ primaries: number
67
+ aliases: number
68
+ abbrevs: number
69
+ postcodes: number
70
+ }
71
+
72
+ interface PlaceAttrs {
73
+ cid: number
74
+ rid: number
75
+ ptid: number
76
+ name: string
77
+ lat: number
78
+ lon: number
79
+ mnLat: number
80
+ mnLon: number
81
+ mxLat: number
82
+ mxLon: number
83
+ pop: number
84
+ neg: number
85
+ pkey: string
86
+ }
87
+
88
+ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<BuildCandidateResult> {
89
+ const progress = opts.onProgress ?? (() => {})
90
+
91
+ if (existsSync(opts.output)) {
92
+ rmSync(opts.output)
93
+ }
94
+
95
+ const src = new DatabaseSync(opts.input, { readOnly: true })
96
+ const out = new DatabaseSync(opts.output)
97
+ // Build-tuning pragmas (raw — Kysely doesn't model PRAGMA). The code dictionaries + the transient
98
+ // staging table come from the SHARED schema DDL, so they can't drift from {@link CandidateDatabase}.
99
+ out.exec("PRAGMA page_size=8192; PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; PRAGMA cache_size=-2000000;")
100
+ const kdb = new DatabaseClient<CandidateDatabase>({ database: out })
101
+ await createCandidateStagingTables(kdb)
102
+
103
+ // --- compact code maps (country/placetype → small int, shrinks the clustered key). The ids are
104
+ // assigned here; the rows are bulk-inserted via kdb once the passes have discovered every code. ---
105
+ const ccodes = new Map<string, number>()
106
+ const ptcodes = new Map<string, number>()
107
+ const ccID = (code: string | null): number => {
108
+ const c = (code || "??").toUpperCase()
109
+ let id = ccodes.get(c)
110
+
111
+ if (id === undefined) {
112
+ id = ccodes.size
113
+ ccodes.set(c, id)
114
+ }
115
+
116
+ return id
117
+ }
118
+ const ptID = (pt: string | null): number => {
119
+ const p = pt || ""
120
+ let id = ptcodes.get(p)
121
+
122
+ if (id === undefined) {
123
+ id = ptcodes.size
124
+ ptcodes.set(p, id)
125
+ }
126
+
127
+ return id
128
+ }
129
+
130
+ // --- region_id per place (its region-tier ancestor) for same-name disambiguation ---
131
+ progress("region", "loading region ancestry")
132
+ const regionOf = new Map<number, number>()
133
+
134
+ for (const r of src.prepare("SELECT id, ancestor_id FROM ancestors WHERE ancestor_placetype='region'").iterate()) {
135
+ regionOf.set(Number(r.id), Number(r.ancestor_id))
136
+ }
137
+ progress("region", `${regionOf.size.toLocaleString()} places carry a region`)
138
+
139
+ // The hot path — millions of clustered rows. Kept a single positional prepared statement (the fastest
140
+ // node:sqlite insert) rather than a per-row query builder. Placeholders come from CANDIDATE_COLUMNS so
141
+ // the column COUNT can't drift; the positional run() args below MUST stay in CANDIDATE_COLUMNS order.
142
+ const insStage = out.prepare(`INSERT INTO cand_stage VALUES (${CANDIDATE_COLUMNS.map(() => "?").join(", ")})`)
143
+
144
+ // --- pass 1: primaries (and the per-place attrs the alias/abbrev passes reuse) ---
145
+ progress("primaries", "indexing place names")
146
+ const attrs = new Map<number, PlaceAttrs>()
147
+ let nPrim = 0
148
+ out.exec("BEGIN")
149
+
150
+ for (const r of src
151
+ .prepare(
152
+ `SELECT s.id AS id, s.name AS name, s.placetype AS placetype, s.country AS country,
153
+ s.latitude AS lat, s.longitude AS lon,
154
+ s.min_latitude AS mnlat, s.min_longitude AS mnlon, s.max_latitude AS mxlat, s.max_longitude AS mxlon,
155
+ COALESCE(pp.population,0) AS pop
156
+ FROM spr s LEFT JOIN place_population pp ON pp.id = s.id
157
+ WHERE s.is_current != 0 AND s.is_deprecated = 0`
158
+ )
159
+ .iterate()) {
160
+ const sid = Number(r.id)
161
+ const cid = ccID(r.country as string | null)
162
+ const ptid = ptID(r.placetype as string | null)
163
+ const rid = regionOf.get(sid) ?? 0
164
+ const pop = Number(r.pop) || 0
165
+ const neg = -Math.log10(pop + 1)
166
+ const name = String(r.name ?? "")
167
+ const pkey = normalizeLocalityForKey(name)
168
+ const a: PlaceAttrs = {
169
+ cid,
170
+ rid,
171
+ ptid,
172
+ name,
173
+ lat: r.lat as number,
174
+ lon: r.lon as number,
175
+ mnLat: r.mnlat as number,
176
+ mnLon: r.mnlon as number,
177
+ mxLat: r.mxlat as number,
178
+ mxLon: r.mxlon as number,
179
+ pop,
180
+ neg,
181
+ pkey,
182
+ }
183
+ attrs.set(sid, a)
184
+
185
+ if (pkey) {
186
+ insStage.run(pkey, cid, rid, ptid, neg, sid, name, a.lat, a.lon, a.mnLat, a.mnLon, a.mxLat, a.mxLon, pop, 1)
187
+ nPrim++
188
+ }
189
+ }
190
+ out.exec("COMMIT")
191
+ progress("primaries", `${nPrim.toLocaleString()} primaries; ${attrs.size.toLocaleString()} places`)
192
+
193
+ const stageRow = (k: string, a: PlaceAttrs, sid: number, isPrimary: number): void => {
194
+ insStage.run(
195
+ k,
196
+ a.cid,
197
+ a.rid,
198
+ a.ptid,
199
+ a.neg,
200
+ sid,
201
+ a.name,
202
+ a.lat,
203
+ a.lon,
204
+ a.mnLat,
205
+ a.mnLon,
206
+ a.mxLat,
207
+ a.mxLon,
208
+ a.pop,
209
+ isPrimary
210
+ )
211
+ }
212
+
213
+ // --- pass 2: distinct normalized aliases from place_search.alt_names ---
214
+ progress("aliases", "exploding alias bags")
215
+ let nAlias = 0
216
+ out.exec("BEGIN")
217
+
218
+ for (const r of src.prepare("SELECT wof_id, alt_names FROM place_search").iterate()) {
219
+ const a = attrs.get(Number(r.wof_id))
220
+ const alt = r.alt_names as string | null
221
+
222
+ if (!a || !alt) continue
223
+ const seen = new Set<string>([a.pkey])
224
+
225
+ for (const piece of alt.split(ALIAS_SEP)) {
226
+ const k = normalizeLocalityForKey(piece)
227
+
228
+ if (!k || seen.has(k)) continue
229
+ seen.add(k)
230
+ stageRow(k, a, Number(r.wof_id), 0)
231
+ nAlias++
232
+ }
233
+ }
234
+ out.exec("COMMIT")
235
+ progress("aliases", `${nAlias.toLocaleString()} aliases`)
236
+
237
+ // --- pass 3: region abbreviations (place_abbr) ---
238
+ let nAbbr = 0
239
+ out.exec("BEGIN")
240
+
241
+ for (const r of src.prepare("SELECT id, abbr FROM place_abbr").iterate()) {
242
+ const a = attrs.get(Number(r.id))
243
+
244
+ if (!a) continue
245
+ const k = normalizeLocalityForKey(String(r.abbr ?? ""))
246
+
247
+ if (!k) continue
248
+ stageRow(k, a, Number(r.id), 1)
249
+ nAbbr++
250
+ }
251
+ out.exec("COMMIT")
252
+ progress("abbrevs", `${nAbbr.toLocaleString()} abbrevs`)
253
+
254
+ // --- pass 4: postcodes (separate shards: spr placetype='postalcode' with real coords) ---
255
+ let nPostcode = 0
256
+
257
+ for (const pcDB of opts.postcodes ?? []) {
258
+ progress("postcodes", `reading ${pcDB}`)
259
+ const pc = new DatabaseSync(pcDB, { readOnly: true })
260
+ const pcPtid = ptID("postalcode")
261
+ out.exec("BEGIN")
262
+
263
+ for (const r of pc
264
+ .prepare(
265
+ `SELECT id, name, country, latitude, longitude,
266
+ min_latitude AS mnlat, min_longitude AS mnlon, max_latitude AS mxlat, max_longitude AS mxlon
267
+ FROM spr WHERE placetype='postalcode' AND latitude != 0 AND longitude != 0`
268
+ )
269
+ .iterate()) {
270
+ const name = String(r.name ?? "")
271
+ const key = normalizeLocalityForKey(name)
272
+
273
+ if (!key) continue
274
+ const lat = r.latitude as number
275
+ const lon = r.longitude as number
276
+ // region_id 0 (a postcode is unique by name+country — no same-name disambiguation); neg_rank 0
277
+ // (no population). bbox = the postcode's own min/max (falls back to the centroid point).
278
+ insStage.run(
279
+ key,
280
+ ccID(r.country as string | null),
281
+ 0,
282
+ pcPtid,
283
+ 0,
284
+ Number(r.id),
285
+ name,
286
+ lat,
287
+ lon,
288
+ (r.mnlat as number) || lat,
289
+ (r.mnlon as number) || lon,
290
+ (r.mxlat as number) || lat,
291
+ (r.mxlon as number) || lon,
292
+ 0,
293
+ 1
294
+ )
295
+ nPostcode++
296
+ }
297
+ out.exec("COMMIT")
298
+ pc.close()
299
+ }
300
+
301
+ if (nPostcode > 0) {
302
+ progress("postcodes", `${nPostcode.toLocaleString()} postcodes`)
303
+ }
304
+
305
+ // --- code dictionaries: typed batch inserts via kdb (a few hundred rows — Kysely is clean here) ---
306
+ if (ccodes.size > 0) {
307
+ await kdb
308
+ .insertInto("country_codes")
309
+ .values([...ccodes].map(([code, id]) => ({ id, code })))
310
+ .execute()
311
+ }
312
+
313
+ if (ptcodes.size > 0) {
314
+ await kdb
315
+ .insertInto("placetype_codes")
316
+ .values([...ptcodes].map(([placetype, id]) => ({ id, placetype })))
317
+ .execute()
318
+ }
319
+
320
+ // --- materialize the clustered WITHOUT ROWID table (sorted insert → contiguous leaves) ---
321
+ progress("cluster", "building clustered candidate table + VACUUM")
322
+ // Column list + clustered-key order are sourced from CANDIDATE_COLUMNS (the first 6 ARE the PRIMARY
323
+ // KEY) so the SELECT, the ORDER BY, and the table can't drift. The table comes from the shared
324
+ // createCandidateTable().
325
+ const cols = CANDIDATE_COLUMNS.join(", ")
326
+ const keyOrder = CANDIDATE_COLUMNS.slice(0, 6).join(", ")
327
+ await createCandidateTable(kdb)
328
+ // OR IGNORE: an abbrev/alias can normalize to a place's primary key (same place, same rank) → any one
329
+ // row. The bulk sorted INSERT…SELECT (clustered materialization) stays raw — a single hot bulk statement.
330
+ out.exec(`INSERT OR IGNORE INTO candidate (${cols}) SELECT ${cols} FROM cand_stage ORDER BY ${keyOrder};`)
331
+ await kdb.schema.dropTable("cand_stage").execute()
332
+ // Typo-tolerant fallback index (the unified gazetteer's second mode): the exact name_key probe can't
333
+ // recover misspellings, so FTS5-trigram over `name` lets the reader fuzzy-match on an exact+strip miss.
334
+ progress("fts", "building FTS5-trigram fuzzy index")
335
+ createCandidateFTS(out)
336
+ // page_size MUST be set right before VACUUM: node:sqlite initializes the file at the 4096 default on
337
+ // `new DatabaseSync`, so the creation-time pragma is a no-op — only a VACUUM rebuilds at the new size.
338
+ // 8192 matches the sql.js-httpvfs 64 KiB request chunk cleanly (8 pages) and shallows the B-tree.
339
+ out.exec("PRAGMA page_size=8192")
340
+ out.exec("VACUUM")
341
+
342
+ const { n: rows } = await kdb
343
+ .selectFrom("candidate")
344
+ .select((eb) => eb.fn.countAll<number>().as("n"))
345
+ .executeTakeFirstOrThrow()
346
+ src.close()
347
+ await kdb.destroy()
348
+
349
+ // closes the underlying `out` connection
350
+ return { rows, places: attrs.size, primaries: nPrim, aliases: nAlias, abbrevs: nAbbr, postcodes: nPostcode }
351
+ }