@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/poi-lookup.ts ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Node reader for `poi.db` (spec §3.4) — the res-9 k-ring reader over the clustered `poi`
7
+ * `WITHOUT ROWID` B-tree Task 1's schema (`poi-schema.ts`) builds. Three search modes share one
8
+ * artifact:
9
+ *
10
+ * - **Category**: `latLngToCell(center, 9)` → `gridDisk` ring-by-ring expansion, probing each
11
+ * cell's clustered `(h3_cell, category_id, neg_rank, …)` range. Rings accumulate until `limit`
12
+ * rows are on hand after a completed ring, or `maxRings` is exhausted; the pool is sorted by
13
+ * haversine distance from `center` after every ring.
14
+ * - **Brand**: NOT a k-ring walk. Brand rows are globally sparse (~0.31% of poi.db; median nearest
15
+ * tagged instance ~110 km), so ring expansion could never reach them. Instead a single brand-wide
16
+ * indexed fetch on `brand_wikidata` (the partial `poi_brand_wikidata` index) pulls EVERY row for
17
+ * the QID — category unconstrained — then haversine-sorts from `center` and takes the nearest
18
+ * `limit`, bounded by a `BRAND_MAX_DISTANCE_KM` sanity radius.
19
+ * - **Name**: FTS5 `MATCH` against the `poi_search` virtual table, hydrated back to full rows by
20
+ * `name_key`. No center required; if one is given, hits are still distance-sorted.
21
+ *
22
+ * `latLngToCell`/`gridDisk` come from `h3-js`; the 48-bit short-cell packing that turns a raw H3
23
+ * cell into the integer `poi.h3_cell` stores is `@mailwoman/spatial`'s `shortenH3Cell` — that math
24
+ * is NEVER reimplemented here (see AGENTS.md on `@mailwoman/spatial` being the one true home for
25
+ * it).
26
+ */
27
+
28
+ import { DatabaseSync } from "node:sqlite"
29
+
30
+ import { haversineKm, shortenH3Cell, type H3Cell } from "@mailwoman/spatial"
31
+ import { gridDisk, latLngToCell } from "h3-js"
32
+
33
+ import type { POICategoryCodeTable, POITable } from "./poi-schema.ts"
34
+
35
+ /** Resolution the `poi` table's `h3_cell` column is keyed at — matches the builder (spec §3.4). */
36
+ export const POI_H3_RESOLUTION = 9
37
+
38
+ /** Ring budget default: 12 res-9 k-rings ≈ ~4 km. Category path only — the brand path ignores rings entirely. */
39
+ const DEFAULT_MAX_RINGS = 12
40
+
41
+ /**
42
+ * Brand sanity radius (km): the brand-wide fetch returns the global nearest at ANY distance, so this drops hits far
43
+ * enough to be certainly the wrong continent — "Applebee's near Marseille" comes back empty rather than with a 5,700 km
44
+ * hit. A product bound, not a reach cap (the index already makes the fetch cheap regardless of distance).
45
+ */
46
+ const BRAND_MAX_DISTANCE_KM = 500
47
+
48
+ /** Row-count default when a query doesn't specify `limit`. */
49
+ const DEFAULT_LIMIT = 20
50
+
51
+ export interface POISearchQuery {
52
+ /** Poi-taxonomy category id (string side of the dictionary). Ignored when `brandWikidata` is also set — brand wins. */
53
+ categoryID?: string
54
+ /**
55
+ * Fan-out category ids — the Overture `taxonomy.primary` leaves a single canonical category rolls up into (e.g.
56
+ * `supermarket` → `grocery_store`, `organic_grocery_store`, …). When set, the k-ring walk probes EVERY resolvable
57
+ * leaf per cell and unions the rows; unknown leaves are skipped. Supersedes `categoryID` (which is treated as a
58
+ * one-element list `[categoryID]` when this is absent). Ignored when `brandWikidata` is set — brand wins.
59
+ */
60
+ categoryIDs?: string[]
61
+ /** Wikidata QID for brand-exact search. */
62
+ brandWikidata?: string
63
+ /** Free-text name (FTS5). */
64
+ name?: string
65
+ /** Search center. Required for category/brand queries (k-ring expansion). */
66
+ center?: { latitude: number; longitude: number }
67
+ /**
68
+ * Ring budget: how many res-9 k-rings to expand before giving up (default 12 ≈ ~4 km). Counts ring 0, so k reaches
69
+ * `maxRings - 1`.
70
+ */
71
+ maxRings?: number
72
+ limit?: number
73
+ }
74
+
75
+ export interface POISearchHit {
76
+ name: string | null
77
+ categoryID: string | null
78
+ brandWikidata: string | null
79
+ latitude: number
80
+ longitude: number
81
+ country: string
82
+ confidence: number
83
+ /** Overture GERS id — nullable METADATA ONLY, never a key (the #470 rule; see `POITable.gers_id`). */
84
+ gersID: string | null
85
+ distanceM?: number
86
+ }
87
+
88
+ export interface POILookupOpts {
89
+ /** Path to a `poi.db` built by the (future) POI builder. Opened read-only. */
90
+ databasePath?: string
91
+ /** Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`. */
92
+ database?: DatabaseSync
93
+ }
94
+
95
+ /** The `poi` columns every search mode hydrates — a typed projection of the SHARED {@link POITable}. */
96
+ type POIRow = Pick<
97
+ POITable,
98
+ | "name"
99
+ | "category_id"
100
+ | "brand_wikidata"
101
+ | "latitude"
102
+ | "longitude"
103
+ | "country"
104
+ | "confidence"
105
+ | "name_key"
106
+ | "gers_id"
107
+ >
108
+
109
+ /**
110
+ * Node reader over `poi.db`. `implements Disposable` so callers can `using lookup = new POILookup(...)` (or call
111
+ * `[Symbol.dispose]()` explicitly), the same precedent as {@link WOFCandidateTableLookup} /
112
+ * {@link WOFSqlitePlaceLookup}.
113
+ */
114
+ export class POILookup implements Disposable {
115
+ #db: DatabaseSync
116
+ #ownsDB: boolean
117
+ readonly #categoryToID = new Map<string, number>()
118
+ readonly #idToCategory = new Map<number, string>()
119
+
120
+ /** `(h3_cell, category_id)` → the cell's category-clustered range, most-confident-first. */
121
+ readonly #categoryCellProbe: ReturnType<DatabaseSync["prepare"]>
122
+ /** `brand_wikidata` → ALL of a brand's rows globally (partial-index range-scan); distance-sorted in JS, not SQL. */
123
+ readonly #brandProbe: ReturnType<DatabaseSync["prepare"]>
124
+ /** FTS5 `MATCH` over `poi_search`, returning candidate `name_key`s to hydrate. */
125
+ readonly #nameFTSProbe: ReturnType<DatabaseSync["prepare"]>
126
+
127
+ constructor(opts: POILookupOpts) {
128
+ if (opts.database) {
129
+ this.#db = opts.database
130
+ this.#ownsDB = false
131
+ } else if (opts.databasePath) {
132
+ this.#db = new DatabaseSync(opts.databasePath, { readOnly: true })
133
+ this.#ownsDB = true
134
+ } else {
135
+ throw new Error("POILookup needs `databasePath` or `database`")
136
+ }
137
+
138
+ // The category dictionary is tiny (poi-taxonomy's category count) — load it once at
139
+ // construction so `search` never round-trips to it.
140
+ for (const r of this.#db
141
+ .prepare("SELECT id, category FROM poi_category_codes")
142
+ .all() as unknown as POICategoryCodeTable[]) {
143
+ this.#categoryToID.set(String(r.category), Number(r.id))
144
+ this.#idToCategory.set(Number(r.id), String(r.category))
145
+ }
146
+
147
+ const columns = "name, category_id, brand_wikidata, latitude, longitude, country, confidence, name_key, gers_id"
148
+
149
+ this.#categoryCellProbe = this.#db.prepare(
150
+ `SELECT ${columns} FROM poi WHERE h3_cell = ? AND category_id = ? ORDER BY neg_rank ASC LIMIT ?`
151
+ )
152
+ this.#brandProbe = this.#db.prepare(`SELECT ${columns} FROM poi WHERE brand_wikidata = ?`)
153
+ this.#nameFTSProbe = this.#db.prepare(
154
+ "SELECT name_key FROM poi_search WHERE poi_search MATCH ? ORDER BY bm25(poi_search) LIMIT ?"
155
+ )
156
+ }
157
+
158
+ search(query: POISearchQuery): POISearchHit[] {
159
+ const limit = Math.max(1, query.limit ?? DEFAULT_LIMIT)
160
+
161
+ if (query.name) {
162
+ return this.#searchByName(query.name, limit, query.center)
163
+ }
164
+
165
+ if (query.categoryID || (query.categoryIDs && query.categoryIDs.length > 0) || query.brandWikidata) {
166
+ if (!query.center) {
167
+ throw new Error("POILookup.search: category/brand search requires a `center`")
168
+ }
169
+
170
+ // brandWikidata wins over categoryID(s) when both are set — see POISearchQuery.categoryID. The brand path is
171
+ // a brand-wide indexed fetch, NOT a k-ring walk (brand rows are too sparse for ring expansion to reach).
172
+ if (query.brandWikidata) {
173
+ return this.#searchBrand(query.brandWikidata, query.center, limit)
174
+ }
175
+
176
+ return this.#searchKRing(query, limit)
177
+ }
178
+
179
+ return []
180
+ }
181
+
182
+ /**
183
+ * Brand path: a single brand-wide indexed fetch — NO k-ring. Fetch EVERY row for the QID (the partial
184
+ * `poi_brand_wikidata` index makes this a range-scan, not a 13.68M full scan), haversine-sort from `center`, drop
185
+ * anything past the {@link BRAND_MAX_DISTANCE_KM} sanity radius, and take the nearest `limit`. Returns the true
186
+ * nearest at any distance — the reach ceiling k-ring hits on sparse brand rows is gone.
187
+ */
188
+ #searchBrand(brandWikidata: string, center: { latitude: number; longitude: number }, limit: number): POISearchHit[] {
189
+ const rows = this.#brandProbe.all(brandWikidata) as unknown as POIRow[]
190
+
191
+ return sortByDistance(rows, center)
192
+ .filter(
193
+ (row) => haversineKm(center.latitude, center.longitude, row.latitude, row.longitude) <= BRAND_MAX_DISTANCE_KM
194
+ )
195
+ .slice(0, limit)
196
+ .map((row) => toHit(row, this.#idToCategory, center))
197
+ }
198
+
199
+ /** Category path: k-ring expansion from `query.center`'s res-9 cell, probing each new ring's cells. */
200
+ #searchKRing(query: POISearchQuery, limit: number): POISearchHit[] {
201
+ const center = query.center!
202
+ const maxRings = query.maxRings ?? DEFAULT_MAX_RINGS
203
+ const categoryIds: number[] = []
204
+
205
+ // `categoryIDs` (the fan-out list) supersedes the single `categoryID`; either way, resolve each id through the
206
+ // dictionary and drop the ones the db doesn't carry (Overture-taxonomy drift, or an identity id with no rows).
207
+ const seedIDs = query.categoryIDs?.length ? query.categoryIDs : query.categoryID ? [query.categoryID] : []
208
+
209
+ for (const id of seedIDs) {
210
+ const resolved = this.#categoryToID.get(id)
211
+
212
+ if (resolved !== undefined) {
213
+ categoryIds.push(resolved)
214
+ }
215
+ }
216
+
217
+ // No resolvable leaf (every id unknown to the dictionary) can't have rows — a clean miss, not a throw.
218
+ if (categoryIds.length === 0) return []
219
+
220
+ const origin = latLngToCell(center.latitude, center.longitude, POI_H3_RESOLUTION) as H3Cell
221
+ const seenCells = new Set<string>()
222
+ let rows: POIRow[] = []
223
+
224
+ // `ring` starts at 0 (the origin cell itself), so this loop's k reaches `maxRings - 1`.
225
+ for (let ring = 0; ring < maxRings; ring++) {
226
+ // gridDisk(origin, ring) returns the WHOLE disk out to `ring`; diffing against what's
227
+ // already been probed derives just this ring's new cells.
228
+ const diskCells = gridDisk(origin, ring) as string[]
229
+ const newCells = diskCells.filter((cell) => !seenCells.has(cell))
230
+
231
+ for (const cell of newCells) {
232
+ seenCells.add(cell)
233
+ const shortCell = h3CellToInt(cell as H3Cell)
234
+
235
+ // Fan-out: probe every resolved Overture leaf for this canonical category, unioning the rows. The
236
+ // post-ring distance sort + `slice(0, limit)` below dedupes the pool down to the nearest `limit`.
237
+ for (const categoryId of categoryIds) {
238
+ rows.push(...(this.#categoryCellProbe.all(shortCell, categoryId, limit) as unknown as POIRow[]))
239
+ }
240
+ }
241
+
242
+ rows = sortByDistance(rows, center)
243
+
244
+ if (rows.length >= limit) break
245
+ }
246
+
247
+ return rows.slice(0, limit).map((row) => toHit(row, this.#idToCategory, center))
248
+ }
249
+
250
+ /**
251
+ * Name path: FTS5 MATCH → hydrate by name_key. No center required; distance-sorts if one is given anyway.
252
+ *
253
+ * Hydration is ONE batched `WHERE name_key IN (...)` query over the FTS hits' unique `name_key`s, not a per-hit probe
254
+ * — with up to `limit` FTS hits, a per-hit probe was up to `limit` full table scans before `createPOINameKeyIndex`
255
+ * (poi-schema.ts) + this batching.
256
+ */
257
+ #searchByName(name: string, limit: number, center?: { latitude: number; longitude: number }): POISearchHit[] {
258
+ const matchQuery = sanitizePOINameQuery(name)
259
+
260
+ if (!matchQuery) return []
261
+
262
+ const ftsHits = this.#nameFTSProbe.all(matchQuery, limit) as unknown as Array<{ name_key: string | null }>
263
+ const uniqueKeys: string[] = []
264
+ const seenKeys = new Set<string>()
265
+
266
+ for (const hit of ftsHits) {
267
+ if (!hit.name_key || seenKeys.has(hit.name_key)) continue
268
+ seenKeys.add(hit.name_key)
269
+ uniqueKeys.push(hit.name_key)
270
+ }
271
+
272
+ if (uniqueKeys.length === 0) return []
273
+
274
+ const hydrated = this.#hydrateByNameKeys(uniqueKeys)
275
+ const rowsByKey = new Map<string, POIRow[]>()
276
+
277
+ for (const row of hydrated) {
278
+ if (row.name_key === null) continue
279
+ const bucket = rowsByKey.get(row.name_key)
280
+
281
+ if (bucket) {
282
+ bucket.push(row)
283
+ } else {
284
+ rowsByKey.set(row.name_key, [row])
285
+ }
286
+ }
287
+
288
+ let rows: POIRow[] = []
289
+
290
+ for (const key of uniqueKeys) {
291
+ rows.push(...(rowsByKey.get(key) ?? []))
292
+ }
293
+
294
+ if (center) {
295
+ rows = sortByDistance(rows, center)
296
+ }
297
+
298
+ return rows.slice(0, limit).map((row) => toHit(row, this.#idToCategory, center))
299
+ }
300
+
301
+ /**
302
+ * Batched hydration for the FTS name path: `WHERE name_key IN (?, ?, …)`, one query for the whole batch instead of
303
+ * one probe per FTS hit. This is a cold path (name search only) with variable arity per call, so the statement is
304
+ * prepared fresh each time rather than cached.
305
+ */
306
+ #hydrateByNameKeys(nameKeys: string[]): POIRow[] {
307
+ const columns = "name, category_id, brand_wikidata, latitude, longitude, country, confidence, name_key, gers_id"
308
+ const placeholders = nameKeys.map(() => "?").join(", ")
309
+ const stmt = this.#db.prepare(`SELECT ${columns} FROM poi WHERE name_key IN (${placeholders})`)
310
+
311
+ return stmt.all(...nameKeys) as unknown as POIRow[]
312
+ }
313
+
314
+ close(): void {
315
+ if (this.#ownsDB) {
316
+ this.#db.close()
317
+ }
318
+ }
319
+
320
+ [Symbol.dispose](): void {
321
+ this.close()
322
+ }
323
+ }
324
+
325
+ /** `poi.h3_cell` is the SHORTENED (48-bit) cell — never the full h3-js cell string. */
326
+ function h3CellToInt(cell: H3Cell): number {
327
+ return Number(BigInt(`0x${shortenH3Cell(cell)}`))
328
+ }
329
+
330
+ function sortByDistance(rows: POIRow[], center: { latitude: number; longitude: number }): POIRow[] {
331
+ return [...rows].sort(
332
+ (a, b) =>
333
+ haversineKm(center.latitude, center.longitude, a.latitude, a.longitude) -
334
+ haversineKm(center.latitude, center.longitude, b.latitude, b.longitude)
335
+ )
336
+ }
337
+
338
+ function toHit(
339
+ row: POIRow,
340
+ idToCategory: ReadonlyMap<number, string>,
341
+ center?: { latitude: number; longitude: number }
342
+ ): POISearchHit {
343
+ return {
344
+ name: row.name,
345
+ categoryID: row.category_id !== 0 ? (idToCategory.get(row.category_id) ?? null) : null,
346
+ brandWikidata: row.brand_wikidata,
347
+ latitude: row.latitude,
348
+ longitude: row.longitude,
349
+ country: row.country,
350
+ confidence: row.confidence,
351
+ gersID: row.gers_id,
352
+ ...(center
353
+ ? { distanceM: haversineKm(center.latitude, center.longitude, row.latitude, row.longitude) * 1000 }
354
+ : {}),
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Sanitize free text into an FTS5-safe MATCH query: strip the characters FTS5 would otherwise read as syntax (`"`
360
+ * phrase delimiters, `*` prefix wildcards, `:` column-filter separators), then phrase-quote each whitespace-separated
361
+ * token (AND-joined).
362
+ *
363
+ * `resolver-wof-sqlite` already has this discipline — `lookup.ts`'s `sanitizeFTSQuery` — but that function is
364
+ * module-private there (not re-exported from `fts.ts` or the package's `index.ts`), so this replicates the same
365
+ * discipline locally rather than reaching across the module boundary for a private helper.
366
+ */
367
+ function sanitizePOINameQuery(text: string): string {
368
+ return text
369
+ .replace(/["*:]/g, "")
370
+ .trim()
371
+ .split(/\s+/u)
372
+ .filter(Boolean)
373
+ .map((token) => `"${token.replace(/"/g, '""')}"`)
374
+ .join(" ")
375
+ }
package/poi-schema.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Typed schema for poi.db — spatial layer #1 (spec §3.4). One clustered `WITHOUT ROWID` B-tree
7
+ * keyed `(h3_cell, category_id, neg_rank, rowid_key)` so "everything near this res-9 cell" is a
8
+ * contiguous key range (the byte-range/httpvfs access pattern, same discipline as the candidate
9
+ * gazetteer). Rows carry denormalized name/brand/coords; category ids are small ints via the
10
+ * `poi_category_codes` dictionary (poi-taxonomy category ids are the string side). The DB also
11
+ * embeds the layer-contract tables from `@mailwoman/core/layers` — the builder writes the
12
+ * manifest (tier `shipped`, spine `h3` res 9) and per-res-6-cell coverage.
13
+ */
14
+
15
+ import type { DatabaseSync } from "node:sqlite"
16
+
17
+ import type { LayerContractDatabase } from "@mailwoman/core/layers"
18
+ import { sql, type Kysely } from "kysely"
19
+
20
+ /** One POI row. Clustered PK: h3_cell → category_id → neg_rank → rowid_key. */
21
+ export interface POITable {
22
+ /** 48-bit short H3 cell at res 9 (`latLngToCell` → `shortenH3Cell`). */
23
+ h3_cell: number
24
+ /** Small int from {@link POICategoryCodeTable}; 0 = uncategorized. */
25
+ category_id: number
26
+ /** `-log10(confidence + epsilon)` so ASC = most-confident-first within a cell+category. */
27
+ neg_rank: number
28
+ /** Uniquifier within the clustered key (builder-assigned monotonic int). */
29
+ rowid_key: number
30
+ name: string | null
31
+ /** Lowercased, diacritic-flattened probe key for exact name lookups. */
32
+ name_key: string | null
33
+ brand_wikidata: string | null
34
+ latitude: number
35
+ longitude: number
36
+ /** ISO 3166-1 alpha-2 (from the Overture partition). */
37
+ country: string
38
+ /** Overture existence confidence (already filtered ≥ 0.85 at build). */
39
+ confidence: number
40
+ /** GERS id — nullable METADATA ONLY, never a key (the #470 rule). */
41
+ gers_id: string | null
42
+ }
43
+
44
+ /**
45
+ * Staging mirror — every column nullable except the coords (the loader fills positionally; the materialize SELECT
46
+ * enforces completeness).
47
+ */
48
+ export interface POIStageTable {
49
+ h3_cell: number | null
50
+ category_id: number | null
51
+ neg_rank: number | null
52
+ rowid_key: number | null
53
+ name: string | null
54
+ name_key: string | null
55
+ brand_wikidata: string | null
56
+ latitude: number
57
+ longitude: number
58
+ country: string | null
59
+ confidence: number | null
60
+ gers_id: string | null
61
+ }
62
+
63
+ /** `(id → poi-taxonomy category id)` dictionary, e.g. `3 → "cafe"`. */
64
+ export interface POICategoryCodeTable {
65
+ id: number
66
+ category: string
67
+ }
68
+
69
+ export interface POIDatabase extends LayerContractDatabase {
70
+ poi: POITable
71
+ poi_stage: POIStageTable
72
+ poi_category_codes: POICategoryCodeTable
73
+ }
74
+
75
+ /** Clustered-key-order column list shared by builder + `INSERT INTO poi SELECT … FROM poi_stage`. */
76
+ export const POI_COLUMNS = [
77
+ "h3_cell",
78
+ "category_id",
79
+ "neg_rank",
80
+ "rowid_key",
81
+ "name",
82
+ "name_key",
83
+ "brand_wikidata",
84
+ "latitude",
85
+ "longitude",
86
+ "country",
87
+ "confidence",
88
+ "gers_id",
89
+ ] as const
90
+
91
+ export async function createPOIStagingTables(db: Kysely<POIDatabase>): Promise<void> {
92
+ await db.schema
93
+ .createTable("poi_category_codes")
94
+ .addColumn("id", "integer", (c) => c.primaryKey())
95
+ .addColumn("category", "text", (c) => c.unique())
96
+ .execute()
97
+ await db.schema
98
+ .createTable("poi_stage")
99
+ .addColumn("h3_cell", "integer")
100
+ .addColumn("category_id", "integer")
101
+ .addColumn("neg_rank", "real")
102
+ .addColumn("rowid_key", "integer")
103
+ .addColumn("name", "text")
104
+ .addColumn("name_key", "text")
105
+ .addColumn("brand_wikidata", "text")
106
+ .addColumn("latitude", "real")
107
+ .addColumn("longitude", "real")
108
+ .addColumn("country", "text")
109
+ .addColumn("confidence", "real")
110
+ .addColumn("gers_id", "text")
111
+ .execute()
112
+ }
113
+
114
+ export async function createPOITable(db: Kysely<POIDatabase>): Promise<void> {
115
+ await db.schema
116
+ .createTable("poi")
117
+ .addColumn("h3_cell", "integer", (c) => c.notNull())
118
+ .addColumn("category_id", "integer", (c) => c.notNull())
119
+ .addColumn("neg_rank", "real", (c) => c.notNull())
120
+ .addColumn("rowid_key", "integer", (c) => c.notNull())
121
+ .addColumn("name", "text")
122
+ .addColumn("name_key", "text")
123
+ .addColumn("brand_wikidata", "text")
124
+ .addColumn("latitude", "real", (c) => c.notNull())
125
+ .addColumn("longitude", "real", (c) => c.notNull())
126
+ .addColumn("country", "text", (c) => c.notNull())
127
+ .addColumn("confidence", "real", (c) => c.notNull())
128
+ .addColumn("gers_id", "text")
129
+ .addPrimaryKeyConstraint("poi_pk", ["h3_cell", "category_id", "neg_rank", "rowid_key"])
130
+ // `WITHOUT ROWID` has no first-class builder; the raw modifier is the idiomatic fallback.
131
+ .modifyEnd(sql`without rowid`)
132
+ .execute()
133
+ }
134
+
135
+ /** Secondary index for the FTS-hydration path. Builders call this AFTER the bulk materialize (index-after-load). */
136
+ export async function createPOINameKeyIndex(db: Kysely<POIDatabase>): Promise<void> {
137
+ await db.schema.createIndex("poi_name_key").on("poi").column("name_key").execute()
138
+ }
139
+
140
+ /**
141
+ * Secondary index for the BRAND path — a brand-wide fetch by `brand_wikidata` (no `h3_cell` prefix). Brand rows are
142
+ * globally sparse (~0.31% of poi.db, median nearest tagged instance ~110 km), so the k-ring walk can never reach them;
143
+ * the reader instead fetches ALL of a brand's rows and distance-sorts. Without this index that is a full-table scan
144
+ * (~600 ms); with it, a range-scan (<1 ms p50). PARTIAL (`WHERE brand_wikidata IS NOT NULL`) so the ~99.7% of rows that
145
+ * carry no QID never enter the B-tree — the index holds only the ~43k branded rows. Builders call this AFTER the bulk
146
+ * materialize (index-after-load), same phase as {@link createPOINameKeyIndex}.
147
+ */
148
+ export async function createPOIBrandIndex(db: Kysely<POIDatabase>): Promise<void> {
149
+ await db.schema
150
+ .createIndex("poi_brand_wikidata")
151
+ .on("poi")
152
+ .column("brand_wikidata")
153
+ .where("brand_wikidata", "is not", null)
154
+ .execute()
155
+ }
156
+
157
+ export const POI_FTS_TABLE = "poi_search"
158
+
159
+ /** FTS5 stays raw SQL by project rule (Kysely can't express virtual tables). Content-keyed by name_key. */
160
+ export function createPOISearchFTS(db: DatabaseSync): void {
161
+ db.exec(
162
+ `CREATE VIRTUAL TABLE ${POI_FTS_TABLE} USING fts5(name, name_key UNINDEXED, h3_cell UNINDEXED, tokenize = 'unicode61')`
163
+ )
164
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Node reader over the POSTAL-CITY ALIAS table (`postal-city-alias-<cc>.db`) — the observed
7
+ * `postal_city → geo_locality` aliases per postcode (`build-postal-city-alias.ts`). Consumed by
8
+ * {@link WOFSqlitePlaceLookup}'s coordinate-first locality scorer: a user-typed postal city
9
+ * ("Antioch", postcode 37013) becomes a name-match alias for the geographic locality the postcode
10
+ * actually sits in ("Nashville"), so the right place tiers to the top instead of a same-named
11
+ * town in another state. Opt-in — the lookup is only constructed when a path is supplied, and
12
+ * absent it the resolver is byte-identical.
13
+ *
14
+ * The reader returns RAW divergent rows for a postcode; normalization + name-matching against the
15
+ * candidate localities is the scorer's job (it owns the case/diacritic fold the soft name score
16
+ * uses), keeping one normalizer in one place.
17
+ */
18
+
19
+ import { DatabaseSync } from "node:sqlite"
20
+
21
+ import { DatabaseClient } from "@mailwoman/core/kysley/client"
22
+
23
+ import type { PostalCityAliasDatabase } from "./postal-city-alias-schema.ts"
24
+
25
+ export interface WOFPostalCityAliasLookupOpts {
26
+ /** Path to a `postal-city-alias-<cc>.db` built by `build-postal-city-alias.ts`. Opened read-only. */
27
+ databasePath?: string
28
+ /** Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`. */
29
+ database?: DatabaseSync
30
+ }
31
+
32
+ /** One divergent alias edge: the postal-system name and the geographic locality it maps to. */
33
+ export interface PostalCityAlias {
34
+ /** The postal-system surface (what a user types). */
35
+ postalCity: string
36
+ /** The geographic locality name the postcode sits in (≈ the gazetteer's canonical name). */
37
+ geoLocality: string
38
+ /** Observed usage count — the evidence weight. */
39
+ n: number
40
+ }
41
+
42
+ /**
43
+ * Reader over `postal_city_alias`. The only query is a postcode-scoped probe for DIVERGENT rows (where the postal name
44
+ * differs from the geographic name — the rows that carry alias signal), issued via the typed Kysely query builder
45
+ * against {@link PostalCityAliasDatabase}.
46
+ */
47
+ export class WOFPostalCityAliasLookup {
48
+ #db: DatabaseSync
49
+ #kdb: DatabaseClient<PostalCityAliasDatabase>
50
+ #ownsDB: boolean
51
+
52
+ constructor(opts: WOFPostalCityAliasLookupOpts) {
53
+ if (opts.database) {
54
+ this.#db = opts.database
55
+ this.#ownsDB = false
56
+ } else if (opts.databasePath) {
57
+ this.#db = new DatabaseSync(opts.databasePath, { readOnly: true })
58
+ this.#ownsDB = true
59
+ } else {
60
+ throw new Error("WOFPostalCityAliasLookup needs `databasePath` or `database`")
61
+ }
62
+ // `#kdb` wraps `#db` for the typed query; close() owns the raw handle directly (sync).
63
+ this.#kdb = new DatabaseClient<PostalCityAliasDatabase>({ database: this.#db })
64
+ }
65
+
66
+ /**
67
+ * Divergent postal-city aliases for a postcode (empty when the postcode isn't in the table). The scorer groups these
68
+ * by normalized `geoLocality` and appends the `postalCity` surfaces to the matching candidate locality's alias set.
69
+ */
70
+ async getDivergentAliases(postcode: string): Promise<PostalCityAlias[]> {
71
+ const pc = postcode.trim()
72
+
73
+ if (!pc) return []
74
+ const rows = await this.#kdb
75
+ .selectFrom("postal_city_alias")
76
+ .select(["postal_city", "geo_locality", "n"])
77
+ .where("postcode", "=", pc)
78
+ .where("divergent", "=", 1)
79
+ .execute()
80
+
81
+ return rows.map((r) => ({ postalCity: String(r.postal_city), geoLocality: String(r.geo_locality), n: Number(r.n) }))
82
+ }
83
+
84
+ close(): void {
85
+ if (this.#ownsDB) {
86
+ this.#db.close()
87
+ }
88
+ }
89
+ }