@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.
- package/address-point-interpolation.ts +207 -0
- package/address-point-schema.ts +107 -0
- package/address-point.ts +122 -0
- package/ancestry-backfill.ts +205 -0
- package/ancestry.ts +70 -0
- package/build-candidate.ts +351 -0
- package/build-slim.ts +394 -0
- package/candidate-fts.ts +43 -0
- package/candidate-lookup.ts +382 -0
- package/candidate-schema.ts +166 -0
- package/coincident-roles.ts +240 -0
- package/convention.ts +152 -0
- package/fst-autocomplete.ts +187 -0
- package/fst-builder.ts +291 -0
- package/fst-deserialize-web.ts +164 -0
- package/fst-matcher.ts +150 -0
- package/fst-serialize.ts +311 -0
- package/fst-types.ts +78 -0
- package/fts.ts +318 -0
- package/geo.ts +140 -0
- package/geonames-aliases.ts +317 -0
- package/geonames-postal.ts +150 -0
- package/index.ts +117 -0
- package/interpolation.ts +232 -0
- package/lookup.ts +1498 -0
- package/out/poi-lookup.d.ts +14 -2
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +55 -21
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +9 -0
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js +16 -0
- package/out/poi-schema.js.map +1 -1
- package/out/reverse.d.ts +8 -1
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +10 -1
- package/out/reverse.js.map +1 -1
- package/package.json +168 -82
- package/poi-lookup.ts +375 -0
- package/poi-schema.ts +164 -0
- package/postal-city-alias-lookup.ts +89 -0
- package/postal-city-alias-schema.ts +75 -0
- package/postal-city-candidate-schema.ts +81 -0
- package/postcode-point-lookup.ts +64 -0
- package/reverse.ts +439 -0
- package/schema.ts +176 -0
- package/sharding.ts +235 -0
- package/sqlite-convention-source.ts +61 -0
- package/sqlite-utils.ts +25 -0
- package/street-centroid-schema.ts +124 -0
- package/street-centroid.ts +124 -0
- package/street-morphology-fst-builder.ts +230 -0
- package/street-name-lookup.ts +101 -0
- package/street-normalize.ts +302 -0
- package/street-segment-schema.ts +104 -0
- package/types.ts +164 -0
- 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
|
+
}
|